feat: 租戶建立自動產生預設網域與管理員帳號
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m0s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

- 修改 TenantController 自動產生預設網域 ({tenant_id}.{TENANT_DEFAULT_DOMAIN})
- 新增 TenantDatabaseSeeder 自動建立 admin 帳號
- 啟用 SeedDatabase Job 在建立租戶時自動執行 seeder
- 新增 TENANT_DEFAULT_DOMAIN 環境變數支援不同環境
- 補充中央資料庫所需的 migrations
This commit is contained in:
2026-01-15 16:55:24 +08:00
parent 287ac6faa3
commit 9bc7c8514b
18 changed files with 452 additions and 25 deletions

View File

@@ -1,5 +1,5 @@
APP_NAME=KooriERP APP_NAME=StarERP
COMPOSE_PROJECT_NAME=koori-erp COMPOSE_PROJECT_NAME=star-erp
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
@@ -7,6 +7,7 @@ APP_URL=http://localhost
# Multi-tenancy 設定 (用逗號分隔多個中央網域) # Multi-tenancy 設定 (用逗號分隔多個中央網域)
CENTRAL_DOMAINS=localhost,127.0.0.1 CENTRAL_DOMAINS=localhost,127.0.0.1
TENANT_DEFAULT_DOMAIN=star-erp.test
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -27,7 +28,7 @@ LOG_LEVEL=debug
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=mysql DB_HOST=mysql
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=koori_erp DB_DATABASE=star_erp
DB_USERNAME=sail DB_USERNAME=sail
DB_PASSWORD=password DB_PASSWORD=password
FORWARD_DB_PORT=3307 FORWARD_DB_PORT=3307

View File

@@ -1,4 +1,4 @@
# Koori ERP # Star ERP
本專案是一個基於 Laravel 12, Inertia.js (React) 與 Tailwind CSS 開發的 ERP 系統。 本專案是一個基於 Laravel 12, Inertia.js (React) 與 Tailwind CSS 開發的 ERP 系統。
@@ -36,25 +36,25 @@ cp .env.example .env
# 背景執行容器 # 背景執行容器
docker compose up -d --build docker compose up -d --build
docker exec -it koori-erp-laravel.test-1 composer install docker exec -it star-erp-laravel composer install
# 生成 App Key # 生成 App Key
docker exec -it koori-erp-laravel.test-1 php artisan key:generate docker exec -it star-erp-laravel php artisan key:generate
``` ```
### 3. 資料庫遷移與初始化 ### 3. 資料庫遷移與初始化
```bash ```bash
# (選填) 如果有種子資料 # (選填) 如果有種子資料
docker exec -it koori-erp-laravel.test-1 php artisan migrate --seed docker exec -it star-erp-laravel php artisan migrate --seed
``` ```
### 4. 啟動前端開發伺服器 (Vite) ### 4. 啟動前端開發伺服器 (Vite)
```bash ```bash
docker exec -it koori-erp-laravel npm install docker exec -it star-erp-laravel npm install
docker exec -it koori-erp-laravel npm run dev docker exec -it star-erp-laravel npm run dev
``` ```
啟動後,您可以透過以下連結瀏覽專案: 啟動後,您可以透過以下連結瀏覽專案:

View File

@@ -58,10 +58,12 @@ class TenantController extends Controller
'is_active' => true, 'is_active' => true,
]); ]);
// 如果有指定域名,則綁定 // 綁定網域(如果沒有輸入,使用預設網域)
if (!empty($validated['domain'])) { $defaultDomain = env('TENANT_DEFAULT_DOMAIN', 'star-erp.test');
$tenant->domains()->create(['domain' => $validated['domain']]); $domain = !empty($validated['domain'])
} ? $validated['domain']
: $validated['id'] . '.' . $defaultDomain;
$tenant->domains()->create(['domain' => $domain]);
return redirect()->route('landlord.tenants.index') return redirect()->route('landlord.tenants.index')
->with('success', "租戶 {$validated['name']} 建立成功!"); ->with('success', "租戶 {$validated['name']} 建立成功!");

View File

@@ -27,7 +27,7 @@ class TenancyServiceProvider extends ServiceProvider
JobPipeline::make([ JobPipeline::make([
Jobs\CreateDatabase::class, Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class, Jobs\MigrateDatabase::class,
// Jobs\SeedDatabase::class, Jobs\SeedDatabase::class,
// Your own jobs to prepare the tenant. // Your own jobs to prepare the tenant.
// Provision API keys, create S3 buckets, anything you want! // Provision API keys, create S3 buckets, anything you want!

View File

@@ -6,8 +6,8 @@ services:
args: args:
WWWGROUP: '${WWWGROUP}' WWWGROUP: '${WWWGROUP}'
image: 'sail-8.5/app' image: 'sail-8.5/app'
container_name: koori-erp-laravel container_name: star-erp-laravel
hostname: koori-erp-laravel hostname: star-erp-laravel
extra_hosts: extra_hosts:
- 'host.docker.internal:host-gateway' - 'host.docker.internal:host-gateway'
ports: ports:
@@ -29,8 +29,8 @@ services:
# - mailpit # - mailpit
mysql: mysql:
image: 'mysql/mysql-server:8.0' image: 'mysql/mysql-server:8.0'
container_name: koori-erp-mysql container_name: star-erp-mysql
hostname: koori-erp-mysql hostname: star-erp-mysql
ports: ports:
- '${FORWARD_DB_PORT:-3306}:3306' - '${FORWARD_DB_PORT:-3306}:3306'
environment: environment:
@@ -56,8 +56,8 @@ services:
timeout: 5s timeout: 5s
redis: redis:
image: 'redis:alpine' image: 'redis:alpine'
container_name: koori-erp-redis container_name: star-erp-redis
hostname: koori-erp-redis hostname: star-erp-redis
# ports: # ports:
# - '${FORWARD_REDIS_PORT:-6379}:6379' # - '${FORWARD_REDIS_PORT:-6379}:6379'
volumes: volumes:

View File

@@ -192,7 +192,7 @@ return [
* Parameters used by the tenants:seed command. * Parameters used by the tenants:seed command.
*/ */
'seeder_parameters' => [ 'seeder_parameters' => [
'--class' => 'DatabaseSeeder', // root seeder class '--class' => 'TenantDatabaseSeeder', // 租戶專用 seeder
// '--force' => true, // This needs to be true to seed tenant databases in production // '--force' => true, // This needs to be true to seed tenant databases in production
], ],
]; ];

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('username')->unique()->after('name');
$table->string('email')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('username');
$table->string('email')->nullable(false)->change();
});
}
};

View File

@@ -0,0 +1,134 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), Exception::class, 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
// $table->engine('InnoDB');
$table->bigIncrements('id'); // permission id
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
// $table->engine('InnoDB');
$table->bigIncrements('id'); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
Schema::drop($tableNames['role_has_permissions']);
Schema::drop($tableNames['model_has_roles']);
Schema::drop($tableNames['model_has_permissions']);
Schema::drop($tableNames['roles']);
Schema::drop($tableNames['permissions']);
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->string('display_name')->nullable()->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('display_name');
});
}
};

View File

@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$roles = [
'super-admin' => '系統管理員',
'admin' => '一般管理員',
'warehouse-manager' => '倉庫管理員',
'purchaser' => '採購人員',
'viewer' => '檢視人員',
];
foreach ($roles as $name => $displayName) {
DB::table('roles')
->where('name', $name)
->update(['display_name' => $displayName]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$roles = [
'super-admin',
'admin',
'warehouse-manager',
'purchaser',
'viewer',
];
DB::table('roles')
->whereIn('name', $roles)
->update(['display_name' => null]);
}
};

View File

@@ -0,0 +1,42 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\User;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
/**
* 租戶資料庫專用 Seeder
*
* 建立新租戶時會自動執行此 Seeder負責
* 1. 建立預設的超級管理員帳號
* 2. 設定權限與角色
*/
class TenantDatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
// 建立預設管理員帳號
$admin = User::firstOrCreate(
['username' => 'admin'],
[
'name' => '系統管理員',
'email' => 'admin@example.com',
'password' => 'password',
]
);
// 呼叫權限 Seeder 設定權限與角色
$this->call(PermissionSeeder::class);
// 確保 admin 擁有 super-admin 角色
if (!$admin->hasRole('super-admin')) {
$admin->assignRole('super-admin');
}
}
}

3
package-lock.json generated
View File

@@ -1,9 +1,10 @@
{ {
"name": "html", "name": "star-erp",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "star-erp",
"dependencies": { "dependencies": {
"@inertiajs/react": "^2.3.4", "@inertiajs/react": "^2.3.4",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",

View File

@@ -1,5 +1,6 @@
{ {
"$schema": "https://www.schemastore.org/package.json", "$schema": "https://www.schemastore.org/package.json",
"name": "star-erp",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -36,7 +36,7 @@ export default function LandlordLogin() {
<div className="w-full max-w-md p-8 relative z-10"> <div className="w-full max-w-md p-8 relative z-10">
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
{/* 使用不同風格的 Logo 或純文字 */} {/* 使用不同風格的 Logo 或純文字 */}
<div className="text-white text-3xl font-bold tracking-wider mb-2">Koori ERP</div> <div className="text-white text-3xl font-bold tracking-wider mb-2">Star ERP</div>
<div className="text-slate-400 text-sm tracking-widest uppercase">Central Administration</div> <div className="text-slate-400 text-sm tracking-widest uppercase">Central Administration</div>
</div> </div>

View File

@@ -7,7 +7,7 @@ export default function Welcome() {
<Head title="Welcome" /> <Head title="Welcome" />
<div className="p-8 bg-white rounded-lg shadow-lg"> <div className="p-8 bg-white rounded-lg shadow-lg">
<h1 className="text-4xl font-bold text-blue-600"> <h1 className="text-4xl font-bold text-blue-600">
Koori ERP Star ERP
</h1> </h1>
<p className="mt-4 text-gray-600"> <p className="mt-4 text-gray-600">
React + Inertia + Laravel Integration Successful! React + Inertia + Laravel Integration Successful!