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
+10 -1
View File
@@ -10,6 +10,7 @@
</div>
</div>
<nav class="nav-list" aria-label="后台模块">
<button class="nav-item" :class="{ active: activeModule === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
<button
class="nav-item"
:class="{ active: activeModule === 'overview' }"
@@ -112,6 +113,11 @@
@open-cleaning="activeModule = 'cleaning'"
/>
<PlatformAppsPanel
v-else-if="activeModule === 'platformApps'"
:session="session"
/>
<StoresRoomsPanel
v-else-if="activeModule === 'stores'"
:session="session"
@@ -306,6 +312,7 @@ import {
Download,
LayoutDashboard,
ListTodo,
PanelsTopLeft,
RadioTower,
RotateCcw,
Save,
@@ -328,6 +335,7 @@ import PaymentsPanel from './components/PaymentsPanel.vue';
import ThirdPartyPanel from './components/ThirdPartyPanel.vue';
import MembersStaffPanel from './components/MembersStaffPanel.vue';
import DevicesPanel from './components/DevicesPanel.vue';
import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -364,7 +372,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value);
const activeModule = ref<'overview' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeModule = ref<'overview' | 'platformApps' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeTab = ref('tasks');
const lastError = ref('');
const lastMessage = ref('');
@@ -375,6 +383,7 @@ const cleanerSearch = ref('');
const session = computed(() => ({ token: savedToken.value }));
const activeModuleMeta = computed(() => ({
overview: { stage: 'M08-D', title: '平台运营总览' },
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
stores: { stage: 'M08-D', title: '门店与房间管理' },
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
+16
View File
@@ -19,6 +19,7 @@ import type {
OrderHistoryItem,
OrderStatus,
PaymentAuthorizationStatus,
PlatformApplication,
ProfitSharingSnapshot,
PayoutStateFilter,
StaffRole,
@@ -29,6 +30,7 @@ import type {
RoomInput,
RoomOperationalStatus,
StoreInput,
TenantApplicationConfig,
ThirdPartyMode,
ThirdPartyProvider,
ThirdPartyRecords,
@@ -419,6 +421,20 @@ export function controlDevice(session: ApiSession, action: 'door' | 'power' | 's
return request<{ commandId?: string; status?: string }>(session, `/device-control/${action}`, { method: 'POST', body: JSON.stringify(input) });
}
export function listPlatformApplications(session: ApiSession) {
return request<PlatformApplication[]>(session, '/platform-apps');
}
export function updatePlatformApplicationConfig(session: ApiSession, platformAppId: string, input: TenantApplicationConfig) {
return request<{ platformAppId: string; updated: boolean }>(session, `/platform-apps/${encodeURIComponent(platformAppId)}/config`, { method: 'PUT', body: JSON.stringify(input) });
}
export function bindPlatformApplication(session: ApiSession, input: {
appId: string; appName: string; appStatus: 'ACTIVE' | 'DISABLED'; config: TenantApplicationConfig;
}) {
return request<{ platformAppId: string; bound: boolean }>(session, '/platform-apps/bind', { method: 'POST', body: JSON.stringify(input) });
}
export function createStaffUser(
session: ApiSession,
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
@@ -0,0 +1,50 @@
<template>
<section class="platform-apps-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<el-alert title="AppSecret、微信支付密钥和证书只允许配置在服务端环境变量中,本页面不会读取、保存或回显。" type="info" show-icon :closable="false" />
<section class="platform-app-metrics"><span><small>已绑定应用</small><strong>{{ apps.length }}</strong></span><span><small>启用绑定</small><strong>{{ activeCount }}</strong></span><span><small>默认应用</small><strong>{{ defaultCount }}</strong></span><span><small>品牌配置完整</small><strong>{{ configuredCount }}</strong></span></section>
<section class="panel">
<header class="panel-toolbar platform-app-toolbar"><div><p class="section-kicker">M08-D · 多应用租户</p><h3>多小程序与租户品牌配置</h3></div><div class="toolbar-actions"><el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button><el-button type="primary" :icon="Plus" @click="openBind">绑定应用</el-button></div></header>
<el-table v-loading="loading" :data="apps" class="data-table" row-key="platformAppId">
<el-table-column label="逻辑应用" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.appName }}</strong><span>{{ row.appId }} · #{{ row.platformAppId }}</span></div></template></el-table-column>
<el-table-column label="绑定" width="130"><template #default="{ row }"><div class="stack"><el-tag :type="row.bindingStatus === 'ACTIVE' ? 'success' : 'info'">{{ row.bindingStatus }}</el-tag><span>{{ row.isDefault ? '租户默认' : '普通绑定' }}</span></div></template></el-table-column>
<el-table-column label="品牌" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.brandName }}</strong><span><i class="theme-dot" :style="{ background: row.themeColor }" />{{ row.themeColor }} · {{ row.servicePhone || '未留客服电话' }}</span></div></template></el-table-column>
<el-table-column label="默认门店" min-width="160"><template #default="{ row }">{{ row.defaultStoreId ? storeName(row.defaultStoreId) : '未配置' }}</template></el-table-column>
<el-table-column label="分享" min-width="200"><template #default="{ row }"><div class="stack"><strong>{{ row.shareTitle || '-' }}</strong><span>{{ row.shareImageUrl ? '已配置分享图' : '未配置分享图' }}</span></div></template></el-table-column>
<el-table-column label="更新时间" width="150"><template #default="{ row }">{{ shortDate(row.updatedAt) }}</template></el-table-column>
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Settings2" @click="openEdit(row)">配置</el-button></template></el-table-column>
</el-table>
</section>
<el-dialog v-model="dialog" :title="form.platformAppId ? '编辑租户应用配置' : '绑定逻辑应用'" width="min(760px, 94vw)">
<el-form label-position="top" class="asset-form">
<template v-if="!form.platformAppId"><el-form-item label="微信 AppID"><el-input v-model="form.appId" autocomplete="off" /></el-form-item><el-form-item label="应用名称"><el-input v-model="form.appName" /></el-form-item><el-form-item label="全局应用状态"><el-select v-model="form.appStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item></template>
<el-form-item label="品牌名称"><el-input v-model="form.brandName" /></el-form-item><el-form-item label="主题色"><el-color-picker v-model="form.themeColor" /><el-input v-model="form.themeColor" class="color-input" /></el-form-item><el-form-item label="客服电话"><el-input v-model="form.servicePhone" /></el-form-item><el-form-item label="加盟电话"><el-input v-model="form.franchisePhone" /></el-form-item><el-form-item label="默认门店"><el-select v-model="form.defaultStoreId" clearable><el-option label="不指定" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="绑定状态"><el-select v-model="form.bindingStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item><el-form-item label="租户默认应用"><el-switch v-model="form.isDefault" /></el-form-item><el-form-item label="Logo URL" class="form-span-2"><el-input v-model="form.logoUrl" /></el-form-item><el-form-item label="分享标题" class="form-span-2"><el-input v-model="form.shareTitle" /></el-form-item><el-form-item label="分享图片 URL" class="form-span-2"><el-input v-model="form.shareImageUrl" /></el-form-item><el-form-item label="非敏感扩展配置 JSON" class="form-span-2"><el-input v-model="form.extraConfigText" type="textarea" :rows="5" placeholder='{"bookingMode":"STANDARD"}' /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Plus, RefreshCw, Settings2 } from '@lucide/vue';
import { ApiError, bindPlatformApplication, listManagedStores, listPlatformApplications, updatePlatformApplicationConfig, type ApiSession } from '../api';
import { shortDate } from '../format';
import type { ManagedStore, PlatformApplication, TenantApplicationConfig } from '../types';
const props = defineProps<{ session: ApiSession }>();
const apps = ref<PlatformApplication[]>([]); const stores = ref<ManagedStore[]>([]); const loading = ref(false); const saving = ref(false); const lastError = ref(''); const dialog = ref(false);
const form = reactive({ platformAppId: '', appId: '', appName: '', appStatus: 'ACTIVE' as 'ACTIVE' | 'DISABLED', brandName: '', logoUrl: '', themeColor: '#1677ff', servicePhone: '', franchisePhone: '', shareTitle: '', shareImageUrl: '', defaultStoreId: '', bindingStatus: 'ACTIVE' as 'ACTIVE' | 'DISABLED', isDefault: false, extraConfigText: '{}' });
const activeCount = computed(() => apps.value.filter((item) => item.bindingStatus === 'ACTIVE').length); const defaultCount = computed(() => apps.value.filter((item) => item.isDefault).length); const configuredCount = computed(() => apps.value.filter((item) => item.brandName && item.themeColor && item.shareTitle).length);
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
async function loadData() { if (!props.session.token) return; loading.value = true; try { [apps.value, stores.value] = await Promise.all([capture(() => listPlatformApplications(props.session)), capture(() => listManagedStores(props.session))]); } catch { /* displayed */ } finally { loading.value = false; } }
function defaults() { return { platformAppId: '', appId: '', appName: '', appStatus: 'ACTIVE', brandName: '', logoUrl: '', themeColor: '#1677ff', servicePhone: '', franchisePhone: '', shareTitle: '', shareImageUrl: '', defaultStoreId: '', bindingStatus: 'ACTIVE', isDefault: apps.value.length === 0, extraConfigText: '{}' }; }
function openBind() { Object.assign(form, defaults()); dialog.value = true; }
function openEdit(row: PlatformApplication) { Object.assign(form, { platformAppId: row.platformAppId, appId: row.appId, appName: row.appName, appStatus: row.appStatus, brandName: row.brandName, logoUrl: row.logoUrl, themeColor: row.themeColor, servicePhone: row.servicePhone, franchisePhone: row.franchisePhone, shareTitle: row.shareTitle, shareImageUrl: row.shareImageUrl, defaultStoreId: row.defaultStoreId || '', bindingStatus: row.bindingStatus, isDefault: row.isDefault, extraConfigText: JSON.stringify(row.extraConfig, null, 2) }); dialog.value = true; }
function configInput(): TenantApplicationConfig | null { let extraConfig: unknown; try { extraConfig = JSON.parse(form.extraConfigText || '{}'); } catch { ElMessage.warning('扩展配置必须是 JSON 对象'); return null; } if (!extraConfig || typeof extraConfig !== 'object' || Array.isArray(extraConfig)) { ElMessage.warning('扩展配置必须是 JSON 对象'); return null; } return { brandName: form.brandName.trim(), logoUrl: form.logoUrl.trim(), themeColor: form.themeColor, servicePhone: form.servicePhone.trim(), franchisePhone: form.franchisePhone.trim(), shareTitle: form.shareTitle.trim(), shareImageUrl: form.shareImageUrl.trim(), defaultStoreId: form.defaultStoreId || null, extraConfig: extraConfig as Record<string, unknown>, bindingStatus: form.bindingStatus, isDefault: form.isDefault }; }
async function save() { const config = configInput(); if (!config || !config.brandName || !/^#[0-9A-Fa-f]{6}$/.test(config.themeColor) || (!form.platformAppId && (!form.appId || !form.appName))) return ElMessage.warning('请填写 AppID、应用名称、品牌名称和有效主题色'); saving.value = true; try { if (form.platformAppId) await capture(() => updatePlatformApplicationConfig(props.session, form.platformAppId, config)); else await capture(() => bindPlatformApplication(props.session, { appId: form.appId.trim(), appName: form.appName.trim(), appStatus: form.appStatus, config })); dialog.value = false; ElMessage.success('应用配置已保存'); await loadData(); } catch { /* displayed */ } finally { saving.value = false; } }
function storeName(id: string) { return stores.value.find((item) => item.id === id)?.name || `门店 #${id}`; }
watch(() => props.session.token, (token) => { if (token) void loadData(); }, { immediate: true });
</script>
+48
View File
@@ -1658,6 +1658,52 @@ textarea {
margin-top: 18px;
}
.platform-apps-page {
display: grid;
gap: 14px;
}
.platform-app-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.platform-app-metrics span {
display: grid;
gap: 6px;
padding: 14px;
background: #fff;
border: 1px solid #d8e0ea;
border-radius: 8px;
}
.platform-app-metrics small {
color: #69788c;
}
.platform-app-metrics strong {
color: #172033;
font-size: 22px;
}
.platform-app-toolbar h3 {
margin: 0;
}
.theme-dot {
display: inline-block;
width: 9px;
height: 9px;
margin-right: 5px;
border-radius: 50%;
}
.color-input {
width: 120px;
margin-left: 8px;
}
.el-button {
border-radius: 8px;
}
@@ -1766,6 +1812,7 @@ textarea {
.people-metrics,
.member-detail-grid,
.device-metrics,
.platform-app-metrics,
.device-topology-grid,
.payment-tools-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1880,6 +1927,7 @@ textarea {
.people-metrics,
.member-detail-grid,
.device-metrics,
.platform-app-metrics,
.device-topology-grid,
.device-control-grid,
.payment-tools-grid {
+33
View File
@@ -437,6 +437,39 @@ export interface DeviceTopology {
maintenance: DeviceMaintenance[];
}
export interface PlatformApplication {
platformAppId: string;
appId: string;
appName: string;
appStatus: 'ACTIVE' | 'DISABLED';
bindingStatus: 'ACTIVE' | 'DISABLED';
isDefault: boolean;
brandName: string;
logoUrl: string;
themeColor: string;
servicePhone: string;
franchisePhone: string;
shareTitle: string;
shareImageUrl: string;
defaultStoreId: string | null;
extraConfig: Record<string, unknown>;
updatedAt: string;
}
export interface TenantApplicationConfig {
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 interface CleaningTask {
id: string;
taskNo: string;
+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 {}; }
}
@@ -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.');
+11
View File
@@ -16,6 +16,7 @@ for (const pattern of [
"activeModule = 'thirdParty'",
"activeModule = 'people'",
"activeModule = 'devices'",
"activeModule = 'platformApps'",
'平台运营总览',
'StoresRoomsPanel',
'OrdersPanel',
@@ -23,6 +24,7 @@ for (const pattern of [
'ThirdPartyPanel',
'MembersStaffPanel',
'DevicesPanel',
'PlatformAppsPanel',
'运营总览',
'savedToken',
'loadCleaningWorkspace'
@@ -175,6 +177,14 @@ for (const pattern of [
assert.match(devices, new RegExp(pattern));
}
const platformApps = read('admin/src/components/PlatformAppsPanel.vue');
for (const pattern of ['listPlatformApplications', 'updatePlatformApplicationConfig', 'bindPlatformApplication', 'AppSecret', 'extraConfigText']) {
assert.match(platformApps, new RegExp(pattern));
}
const platformRoutes = read('backend/src/routes/platform-management.ts');
assert.match(platformRoutes, /'\/admin-api\/platform-apps'/);
assert.match(platformRoutes, /PLATFORM_MANAGEMENT_FORBIDDEN/);
const routes = read('backend/src/routes/business-statistics.ts');
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
@@ -212,6 +222,7 @@ for (const pattern of [
'.member-detail-grid',
'.device-metrics',
'.device-topology-grid',
'.platform-app-metrics',
'@media (max-width: 980px)',
'@media (max-width: 560px)'
]) {
+8
View File
@@ -20,8 +20,10 @@ $requiredFiles = @(
"backend/src/tasks/worker.ts",
"backend/src/routes/health.ts",
"backend/src/routes/platform-bootstrap.ts",
"backend/src/routes/platform-management.ts",
"backend/src/routes/auth.ts",
"backend/src/tenancy/platform-config-repository.ts",
"backend/src/tenancy/platform-admin-repository.ts",
"backend/src/server.ts",
"backend/tests/backend-contract.test.mjs",
"backend/tests/migration-contract.test.mjs",
@@ -31,6 +33,7 @@ $requiredFiles = @(
"backend/tests/legacy-read-repository.test.mjs",
"backend/tests/task-repository.test.mjs",
"backend/tests/platform-config-repository.test.mjs",
"backend/tests/platform-management.test.mjs",
"backend/tests/auth.test.mjs",
"backend/tests/rbac.test.mjs",
"scripts/dev/wsl/mysql-migration-roundtrip.sh",
@@ -81,6 +84,11 @@ if ($LASTEXITCODE -ne 0) {
throw "backend contract test failed"
}
& node backend/tests/platform-management.test.mjs
if ($LASTEXITCODE -ne 0) {
throw "platform management test failed"
}
& npm --prefix backend run build
if ($LASTEXITCODE -ne 0) {
throw "backend build failed"