From 8b8fb28c36c7a27e34bde3dcb60eada45025913e Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 18 Jun 2026 10:30:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(M02-A):=20=E5=BB=BA=E7=AB=8B=E5=A4=9A?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=A7=9F=E6=88=B7=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/app.ts | 8 ++ backend/src/db/migration-runner.ts | 9 +- backend/src/routes/platform-bootstrap.ts | 55 +++++++++ backend/src/server.ts | 11 +- .../src/tenancy/platform-config-repository.ts | 107 ++++++++++++++++++ backend/tests/migration-contract.test.mjs | 15 ++- backend/tests/migration-runner.test.mjs | 3 +- .../tests/mysql-migration-roundtrip.test.mjs | 66 ++++++++++- .../tests/platform-config-repository.test.mjs | 88 ++++++++++++++ .../2026061803_m02a_tenant_apps.down.sql | 6 + .../2026061803_m02a_tenant_apps.up.sql | 60 ++++++++++ .../2026061803_m02a_tenant_apps.verify.sql | 34 ++++++ scripts/dev/windows/check-backend.ps1 | 6 + scripts/dev/wsl/mysql-migration-roundtrip.sh | 4 +- 15 files changed, 459 insertions(+), 15 deletions(-) create mode 100644 backend/src/routes/platform-bootstrap.ts create mode 100644 backend/src/tenancy/platform-config-repository.ts create mode 100644 backend/tests/platform-config-repository.test.mjs create mode 100644 database/migrations/2026061803_m02a_tenant_apps.down.sql create mode 100644 database/migrations/2026061803_m02a_tenant_apps.up.sql create mode 100644 database/migrations/2026061803_m02a_tenant_apps.verify.sql diff --git a/backend/package.json b/backend/package.json index c990e23..b8e212d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", - "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs" + "test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 008b49b..ab79270 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,9 +4,14 @@ import rateLimit from '@fastify/rate-limit'; import Fastify, { type FastifyInstance } from 'fastify'; import { loadConfig, type AppConfig } from './config.js'; import { registerHealthRoutes } from './routes/health.js'; +import { + registerPlatformBootstrapRoutes, + type PlatformConfigResolver +} from './routes/platform-bootstrap.js'; export interface BuildAppOptions { config?: AppConfig; + platformConfigRepository?: PlatformConfigResolver; } declare module 'fastify' { @@ -49,6 +54,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise = { up: [ 'database/migrations/2026061601_m01b_core_schema.up.sql', - 'database/migrations/2026061802_m01c_async_tasks.up.sql' + 'database/migrations/2026061802_m01c_async_tasks.up.sql', + 'database/migrations/2026061803_m02a_tenant_apps.up.sql' ], verify: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', - 'database/migrations/2026061802_m01c_async_tasks.verify.sql' + 'database/migrations/2026061802_m01c_async_tasks.verify.sql', + 'database/migrations/2026061803_m02a_tenant_apps.verify.sql' ], down: [ + 'database/migrations/2026061803_m02a_tenant_apps.down.sql', 'database/migrations/2026061802_m01c_async_tasks.down.sql', 'database/migrations/2026061601_m01b_core_schema.down.sql' ] @@ -146,7 +149,7 @@ export async function executeMigrationPlan( for (const [index, statement] of plan.statements.entries()) { const [result] = await pool.query(statement); if (plan.direction === 'verify') { - const minimumRows = [10, 26, 1, 2, 5, 1][index] ?? 1; + const minimumRows = [10, 26, 1, 2, 5, 1, 3, 5, 1][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( `Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.` diff --git a/backend/src/routes/platform-bootstrap.ts b/backend/src/routes/platform-bootstrap.ts new file mode 100644 index 0000000..c2c3e21 --- /dev/null +++ b/backend/src/routes/platform-bootstrap.ts @@ -0,0 +1,55 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { + AmbiguousAppTenantError, + type PlatformBootstrap +} from '../tenancy/platform-config-repository.js'; + +const headerSchema = z.object({ + 'x-wechat-appid': z.string().trim().min(6).max(64), + 'tenant-id': z.string().regex(/^[1-9]\d{0,19}$/).optional() +}); + +export interface PlatformConfigResolver { + resolveBootstrap(appId: string, tenantId?: string): Promise; +} + +export async function registerPlatformBootstrapRoutes( + app: FastifyInstance, + repository: PlatformConfigResolver +): Promise { + app.get('/app-api/bootstrap', async (request, reply) => { + const parsed = headerSchema.safeParse(request.headers); + if (!parsed.success) { + return reply.status(400).send({ + code: 'INVALID_APP_CONTEXT', + message: 'x-wechat-appid is required and tenant-id must be a positive integer.', + traceId: request.traceId + }); + } + + try { + const bootstrap = await repository.resolveBootstrap( + parsed.data['x-wechat-appid'], + parsed.data['tenant-id'] + ); + if (!bootstrap) { + return reply.status(404).send({ + code: 'APP_TENANT_NOT_FOUND', + message: 'The application and tenant binding is not active.', + traceId: request.traceId + }); + } + return { code: 0, data: bootstrap, traceId: request.traceId }; + } catch (error) { + if (error instanceof AmbiguousAppTenantError) { + return reply.status(409).send({ + code: 'TENANT_SELECTION_REQUIRED', + message: error.message, + traceId: request.traceId + }); + } + throw error; + } + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index db92584..f04aea7 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,8 +1,17 @@ import { buildApp } from './app.js'; import { loadConfig } from './config.js'; +import { closeMySqlPool, createMySqlPool } from './db/mysql.js'; +import { PlatformConfigRepository } from './tenancy/platform-config-repository.js'; const config = loadConfig(); -const app = await buildApp({ config }); +const pool = createMySqlPool(config); +const app = await buildApp({ + config, + platformConfigRepository: new PlatformConfigRepository(pool) +}); +app.addHook('onClose', async () => { + await closeMySqlPool(pool); +}); try { await app.listen({ host: config.host, port: config.port }); diff --git a/backend/src/tenancy/platform-config-repository.ts b/backend/src/tenancy/platform-config-repository.ts new file mode 100644 index 0000000..ced579b --- /dev/null +++ b/backend/src/tenancy/platform-config-repository.ts @@ -0,0 +1,107 @@ +import type { RowDataPacket } from 'mysql2/promise'; +import type { MySqlPool } from '../db/mysql.js'; + +export interface PlatformBootstrap { + appId: string; + tenantId: string; + tenantCode: string; + tenantName: string; + brand: { + name: string; + logoUrl: string; + themeColor: string; + servicePhone: string; + franchisePhone: string; + shareTitle: string; + shareImageUrl: string; + }; + defaultStoreId: string | null; +} + +interface PlatformBootstrapRow extends RowDataPacket { + appId: string; + tenantId: string; + tenantCode: string; + tenantName: string; + brandName: string; + logoUrl: string; + themeColor: string; + servicePhone: string; + franchisePhone: string; + shareTitle: string; + shareImageUrl: string; + defaultStoreId: string | null; +} + +export class AmbiguousAppTenantError extends Error { + constructor(appId: string) { + super(`Application ${appId} is bound to multiple tenants; tenant-id is required.`); + this.name = 'AmbiguousAppTenantError'; + } +} + +export class PlatformConfigRepository { + constructor(private readonly pool: Pick) {} + + async resolveBootstrap(appId: string, tenantId?: string): Promise { + const tenantFilter = tenantId ? 'AND ta.tenant_id = ?' : ''; + const params = tenantId ? [appId, tenantId] : [appId]; + const [rows] = await this.pool.execute( + `SELECT pa.appid AS appId, + ta.tenant_id AS tenantId, + t.code AS tenantCode, + t.name AS tenantName, + tc.brand_name AS brandName, + tc.logo_url AS logoUrl, + tc.theme_color AS themeColor, + tc.service_phone AS servicePhone, + tc.franchise_phone AS franchisePhone, + tc.share_title AS shareTitle, + tc.share_image_url AS shareImageUrl, + tc.default_store_id AS defaultStoreId + FROM qipai_platform_apps pa + INNER JOIN qipai_tenant_apps ta + ON ta.platform_app_id = pa.id + AND ta.status = 'ACTIVE' + AND ta.deleted_at IS NULL + INNER JOIN qipai_tenants t + ON t.id = ta.tenant_id + AND t.status = 'ACTIVE' + AND t.deleted_at IS NULL + INNER JOIN qipai_tenant_configs tc + ON tc.platform_app_id = pa.id + AND tc.tenant_id = ta.tenant_id + AND tc.deleted_at IS NULL + WHERE pa.appid = ? + AND pa.status = 'ACTIVE' + AND pa.deleted_at IS NULL + ${tenantFilter} + ORDER BY ta.is_default DESC, ta.tenant_id ASC + LIMIT 2`, + params + ); + + if (!tenantId && rows.length > 1) { + throw new AmbiguousAppTenantError(appId); + } + const row = rows[0]; + if (!row) return null; + + return { + appId: row.appId, + tenantId: String(row.tenantId), + tenantCode: row.tenantCode, + tenantName: row.tenantName, + brand: { + name: row.brandName, + logoUrl: row.logoUrl, + themeColor: row.themeColor, + servicePhone: row.servicePhone, + franchisePhone: row.franchisePhone, + shareTitle: row.shareTitle, + shareImageUrl: row.shareImageUrl + }, + defaultStoreId: row.defaultStoreId === null ? null : String(row.defaultStoreId) + }; + } +} diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 7f6274c..a5c55af 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -15,6 +15,9 @@ const legacyFixtureSql = read('database/fixtures/2026061801_m01b_legacy_schema.s const asyncUpSql = read('database/migrations/2026061802_m01c_async_tasks.up.sql'); const asyncDownSql = read('database/migrations/2026061802_m01c_async_tasks.down.sql'); const asyncVerifySql = read('database/migrations/2026061802_m01c_async_tasks.verify.sql'); +const tenantAppsUpSql = read('database/migrations/2026061803_m02a_tenant_apps.up.sql'); +const tenantAppsDownSql = read('database/migrations/2026061803_m02a_tenant_apps.down.sql'); +const tenantAppsVerifySql = read('database/migrations/2026061803_m02a_tenant_apps.verify.sql'); const coreTables = [ 'qipai_schema_migrations', @@ -86,4 +89,14 @@ assert.match(asyncUpSql, /UNIQUE KEY uq_qipai_async_tasks_idempotency/); assert.match(asyncUpSql, /lease_expires_at DATETIME\(3\)/); assert.match(asyncUpSql, /COMPENSATION_REQUIRED|status VARCHAR/); -console.log('PASS: M01-B/M01-C migration contracts are present.'); +for (const table of ['qipai_platform_apps', 'qipai_tenant_apps', 'qipai_tenant_configs']) { + assert.match(tenantAppsUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`)); + assert.match(tenantAppsDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`)); + assert.match(tenantAppsVerifySql, new RegExp(`'${table}'`)); +} +assert.match(tenantAppsUpSql, /UNIQUE KEY uq_qipai_tenant_apps_tenant_app \(tenant_id, platform_app_id\)/); +assert.match(tenantAppsUpSql, /UNIQUE KEY uq_qipai_tenant_configs_tenant_app \(tenant_id, platform_app_id\)/); +assert.match(tenantAppsUpSql, /brand_name VARCHAR/); +assert.match(tenantAppsUpSql, /theme_color VARCHAR/); + +console.log('PASS: M01-B through M02-A migration contracts are present.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 5a43ae2..509c6b7 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -13,7 +13,8 @@ assert.deepEqual( const plan = await loadMigrationPlan('up'); assert.equal(plan.direction, 'up'); assert.match(plan.file, /2026061601_m01b_core_schema\.up\.sql/); -assert.match(plan.file, /2026061802_m01c_async_tasks\.up\.sql$/); +assert.match(plan.file, /2026061802_m01c_async_tasks\.up\.sql/); +assert.match(plan.file, /2026061803_m02a_tenant_apps\.up\.sql$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.ok(plan.statements.length >= 11); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index 2636b38..8ac8c16 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -6,6 +6,10 @@ import { loadConfig } from '../dist/config.js'; import { closeMySqlPool, createMySqlPool } from '../dist/db/mysql.js'; import { LegacyReadRepository } from '../dist/db/legacy-read-repository.js'; import { TaskRepository } from '../dist/tasks/task-repository.js'; +import { + AmbiguousAppTenantError, + PlatformConfigRepository +} from '../dist/tenancy/platform-config-repository.js'; import { executeMigrationPlan, loadMigrationPlan, @@ -21,9 +25,12 @@ const expectedTables = [ 'qipai_orders', 'qipai_outbox_events', 'qipai_payments', + 'qipai_platform_apps', 'qipai_rooms', 'qipai_schema_migrations', 'qipai_stores', + 'qipai_tenant_apps', + 'qipai_tenant_configs', 'qipai_tenants' ]; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); @@ -45,9 +52,9 @@ async function readMigrationVersions(pool) { const [rows] = await pool.query( `SELECT version, name FROM qipai_schema_migrations - WHERE version IN (?, ?) + WHERE version IN (?, ?, ?) ORDER BY version`, - ['2026061601', '2026061802'] + ['2026061601', '2026061802', '2026061803'] ); return rows; } @@ -120,6 +127,48 @@ async function assertTaskDurability(pool) { assert.deepEqual(rows, [{ status: 'SUCCEEDED', attempts: 1 }]); } +async function assertPlatformTenantIsolation(pool) { + const [tenantResult] = await pool.query( + `INSERT INTO qipai_tenants (code, name) + VALUES ('M02A-A', 'M02A tenant A'), ('M02A-B', 'M02A tenant B')` + ); + const firstTenantId = Number(tenantResult.insertId); + const secondTenantId = firstTenantId + 1; + const [appResult] = await pool.query( + `INSERT INTO qipai_platform_apps (appid, name) + VALUES ('wx-m02a-shared', 'M02A shared app')` + ); + const platformAppId = Number(appResult.insertId); + + await pool.query( + `INSERT INTO qipai_tenant_apps + (tenant_id, platform_app_id, is_default) + VALUES (?, ?, 1), (?, ?, 0)`, + [firstTenantId, platformAppId, secondTenantId, platformAppId] + ); + await pool.query( + `INSERT INTO qipai_tenant_configs + (tenant_id, platform_app_id, brand_name, theme_color) + VALUES (?, ?, 'Tenant A Brand', '#111111'), + (?, ?, 'Tenant B Brand', '#222222')`, + [firstTenantId, platformAppId, secondTenantId, platformAppId] + ); + + const repository = new PlatformConfigRepository(pool); + const tenantA = await repository.resolveBootstrap('wx-m02a-shared', String(firstTenantId)); + const tenantB = await repository.resolveBootstrap('wx-m02a-shared', String(secondTenantId)); + assert.equal(tenantA?.brand.name, 'Tenant A Brand'); + assert.equal(tenantB?.brand.name, 'Tenant B Brand'); + assert.equal( + await repository.resolveBootstrap('wx-m02a-shared', String(secondTenantId + 999)), + null + ); + await assert.rejects( + () => repository.resolveBootstrap('wx-m02a-shared'), + AmbiguousAppTenantError + ); +} + const config = loadConfig(); assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.'); assert.match( @@ -145,11 +194,13 @@ try { assert.deepEqual(await readCoreTables(pool), expectedTables); assert.deepEqual(await readMigrationVersions(pool), [ { version: '2026061601', name: 'm01b_core_schema' }, - { version: '2026061802', name: 'm01c_async_tasks' } + { version: '2026061802', name: 'm01c_async_tasks' }, + { version: '2026061803', name: 'm02a_tenant_apps' } ]); await assertTaskDurability(pool); + await assertPlatformTenantIsolation(pool); await assertLegacyCompatibility(pool); - console.log('PASS: first up, verify and durable task restart check completed.'); + console.log('PASS: first up, verify, durable task and tenant isolation checks completed.'); await executeMigrationPlan(pool, plans.down); assert.deepEqual(await readCoreTables(pool), []); @@ -161,7 +212,8 @@ try { assert.deepEqual(await readCoreTables(pool), expectedTables); assert.deepEqual(await readMigrationVersions(pool), [ { version: '2026061601', name: 'm01b_core_schema' }, - { version: '2026061802', name: 'm01c_async_tasks' } + { version: '2026061802', name: 'm01c_async_tasks' }, + { version: '2026061803', name: 'm02a_tenant_apps' } ]); await assertLegacyCompatibility(pool); console.log('PASS: second up and verify restored the schema.'); @@ -187,7 +239,9 @@ try { 'orders', 'devices', 'tenant isolation', - 'decimal cents' + 'decimal cents', + 'app-to-tenant binding', + 'cross-tenant bootstrap rejection' ] }, null, 2)); } finally { diff --git a/backend/tests/platform-config-repository.test.mjs b/backend/tests/platform-config-repository.test.mjs new file mode 100644 index 0000000..a75dbb4 --- /dev/null +++ b/backend/tests/platform-config-repository.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { buildApp } from '../dist/app.js'; +import { + AmbiguousAppTenantError, + PlatformConfigRepository +} from '../dist/tenancy/platform-config-repository.js'; + +const calls = []; +const repository = new PlatformConfigRepository({ + async execute(sql, params) { + calls.push([sql, params]); + return [[{ + appId: 'wx-test-app', + tenantId: 7, + tenantCode: 'tenant-seven', + tenantName: 'Tenant Seven', + brandName: 'Seven棋牌', + logoUrl: 'https://api.txyundm.cn/uploads/tenant-7/logo.png', + themeColor: '#1677ff', + servicePhone: '4000000000', + franchisePhone: '', + shareTitle: 'Seven棋牌', + shareImageUrl: '', + defaultStoreId: 9 + }], []]; + } +}); + +const bootstrap = await repository.resolveBootstrap('wx-test-app', '7'); +assert.equal(bootstrap.tenantId, '7'); +assert.equal(bootstrap.defaultStoreId, '9'); +assert.equal(bootstrap.brand.name, 'Seven棋牌'); +assert.match(calls[0][0], /tc\.tenant_id = ta\.tenant_id/); +assert.match(calls[0][0], /AND ta\.tenant_id = \?/); +assert.deepEqual(calls[0][1], ['wx-test-app', '7']); + +const ambiguousRepository = new PlatformConfigRepository({ + async execute() { + return [[ + { tenantId: 1 }, + { tenantId: 2 } + ], []]; + } +}); +await assert.rejects( + () => ambiguousRepository.resolveBootstrap('wx-multi-app'), + AmbiguousAppTenantError +); + +const app = await buildApp({ + platformConfigRepository: { + async resolveBootstrap(appId, tenantId) { + assert.equal(appId, 'wx-test-app'); + assert.equal(tenantId, '7'); + return bootstrap; + } + } +}); +const response = await app.inject({ + method: 'GET', + url: '/app-api/bootstrap', + headers: { + 'x-wechat-appid': 'wx-test-app', + 'tenant-id': '7', + 'x-trace-id': 'm02a-test' + } +}); +assert.equal(response.statusCode, 200); +assert.equal(response.json().data.tenantId, '7'); +assert.equal(response.headers['x-trace-id'], 'm02a-test'); +await app.close(); + +const missingContextApp = await buildApp({ + platformConfigRepository: { + async resolveBootstrap() { + throw new Error('must not be called'); + } + } +}); +const invalidResponse = await missingContextApp.inject({ + method: 'GET', + url: '/app-api/bootstrap' +}); +assert.equal(invalidResponse.statusCode, 400); +assert.equal(invalidResponse.json().code, 'INVALID_APP_CONTEXT'); +await missingContextApp.close(); + +console.log('PASS: M02-A app and tenant bootstrap enforces explicit bindings.'); diff --git a/database/migrations/2026061803_m02a_tenant_apps.down.sql b/database/migrations/2026061803_m02a_tenant_apps.down.sql new file mode 100644 index 0000000..bab24f2 --- /dev/null +++ b/database/migrations/2026061803_m02a_tenant_apps.down.sql @@ -0,0 +1,6 @@ +-- Roll back the M02-A multi-application and tenant configuration model. + +DELETE FROM qipai_schema_migrations WHERE version = '2026061803'; +DROP TABLE IF EXISTS qipai_tenant_configs; +DROP TABLE IF EXISTS qipai_tenant_apps; +DROP TABLE IF EXISTS qipai_platform_apps; diff --git a/database/migrations/2026061803_m02a_tenant_apps.up.sql b/database/migrations/2026061803_m02a_tenant_apps.up.sql new file mode 100644 index 0000000..b4f764c --- /dev/null +++ b/database/migrations/2026061803_m02a_tenant_apps.up.sql @@ -0,0 +1,60 @@ +-- M02-A multi-application and tenant configuration model. + +CREATE TABLE IF NOT EXISTS qipai_platform_apps ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + appid VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + UNIQUE KEY uq_qipai_platform_apps_appid (appid), + KEY idx_qipai_platform_apps_status (status, deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_tenant_apps ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + platform_app_id BIGINT UNSIGNED NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + is_default TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_tenant_apps_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_tenant_apps_platform_app + FOREIGN KEY (platform_app_id) REFERENCES qipai_platform_apps(id), + UNIQUE KEY uq_qipai_tenant_apps_tenant_app (tenant_id, platform_app_id), + KEY idx_qipai_tenant_apps_app_tenant (platform_app_id, tenant_id, status, deleted_at), + KEY idx_qipai_tenant_apps_tenant_default (tenant_id, is_default, status, deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS qipai_tenant_configs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + platform_app_id BIGINT UNSIGNED NOT NULL, + brand_name VARCHAR(128) NOT NULL, + logo_url VARCHAR(512) NOT NULL DEFAULT '', + theme_color VARCHAR(16) NOT NULL DEFAULT '#1677ff', + service_phone VARCHAR(32) NOT NULL DEFAULT '', + franchise_phone VARCHAR(32) NOT NULL DEFAULT '', + share_title VARCHAR(128) NOT NULL DEFAULT '', + share_image_url VARCHAR(512) NOT NULL DEFAULT '', + default_store_id BIGINT UNSIGNED NULL, + extra_config JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + CONSTRAINT fk_qipai_tenant_configs_tenant + FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id), + CONSTRAINT fk_qipai_tenant_configs_platform_app + FOREIGN KEY (platform_app_id) REFERENCES qipai_platform_apps(id), + CONSTRAINT fk_qipai_tenant_configs_default_store + FOREIGN KEY (default_store_id) REFERENCES qipai_stores(id), + UNIQUE KEY uq_qipai_tenant_configs_tenant_app (tenant_id, platform_app_id), + KEY idx_qipai_tenant_configs_app_tenant (platform_app_id, tenant_id, deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO qipai_schema_migrations (version, name) +VALUES ('2026061803', 'm02a_tenant_apps'); diff --git a/database/migrations/2026061803_m02a_tenant_apps.verify.sql b/database/migrations/2026061803_m02a_tenant_apps.verify.sql new file mode 100644 index 0000000..7e67f29 --- /dev/null +++ b/database/migrations/2026061803_m02a_tenant_apps.verify.sql @@ -0,0 +1,34 @@ +-- Verify the M02-A multi-application and tenant configuration model. + +SELECT table_name +FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name IN ( + 'qipai_platform_apps', + 'qipai_tenant_apps', + 'qipai_tenant_configs' + ) +ORDER BY table_name; + +SELECT table_name, index_name +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND ( + (table_name = 'qipai_platform_apps' AND index_name = 'uq_qipai_platform_apps_appid') + OR + (table_name = 'qipai_tenant_apps' AND index_name IN ( + 'uq_qipai_tenant_apps_tenant_app', + 'idx_qipai_tenant_apps_app_tenant' + )) + OR + (table_name = 'qipai_tenant_configs' AND index_name IN ( + 'uq_qipai_tenant_configs_tenant_app', + 'idx_qipai_tenant_configs_app_tenant' + )) + ) +GROUP BY table_name, index_name +ORDER BY table_name, index_name; + +SELECT version, name +FROM qipai_schema_migrations +WHERE version = '2026061803'; diff --git a/scripts/dev/windows/check-backend.ps1 b/scripts/dev/windows/check-backend.ps1 index 9d8fbd0..e25ecc3 100644 --- a/scripts/dev/windows/check-backend.ps1 +++ b/scripts/dev/windows/check-backend.ps1 @@ -15,6 +15,8 @@ $requiredFiles = @( "backend/src/tasks/outbox-repository.ts", "backend/src/tasks/worker.ts", "backend/src/routes/health.ts", + "backend/src/routes/platform-bootstrap.ts", + "backend/src/tenancy/platform-config-repository.ts", "backend/src/server.ts", "backend/tests/backend-contract.test.mjs", "backend/tests/migration-contract.test.mjs", @@ -23,6 +25,7 @@ $requiredFiles = @( "backend/tests/mysql-migration-roundtrip.test.mjs", "backend/tests/legacy-read-repository.test.mjs", "backend/tests/task-repository.test.mjs", + "backend/tests/platform-config-repository.test.mjs", "scripts/dev/wsl/mysql-migration-roundtrip.sh", "database/migrations/2026061601_m01b_core_schema.up.sql", "database/migrations/2026061601_m01b_core_schema.down.sql", @@ -30,6 +33,9 @@ $requiredFiles = @( "database/migrations/2026061802_m01c_async_tasks.up.sql", "database/migrations/2026061802_m01c_async_tasks.down.sql", "database/migrations/2026061802_m01c_async_tasks.verify.sql", + "database/migrations/2026061803_m02a_tenant_apps.up.sql", + "database/migrations/2026061803_m02a_tenant_apps.down.sql", + "database/migrations/2026061803_m02a_tenant_apps.verify.sql", "database/seeds/2026061601_m01b_minimal_seed.sql", "deploy/pm2/ecosystem.config.cjs" ) diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index 824a534..e211db2 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}" export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_CONNECTION_LIMIT=2 -echo "INFO: MySQL ${mysql_version}; running M01-B/M01-C migration roundtrip in a temporary database." +echo "INFO: MySQL ${mysql_version}; running M01-B through M02-A migration roundtrip in a temporary database." npm --prefix backend run test:mysql:migration -echo "PASS: M01-B/M01-C live MySQL migration roundtrip completed." +echo "PASS: M01-B through M02-A live MySQL migration roundtrip completed."