feat(M02-A): 建立多小程序租户配置模型

This commit is contained in:
Codex
2026-06-18 10:30:31 +08:00
parent 9d13f75dc0
commit 8b8fb28c36
15 changed files with 459 additions and 15 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "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", "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: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": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+8
View File
@@ -4,9 +4,14 @@ import rateLimit from '@fastify/rate-limit';
import Fastify, { type FastifyInstance } from 'fastify'; import Fastify, { type FastifyInstance } from 'fastify';
import { loadConfig, type AppConfig } from './config.js'; import { loadConfig, type AppConfig } from './config.js';
import { registerHealthRoutes } from './routes/health.js'; import { registerHealthRoutes } from './routes/health.js';
import {
registerPlatformBootstrapRoutes,
type PlatformConfigResolver
} from './routes/platform-bootstrap.js';
export interface BuildAppOptions { export interface BuildAppOptions {
config?: AppConfig; config?: AppConfig;
platformConfigRepository?: PlatformConfigResolver;
} }
declare module 'fastify' { declare module 'fastify' {
@@ -49,6 +54,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}); });
await registerHealthRoutes(app, config); await registerHealthRoutes(app, config);
if (options.platformConfigRepository) {
await registerPlatformBootstrapRoutes(app, options.platformConfigRepository);
}
return app; return app;
} }
+6 -3
View File
@@ -22,13 +22,16 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const migrationFiles: Record<MigrationDirection, readonly string[]> = { const migrationFiles: Record<MigrationDirection, readonly string[]> = {
up: [ up: [
'database/migrations/2026061601_m01b_core_schema.up.sql', '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: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', '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: [ down: [
'database/migrations/2026061803_m02a_tenant_apps.down.sql',
'database/migrations/2026061802_m01c_async_tasks.down.sql', 'database/migrations/2026061802_m01c_async_tasks.down.sql',
'database/migrations/2026061601_m01b_core_schema.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()) { for (const [index, statement] of plan.statements.entries()) {
const [result] = await pool.query(statement); const [result] = await pool.query(statement);
if (plan.direction === 'verify') { 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) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( throw new Error(
`Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.` `Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.`
+55
View File
@@ -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<PlatformBootstrap | null>;
}
export async function registerPlatformBootstrapRoutes(
app: FastifyInstance,
repository: PlatformConfigResolver
): Promise<void> {
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;
}
});
}
+10 -1
View File
@@ -1,8 +1,17 @@
import { buildApp } from './app.js'; import { buildApp } from './app.js';
import { loadConfig } from './config.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 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 { try {
await app.listen({ host: config.host, port: config.port }); await app.listen({ host: config.host, port: config.port });
@@ -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<MySqlPool, 'execute'>) {}
async resolveBootstrap(appId: string, tenantId?: string): Promise<PlatformBootstrap | null> {
const tenantFilter = tenantId ? 'AND ta.tenant_id = ?' : '';
const params = tenantId ? [appId, tenantId] : [appId];
const [rows] = await this.pool.execute<PlatformBootstrapRow[]>(
`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)
};
}
}
+14 -1
View File
@@ -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 asyncUpSql = read('database/migrations/2026061802_m01c_async_tasks.up.sql');
const asyncDownSql = read('database/migrations/2026061802_m01c_async_tasks.down.sql'); const asyncDownSql = read('database/migrations/2026061802_m01c_async_tasks.down.sql');
const asyncVerifySql = read('database/migrations/2026061802_m01c_async_tasks.verify.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 = [ const coreTables = [
'qipai_schema_migrations', '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, /lease_expires_at DATETIME\(3\)/);
assert.match(asyncUpSql, /COMPENSATION_REQUIRED|status VARCHAR/); 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.');
+2 -1
View File
@@ -13,7 +13,8 @@ assert.deepEqual(
const plan = await loadMigrationPlan('up'); const plan = await loadMigrationPlan('up');
assert.equal(plan.direction, 'up'); assert.equal(plan.direction, 'up');
assert.match(plan.file, /2026061601_m01b_core_schema\.up\.sql/); 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.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); assert.ok(plan.statements.length >= 11);
@@ -6,6 +6,10 @@ import { loadConfig } from '../dist/config.js';
import { closeMySqlPool, createMySqlPool } from '../dist/db/mysql.js'; import { closeMySqlPool, createMySqlPool } from '../dist/db/mysql.js';
import { LegacyReadRepository } from '../dist/db/legacy-read-repository.js'; import { LegacyReadRepository } from '../dist/db/legacy-read-repository.js';
import { TaskRepository } from '../dist/tasks/task-repository.js'; import { TaskRepository } from '../dist/tasks/task-repository.js';
import {
AmbiguousAppTenantError,
PlatformConfigRepository
} from '../dist/tenancy/platform-config-repository.js';
import { import {
executeMigrationPlan, executeMigrationPlan,
loadMigrationPlan, loadMigrationPlan,
@@ -21,9 +25,12 @@ const expectedTables = [
'qipai_orders', 'qipai_orders',
'qipai_outbox_events', 'qipai_outbox_events',
'qipai_payments', 'qipai_payments',
'qipai_platform_apps',
'qipai_rooms', 'qipai_rooms',
'qipai_schema_migrations', 'qipai_schema_migrations',
'qipai_stores', 'qipai_stores',
'qipai_tenant_apps',
'qipai_tenant_configs',
'qipai_tenants' 'qipai_tenants'
]; ];
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
@@ -45,9 +52,9 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT version, name `SELECT version, name
FROM qipai_schema_migrations FROM qipai_schema_migrations
WHERE version IN (?, ?) WHERE version IN (?, ?, ?)
ORDER BY version`, ORDER BY version`,
['2026061601', '2026061802'] ['2026061601', '2026061802', '2026061803']
); );
return rows; return rows;
} }
@@ -120,6 +127,48 @@ async function assertTaskDurability(pool) {
assert.deepEqual(rows, [{ status: 'SUCCEEDED', attempts: 1 }]); 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(); const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.'); assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match( assert.match(
@@ -145,11 +194,13 @@ try {
assert.deepEqual(await readCoreTables(pool), expectedTables); assert.deepEqual(await readCoreTables(pool), expectedTables);
assert.deepEqual(await readMigrationVersions(pool), [ assert.deepEqual(await readMigrationVersions(pool), [
{ version: '2026061601', name: 'm01b_core_schema' }, { 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 assertTaskDurability(pool);
await assertPlatformTenantIsolation(pool);
await assertLegacyCompatibility(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); await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []); assert.deepEqual(await readCoreTables(pool), []);
@@ -161,7 +212,8 @@ try {
assert.deepEqual(await readCoreTables(pool), expectedTables); assert.deepEqual(await readCoreTables(pool), expectedTables);
assert.deepEqual(await readMigrationVersions(pool), [ assert.deepEqual(await readMigrationVersions(pool), [
{ version: '2026061601', name: 'm01b_core_schema' }, { 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); await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.'); console.log('PASS: second up and verify restored the schema.');
@@ -187,7 +239,9 @@ try {
'orders', 'orders',
'devices', 'devices',
'tenant isolation', 'tenant isolation',
'decimal cents' 'decimal cents',
'app-to-tenant binding',
'cross-tenant bootstrap rejection'
] ]
}, null, 2)); }, null, 2));
} finally { } finally {
@@ -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.');
@@ -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;
@@ -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');
@@ -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';
+6
View File
@@ -15,6 +15,8 @@ $requiredFiles = @(
"backend/src/tasks/outbox-repository.ts", "backend/src/tasks/outbox-repository.ts",
"backend/src/tasks/worker.ts", "backend/src/tasks/worker.ts",
"backend/src/routes/health.ts", "backend/src/routes/health.ts",
"backend/src/routes/platform-bootstrap.ts",
"backend/src/tenancy/platform-config-repository.ts",
"backend/src/server.ts", "backend/src/server.ts",
"backend/tests/backend-contract.test.mjs", "backend/tests/backend-contract.test.mjs",
"backend/tests/migration-contract.test.mjs", "backend/tests/migration-contract.test.mjs",
@@ -23,6 +25,7 @@ $requiredFiles = @(
"backend/tests/mysql-migration-roundtrip.test.mjs", "backend/tests/mysql-migration-roundtrip.test.mjs",
"backend/tests/legacy-read-repository.test.mjs", "backend/tests/legacy-read-repository.test.mjs",
"backend/tests/task-repository.test.mjs", "backend/tests/task-repository.test.mjs",
"backend/tests/platform-config-repository.test.mjs",
"scripts/dev/wsl/mysql-migration-roundtrip.sh", "scripts/dev/wsl/mysql-migration-roundtrip.sh",
"database/migrations/2026061601_m01b_core_schema.up.sql", "database/migrations/2026061601_m01b_core_schema.up.sql",
"database/migrations/2026061601_m01b_core_schema.down.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.up.sql",
"database/migrations/2026061802_m01c_async_tasks.down.sql", "database/migrations/2026061802_m01c_async_tasks.down.sql",
"database/migrations/2026061802_m01c_async_tasks.verify.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", "database/seeds/2026061601_m01b_minimal_seed.sql",
"deploy/pm2/ecosystem.config.cjs" "deploy/pm2/ecosystem.config.cjs"
) )
+2 -2
View File
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2 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 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."