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
+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 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.');
+2 -1
View File
@@ -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);
@@ -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 {
@@ -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.');