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
+8
View File
@@ -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<FastifyIn
});
await registerHealthRoutes(app, config);
if (options.platformConfigRepository) {
await registerPlatformBootstrapRoutes(app, options.platformConfigRepository);
}
return app;
}
+6 -3
View File
@@ -22,13 +22,16 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const migrationFiles: Record<MigrationDirection, readonly string[]> = {
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.`
+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 { 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 });
@@ -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)
};
}
}