feat(M08-D): 补多小程序与租户配置
This commit is contained in:
+10
-1
@@ -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: '设备资产、拓扑与控制' },
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
@@ -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)'
|
||||
]) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user