feat: 實作 Multi-tenancy 多租戶架構 (stancl/tenancy)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m3s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

- 安裝並設定 stancl/tenancy 套件
- 分離 Central / Tenant migrations
- 建立 Tenant Model 與資料遷移指令
- 建立房東後台 CRUD (Landlord Dashboard)
- 新增租戶管理頁面 (列表、新增、編輯、詳情)
- 新增域名管理功能
- 更新部署手冊
This commit is contained in:
2026-01-15 13:15:18 +08:00
parent 3e3d8ffb6c
commit 4f745c1021
51 changed files with 1954 additions and 1 deletions

View 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;
}
}

View 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);
}
}

View 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
View 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',
];
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Route;
class AppServiceProvider extends ServiceProvider
{
@@ -26,5 +27,13 @@ class AppServiceProvider extends ServiceProvider
\Illuminate\Support\Facades\Gate::before(function ($user, $ability) {
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'));
}
});
}
}

View 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);
}
}
}