feat(M08-D): 补多小程序与租户配置

This commit is contained in:
Codex
2026-08-10 12:37:53 +08:00
parent b3fba4b815
commit c7f27bc724
12 changed files with 517 additions and 1 deletions
+5
View File
@@ -8,6 +8,7 @@ import {
registerPlatformBootstrapRoutes,
type PlatformConfigResolver
} from './routes/platform-bootstrap.js';
import { registerPlatformManagementRoutes, type PlatformManagementRouteOptions } from './routes/platform-management.js';
import { registerAuthRoutes, type AuthRouteOptions } from './routes/auth.js';
import {
registerUserManagementRoutes,
@@ -59,6 +60,7 @@ import {
export interface BuildAppOptions {
config?: AppConfig;
platformConfigRepository?: PlatformConfigResolver;
platformManagement?: PlatformManagementRouteOptions;
auth?: AuthRouteOptions;
userManagement?: UserManagementRouteOptions;
storeRoom?: StoreRoomRouteOptions;
@@ -126,6 +128,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.platformConfigRepository) {
await registerPlatformBootstrapRoutes(app, options.platformConfigRepository);
}
if (options.platformManagement) {
await registerPlatformManagementRoutes(app, options.platformManagement);
}
if (options.auth) {
await registerAuthRoutes(app, options.auth);
}
+91
View File
@@ -0,0 +1,91 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import { PlatformAdminError, type PlatformAdminRepository } from '../tenancy/platform-admin-repository.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const configSchema = z.object({
brandName: z.string().trim().min(1).max(128),
logoUrl: z.string().trim().max(512).default(''),
themeColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/),
servicePhone: z.string().trim().max(32).default(''),
franchisePhone: z.string().trim().max(32).default(''),
shareTitle: z.string().trim().max(128).default(''),
shareImageUrl: z.string().trim().max(512).default(''),
defaultStoreId: id.nullable().default(null),
extraConfig: z.record(z.unknown()).default({}),
bindingStatus: z.enum(['ACTIVE', 'DISABLED']),
isDefault: z.boolean()
});
const bindSchema = z.object({
appId: z.string().regex(/^[A-Za-z0-9_-]{6,64}$/),
appName: z.string().trim().min(1).max(128),
appStatus: z.enum(['ACTIVE', 'DISABLED']),
config: configSchema
});
export interface PlatformManagementRouteOptions {
repository: Pick<PlatformAdminRepository, 'listTenantApps' | 'updateTenantConfig' | 'bindApplication'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerPlatformManagementRoutes(app: FastifyInstance, options: PlatformManagementRouteOptions) {
app.get('/admin-api/platform-apps', async (request, reply) => {
const actor = await requireActor(request, reply, options, false);
if (!actor) return;
return handle(reply, request.traceId, async () => ({
code: 0, data: await options.repository.listTenantApps(actor.tenantId), traceId: request.traceId
}));
});
app.put('/admin-api/platform-apps/:id/config', async (request, reply) => {
const actor = await requireActor(request, reply, options, false);
const params = z.object({ id }).safeParse(request.params);
const body = configSchema.safeParse(request.body);
if (!actor) return;
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateTenantConfig(actor, params.data.id, body.data),
traceId: request.traceId
}));
});
app.post('/admin-api/platform-apps/bind', async (request, reply) => {
const actor = await requireActor(request, reply, options, true);
const body = bindSchema.safeParse(request.body);
if (!actor) return;
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0, data: await options.repository.bindApplication(actor, body.data), traceId: request.traceId
}));
});
}
async function requireActor(request: FastifyRequest, reply: FastifyReply, options: PlatformManagementRouteOptions, platformOnly: boolean): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) { reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId }); return null; }
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
const platform = access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage');
if ((platformOnly && !platform) || (!platformOnly && !platform && !access.capabilities.includes('tenant.manage'))) {
reply.status(403).send({ code: platformOnly ? 'PLATFORM_MANAGEMENT_FORBIDDEN' : 'TENANT_MANAGEMENT_FORBIDDEN', message: 'Management permission is required.', traceId: request.traceId });
return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access, traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try { return await work(); }
catch (error) {
if (!(error instanceof PlatformAdminError)) throw error;
const missing = error.code.endsWith('_NOT_FOUND');
return reply.status(missing ? 404 : 400).send({ code: error.code, message: 'The platform application operation is invalid.', traceId });
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({ code: 'INVALID_PLATFORM_APP_REQUEST', message: 'The platform application request is invalid.', traceId });
}
+7
View File
@@ -2,6 +2,7 @@ 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';
import { PlatformAdminRepository } from './tenancy/platform-admin-repository.js';
import { AuthRepository } from './auth/auth-repository.js';
import { RbacRepository } from './auth/rbac-repository.js';
import { parseWechatAppSecrets, WechatHttpClient } from './auth/wechat-client.js';
@@ -69,6 +70,12 @@ const app = await buildApp({
config,
mqtt,
platformConfigRepository: new PlatformConfigRepository(pool),
platformManagement: {
repository: new PlatformAdminRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
auth: {
repository: authRepository,
wechat: new WechatHttpClient(parseWechatAppSecrets(config.auth.wechatAppSecretsJson)),
@@ -0,0 +1,200 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
export interface TenantAppConfigInput {
brandName: string;
logoUrl: string;
themeColor: string;
servicePhone: string;
franchisePhone: string;
shareTitle: string;
shareImageUrl: string;
defaultStoreId: string | null;
extraConfig: Record<string, unknown>;
bindingStatus: 'ACTIVE' | 'DISABLED';
isDefault: boolean;
}
export class PlatformAdminError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class PlatformAdminRepository {
constructor(private readonly pool: MySqlPool) {}
async listTenantApps(tenantId: string) {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT pa.id AS platformAppId, pa.appid AS appId, pa.name AS appName,
pa.status AS appStatus, ta.status AS bindingStatus, ta.is_default AS isDefault,
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,
tc.extra_config AS extraConfig, tc.updated_at AS updatedAt
FROM qipai_tenant_apps ta
INNER JOIN qipai_platform_apps pa
ON pa.id = ta.platform_app_id AND pa.deleted_at IS NULL
INNER JOIN qipai_tenant_configs tc
ON tc.tenant_id = ta.tenant_id AND tc.platform_app_id = ta.platform_app_id
AND tc.deleted_at IS NULL
WHERE ta.tenant_id = ? AND ta.deleted_at IS NULL
ORDER BY ta.is_default DESC, pa.id`,
[tenantId]
);
return rows.map((row) => ({
platformAppId: String(row.platformAppId),
appId: row.appId,
appName: row.appName,
appStatus: row.appStatus,
bindingStatus: row.bindingStatus,
isDefault: Boolean(row.isDefault),
brandName: 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),
extraConfig: parseJson(row.extraConfig),
updatedAt: row.updatedAt
}));
}
async updateTenantConfig(actor: ManagementActor, platformAppId: string, input: TenantAppConfigInput) {
return this.transaction(async (connection) => {
await this.assertBinding(connection, actor.tenantId, platformAppId);
await this.assertDefaultStore(connection, actor.tenantId, input.defaultStoreId);
if (input.isDefault) {
await connection.execute(
'UPDATE qipai_tenant_apps SET is_default = 0 WHERE tenant_id = ? AND deleted_at IS NULL',
[actor.tenantId]
);
}
await connection.execute(
`UPDATE qipai_tenant_apps
SET status = ?, is_default = ?
WHERE tenant_id = ? AND platform_app_id = ? AND deleted_at IS NULL`,
[input.bindingStatus, input.isDefault ? 1 : 0, actor.tenantId, platformAppId]
);
await connection.execute(
`UPDATE qipai_tenant_configs SET brand_name = ?, logo_url = ?, theme_color = ?,
service_phone = ?, franchise_phone = ?, share_title = ?, share_image_url = ?,
default_store_id = ?, extra_config = ?
WHERE tenant_id = ? AND platform_app_id = ? AND deleted_at IS NULL`,
[input.brandName, input.logoUrl, input.themeColor, input.servicePhone,
input.franchisePhone, input.shareTitle, input.shareImageUrl, input.defaultStoreId,
JSON.stringify(input.extraConfig), actor.tenantId, platformAppId]
);
await this.audit(connection, actor, 'TENANT_APP_CONFIG_UPDATED', platformAppId, {
bindingStatus: input.bindingStatus, isDefault: input.isDefault,
defaultStoreId: input.defaultStoreId
});
return { platformAppId, updated: true };
});
}
async bindApplication(actor: ManagementActor, input: {
appId: string; appName: string; appStatus: 'ACTIVE' | 'DISABLED'; config: TenantAppConfigInput;
}) {
return this.transaction(async (connection) => {
await this.assertDefaultStore(connection, actor.tenantId, input.config.defaultStoreId);
await connection.execute(
`INSERT INTO qipai_platform_apps (appid, name, status)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), status = VALUES(status), deleted_at = NULL`,
[input.appId, input.appName, input.appStatus]
);
const [apps] = await connection.execute<Array<RowDataPacket & { id: string }>>(
'SELECT id FROM qipai_platform_apps WHERE appid = ? AND deleted_at IS NULL FOR UPDATE',
[input.appId]
);
if (!apps[0]) throw new PlatformAdminError('PLATFORM_APP_NOT_FOUND');
const platformAppId = String(apps[0].id);
if (input.config.isDefault) {
await connection.execute(
'UPDATE qipai_tenant_apps SET is_default = 0 WHERE tenant_id = ? AND deleted_at IS NULL',
[actor.tenantId]
);
}
await connection.execute(
`INSERT INTO qipai_tenant_apps (tenant_id, platform_app_id, status, is_default)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE status = VALUES(status), is_default = VALUES(is_default), deleted_at = NULL`,
[actor.tenantId, platformAppId, input.config.bindingStatus, input.config.isDefault ? 1 : 0]
);
await connection.execute(
`INSERT INTO qipai_tenant_configs
(tenant_id, platform_app_id, brand_name, logo_url, theme_color, service_phone,
franchise_phone, share_title, share_image_url, default_store_id, extra_config)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE brand_name = VALUES(brand_name), logo_url = VALUES(logo_url),
theme_color = VALUES(theme_color), service_phone = VALUES(service_phone),
franchise_phone = VALUES(franchise_phone), share_title = VALUES(share_title),
share_image_url = VALUES(share_image_url), default_store_id = VALUES(default_store_id),
extra_config = VALUES(extra_config), deleted_at = NULL`,
[actor.tenantId, platformAppId, input.config.brandName, input.config.logoUrl,
input.config.themeColor, input.config.servicePhone, input.config.franchisePhone,
input.config.shareTitle, input.config.shareImageUrl, input.config.defaultStoreId,
JSON.stringify(input.config.extraConfig)]
);
await this.audit(connection, actor, 'TENANT_APP_BOUND', platformAppId, {
appId: input.appId, bindingStatus: input.config.bindingStatus,
isDefault: input.config.isDefault
});
return { platformAppId, bound: true };
});
}
private async assertBinding(connection: PoolConnection, tenantId: string, platformAppId: string) {
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_tenant_apps
WHERE tenant_id = ? AND platform_app_id = ? AND deleted_at IS NULL FOR UPDATE`,
[tenantId, platformAppId]
);
if (!rows[0]) throw new PlatformAdminError('TENANT_APP_BINDING_NOT_FOUND');
}
private async assertDefaultStore(connection: PoolConnection, tenantId: string, storeId: string | null) {
if (!storeId) return;
const [rows] = await connection.execute<RowDataPacket[]>(
'SELECT id FROM qipai_stores WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL',
[tenantId, storeId]
);
if (!rows[0]) throw new PlatformAdminError('TENANT_APP_DEFAULT_STORE_INVALID');
}
private async audit(connection: PoolConnection, actor: ManagementActor, action: string, id: string, metadata: object) {
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'USER', ?, ?, 'PLATFORM_APP', ?, ?, ?, ?, ?)`,
[actor.tenantId, actor.userId, action, id, actor.traceId, actor.ip,
actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
function parseJson(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object') return value as Record<string, unknown>;
if (typeof value !== 'string') return {};
try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : {}; }
catch { return {}; }
}