feat: 實作 Multi-tenancy 多租戶架構 (stancl/tenancy)
- 安裝並設定 stancl/tenancy 套件 - 分離 Central / Tenant migrations - 建立 Tenant Model 與資料遷移指令 - 建立房東後台 CRUD (Landlord Dashboard) - 新增租戶管理頁面 (列表、新增、編輯、詳情) - 新增域名管理功能 - 更新部署手冊
This commit is contained in:
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\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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user