feat: 實作 Multi-tenancy 多租戶架構 (stancl/tenancy)
- 安裝並設定 stancl/tenancy 套件 - 分離 Central / Tenant migrations - 建立 Tenant Model 與資料遷移指令 - 建立房東後台 CRUD (Landlord Dashboard) - 新增租戶管理頁面 (列表、新增、編輯、詳情) - 新增域名管理功能 - 更新部署手冊
This commit is contained in:
@@ -5,6 +5,9 @@ APP_KEY=
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
|
# Multi-tenancy 設定 (用逗號分隔多個中央網域)
|
||||||
|
CENTRAL_DOMAINS=localhost,127.0.0.1
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|||||||
131
app/Console/Commands/MigrateToTenant.php
Normal file
131
app/Console/Commands/MigrateToTenant.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將現有資料遷移到租戶資料庫
|
||||||
|
*
|
||||||
|
* 此指令用於初次設定多租戶時,將現有的 ERP 資料遷移到第一個租戶
|
||||||
|
*/
|
||||||
|
class MigrateToTenant extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenancy:migrate-data {tenant_id} {--dry-run : 只顯示會遷移的表,不實際執行}';
|
||||||
|
protected $description = '將現有 central DB 資料遷移到指定租戶資料庫';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 需要遷移的表 (依賴順序排列)
|
||||||
|
*/
|
||||||
|
protected array $tablesToMigrate = [
|
||||||
|
'users',
|
||||||
|
'password_reset_tokens',
|
||||||
|
'sessions',
|
||||||
|
'cache',
|
||||||
|
'cache_locks',
|
||||||
|
'jobs',
|
||||||
|
'job_batches',
|
||||||
|
'failed_jobs',
|
||||||
|
'categories',
|
||||||
|
'units',
|
||||||
|
'vendors',
|
||||||
|
'products',
|
||||||
|
'product_vendor',
|
||||||
|
'warehouses',
|
||||||
|
'inventories',
|
||||||
|
'inventory_transactions',
|
||||||
|
'purchase_orders',
|
||||||
|
'purchase_order_items',
|
||||||
|
'permissions',
|
||||||
|
'roles',
|
||||||
|
'model_has_permissions',
|
||||||
|
'model_has_roles',
|
||||||
|
'role_has_permissions',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$tenantId = $this->argument('tenant_id');
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
// 檢查租戶是否存在
|
||||||
|
$tenant = Tenant::find($tenantId);
|
||||||
|
if (!$tenant) {
|
||||||
|
$this->error("租戶 '{$tenantId}' 不存在!請先建立租戶。");
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("開始遷移資料到租戶: {$tenantId}");
|
||||||
|
$this->info("租戶資料庫: tenant{$tenantId}");
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('⚠️ Dry Run 模式 - 不會實際遷移資料');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取得 central 資料庫連線
|
||||||
|
$centralConnection = config('database.default');
|
||||||
|
$tenantDbName = 'tenant' . $tenantId;
|
||||||
|
|
||||||
|
foreach ($this->tablesToMigrate as $table) {
|
||||||
|
// 檢查表是否存在於 central
|
||||||
|
if (!Schema::connection($centralConnection)->hasTable($table)) {
|
||||||
|
$this->line(" ⏭️ 跳過 {$table} (表不存在)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 計算資料筆數
|
||||||
|
$count = DB::connection($centralConnection)->table($table)->count();
|
||||||
|
if ($count === 0) {
|
||||||
|
$this->line(" ⏭️ 跳過 {$table} (無資料)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info(" 📋 {$table}: {$count} 筆資料");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 實際遷移資料
|
||||||
|
$this->info(" 🔄 遷移 {$table}: {$count} 筆資料...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用租戶上下文執行
|
||||||
|
$tenant->run(function () use ($centralConnection, $table) {
|
||||||
|
// 取得 central 資料
|
||||||
|
$data = DB::connection($centralConnection)->table($table)->get();
|
||||||
|
|
||||||
|
if ($data->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 關閉外鍵檢查
|
||||||
|
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
||||||
|
|
||||||
|
// 清空目標表
|
||||||
|
DB::table($table)->truncate();
|
||||||
|
|
||||||
|
// 分批插入 (每批 100 筆)
|
||||||
|
foreach ($data->chunk(100) as $chunk) {
|
||||||
|
DB::table($table)->insert($chunk->map(fn($item) => (array) $item)->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢復外鍵檢查
|
||||||
|
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info(" ✅ {$table} 遷移完成");
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error(" ❌ {$table} 遷移失敗: " . $e->getMessage());
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('🎉 資料遷移完成!');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Controllers/Landlord/DashboardController.php
Normal file
29
app/Http/Controllers/Landlord/DashboardController.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Landlord;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$stats = [
|
||||||
|
'totalTenants' => Tenant::count(),
|
||||||
|
'activeTenants' => Tenant::whereJsonContains('data->is_active', true)->count(),
|
||||||
|
'recentTenants' => Tenant::latest()->take(5)->get()->map(function ($tenant) {
|
||||||
|
return [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'is_active' => $tenant->is_active ?? true,
|
||||||
|
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||||
|
'domains' => $tenant->domains->pluck('domain')->toArray(),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('Landlord/Dashboard', $stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/Http/Controllers/Landlord/TenantController.php
Normal file
172
app/Http/Controllers/Landlord/TenantController.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Landlord;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class TenantController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 顯示租戶列表
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$tenants = Tenant::with('domains')->get()->map(function ($tenant) {
|
||||||
|
return [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'email' => $tenant->email ?? null,
|
||||||
|
'is_active' => $tenant->is_active ?? true,
|
||||||
|
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||||
|
'domains' => $tenant->domains->pluck('domain')->toArray(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Inertia::render('Landlord/Tenant/Index', [
|
||||||
|
'tenants' => $tenants,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示新增租戶表單
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
return Inertia::render('Landlord/Tenant/Create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 儲存新租戶
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'id' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('tenants', 'id')],
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'email' => ['nullable', 'email', 'max:100'],
|
||||||
|
'domain' => ['nullable', 'string', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'id' => $validated['id'],
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'email' => $validated['email'] ?? null,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 如果有指定域名,則綁定
|
||||||
|
if (!empty($validated['domain'])) {
|
||||||
|
$tenant->domains()->create(['domain' => $validated['domain']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('landlord.tenants.index')
|
||||||
|
->with('success', "租戶 {$validated['name']} 建立成功!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示單一租戶詳情
|
||||||
|
*/
|
||||||
|
public function show(string $id)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::with('domains')->findOrFail($id);
|
||||||
|
|
||||||
|
return Inertia::render('Landlord/Tenant/Show', [
|
||||||
|
'tenant' => [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'email' => $tenant->email ?? null,
|
||||||
|
'is_active' => $tenant->is_active ?? true,
|
||||||
|
'created_at' => $tenant->created_at->format('Y-m-d H:i'),
|
||||||
|
'updated_at' => $tenant->updated_at->format('Y-m-d H:i'),
|
||||||
|
'domains' => $tenant->domains->map(fn($d) => [
|
||||||
|
'id' => $d->id,
|
||||||
|
'domain' => $d->domain,
|
||||||
|
])->toArray(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顯示編輯租戶表單
|
||||||
|
*/
|
||||||
|
public function edit(string $id)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::findOrFail($id);
|
||||||
|
|
||||||
|
return Inertia::render('Landlord/Tenant/Edit', [
|
||||||
|
'tenant' => [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'name' => $tenant->name ?? $tenant->id,
|
||||||
|
'email' => $tenant->email ?? null,
|
||||||
|
'is_active' => $tenant->is_active ?? true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新租戶
|
||||||
|
*/
|
||||||
|
public function update(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'email' => ['nullable', 'email', 'max:100'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->update($validated);
|
||||||
|
|
||||||
|
return redirect()->route('landlord.tenants.index')
|
||||||
|
->with('success', "租戶 {$validated['name']} 更新成功!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刪除租戶
|
||||||
|
*/
|
||||||
|
public function destroy(string $id)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::findOrFail($id);
|
||||||
|
$name = $tenant->name ?? $id;
|
||||||
|
|
||||||
|
$tenant->delete();
|
||||||
|
|
||||||
|
return redirect()->route('landlord.tenants.index')
|
||||||
|
->with('success', "租戶 {$name} 已刪除!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增域名到租戶
|
||||||
|
*/
|
||||||
|
public function addDomain(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'domain' => ['required', 'string', 'max:100', Rule::unique('domains', 'domain')],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->domains()->create(['domain' => $validated['domain']]);
|
||||||
|
|
||||||
|
return back()->with('success', "域名 {$validated['domain']} 已綁定!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除租戶的域名
|
||||||
|
*/
|
||||||
|
public function removeDomain(string $id, int $domainId)
|
||||||
|
{
|
||||||
|
$tenant = Tenant::findOrFail($id);
|
||||||
|
$domain = $tenant->domains()->findOrFail($domainId);
|
||||||
|
$domainName = $domain->domain;
|
||||||
|
|
||||||
|
$domain->delete();
|
||||||
|
|
||||||
|
return back()->with('success', "域名 {$domainName} 已移除!");
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Models/Tenant.php
Normal file
34
app/Models/Tenant.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
|
||||||
|
use Stancl\Tenancy\Contracts\TenantWithDatabase;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\HasDatabase;
|
||||||
|
use Stancl\Tenancy\Database\Concerns\HasDomains;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租戶 Model
|
||||||
|
*
|
||||||
|
* 代表 ERP 系統中的每一個客戶公司 (如:小小冰室、酒水客戶等)
|
||||||
|
*
|
||||||
|
* 自訂屬性 (存在 data JSON 欄位中,可透過 $tenant->name 存取):
|
||||||
|
* - name: 租戶名稱 (如: 小小冰室)
|
||||||
|
* - email: 聯絡信箱
|
||||||
|
* - is_active: 是否啟用
|
||||||
|
*/
|
||||||
|
class Tenant extends BaseTenant implements TenantWithDatabase
|
||||||
|
{
|
||||||
|
use HasDatabase, HasDomains;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定義獨立欄位 (非 data JSON)
|
||||||
|
* 只有 id 是獨立欄位,其他自訂屬性都存在 data JSON 中
|
||||||
|
*/
|
||||||
|
public static function getCustomColumns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Providers;
|
|||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -26,5 +27,13 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
\Illuminate\Support\Facades\Gate::before(function ($user, $ability) {
|
\Illuminate\Support\Facades\Gate::before(function ($user, $ability) {
|
||||||
return $user->hasRole('super-admin') ? true : null;
|
return $user->hasRole('super-admin') ? true : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 載入房東後台路由 (只在 central domain 可用)
|
||||||
|
$this->app->booted(function () {
|
||||||
|
if (file_exists(base_path('routes/landlord.php'))) {
|
||||||
|
Route::middleware('web')->group(base_path('routes/landlord.php'));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
148
app/Providers/TenancyServiceProvider.php
Normal file
148
app/Providers/TenancyServiceProvider.php
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Stancl\JobPipeline\JobPipeline;
|
||||||
|
use Stancl\Tenancy\Events;
|
||||||
|
use Stancl\Tenancy\Jobs;
|
||||||
|
use Stancl\Tenancy\Listeners;
|
||||||
|
use Stancl\Tenancy\Middleware;
|
||||||
|
|
||||||
|
class TenancyServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
// By default, no namespace is used to support the callable array syntax.
|
||||||
|
public static string $controllerNamespace = '';
|
||||||
|
|
||||||
|
public function events()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Tenant events
|
||||||
|
Events\CreatingTenant::class => [],
|
||||||
|
Events\TenantCreated::class => [
|
||||||
|
JobPipeline::make([
|
||||||
|
Jobs\CreateDatabase::class,
|
||||||
|
Jobs\MigrateDatabase::class,
|
||||||
|
// Jobs\SeedDatabase::class,
|
||||||
|
|
||||||
|
// Your own jobs to prepare the tenant.
|
||||||
|
// Provision API keys, create S3 buckets, anything you want!
|
||||||
|
|
||||||
|
])->send(function (Events\TenantCreated $event) {
|
||||||
|
return $event->tenant;
|
||||||
|
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
|
||||||
|
],
|
||||||
|
Events\SavingTenant::class => [],
|
||||||
|
Events\TenantSaved::class => [],
|
||||||
|
Events\UpdatingTenant::class => [],
|
||||||
|
Events\TenantUpdated::class => [],
|
||||||
|
Events\DeletingTenant::class => [],
|
||||||
|
Events\TenantDeleted::class => [
|
||||||
|
JobPipeline::make([
|
||||||
|
Jobs\DeleteDatabase::class,
|
||||||
|
])->send(function (Events\TenantDeleted $event) {
|
||||||
|
return $event->tenant;
|
||||||
|
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
|
||||||
|
],
|
||||||
|
|
||||||
|
// Domain events
|
||||||
|
Events\CreatingDomain::class => [],
|
||||||
|
Events\DomainCreated::class => [],
|
||||||
|
Events\SavingDomain::class => [],
|
||||||
|
Events\DomainSaved::class => [],
|
||||||
|
Events\UpdatingDomain::class => [],
|
||||||
|
Events\DomainUpdated::class => [],
|
||||||
|
Events\DeletingDomain::class => [],
|
||||||
|
Events\DomainDeleted::class => [],
|
||||||
|
|
||||||
|
// Database events
|
||||||
|
Events\DatabaseCreated::class => [],
|
||||||
|
Events\DatabaseMigrated::class => [],
|
||||||
|
Events\DatabaseSeeded::class => [],
|
||||||
|
Events\DatabaseRolledBack::class => [],
|
||||||
|
Events\DatabaseDeleted::class => [],
|
||||||
|
|
||||||
|
// Tenancy events
|
||||||
|
Events\InitializingTenancy::class => [],
|
||||||
|
Events\TenancyInitialized::class => [
|
||||||
|
Listeners\BootstrapTenancy::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
Events\EndingTenancy::class => [],
|
||||||
|
Events\TenancyEnded::class => [
|
||||||
|
Listeners\RevertToCentralContext::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
Events\BootstrappingTenancy::class => [],
|
||||||
|
Events\TenancyBootstrapped::class => [],
|
||||||
|
Events\RevertingToCentralContext::class => [],
|
||||||
|
Events\RevertedToCentralContext::class => [],
|
||||||
|
|
||||||
|
// Resource syncing
|
||||||
|
Events\SyncedResourceSaved::class => [
|
||||||
|
Listeners\UpdateSyncedResource::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops)
|
||||||
|
Events\SyncedResourceChangedInForeignDatabase::class => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot()
|
||||||
|
{
|
||||||
|
$this->bootEvents();
|
||||||
|
$this->mapRoutes();
|
||||||
|
|
||||||
|
$this->makeTenancyMiddlewareHighestPriority();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function bootEvents()
|
||||||
|
{
|
||||||
|
foreach ($this->events() as $event => $listeners) {
|
||||||
|
foreach ($listeners as $listener) {
|
||||||
|
if ($listener instanceof JobPipeline) {
|
||||||
|
$listener = $listener->toListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
Event::listen($event, $listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mapRoutes()
|
||||||
|
{
|
||||||
|
$this->app->booted(function () {
|
||||||
|
if (file_exists(base_path('routes/tenant.php'))) {
|
||||||
|
Route::namespace(static::$controllerNamespace)
|
||||||
|
->group(base_path('routes/tenant.php'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function makeTenancyMiddlewareHighestPriority()
|
||||||
|
{
|
||||||
|
$tenancyMiddleware = [
|
||||||
|
// Even higher priority than the initialization middleware
|
||||||
|
Middleware\PreventAccessFromCentralDomains::class,
|
||||||
|
|
||||||
|
Middleware\InitializeTenancyByDomain::class,
|
||||||
|
Middleware\InitializeTenancyBySubdomain::class,
|
||||||
|
Middleware\InitializeTenancyByDomainOrSubdomain::class,
|
||||||
|
Middleware\InitializeTenancyByPath::class,
|
||||||
|
Middleware\InitializeTenancyByRequestData::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (array_reverse($tenancyMiddleware) as $middleware) {
|
||||||
|
$this->app[\Illuminate\Contracts\Http\Kernel::class]->prependToMiddlewarePriority($middleware);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\TenancyServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"spatie/laravel-permission": "^6.24",
|
"spatie/laravel-permission": "^6.24",
|
||||||
|
"stancl/tenancy": "^3.9",
|
||||||
"tightenco/ziggy": "^2.6"
|
"tightenco/ziggy": "^2.6"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
228
composer.lock
generated
228
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "edfbf8e1cc43c925ea8b04fc1f93da65",
|
"content-hash": "931b01f076d9ee28568cd36f178a0c04",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -508,6 +508,59 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"time": "2025-03-06T22:45:56+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "facade/ignition-contracts",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/facade/ignition-contracts.git",
|
||||||
|
"reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
|
||||||
|
"reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.3|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^v2.15.8",
|
||||||
|
"phpunit/phpunit": "^9.3.11",
|
||||||
|
"vimeo/psalm": "^3.17.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Facade\\IgnitionContracts\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Freek Van der Herten",
|
||||||
|
"email": "freek@spatie.be",
|
||||||
|
"homepage": "https://flareapp.io",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Solution contracts for Ignition",
|
||||||
|
"homepage": "https://github.com/facade/ignition-contracts",
|
||||||
|
"keywords": [
|
||||||
|
"contracts",
|
||||||
|
"flare",
|
||||||
|
"ignition"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/facade/ignition-contracts/issues",
|
||||||
|
"source": "https://github.com/facade/ignition-contracts/tree/1.0.2"
|
||||||
|
},
|
||||||
|
"time": "2020-10-16T08:27:54+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "fruitcake/php-cors",
|
"name": "fruitcake/php-cors",
|
||||||
"version": "v1.4.0",
|
"version": "v1.4.0",
|
||||||
@@ -3443,6 +3496,179 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-13T21:45:21+00:00"
|
"time": "2025-12-13T21:45:21+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "stancl/jobpipeline",
|
||||||
|
"version": "v1.8.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/archtechx/jobpipeline.git",
|
||||||
|
"reference": "c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/archtechx/jobpipeline/zipball/c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c",
|
||||||
|
"reference": "c4ba5ef04c99176eb000abb05fc81fb0f44f5d9c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ext-redis": "*",
|
||||||
|
"orchestra/testbench": "^8.0|^9.0|^10.0",
|
||||||
|
"spatie/valuestore": "^1.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Stancl\\JobPipeline\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Samuel Štancl",
|
||||||
|
"email": "samuel.stancl@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Turn any series of jobs into Laravel listeners.",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/archtechx/jobpipeline/issues",
|
||||||
|
"source": "https://github.com/archtechx/jobpipeline/tree/v1.8.1"
|
||||||
|
},
|
||||||
|
"time": "2025-07-29T20:21:17+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stancl/tenancy",
|
||||||
|
"version": "v3.9.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/archtechx/tenancy.git",
|
||||||
|
"reference": "d98a170fbd2e114604bfec3bc6267a3d6e02dec1"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/archtechx/tenancy/zipball/d98a170fbd2e114604bfec3bc6267a3d6e02dec1",
|
||||||
|
"reference": "d98a170fbd2e114604bfec3bc6267a3d6e02dec1",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"facade/ignition-contracts": "^1.0.2",
|
||||||
|
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.0",
|
||||||
|
"ramsey/uuid": "^4.7.3",
|
||||||
|
"stancl/jobpipeline": "^1.8.0",
|
||||||
|
"stancl/virtualcolumn": "^1.5.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/dbal": "^3.6.0",
|
||||||
|
"laravel/framework": "^10.0|^11.0|^12.0",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.12.2",
|
||||||
|
"orchestra/testbench": "^8.0|^9.0|^10.0",
|
||||||
|
"spatie/valuestore": "^1.3.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Tenancy": "Stancl\\Tenancy\\Facades\\Tenancy",
|
||||||
|
"GlobalCache": "Stancl\\Tenancy\\Facades\\GlobalCache"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Stancl\\Tenancy\\TenancyServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Stancl\\Tenancy\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Samuel Štancl",
|
||||||
|
"email": "samuel.stancl@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Automatic multi-tenancy for your Laravel application.",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"multi-database",
|
||||||
|
"multi-tenancy",
|
||||||
|
"tenancy"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/archtechx/tenancy/issues",
|
||||||
|
"source": "https://github.com/archtechx/tenancy/tree/v3.9.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://tenancyforlaravel.com/donate",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/stancl",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-03-13T16:02:11+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "stancl/virtualcolumn",
|
||||||
|
"version": "v1.5.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/archtechx/virtualcolumn.git",
|
||||||
|
"reference": "75718edcfeeb19abc1970f5395043f7d43cce5bc"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/archtechx/virtualcolumn/zipball/75718edcfeeb19abc1970f5395043f7d43cce5bc",
|
||||||
|
"reference": "75718edcfeeb19abc1970f5395043f7d43cce5bc",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/database": ">=10.0",
|
||||||
|
"illuminate/support": ">=10.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"orchestra/testbench": ">=8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Stancl\\VirtualColumn\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Samuel Štancl",
|
||||||
|
"email": "samuel.stancl@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Eloquent virtual column.",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/archtechx/virtualcolumn/issues",
|
||||||
|
"source": "https://github.com/archtechx/virtualcolumn/tree/v1.5.0"
|
||||||
|
},
|
||||||
|
"time": "2025-02-25T13:12:44+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/clock",
|
"name": "symfony/clock",
|
||||||
"version": "v8.0.0",
|
"version": "v8.0.0",
|
||||||
|
|||||||
199
config/tenancy.php
Normal file
199
config/tenancy.php
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Stancl\Tenancy\Database\Models\Domain;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenant_model' => Tenant::class,
|
||||||
|
'id_generator' => Stancl\Tenancy\UUIDGenerator::class,
|
||||||
|
|
||||||
|
'domain_model' => Domain::class,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of domains hosting your central app.
|
||||||
|
*
|
||||||
|
* Only relevant if you're using the domain or subdomain identification middleware.
|
||||||
|
*/
|
||||||
|
'central_domains' => [
|
||||||
|
'127.0.0.1',
|
||||||
|
'localhost',
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenancy bootstrappers are executed when tenancy is initialized.
|
||||||
|
* Their responsibility is making Laravel features tenant-aware.
|
||||||
|
*
|
||||||
|
* To configure their behavior, see the config keys below.
|
||||||
|
*/
|
||||||
|
'bootstrappers' => [
|
||||||
|
Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class,
|
||||||
|
Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
|
||||||
|
Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
|
||||||
|
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
|
||||||
|
// Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database tenancy config. Used by DatabaseTenancyBootstrapper.
|
||||||
|
*/
|
||||||
|
'database' => [
|
||||||
|
'central_connection' => env('DB_CONNECTION', 'central'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection used as a "template" for the dynamically created tenant database connection.
|
||||||
|
* Note: don't name your template connection tenant. That name is reserved by package.
|
||||||
|
*/
|
||||||
|
'template_tenant_connection' => null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant database names are created like this:
|
||||||
|
* prefix + tenant_id + suffix.
|
||||||
|
*/
|
||||||
|
'prefix' => 'tenant',
|
||||||
|
'suffix' => '',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TenantDatabaseManagers are classes that handle the creation & deletion of tenant databases.
|
||||||
|
*/
|
||||||
|
'managers' => [
|
||||||
|
'sqlite' => Stancl\Tenancy\TenantDatabaseManagers\SQLiteDatabaseManager::class,
|
||||||
|
'mysql' => Stancl\Tenancy\TenantDatabaseManagers\MySQLDatabaseManager::class,
|
||||||
|
'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLDatabaseManager::class,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use this database manager for MySQL to have a DB user created for each tenant database.
|
||||||
|
* You can customize the grants given to these users by changing the $grants property.
|
||||||
|
*/
|
||||||
|
// 'mysql' => Stancl\Tenancy\TenantDatabaseManagers\PermissionControlledMySQLDatabaseManager::class,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable the pgsql manager above, and enable the one below if you
|
||||||
|
* want to separate tenant DBs by schemas rather than databases.
|
||||||
|
*/
|
||||||
|
// 'pgsql' => Stancl\Tenancy\TenantDatabaseManagers\PostgreSQLSchemaManager::class, // Separate by schema instead of database
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache tenancy config. Used by CacheTenancyBootstrapper.
|
||||||
|
*
|
||||||
|
* This works for all Cache facade calls, cache() helper
|
||||||
|
* calls and direct calls to injected cache stores.
|
||||||
|
*
|
||||||
|
* Each key in cache will have a tag applied on it. This tag is used to
|
||||||
|
* scope the cache both when writing to it and when reading from it.
|
||||||
|
*
|
||||||
|
* You can clear cache selectively by specifying the tag.
|
||||||
|
*/
|
||||||
|
'cache' => [
|
||||||
|
'tag_base' => 'tenant', // This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call.
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem tenancy config. Used by FilesystemTenancyBootstrapper.
|
||||||
|
* https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper.
|
||||||
|
*/
|
||||||
|
'filesystem' => [
|
||||||
|
/**
|
||||||
|
* Each disk listed in the 'disks' array will be suffixed by the suffix_base, followed by the tenant_id.
|
||||||
|
*/
|
||||||
|
'suffix_base' => 'tenant',
|
||||||
|
'disks' => [
|
||||||
|
'local',
|
||||||
|
'public',
|
||||||
|
// 's3',
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use this for local disks.
|
||||||
|
*
|
||||||
|
* See https://tenancyforlaravel.com/docs/v3/tenancy-bootstrappers/#filesystem-tenancy-boostrapper
|
||||||
|
*/
|
||||||
|
'root_override' => [
|
||||||
|
// Disks whose roots should be overridden after storage_path() is suffixed.
|
||||||
|
'local' => '%storage_path%/app/',
|
||||||
|
'public' => '%storage_path%/app/public/',
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should storage_path() be suffixed.
|
||||||
|
*
|
||||||
|
* Note: Disabling this will likely break local disk tenancy. Only disable this if you're using an external file storage service like S3.
|
||||||
|
*
|
||||||
|
* For the vast majority of applications, this feature should be enabled. But in some
|
||||||
|
* edge cases, it can cause issues (like using Passport with Vapor - see #196), so
|
||||||
|
* you may want to disable this if you are experiencing these edge case issues.
|
||||||
|
*/
|
||||||
|
'suffix_storage_path' => true,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* By default, asset() calls are made multi-tenant too. You can use global_asset() and mix()
|
||||||
|
* for global, non-tenant-specific assets. However, you might have some issues when using
|
||||||
|
* packages that use asset() calls inside the tenant app. To avoid such issues, you can
|
||||||
|
* disable asset() helper tenancy and explicitly use tenant_asset() calls in places
|
||||||
|
* where you want to use tenant-specific assets (product images, avatars, etc).
|
||||||
|
*/
|
||||||
|
'asset_helper_tenancy' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis tenancy config. Used by RedisTenancyBootstrapper.
|
||||||
|
*
|
||||||
|
* Note: You need phpredis to use Redis tenancy.
|
||||||
|
*
|
||||||
|
* Note: You don't need to use this if you're using Redis only for cache.
|
||||||
|
* Redis tenancy is only relevant if you're making direct Redis calls,
|
||||||
|
* either using the Redis facade or by injecting it as a dependency.
|
||||||
|
*/
|
||||||
|
'redis' => [
|
||||||
|
'prefix_base' => 'tenant', // Each key in Redis will be prepended by this prefix_base, followed by the tenant id.
|
||||||
|
'prefixed_connections' => [ // Redis connections whose keys are prefixed, to separate one tenant's keys from another.
|
||||||
|
// 'default',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Features are classes that provide additional functionality
|
||||||
|
* not needed for tenancy to be bootstrapped. They are run
|
||||||
|
* regardless of whether tenancy has been initialized.
|
||||||
|
*
|
||||||
|
* See the documentation page for each class to
|
||||||
|
* understand which ones you want to enable.
|
||||||
|
*/
|
||||||
|
'features' => [
|
||||||
|
// Stancl\Tenancy\Features\UserImpersonation::class,
|
||||||
|
// Stancl\Tenancy\Features\TelescopeTags::class,
|
||||||
|
// Stancl\Tenancy\Features\UniversalRoutes::class,
|
||||||
|
// Stancl\Tenancy\Features\TenantConfig::class, // https://tenancyforlaravel.com/docs/v3/features/tenant-config
|
||||||
|
// Stancl\Tenancy\Features\CrossDomainRedirect::class, // https://tenancyforlaravel.com/docs/v3/features/cross-domain-redirect
|
||||||
|
// Stancl\Tenancy\Features\ViteBundler::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should tenancy routes be registered.
|
||||||
|
*
|
||||||
|
* Tenancy routes include tenant asset routes. By default, this route is
|
||||||
|
* enabled. But it may be useful to disable them if you use external
|
||||||
|
* storage (e.g. S3 / Dropbox) or have a custom asset controller.
|
||||||
|
*/
|
||||||
|
'routes' => true,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters used by the tenants:migrate command.
|
||||||
|
*/
|
||||||
|
'migration_parameters' => [
|
||||||
|
'--force' => true, // This needs to be true to run migrations in production.
|
||||||
|
'--path' => [database_path('migrations/tenant')],
|
||||||
|
'--realpath' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters used by the tenants:seed command.
|
||||||
|
*/
|
||||||
|
'seeder_parameters' => [
|
||||||
|
'--class' => 'DatabaseSeeder', // root seeder class
|
||||||
|
// '--force' => true, // This needs to be true to seed tenant databases in production
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class CreateTenantsTable extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('tenants', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
|
||||||
|
// your custom columns may go here
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
$table->json('data')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('tenants');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class CreateDomainsTable extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('domains', function (Blueprint $table) {
|
||||||
|
$table->increments('id');
|
||||||
|
$table->string('domain', 255)->unique();
|
||||||
|
$table->string('tenant_id');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('domains');
|
||||||
|
}
|
||||||
|
}
|
||||||
71
docs/multi-tenancy-deployment.md
Normal file
71
docs/multi-tenancy-deployment.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Multi-tenancy 部署手冊
|
||||||
|
|
||||||
|
> 記錄本地開發完成後,上 Demo/Production 環境時需要手動執行的操作。
|
||||||
|
> CI/CD 會自動執行的項目已排除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: 安裝 stancl/tenancy
|
||||||
|
**CI/CD 會自動執行**:`composer install`
|
||||||
|
**手動操作**:無
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: 設定 Central Domain + Tenant 識別
|
||||||
|
**手動操作**:
|
||||||
|
1. 修改 `.env`,加入:
|
||||||
|
```bash
|
||||||
|
# Demo 環境 (192.168.0.103)
|
||||||
|
CENTRAL_DOMAINS=192.168.0.103,localhost
|
||||||
|
|
||||||
|
# Production 環境 (erp.koori.tw)
|
||||||
|
CENTRAL_DOMAINS=erp.koori.tw
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: 分離 Migrations
|
||||||
|
**CI/CD 會自動執行**:`php artisan migrate --force`
|
||||||
|
**手動操作**:無
|
||||||
|
|
||||||
|
> 注意:migrations 結構已調整如下:
|
||||||
|
> - `database/migrations/` - Central tables (tenants, domains)
|
||||||
|
> - `database/migrations/tenant/` - Tenant tables (所有業務表)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: 遷移現有資料到 tenant_koori
|
||||||
|
**首次部署手動操作**:
|
||||||
|
1. 授予 MySQL sail 使用者 CREATE DATABASE 權限:
|
||||||
|
```bash
|
||||||
|
docker exec koori-erp-mysql mysql -uroot -p[PASSWORD] -e "GRANT ALL PRIVILEGES ON *.* TO 'sail'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 建立第一個租戶 (小小冰室):
|
||||||
|
```bash
|
||||||
|
docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute="
|
||||||
|
use App\Models\Tenant;
|
||||||
|
Tenant::create(['id' => 'koori', 'name' => '小小冰室']);
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 為租戶綁定域名:
|
||||||
|
```bash
|
||||||
|
docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute="
|
||||||
|
use App\Models\Tenant;
|
||||||
|
Tenant::find('koori')->domains()->create(['domain' => 'koori.your-domain.com']);
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 執行資料遷移 (從 central DB 複製到 tenant DB):
|
||||||
|
```bash
|
||||||
|
docker exec -w /var/www/html koori-erp-laravel php artisan tenancy:migrate-data koori
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: 建立房東後台
|
||||||
|
**手動操作**:無
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 其他注意事項
|
||||||
|
- 待補充...
|
||||||
132
resources/js/Layouts/LandlordLayout.tsx
Normal file
132
resources/js/Layouts/LandlordLayout.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Link, usePage } from "@inertiajs/react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/Components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
interface LandlordLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LandlordLayout({ children, title }: LandlordLayoutProps) {
|
||||||
|
const { url, props } = usePage();
|
||||||
|
// @ts-ignore
|
||||||
|
const user = props.auth?.user || { name: 'Admin', username: 'admin' };
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
label: "儀表板",
|
||||||
|
href: "/landlord",
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
active: url === "/landlord" || url === "/landlord/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "租戶管理",
|
||||||
|
href: "/landlord/tenants",
|
||||||
|
icon: Building2,
|
||||||
|
active: url.startsWith("/landlord/tenants"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-slate-50">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="fixed left-0 top-0 bottom-0 w-64 bg-slate-900 text-white flex flex-col">
|
||||||
|
<div className="h-16 flex items-center px-6 border-b border-slate-800">
|
||||||
|
<Link href="/landlord" className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-primary-main rounded-lg flex items-center justify-center">
|
||||||
|
<Building2 className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-lg">房東後台</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors",
|
||||||
|
item.active
|
||||||
|
? "bg-primary-main text-white"
|
||||||
|
: "text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-slate-800">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors text-sm"
|
||||||
|
>
|
||||||
|
← 返回 ERP 系統
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 ml-64">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6">
|
||||||
|
<h1 className="text-lg font-semibold text-slate-900">
|
||||||
|
{title || "房東管理後台"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger className="flex items-center gap-2 outline-none group">
|
||||||
|
<div className="flex flex-col items-end mr-1">
|
||||||
|
<span className="text-sm font-medium text-slate-700">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">系統管理員</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-9 w-9 bg-slate-100 rounded-full flex items-center justify-center">
|
||||||
|
<User className="h-5 w-5 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56 z-[100]">
|
||||||
|
<DropdownMenuLabel>我的帳號</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href={route('logout')}
|
||||||
|
method="post"
|
||||||
|
as="button"
|
||||||
|
className="w-full flex items-center cursor-pointer text-red-600"
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
<span>登出系統</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Toaster richColors closeButton position="top-center" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
resources/js/Pages/Landlord/Dashboard.tsx
Normal file
106
resources/js/Pages/Landlord/Dashboard.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import LandlordLayout from "@/Layouts/LandlordLayout";
|
||||||
|
import { Building2, Users, Activity } from "lucide-react";
|
||||||
|
import { Link } from "@inertiajs/react";
|
||||||
|
|
||||||
|
interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
domains: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardProps {
|
||||||
|
totalTenants: number;
|
||||||
|
activeTenants: number;
|
||||||
|
recentTenants: Tenant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard({ totalTenants, activeTenants, recentTenants }: DashboardProps) {
|
||||||
|
const statsCards = [
|
||||||
|
{
|
||||||
|
title: "租戶總數",
|
||||||
|
value: totalTenants,
|
||||||
|
icon: Building2,
|
||||||
|
color: "bg-blue-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "啟用中",
|
||||||
|
value: activeTenants,
|
||||||
|
icon: Activity,
|
||||||
|
color: "bg-green-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "停用中",
|
||||||
|
value: totalTenants - activeTenants,
|
||||||
|
icon: Users,
|
||||||
|
color: "bg-slate-400",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LandlordLayout title="儀表板">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{statsCards.map((stat) => (
|
||||||
|
<div
|
||||||
|
key={stat.title}
|
||||||
|
className="bg-white rounded-xl border border-slate-200 p-6 flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className={`w-12 h-12 ${stat.color} rounded-lg flex items-center justify-center`}>
|
||||||
|
<stat.icon className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">{stat.title}</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Tenants */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">最近新增的租戶</h2>
|
||||||
|
<Link
|
||||||
|
href="/landlord/tenants"
|
||||||
|
className="text-sm text-primary-main hover:underline"
|
||||||
|
>
|
||||||
|
查看全部 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{recentTenants.length === 0 ? (
|
||||||
|
<div className="px-6 py-8 text-center text-slate-500">
|
||||||
|
尚無租戶資料
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
recentTenants.map((tenant) => (
|
||||||
|
<div key={tenant.id} className="px-6 py-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">{tenant.name}</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{tenant.domains.length > 0 ? tenant.domains.join(", ") : "無綁定域名"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-medium ${tenant.is_active
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-slate-100 text-slate-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tenant.is_active ? "啟用" : "停用"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-slate-400">{tenant.created_at}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</LandlordLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
resources/js/Pages/Landlord/Tenant/Create.tsx
Normal file
104
resources/js/Pages/Landlord/Tenant/Create.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import LandlordLayout from "@/Layouts/LandlordLayout";
|
||||||
|
import { useForm, Link } from "@inertiajs/react";
|
||||||
|
import { FormEvent } from "react";
|
||||||
|
|
||||||
|
export default function TenantCreate() {
|
||||||
|
const { data, setData, post, processing, errors } = useForm({
|
||||||
|
id: "",
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
domain: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
post(route("landlord.tenants.store"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LandlordLayout title="新增租戶">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">新增租戶</h1>
|
||||||
|
<p className="text-slate-500 mt-1">建立一個新的租戶帳號</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-slate-200 p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
租戶 ID <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.id}
|
||||||
|
onChange={(e) => setData("id", e.target.value.toLowerCase())}
|
||||||
|
placeholder="例如:koori, alcohol"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">只能使用英文、數字、底線</p>
|
||||||
|
{errors.id && <p className="mt-1 text-sm text-red-500">{errors.id}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
租戶名稱 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.name}
|
||||||
|
onChange={(e) => setData("name", e.target.value)}
|
||||||
|
placeholder="例如:小小冰室"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
聯絡信箱
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={data.email}
|
||||||
|
onChange={(e) => setData("email", e.target.value)}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
|
||||||
|
/>
|
||||||
|
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
綁定域名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.domain}
|
||||||
|
onChange={(e) => setData("domain", e.target.value)}
|
||||||
|
placeholder="例如:koori.erp.koori.tw"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">可之後再設定</p>
|
||||||
|
{errors.domain && <p className="mt-1 text-sm text-red-500">{errors.domain}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing}
|
||||||
|
className="bg-primary-main hover:bg-primary-dark text-white px-6 py-2 rounded-lg disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{processing ? "處理中..." : "建立租戶"}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/landlord/tenants"
|
||||||
|
className="text-slate-600 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</LandlordLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
resources/js/Pages/Landlord/Tenant/Edit.tsx
Normal file
107
resources/js/Pages/Landlord/Tenant/Edit.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import LandlordLayout from "@/Layouts/LandlordLayout";
|
||||||
|
import { useForm, Link } from "@inertiajs/react";
|
||||||
|
import { FormEvent } from "react";
|
||||||
|
|
||||||
|
interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tenant: Tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TenantEdit({ tenant }: Props) {
|
||||||
|
const { data, setData, put, processing, errors } = useForm({
|
||||||
|
name: tenant.name,
|
||||||
|
email: tenant.email || "",
|
||||||
|
is_active: tenant.is_active,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
put(route("landlord.tenants.update", tenant.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LandlordLayout title="編輯租戶">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">編輯租戶</h1>
|
||||||
|
<p className="text-slate-500 mt-1">修改租戶 {tenant.id} 的資訊</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-slate-200 p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
租戶 ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tenant.id}
|
||||||
|
disabled
|
||||||
|
className="w-full px-4 py-2 border border-slate-200 rounded-lg bg-slate-50 text-slate-500"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">租戶 ID 無法修改</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
租戶名稱 <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.name}
|
||||||
|
onChange={(e) => setData("name", e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="mt-1 text-sm text-red-500">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
|
聯絡信箱
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={data.email}
|
||||||
|
onChange={(e) => setData("email", e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
|
||||||
|
/>
|
||||||
|
{errors.email && <p className="mt-1 text-sm text-red-500">{errors.email}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={data.is_active}
|
||||||
|
onChange={(e) => setData("is_active", e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-slate-300 text-primary-main focus:ring-primary-main"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-slate-700">啟用此租戶</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing}
|
||||||
|
className="bg-primary-main hover:bg-primary-dark text-white px-6 py-2 rounded-lg disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{processing ? "儲存中..." : "儲存變更"}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/landlord/tenants"
|
||||||
|
className="text-slate-600 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</LandlordLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
resources/js/Pages/Landlord/Tenant/Index.tsx
Normal file
188
resources/js/Pages/Landlord/Tenant/Index.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import LandlordLayout from "@/Layouts/LandlordLayout";
|
||||||
|
import { Link, router } from "@inertiajs/react";
|
||||||
|
import { Plus, Edit, Trash2, Globe } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/Components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
domains: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tenants: Tenant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TenantIndex({ tenants }: Props) {
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Tenant | null>(null);
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (deleteTarget) {
|
||||||
|
router.delete(route("landlord.tenants.destroy", deleteTarget.id));
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LandlordLayout title="租戶管理">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">租戶管理</h1>
|
||||||
|
<p className="text-slate-500 mt-1">管理系統中的所有租戶</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/landlord/tenants/create"
|
||||||
|
className="bg-primary-main hover:bg-primary-dark text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
新增租戶
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
ID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
名稱
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
域名
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
狀態
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
建立時間
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-semibold text-slate-500 uppercase">
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{tenants.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-6 py-12 text-center text-slate-500">
|
||||||
|
尚無租戶資料,請點擊「新增租戶」建立第一個租戶
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
tenants.map((tenant) => (
|
||||||
|
<tr key={tenant.id} className="hover:bg-slate-50">
|
||||||
|
<td className="px-6 py-4 font-mono text-sm text-slate-600">
|
||||||
|
{tenant.id}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">{tenant.name}</p>
|
||||||
|
{tenant.email && (
|
||||||
|
<p className="text-sm text-slate-500">{tenant.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{tenant.domains.length > 0 ? (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{tenant.domains.map((domain) => (
|
||||||
|
<span
|
||||||
|
key={domain}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-slate-100 rounded text-xs"
|
||||||
|
>
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
{domain}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400 text-sm">無</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-medium ${tenant.is_active
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-slate-100 text-slate-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tenant.is_active ? "啟用" : "停用"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-500">
|
||||||
|
{tenant.created_at}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/landlord/tenants/${tenant.id}`}
|
||||||
|
className="p-2 text-slate-400 hover:text-primary-main hover:bg-slate-100 rounded-lg transition-colors"
|
||||||
|
title="查看詳情"
|
||||||
|
>
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/landlord/tenants/${tenant.id}/edit`}
|
||||||
|
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||||
|
title="編輯"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(tenant)}
|
||||||
|
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="刪除"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>確定要刪除這個租戶嗎?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
刪除租戶將會同時刪除其資料庫和所有資料,此操作無法復原。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
確定刪除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</LandlordLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
resources/js/Pages/Landlord/Tenant/Show.tsx
Normal file
165
resources/js/Pages/Landlord/Tenant/Show.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import LandlordLayout from "@/Layouts/LandlordLayout";
|
||||||
|
import { Link, useForm, router } from "@inertiajs/react";
|
||||||
|
import { Globe, Plus, Trash2, ArrowLeft } from "lucide-react";
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
interface Domain {
|
||||||
|
id: number;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
domains: Domain[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tenant: Tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TenantShow({ tenant }: Props) {
|
||||||
|
const [showAddDomain, setShowAddDomain] = useState(false);
|
||||||
|
const { data, setData, post, processing, errors, reset } = useForm({
|
||||||
|
domain: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddDomain = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
post(route("landlord.tenants.domains.store", tenant.id), {
|
||||||
|
onSuccess: () => {
|
||||||
|
reset();
|
||||||
|
setShowAddDomain(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveDomain = (domainId: number) => {
|
||||||
|
if (confirm("確定要移除這個域名嗎?")) {
|
||||||
|
router.delete(route("landlord.tenants.domains.destroy", [tenant.id, domainId]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LandlordLayout title="租戶詳情">
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
{/* Back Link */}
|
||||||
|
<Link
|
||||||
|
href="/landlord/tenants"
|
||||||
|
className="inline-flex items-center gap-1 text-slate-600 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
返回列表
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">{tenant.name}</h1>
|
||||||
|
<p className="text-slate-500 mt-1">租戶 ID: {tenant.id}</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/landlord/tenants/${tenant.id}/edit`}
|
||||||
|
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
編輯
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 mb-4">基本資訊</h2>
|
||||||
|
<dl className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-slate-500">狀態</dt>
|
||||||
|
<dd className="mt-1">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-medium ${tenant.is_active
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-slate-100 text-slate-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tenant.is_active ? "啟用" : "停用"}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-slate-500">聯絡信箱</dt>
|
||||||
|
<dd className="mt-1 text-slate-900">{tenant.email || "-"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-slate-500">建立時間</dt>
|
||||||
|
<dd className="mt-1 text-slate-900">{tenant.created_at}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-slate-500">更新時間</dt>
|
||||||
|
<dd className="mt-1 text-slate-900">{tenant.updated_at}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domains Card */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">綁定域名</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddDomain(!showAddDomain)}
|
||||||
|
className="text-primary-main hover:text-primary-dark flex items-center gap-1 text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
新增域名
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddDomain && (
|
||||||
|
<form onSubmit={handleAddDomain} className="mb-4 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.domain}
|
||||||
|
onChange={(e) => setData("domain", e.target.value)}
|
||||||
|
placeholder="例如:koori.erp.koori.tw"
|
||||||
|
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing}
|
||||||
|
className="bg-primary-main hover:bg-primary-dark text-white px-4 py-2 rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
新增
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{errors.domain && <p className="mb-4 text-sm text-red-500">{errors.domain}</p>}
|
||||||
|
|
||||||
|
{tenant.domains.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm">尚未綁定任何域名</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{tenant.domains.map((domain) => (
|
||||||
|
<li
|
||||||
|
key={domain.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-900">{domain.domain}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveDomain(domain.id)}
|
||||||
|
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</LandlordLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
routes/landlord.php
Normal file
27
routes/landlord.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Http\Controllers\Landlord\DashboardController;
|
||||||
|
use App\Http\Controllers\Landlord\TenantController;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Landlord Routes (房東後台路由)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| 這些路由用於中央管理後台,只能透過 central domain 存取。
|
||||||
|
| 用於管理租戶 (新增、編輯、停用) 等系統級操作。
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::prefix('landlord')->name('landlord.')->middleware(['web', 'auth'])->group(function () {
|
||||||
|
// 房東儀表板
|
||||||
|
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
|
||||||
|
// 租戶管理 CRUD
|
||||||
|
Route::resource('tenants', TenantController::class);
|
||||||
|
|
||||||
|
// 租戶域名管理
|
||||||
|
Route::post('tenants/{tenant}/domains', [TenantController::class, 'addDomain'])->name('tenants.domains.store');
|
||||||
|
Route::delete('tenants/{tenant}/domains/{domain}', [TenantController::class, 'removeDomain'])->name('tenants.domains.destroy');
|
||||||
|
});
|
||||||
26
routes/tenant.php
Normal file
26
routes/tenant.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
|
||||||
|
use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Tenant Routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| 租戶專屬路由。當使用者透過租戶網域 (如 koori.koori-erp.test) 存取時,
|
||||||
|
| 會自動初始化租戶 context 並連接到對應的租戶資料庫。
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
Route::middleware([
|
||||||
|
'web',
|
||||||
|
InitializeTenancyByDomain::class,
|
||||||
|
PreventAccessFromCentralDomains::class,
|
||||||
|
])->group(function () {
|
||||||
|
// 載入與 central 相同的 ERP 路由,但運行在租戶資料庫 context 中
|
||||||
|
require base_path('routes/web.php');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user