feat(M08-D): 补多小程序与租户配置
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 {}; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
|
||||
const secret = 'test-only-platform-management-secret-32';
|
||||
const token = signAccessToken({ sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1 }, secret, 900);
|
||||
const config = {
|
||||
brandName: '七号棋牌', logoUrl: '', themeColor: '#1677ff', servicePhone: '4000000000',
|
||||
franchisePhone: '', shareTitle: '七号棋牌', shareImageUrl: '', defaultStoreId: '11',
|
||||
extraConfig: { bookingMode: 'STANDARD' }, bindingStatus: 'ACTIVE', isDefault: true
|
||||
};
|
||||
let updateInput;
|
||||
let bindInput;
|
||||
const app = await buildApp({
|
||||
platformManagement: {
|
||||
jwtSecret: secret,
|
||||
authRepository: { async validateSession() { return { id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000), user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1, nickname: '', avatarUrl: '', phone: '' } }; } },
|
||||
accessControl: { async getAccessProfile() { return { roles: ['PLATFORM_ADMIN'], capabilities: ['platform.manage', 'tenant.manage'], storeIds: [] }; } },
|
||||
repository: {
|
||||
async listTenantApps(tenantId) { return [{ platformAppId: '9', appId: 'wx-test-app', tenantId }]; },
|
||||
async updateTenantConfig(_actor, appId, input) { updateInput = input; return { platformAppId: appId, updated: true }; },
|
||||
async bindApplication(_actor, input) { bindInput = input; return { platformAppId: '10', bound: true }; }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const listed = await app.inject({ method: 'GET', url: '/admin-api/platform-apps', headers: { authorization: `Bearer ${token}` } });
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.equal(listed.json().data[0].appId, 'wx-test-app');
|
||||
const updated = await app.inject({ method: 'PUT', url: '/admin-api/platform-apps/9/config', headers: { authorization: `Bearer ${token}` }, payload: config });
|
||||
assert.equal(updated.statusCode, 200);
|
||||
assert.equal(updateInput.defaultStoreId, '11');
|
||||
const bound = await app.inject({ method: 'POST', url: '/admin-api/platform-apps/bind', headers: { authorization: `Bearer ${token}` }, payload: { appId: 'wx-new-app', appName: '新小程序', appStatus: 'ACTIVE', config } });
|
||||
assert.equal(bound.statusCode, 201);
|
||||
assert.equal(bindInput.appId, 'wx-new-app');
|
||||
assert.equal('appSecret' in bindInput, false);
|
||||
await app.close();
|
||||
console.log('PASS: M08-D platform application management enforces scoped configuration without secrets.');
|
||||
Reference in New Issue
Block a user