feat(M08-D): 补审计日志与系统配置
This commit is contained in:
+10
-1
@@ -13,6 +13,7 @@
|
|||||||
<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 === 'platformApps' }" type="button" @click="activeModule = 'platformApps'"><PanelsTopLeft :size="18" /><span>小程序租户</span></button>
|
||||||
<button class="nav-item" :class="{ active: activeModule === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
|
<button class="nav-item" :class="{ active: activeModule === 'content' }" type="button" @click="activeModule = 'content'"><Images :size="18" /><span>广告装修</span></button>
|
||||||
<button class="nav-item" :class="{ active: activeModule === 'franchise' }" type="button" @click="activeModule = 'franchise'"><Handshake :size="18" /><span>加盟跟进</span></button>
|
<button class="nav-item" :class="{ active: activeModule === 'franchise' }" type="button" @click="activeModule = 'franchise'"><Handshake :size="18" /><span>加盟跟进</span></button>
|
||||||
|
<button class="nav-item" :class="{ active: activeModule === 'system' }" type="button" @click="activeModule = 'system'"><ScrollText :size="18" /><span>日志配置</span></button>
|
||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
:class="{ active: activeModule === 'overview' }"
|
:class="{ active: activeModule === 'overview' }"
|
||||||
@@ -130,6 +131,11 @@
|
|||||||
:session="session"
|
:session="session"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LogsSystemPanel
|
||||||
|
v-else-if="activeModule === 'system'"
|
||||||
|
:session="session"
|
||||||
|
/>
|
||||||
|
|
||||||
<StoresRoomsPanel
|
<StoresRoomsPanel
|
||||||
v-else-if="activeModule === 'stores'"
|
v-else-if="activeModule === 'stores'"
|
||||||
:session="session"
|
:session="session"
|
||||||
@@ -329,6 +335,7 @@ import {
|
|||||||
PanelsTopLeft,
|
PanelsTopLeft,
|
||||||
RadioTower,
|
RadioTower,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
ScrollText,
|
||||||
Save,
|
Save,
|
||||||
Send,
|
Send,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -352,6 +359,7 @@ import DevicesPanel from './components/DevicesPanel.vue';
|
|||||||
import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
|
import PlatformAppsPanel from './components/PlatformAppsPanel.vue';
|
||||||
import ContentManagementPanel from './components/ContentManagementPanel.vue';
|
import ContentManagementPanel from './components/ContentManagementPanel.vue';
|
||||||
import FranchisePanel from './components/FranchisePanel.vue';
|
import FranchisePanel from './components/FranchisePanel.vue';
|
||||||
|
import LogsSystemPanel from './components/LogsSystemPanel.vue';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
assignCleaningTask,
|
assignCleaningTask,
|
||||||
@@ -388,7 +396,7 @@ import { money } from './format';
|
|||||||
|
|
||||||
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
||||||
const tokenDraft = ref(savedToken.value);
|
const tokenDraft = ref(savedToken.value);
|
||||||
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'franchise' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
|
const activeModule = ref<'overview' | 'platformApps' | 'content' | 'franchise' | 'system' | 'stores' | 'orders' | 'devices' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
|
||||||
const activeTab = ref('tasks');
|
const activeTab = ref('tasks');
|
||||||
const lastError = ref('');
|
const lastError = ref('');
|
||||||
const lastMessage = ref('');
|
const lastMessage = ref('');
|
||||||
@@ -402,6 +410,7 @@ const activeModuleMeta = computed(() => ({
|
|||||||
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
|
platformApps: { stage: 'M08-D', title: '多小程序与租户品牌配置' },
|
||||||
content: { stage: 'M08-D', title: '广告投放与门店装修' },
|
content: { stage: 'M08-D', title: '广告投放与门店装修' },
|
||||||
franchise: { stage: 'M08-D', title: '加盟申请与跟进运营' },
|
franchise: { stage: 'M08-D', title: '加盟申请与跟进运营' },
|
||||||
|
system: { stage: 'M08-D', title: '安全审计与系统配置' },
|
||||||
stores: { stage: 'M08-D', title: '门店与房间管理' },
|
stores: { stage: 'M08-D', title: '门店与房间管理' },
|
||||||
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
|
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
|
||||||
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
|
devices: { stage: 'M08-D', title: '设备资产、拓扑与控制' },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Advertisement,
|
Advertisement,
|
||||||
AdvertisementInput,
|
AdvertisementInput,
|
||||||
|
AuditLog,
|
||||||
CleaningSettlement,
|
CleaningSettlement,
|
||||||
CleaningSettlementDetail,
|
CleaningSettlementDetail,
|
||||||
CleaningStatistics,
|
CleaningStatistics,
|
||||||
@@ -33,6 +34,7 @@ import type {
|
|||||||
PayoutStateFilter,
|
PayoutStateFilter,
|
||||||
StaffRole,
|
StaffRole,
|
||||||
SettlementStatus,
|
SettlementStatus,
|
||||||
|
SystemOverview,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
TransferMode,
|
TransferMode,
|
||||||
RoomConfigurationStatus,
|
RoomConfigurationStatus,
|
||||||
@@ -532,6 +534,31 @@ export function addFranchiseFollowUp(session: ApiSession, applicationId: string,
|
|||||||
{ method: 'POST', body: JSON.stringify(input) });
|
{ method: 'POST', body: JSON.stringify(input) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listAuditLogs(session: ApiSession, input: {
|
||||||
|
page: number; pageSize: number; action?: string; resourceType?: string; search?: string;
|
||||||
|
from?: string; to?: string;
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
|
||||||
|
if (input.action) params.set('action', input.action);
|
||||||
|
if (input.resourceType) params.set('resourceType', input.resourceType);
|
||||||
|
if (input.search) params.set('search', input.search);
|
||||||
|
if (input.from) params.set('from', input.from);
|
||||||
|
if (input.to) params.set('to', input.to);
|
||||||
|
return request<PageResult<AuditLog>>(session, `/audit-logs?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSystemOverview(session: ApiSession) {
|
||||||
|
return request<SystemOverview>(session, '/system/overview');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTenantSystemConfig(session: ApiSession, input: {
|
||||||
|
name: string; timezone: string; status?: 'ACTIVE' | 'DISABLED';
|
||||||
|
}) {
|
||||||
|
return request<{ tenantId: string; updated: boolean }>(session, '/system/tenant', {
|
||||||
|
method: 'PUT', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createStaffUser(
|
export function createStaffUser(
|
||||||
session: ApiSession,
|
session: ApiSession,
|
||||||
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<section class="system-page">
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
|
||||||
|
<el-tabs v-model="activeTab" class="panel system-tabs">
|
||||||
|
<el-tab-pane label="安全审计" name="audit">
|
||||||
|
<header class="panel-toolbar">
|
||||||
|
<div><p class="section-kicker">M08-D · 可追溯运营</p><h3>安全审计日志</h3></div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button :icon="Download" :disabled="page.items.length === 0" @click="exportCsv">导出当前页</el-button>
|
||||||
|
<el-button :icon="RefreshCw" :loading="auditLoading" @click="loadAudit">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="audit-filters">
|
||||||
|
<el-input v-model="filters.search" clearable placeholder="操作、资源、追踪号或操作人" @keyup.enter="applyFilters" />
|
||||||
|
<el-input v-model="filters.action" clearable placeholder="操作代码,如 ORDER_CREATED" @keyup.enter="applyFilters" />
|
||||||
|
<el-input v-model="filters.resourceType" clearable placeholder="资源类型,如 ORDER" @keyup.enter="applyFilters" />
|
||||||
|
<el-date-picker v-model="dateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" />
|
||||||
|
<el-button type="primary" @click="applyFilters">筛选</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="auditLoading" :data="page.items" row-key="id" class="data-table">
|
||||||
|
<el-table-column label="时间" min-width="168"><template #default="{ row }">{{ shortDate(row.createdAt) }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.action }}</strong><span>{{ row.resourceType }}{{ row.resourceId ? ` #${row.resourceId}` : '' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="操作人" min-width="150"><template #default="{ row }"><div class="stack"><strong>{{ row.actorName || row.actorType }}</strong><span>{{ row.actorId ? `#${row.actorId}` : '系统' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="来源" min-width="150"><template #default="{ row }"><div class="stack"><span>{{ row.ip || '-' }}</span><small>{{ compactAgent(row.userAgent) }}</small></div></template></el-table-column>
|
||||||
|
<el-table-column label="追踪号" min-width="180" show-overflow-tooltip prop="traceId" />
|
||||||
|
<el-table-column label="详情" width="92" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Eye" @click="selected = row; drawer = true">查看</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination v-model:current-page="page.page" :page-size="page.pageSize" :total="page.total" layout="prev, pager, next, total" @current-change="loadAudit" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="系统配置" name="system">
|
||||||
|
<div v-loading="systemLoading" class="system-workspace">
|
||||||
|
<section class="system-metrics">
|
||||||
|
<span><small>门店</small><strong>{{ overview?.counts.storeCount ?? 0 }}</strong></span>
|
||||||
|
<span><small>用户</small><strong>{{ overview?.counts.userCount ?? 0 }}</strong></span>
|
||||||
|
<span><small>活跃会话</small><strong>{{ overview?.counts.activeSessionCount ?? 0 }}</strong></span>
|
||||||
|
<span><small>今日审计</small><strong>{{ overview?.counts.auditTodayCount ?? 0 }}</strong></span>
|
||||||
|
</section>
|
||||||
|
<section class="system-config-grid">
|
||||||
|
<article class="config-card">
|
||||||
|
<header><div><p class="section-kicker">租户配置</p><h3>{{ overview?.tenant.code || '当前租户' }}</h3></div><el-tag :type="tenantStatusType">{{ overview?.tenant.status || '-' }}</el-tag></header>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="租户名称"><el-input v-model="tenantForm.name" maxlength="128" /></el-form-item>
|
||||||
|
<el-form-item label="默认时区"><el-input v-model="tenantForm.timezone" placeholder="Asia/Shanghai" /></el-form-item>
|
||||||
|
<el-form-item label="平台状态操作">
|
||||||
|
<div class="status-control"><el-switch v-model="applyStatus" active-text="本次更新状态" /><el-select v-model="tenantForm.status" :disabled="!applyStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></div>
|
||||||
|
<small class="field-help">只有平台管理员可以变更租户启停状态;普通租户管理员请保持关闭。</small>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveConfig">保存系统配置</el-button>
|
||||||
|
</article>
|
||||||
|
<article class="config-card migration-card">
|
||||||
|
<p class="section-kicker">数据库状态</p><h3>Schema 迁移</h3>
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<el-descriptions-item label="最新版本">{{ overview?.latestMigration?.version || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="迁移名称">{{ overview?.latestMigration?.name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="执行时间">{{ overview?.latestMigration?.appliedAt ? shortDate(overview.latestMigration.appliedAt) : '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="小程序绑定">{{ overview?.counts.appBindingCount ?? 0 }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-button :icon="RefreshCw" :loading="systemLoading" @click="loadSystem">刷新运行状态</el-button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-drawer v-model="drawer" title="审计事件详情" size="min(640px, 96vw)">
|
||||||
|
<el-descriptions v-if="selected" :column="1" border>
|
||||||
|
<el-descriptions-item label="操作">{{ selected.action }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="资源">{{ selected.resourceType }} {{ selected.resourceId || '' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="追踪号">{{ selected.traceId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作人">{{ selected.actorName || selected.actorType }} {{ selected.actorId || '' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="脱敏来源 IP">{{ selected.ip || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<h4>脱敏元数据</h4><pre class="audit-metadata">{{ prettyMetadata }}</pre>
|
||||||
|
</el-drawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { Download, Eye, RefreshCw } from '@lucide/vue';
|
||||||
|
import { ApiError, getSystemOverview, listAuditLogs, updateTenantSystemConfig, type ApiSession } from '../api';
|
||||||
|
import { shortDate } from '../format';
|
||||||
|
import type { AuditLog, PageResult, SystemOverview } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const activeTab = ref('audit'); const auditLoading = ref(false); const systemLoading = ref(false); const saving = ref(false);
|
||||||
|
const lastError = ref(''); const drawer = ref(false); const selected = ref<AuditLog | null>(null);
|
||||||
|
const overview = ref<SystemOverview | null>(null); const dateRange = ref<[Date, Date] | null>(null);
|
||||||
|
const applyStatus = ref(false);
|
||||||
|
const filters = reactive({ search: '', action: '', resourceType: '' });
|
||||||
|
const tenantForm = reactive({ name: '', timezone: 'Asia/Shanghai', status: 'ACTIVE' as 'ACTIVE' | 'DISABLED' });
|
||||||
|
const page = reactive<PageResult<AuditLog>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
|
const prettyMetadata = computed(() => JSON.stringify(selected.value?.metadata ?? {}, null, 2));
|
||||||
|
const tenantStatusType = computed(() => overview.value?.tenant.status === 'ACTIVE' ? 'success' : 'danger');
|
||||||
|
|
||||||
|
function captureError(error: unknown) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; }
|
||||||
|
async function loadAudit() { if (!props.session.token) return; auditLoading.value = true; lastError.value = ''; try { const result = await listAuditLogs(props.session, { page: page.page, pageSize: page.pageSize, search: filters.search.trim() || undefined, action: filters.action.trim() || undefined, resourceType: filters.resourceType.trim() || undefined, from: dateRange.value?.[0].toISOString(), to: dateRange.value?.[1].toISOString() }); Object.assign(page, result); } catch (error) { captureError(error); } finally { auditLoading.value = false; } }
|
||||||
|
async function loadSystem() { if (!props.session.token) return; systemLoading.value = true; lastError.value = ''; try { overview.value = await getSystemOverview(props.session); Object.assign(tenantForm, { name: overview.value.tenant.name, timezone: overview.value.tenant.timezone, status: overview.value.tenant.status }); applyStatus.value = false; } catch (error) { captureError(error); } finally { systemLoading.value = false; } }
|
||||||
|
function applyFilters() { page.page = 1; void loadAudit(); }
|
||||||
|
async function saveConfig() { if (!tenantForm.name.trim() || !tenantForm.timezone.trim()) return ElMessage.warning('请填写租户名称和有效时区'); saving.value = true; try { await updateTenantSystemConfig(props.session, { name: tenantForm.name.trim(), timezone: tenantForm.timezone.trim(), status: applyStatus.value ? tenantForm.status : undefined }); ElMessage.success('系统配置已保存'); await loadSystem(); } catch (error) { captureError(error); } finally { saving.value = false; } }
|
||||||
|
function compactAgent(value: string) { return value ? value.slice(0, 36) : '-'; }
|
||||||
|
function csvCell(value: unknown) { return `"${String(value ?? '').replace(/"/g, '""')}"`; }
|
||||||
|
function exportCsv() { const rows = [['时间', '操作', '资源类型', '资源ID', '操作人', '来源IP', '追踪号'], ...page.items.map((item) => [item.createdAt, item.action, item.resourceType, item.resourceId, item.actorName || item.actorType, item.ip, item.traceId])]; const blob = new Blob([`\ufeff${rows.map((row) => row.map(csvCell).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click(); URL.revokeObjectURL(url); }
|
||||||
|
watch(() => props.session.token, (token) => { if (token) void Promise.all([loadAudit(), loadSystem()]); }, { immediate: true });
|
||||||
|
</script>
|
||||||
+103
-1
@@ -239,6 +239,102 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-page,
|
||||||
|
.system-workspace {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-tabs {
|
||||||
|
padding: 0 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(210px, 1fr) 210px 180px minmax(320px, 1.2fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-metrics > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid #dbe3ec;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-metrics small,
|
||||||
|
.field-help {
|
||||||
|
color: #66758a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-metrics strong {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(320px, .8fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid #dbe3ec;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card > header,
|
||||||
|
.status-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card h3 {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-control .el-select {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
display: block;
|
||||||
|
margin-top: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-card .el-button {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-metadata {
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 160px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #14202b;
|
||||||
|
color: #d9e7ef;
|
||||||
|
font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.franchise-metrics {
|
.franchise-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
@@ -321,7 +417,10 @@
|
|||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.franchise-metrics,
|
.franchise-metrics,
|
||||||
.franchise-filters {
|
.franchise-filters,
|
||||||
|
.system-metrics,
|
||||||
|
.audit-filters,
|
||||||
|
.system-config-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,6 +428,9 @@
|
|||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.franchise-metrics,
|
.franchise-metrics,
|
||||||
.franchise-filters,
|
.franchise-filters,
|
||||||
|
.system-metrics,
|
||||||
|
.audit-filters,
|
||||||
|
.system-config-grid,
|
||||||
.follow-up-form .el-form {
|
.follow-up-form .el-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -539,6 +539,42 @@ export interface FranchiseApplicationDetail {
|
|||||||
followUps: FranchiseFollowUp[];
|
followUps: FranchiseFollowUp[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
actorType: string;
|
||||||
|
actorId: string | null;
|
||||||
|
actorName: string | null;
|
||||||
|
action: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string | null;
|
||||||
|
traceId: string;
|
||||||
|
ip: string;
|
||||||
|
userAgent: string;
|
||||||
|
metadata: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemOverview {
|
||||||
|
tenant: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
status: 'ACTIVE' | 'DISABLED';
|
||||||
|
timezone: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
counts: {
|
||||||
|
storeCount: number;
|
||||||
|
userCount: number;
|
||||||
|
activeSessionCount: number;
|
||||||
|
appBindingCount: number;
|
||||||
|
auditTodayCount: number;
|
||||||
|
};
|
||||||
|
latestMigration: null | { version: string; name: string; appliedAt: string };
|
||||||
|
}
|
||||||
|
|
||||||
export interface CleaningTask {
|
export interface CleaningTask {
|
||||||
id: string;
|
id: string;
|
||||||
taskNo: string;
|
taskNo: string;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
|
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ import {
|
|||||||
type BusinessStatisticsRouteOptions
|
type BusinessStatisticsRouteOptions
|
||||||
} from './routes/business-statistics.js';
|
} from './routes/business-statistics.js';
|
||||||
import { registerFranchiseRoutes, type FranchiseRouteOptions } from './routes/franchise.js';
|
import { registerFranchiseRoutes, type FranchiseRouteOptions } from './routes/franchise.js';
|
||||||
|
import {
|
||||||
|
registerSystemOperationsRoutes,
|
||||||
|
type SystemOperationsRouteOptions
|
||||||
|
} from './routes/system-operations.js';
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
config?: AppConfig;
|
config?: AppConfig;
|
||||||
@@ -83,6 +87,7 @@ export interface BuildAppOptions {
|
|||||||
cleaning?: CleaningRouteOptions;
|
cleaning?: CleaningRouteOptions;
|
||||||
businessStatistics?: BusinessStatisticsRouteOptions;
|
businessStatistics?: BusinessStatisticsRouteOptions;
|
||||||
franchise?: FranchiseRouteOptions;
|
franchise?: FranchiseRouteOptions;
|
||||||
|
systemOperations?: SystemOperationsRouteOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@@ -193,6 +198,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||||||
if (options.franchise) {
|
if (options.franchise) {
|
||||||
await registerFranchiseRoutes(app, options.franchise);
|
await registerFranchiseRoutes(app, options.franchise);
|
||||||
}
|
}
|
||||||
|
if (options.systemOperations) {
|
||||||
|
await registerSystemOperationsRoutes(app, options.systemOperations);
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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 AuditLogQuery {
|
||||||
|
tenantId: string; page: number; pageSize: number; action?: string; resourceType?: string;
|
||||||
|
actorId?: string; search?: string; from?: Date; to?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditRow extends RowDataPacket {
|
||||||
|
id: string; tenantId: string; actorType: string; actorId: string | null; actorName: string | null;
|
||||||
|
action: string; resourceType: string; resourceId: string | null; traceId: string;
|
||||||
|
ip: string; userAgent: string; metadata: unknown; createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SystemOperationsError extends Error {
|
||||||
|
constructor(public readonly code: string) { super(code); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SystemOperationsRepository {
|
||||||
|
constructor(private readonly pool: MySqlPool) {}
|
||||||
|
|
||||||
|
async listAuditLogs(input: AuditLogQuery) {
|
||||||
|
const page = Number.isSafeInteger(input.page) && input.page > 0 ? input.page : 1;
|
||||||
|
const pageSize = Number.isSafeInteger(input.pageSize)
|
||||||
|
? Math.min(100, Math.max(1, input.pageSize)) : 20;
|
||||||
|
const where = ['l.tenant_id = ?']; const params: Array<string | Date> = [input.tenantId];
|
||||||
|
if (input.action) { where.push('l.action = ?'); params.push(input.action); }
|
||||||
|
if (input.resourceType) { where.push('l.resource_type = ?'); params.push(input.resourceType); }
|
||||||
|
if (input.actorId) { where.push('l.actor_id = ?'); params.push(input.actorId); }
|
||||||
|
if (input.from) { where.push('l.created_at >= ?'); params.push(input.from); }
|
||||||
|
if (input.to) { where.push('l.created_at < ?'); params.push(input.to); }
|
||||||
|
if (input.search) {
|
||||||
|
where.push('(l.action LIKE ? OR l.resource_type LIKE ? OR l.trace_id LIKE ? OR u.nickname LIKE ?)');
|
||||||
|
const term = `%${input.search}%`; params.push(term, term, term, term);
|
||||||
|
}
|
||||||
|
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
|
||||||
|
`SELECT COUNT(*) AS total FROM qipai_audit_logs l
|
||||||
|
LEFT JOIN qipai_users u ON u.id = l.actor_id WHERE ${where.join(' AND ')}`, params
|
||||||
|
);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const [rows] = await this.pool.execute<AuditRow[]>(
|
||||||
|
`SELECT l.id, l.tenant_id AS tenantId, l.actor_type AS actorType,
|
||||||
|
l.actor_id AS actorId, u.nickname AS actorName, l.action,
|
||||||
|
l.resource_type AS resourceType, l.resource_id AS resourceId,
|
||||||
|
l.trace_id AS traceId, l.ip, l.user_agent AS userAgent,
|
||||||
|
l.metadata, l.created_at AS createdAt
|
||||||
|
FROM qipai_audit_logs l LEFT JOIN qipai_users u ON u.id = l.actor_id
|
||||||
|
WHERE ${where.join(' AND ')} ORDER BY l.id DESC
|
||||||
|
LIMIT ${pageSize} OFFSET ${offset}`, params
|
||||||
|
);
|
||||||
|
return { items: rows.map((row) => ({ ...row, id: String(row.id), tenantId: String(row.tenantId),
|
||||||
|
actorId: row.actorId === null ? null : String(row.actorId),
|
||||||
|
resourceId: row.resourceId === null ? null : String(row.resourceId),
|
||||||
|
ip: maskIp(row.ip), metadata: sanitizeMetadata(parseJson(row.metadata)) })),
|
||||||
|
total: Number(counts[0]?.total ?? 0), page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSystemOverview(tenantId: string) {
|
||||||
|
const [tenants] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT id, code, name, status, timezone, created_at AS createdAt, updated_at AS updatedAt
|
||||||
|
FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL`, [tenantId]
|
||||||
|
);
|
||||||
|
if (!tenants[0]) throw new SystemOperationsError('SYSTEM_TENANT_NOT_FOUND');
|
||||||
|
const [counts] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COUNT(*) FROM qipai_stores WHERE tenant_id = ? AND deleted_at IS NULL) AS storeCount,
|
||||||
|
(SELECT COUNT(*) FROM qipai_users WHERE tenant_id = ? AND deleted_at IS NULL) AS userCount,
|
||||||
|
(SELECT COUNT(*) FROM qipai_auth_sessions WHERE tenant_id = ? AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP(3)) AS activeSessionCount,
|
||||||
|
(SELECT COUNT(*) FROM qipai_tenant_apps WHERE tenant_id = ? AND deleted_at IS NULL) AS appBindingCount,
|
||||||
|
(SELECT COUNT(*) FROM qipai_audit_logs WHERE tenant_id = ? AND created_at >= UTC_DATE()) AS auditTodayCount`,
|
||||||
|
[tenantId, tenantId, tenantId, tenantId, tenantId]
|
||||||
|
);
|
||||||
|
const [migrations] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
'SELECT version, name, applied_at AS appliedAt FROM qipai_schema_migrations ORDER BY version DESC LIMIT 1'
|
||||||
|
);
|
||||||
|
return { tenant: { ...tenants[0], id: String(tenants[0].id) },
|
||||||
|
counts: Object.fromEntries(Object.entries(counts[0] ?? {}).map(([key, value]) => [key, Number(value)])),
|
||||||
|
latestMigration: migrations[0] ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTenant(actor: ManagementActor, tenantId: string, input: {
|
||||||
|
name: string; timezone: string; status?: 'ACTIVE' | 'DISABLED';
|
||||||
|
}) {
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
const [rows] = await connection.execute<Array<RowDataPacket & { name: string; timezone: string; status: string }>>(
|
||||||
|
'SELECT name, timezone, status FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL FOR UPDATE', [tenantId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new SystemOperationsError('SYSTEM_TENANT_NOT_FOUND');
|
||||||
|
await connection.execute<ResultSetHeader>(
|
||||||
|
`UPDATE qipai_tenants SET name = ?, timezone = ?, status = COALESCE(?, status)
|
||||||
|
WHERE id = ? AND deleted_at IS NULL`, [input.name, input.timezone, input.status ?? null, tenantId]
|
||||||
|
);
|
||||||
|
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', ?, 'TENANT_SYSTEM_CONFIG_UPDATED',
|
||||||
|
'TENANT', ?, ?, ?, ?, ?)`,
|
||||||
|
[tenantId, actor.userId, tenantId, actor.traceId, actor.ip, actor.userAgent.slice(0, 255),
|
||||||
|
JSON.stringify({ before: rows[0], after: input })]
|
||||||
|
);
|
||||||
|
return { tenantId, updated: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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): unknown {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
try { return JSON.parse(value); } catch { return {}; }
|
||||||
|
}
|
||||||
|
function sanitizeMetadata(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(sanitizeMetadata);
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) =>
|
||||||
|
[key, /(secret|token|password|private|credential|certificate|api.?key|phone|openid|receiver|voucher)/i.test(key)
|
||||||
|
? '[REDACTED]' : sanitizeMetadata(item)]));
|
||||||
|
}
|
||||||
|
function maskIp(ip: string) {
|
||||||
|
if (!ip) return '';
|
||||||
|
if (ip.includes(':')) return `${ip.split(':').slice(0, 2).join(':')}::****`;
|
||||||
|
const parts = ip.split('.'); return parts.length === 4 ? `${parts[0]}.${parts[1]}.***.***` : '***';
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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 {
|
||||||
|
SystemOperationsError,
|
||||||
|
type SystemOperationsRepository
|
||||||
|
} from '../operations/system-operations-repository.js';
|
||||||
|
|
||||||
|
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||||
|
const auditQuerySchema = z.object({
|
||||||
|
tenantId: id.optional(),
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
action: z.string().trim().min(1).max(128).optional(),
|
||||||
|
resourceType: z.string().trim().min(1).max(64).optional(),
|
||||||
|
actorId: id.optional(),
|
||||||
|
search: z.string().trim().min(1).max(128).optional(),
|
||||||
|
from: z.coerce.date().optional(),
|
||||||
|
to: z.coerce.date().optional()
|
||||||
|
}).strict();
|
||||||
|
const tenantQuerySchema = z.object({ tenantId: id.optional() }).strict();
|
||||||
|
const updateTenantSchema = z.object({
|
||||||
|
tenantId: id.optional(),
|
||||||
|
name: z.string().trim().min(1).max(128),
|
||||||
|
timezone: z.string().trim().min(1).max(64),
|
||||||
|
status: z.enum(['ACTIVE', 'DISABLED']).optional()
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export interface SystemOperationsRouteOptions {
|
||||||
|
repository: Pick<SystemOperationsRepository, 'listAuditLogs' | 'getSystemOverview' | 'updateTenant'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerSystemOperationsRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
options: SystemOperationsRouteOptions
|
||||||
|
) {
|
||||||
|
app.get('/admin-api/audit-logs', async (request, reply) => {
|
||||||
|
const actor = await requireManager(request, reply, options);
|
||||||
|
const query = auditQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
const tenantId = resolveTenant(actor, query.data.tenantId);
|
||||||
|
if (!tenantId) return tenantForbidden(reply, request.traceId);
|
||||||
|
const to = query.data.to ?? new Date();
|
||||||
|
const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000);
|
||||||
|
if (to.getTime() <= from.getTime() || to.getTime() - from.getTime() > 366 * 86400000) {
|
||||||
|
return invalid(reply, request.traceId);
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listAuditLogs({ ...query.data, tenantId, from, to }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/system/overview', async (request, reply) => {
|
||||||
|
const actor = await requireManager(request, reply, options);
|
||||||
|
const query = tenantQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
const tenantId = resolveTenant(actor, query.data.tenantId);
|
||||||
|
if (!tenantId) return tenantForbidden(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.getSystemOverview(tenantId),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin-api/system/tenant', async (request, reply) => {
|
||||||
|
const actor = await requireManager(request, reply, options);
|
||||||
|
const body = updateTenantSchema.safeParse(request.body);
|
||||||
|
if (!actor || !body.success || !isValidTimeZone(body.data.timezone)) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
const tenantId = resolveTenant(actor, body.data.tenantId);
|
||||||
|
if (!tenantId) return tenantForbidden(reply, request.traceId);
|
||||||
|
if (body.data.status && !isPlatform(actor.access)) {
|
||||||
|
return reply.status(403).send({
|
||||||
|
code: 'SYSTEM_STATUS_FORBIDDEN',
|
||||||
|
message: 'Platform management permission is required to change tenant status.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.updateTenant(actor, tenantId, body.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireManager(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
options: SystemOperationsRouteOptions
|
||||||
|
): 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
|
||||||
|
);
|
||||||
|
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
|
||||||
|
reply.status(403).send({
|
||||||
|
code: 'SYSTEM_OPERATIONS_FORBIDDEN',
|
||||||
|
message: 'Tenant 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'] ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTenant(actor: ManagementActor, requested?: string) {
|
||||||
|
if (!requested || requested === actor.tenantId) return actor.tenantId;
|
||||||
|
return isPlatform(actor.access) ? requested : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlatform(access: AccessProfile) {
|
||||||
|
return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTimeZone(value: string) {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat('en-US', { timeZone: value }).format();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||||
|
try {
|
||||||
|
return await work();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof SystemOperationsError)) throw error;
|
||||||
|
const statusCode = error.code.endsWith('_NOT_FOUND') ? 404 : 400;
|
||||||
|
return reply.status(statusCode).send({
|
||||||
|
code: error.code,
|
||||||
|
message: 'The system operations request is invalid.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
code: 'INVALID_SYSTEM_OPERATIONS_REQUEST',
|
||||||
|
message: 'The system operations request is invalid.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tenantForbidden(reply: FastifyReply, traceId: string) {
|
||||||
|
return reply.status(403).send({
|
||||||
|
code: 'SYSTEM_TENANT_FORBIDDEN',
|
||||||
|
message: 'The tenant is outside the allowed scope.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
|
|||||||
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
|
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
|
||||||
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
|
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
|
||||||
import { FranchiseRepository } from './franchise/franchise-repository.js';
|
import { FranchiseRepository } from './franchise/franchise-repository.js';
|
||||||
|
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const pool = createMySqlPool(config);
|
const pool = createMySqlPool(config);
|
||||||
@@ -225,6 +226,12 @@ const app = await buildApp({
|
|||||||
authRepository,
|
authRepository,
|
||||||
accessControl,
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret
|
jwtSecret: config.auth.jwtSecret
|
||||||
|
},
|
||||||
|
systemOperations: {
|
||||||
|
repository: new SystemOperationsRepository(pool),
|
||||||
|
authRepository,
|
||||||
|
accessControl,
|
||||||
|
jwtSecret: config.auth.jwtSecret
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { DeviceCommandService } from '../dist/devices/device-command-service.js'
|
|||||||
import { DeviceControlService } from '../dist/devices/device-control-service.js';
|
import { DeviceControlService } from '../dist/devices/device-control-service.js';
|
||||||
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
|
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
|
||||||
import { BusinessStatisticsRepository } from '../dist/operations/business-statistics-repository.js';
|
import { BusinessStatisticsRepository } from '../dist/operations/business-statistics-repository.js';
|
||||||
|
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
|
||||||
import {
|
import {
|
||||||
executeMigrationPlan,
|
executeMigrationPlan,
|
||||||
loadMigrationPlan,
|
loadMigrationPlan,
|
||||||
@@ -1795,6 +1796,49 @@ async function assertFranchiseManagement(pool, context) {
|
|||||||
assert.equal(auditRows.some((row) => row.metadata.includes('13800138000')), false);
|
assert.equal(auditRows.some((row) => row.metadata.includes('13800138000')), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertSystemOperations(pool, context) {
|
||||||
|
const [adminRows] = await pool.query(
|
||||||
|
`SELECT u.id FROM qipai_users u
|
||||||
|
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||||
|
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
|
||||||
|
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`, [context.tenantId]
|
||||||
|
);
|
||||||
|
const adminId = String(adminRows[0].id);
|
||||||
|
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
|
||||||
|
const actor = { tenantId: context.tenantId, userId: adminId, access,
|
||||||
|
traceId: 'm08d-system-live', ip: '172.18.20.42', userAgent: 'M08-D system live test' };
|
||||||
|
await pool.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', ?, 'M08D_REDACTION_PROBE',
|
||||||
|
'TENANT', ?, ?, ?, ?, ?)`,
|
||||||
|
[context.tenantId, adminId, context.tenantId, actor.traceId, actor.ip, actor.userAgent,
|
||||||
|
JSON.stringify({ orderId: 'safe-order', phone: '13800138000', nested: { apiKey: 'secret', safe: 'visible' } })]
|
||||||
|
);
|
||||||
|
const repository = new SystemOperationsRepository(pool);
|
||||||
|
const logs = await repository.listAuditLogs({
|
||||||
|
tenantId: context.tenantId, page: 1, pageSize: 20, action: 'M08D_REDACTION_PROBE'
|
||||||
|
});
|
||||||
|
assert.equal(logs.total, 1);
|
||||||
|
assert.equal(logs.items[0].tenantId, context.tenantId);
|
||||||
|
assert.equal(logs.items[0].ip, '172.18.***.***');
|
||||||
|
assert.equal(logs.items[0].metadata.phone, '[REDACTED]');
|
||||||
|
assert.equal(logs.items[0].metadata.nested.apiKey, '[REDACTED]');
|
||||||
|
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||||
|
const overview = await repository.getSystemOverview(context.tenantId);
|
||||||
|
assert.equal(overview.tenant.id, context.tenantId);
|
||||||
|
assert.equal(overview.latestMigration.version, '2026081003');
|
||||||
|
assert.ok(overview.counts.userCount > 0);
|
||||||
|
await repository.updateTenant(actor, context.tenantId, {
|
||||||
|
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||||
|
});
|
||||||
|
const updatedLogs = await repository.listAuditLogs({
|
||||||
|
tenantId: context.tenantId, page: 1, pageSize: 20, action: 'TENANT_SYSTEM_CONFIG_UPDATED'
|
||||||
|
});
|
||||||
|
assert.equal(updatedLogs.total, 1);
|
||||||
|
assert.equal(updatedLogs.items[0].actorId, adminId);
|
||||||
|
}
|
||||||
|
|
||||||
async function assertDeviceTopology(pool, context) {
|
async function assertDeviceTopology(pool, context) {
|
||||||
const [adminRows] = await pool.query(
|
const [adminRows] = await pool.query(
|
||||||
`SELECT u.id FROM qipai_users u
|
`SELECT u.id FROM qipai_users u
|
||||||
@@ -2083,6 +2127,7 @@ try {
|
|||||||
await assertStoreRoomDomain(pool, loginContext);
|
await assertStoreRoomDomain(pool, loginContext);
|
||||||
await assertContentManagement(pool, loginContext);
|
await assertContentManagement(pool, loginContext);
|
||||||
await assertFranchiseManagement(pool, loginContext);
|
await assertFranchiseManagement(pool, loginContext);
|
||||||
|
await assertSystemOperations(pool, loginContext);
|
||||||
await assertStoreDiscovery(pool, loginContext);
|
await assertStoreDiscovery(pool, loginContext);
|
||||||
await assertSceneAndWifiAccess(pool, loginContext);
|
await assertSceneAndWifiAccess(pool, loginContext);
|
||||||
await assertPricingAndReservations(pool, loginContext);
|
await assertPricingAndReservations(pool, loginContext);
|
||||||
@@ -2173,6 +2218,10 @@ try {
|
|||||||
'versioned decoration publish and archive',
|
'versioned decoration publish and archive',
|
||||||
'store advertisement delivery scope',
|
'store advertisement delivery scope',
|
||||||
'platform advertisement rejection',
|
'platform advertisement rejection',
|
||||||
|
'franchise application idempotency and phone-free audit metadata',
|
||||||
|
'franchise assignment and controlled follow-up status transition',
|
||||||
|
'tenant-scoped audit filtering with recursive sensitive metadata redaction',
|
||||||
|
'system overview and audited tenant configuration update',
|
||||||
'city fallback store filtering',
|
'city fallback store filtering',
|
||||||
'server-side distance sorting',
|
'server-side distance sorting',
|
||||||
'empty manual city result',
|
'empty manual city result',
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildApp } from '../dist/app.js';
|
||||||
|
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||||
|
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
|
||||||
|
|
||||||
|
const secret = 'test-only-system-operations-secret-32';
|
||||||
|
const token = signAccessToken({
|
||||||
|
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1
|
||||||
|
}, secret, 900);
|
||||||
|
let auditQuery;
|
||||||
|
let overviewTenantId;
|
||||||
|
let updateCall;
|
||||||
|
const repository = {
|
||||||
|
async listAuditLogs(input) {
|
||||||
|
auditQuery = input;
|
||||||
|
return { items: [], total: 0, page: input.page, pageSize: input.pageSize };
|
||||||
|
},
|
||||||
|
async getSystemOverview(tenantId) {
|
||||||
|
overviewTenantId = tenantId;
|
||||||
|
return {
|
||||||
|
tenant: { id: tenantId, code: 'demo', name: '演示租户', status: 'ACTIVE', timezone: 'Asia/Shanghai' },
|
||||||
|
counts: { storeCount: 1, userCount: 2, activeSessionCount: 1, appBindingCount: 1, auditTodayCount: 3 },
|
||||||
|
latestMigration: { version: '2026081003', name: 'm08d_franchise_leads' }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async updateTenant(actor, tenantId, input) {
|
||||||
|
updateCall = { actor, tenantId, input };
|
||||||
|
return { tenantId, updated: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const 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: '' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const tenantAccess = { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] };
|
||||||
|
const app = await buildApp({ systemOperations: {
|
||||||
|
repository, authRepository,
|
||||||
|
accessControl: { async getAccessProfile() { return tenantAccess; } },
|
||||||
|
jwtSecret: secret
|
||||||
|
} });
|
||||||
|
const auth = { authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
const listed = await app.inject({ method: 'GET',
|
||||||
|
url: '/admin-api/audit-logs?action=ORDER_CREATED&resourceType=ORDER&page=2&pageSize=10', headers: auth });
|
||||||
|
assert.equal(listed.statusCode, 200);
|
||||||
|
assert.deepEqual({ tenantId: auditQuery.tenantId, action: auditQuery.action,
|
||||||
|
resourceType: auditQuery.resourceType, page: auditQuery.page },
|
||||||
|
{ tenantId: '7', action: 'ORDER_CREATED', resourceType: 'ORDER', page: 2 });
|
||||||
|
assert.ok(auditQuery.from instanceof Date);
|
||||||
|
assert.ok(auditQuery.to instanceof Date);
|
||||||
|
|
||||||
|
const crossTenant = await app.inject({ method: 'GET', url: '/admin-api/audit-logs?tenantId=8', headers: auth });
|
||||||
|
assert.equal(crossTenant.statusCode, 403);
|
||||||
|
const invalidRange = await app.inject({ method: 'GET',
|
||||||
|
url: '/admin-api/audit-logs?from=2025-01-01&to=2026-08-01', headers: auth });
|
||||||
|
assert.equal(invalidRange.statusCode, 400);
|
||||||
|
|
||||||
|
const overview = await app.inject({ method: 'GET', url: '/admin-api/system/overview', headers: auth });
|
||||||
|
assert.equal(overview.statusCode, 200);
|
||||||
|
assert.equal(overviewTenantId, '7');
|
||||||
|
assert.equal(overview.json().data.counts.auditTodayCount, 3);
|
||||||
|
|
||||||
|
const updated = await app.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
|
||||||
|
payload: { name: '新租户名称', timezone: 'Asia/Shanghai' } });
|
||||||
|
assert.equal(updated.statusCode, 200);
|
||||||
|
assert.deepEqual({ tenantId: updateCall.tenantId, name: updateCall.input.name,
|
||||||
|
timezone: updateCall.input.timezone },
|
||||||
|
{ tenantId: '7', name: '新租户名称', timezone: 'Asia/Shanghai' });
|
||||||
|
const invalidTimezone = await app.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
|
||||||
|
payload: { name: '新租户名称', timezone: 'Mars/Olympus' } });
|
||||||
|
assert.equal(invalidTimezone.statusCode, 400);
|
||||||
|
const tenantStatusChange = await app.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
|
||||||
|
payload: { name: '新租户名称', timezone: 'Asia/Shanghai', status: 'DISABLED' } });
|
||||||
|
assert.equal(tenantStatusChange.statusCode, 403);
|
||||||
|
assert.equal(tenantStatusChange.json().code, 'SYSTEM_STATUS_FORBIDDEN');
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
const platformApp = await buildApp({ systemOperations: {
|
||||||
|
repository, authRepository,
|
||||||
|
accessControl: { async getAccessProfile() {
|
||||||
|
return { roles: ['PLATFORM_ADMIN'], capabilities: ['platform.manage'], storeIds: [] };
|
||||||
|
} }, jwtSecret: secret
|
||||||
|
} });
|
||||||
|
const platformUpdate = await platformApp.inject({ method: 'PUT', url: '/admin-api/system/tenant', headers: auth,
|
||||||
|
payload: { tenantId: '8', name: '平台目标租户', timezone: 'UTC', status: 'DISABLED' } });
|
||||||
|
assert.equal(platformUpdate.statusCode, 200);
|
||||||
|
assert.equal(updateCall.tenantId, '8');
|
||||||
|
assert.equal(updateCall.input.status, 'DISABLED');
|
||||||
|
await platformApp.close();
|
||||||
|
|
||||||
|
const sensitivePool = {
|
||||||
|
async execute(sql) {
|
||||||
|
if (sql.includes('COUNT(*)')) return [[{ total: 1 }], []];
|
||||||
|
return [[{
|
||||||
|
id: '99', tenantId: '7', actorType: 'USER', actorId: '21', actorName: '管理员',
|
||||||
|
action: 'CONFIG_UPDATED', resourceType: 'TENANT', resourceId: '7', traceId: 'trace-99',
|
||||||
|
ip: '192.168.10.22', userAgent: 'test',
|
||||||
|
metadata: JSON.stringify({ orderId: '88', phone: '13800138000',
|
||||||
|
nested: { apiKey: 'secret-key', safe: 'visible' } }), createdAt: new Date()
|
||||||
|
}], []];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sanitized = await new SystemOperationsRepository(sensitivePool).listAuditLogs({
|
||||||
|
tenantId: '7', page: 1, pageSize: 20
|
||||||
|
});
|
||||||
|
assert.equal(sanitized.items[0].ip, '192.168.***.***');
|
||||||
|
assert.equal(sanitized.items[0].metadata.phone, '[REDACTED]');
|
||||||
|
assert.equal(sanitized.items[0].metadata.nested.apiKey, '[REDACTED]');
|
||||||
|
assert.equal(sanitized.items[0].metadata.nested.safe, 'visible');
|
||||||
|
|
||||||
|
console.log('PASS: M08-D tenant-scoped audit logs, metadata redaction and system settings routes are present.');
|
||||||
@@ -19,6 +19,7 @@ for (const pattern of [
|
|||||||
"activeModule = 'platformApps'",
|
"activeModule = 'platformApps'",
|
||||||
"activeModule = 'content'",
|
"activeModule = 'content'",
|
||||||
"activeModule = 'franchise'",
|
"activeModule = 'franchise'",
|
||||||
|
"activeModule = 'system'",
|
||||||
'平台运营总览',
|
'平台运营总览',
|
||||||
'StoresRoomsPanel',
|
'StoresRoomsPanel',
|
||||||
'OrdersPanel',
|
'OrdersPanel',
|
||||||
@@ -29,6 +30,7 @@ for (const pattern of [
|
|||||||
'PlatformAppsPanel',
|
'PlatformAppsPanel',
|
||||||
'ContentManagementPanel',
|
'ContentManagementPanel',
|
||||||
'FranchisePanel',
|
'FranchisePanel',
|
||||||
|
'LogsSystemPanel',
|
||||||
'运营总览',
|
'运营总览',
|
||||||
'savedToken',
|
'savedToken',
|
||||||
'loadCleaningWorkspace'
|
'loadCleaningWorkspace'
|
||||||
@@ -219,6 +221,19 @@ const franchiseRoutes = read('backend/src/routes/franchise.ts');
|
|||||||
assert.match(franchiseRoutes, /'\/app-api\/franchise-applications'/);
|
assert.match(franchiseRoutes, /'\/app-api\/franchise-applications'/);
|
||||||
assert.match(franchiseRoutes, /'\/admin-api\/franchise-applications\/:id\/follow-ups'/);
|
assert.match(franchiseRoutes, /'\/admin-api\/franchise-applications\/:id\/follow-ups'/);
|
||||||
|
|
||||||
|
const systemPanel = read('admin/src/components/LogsSystemPanel.vue');
|
||||||
|
for (const pattern of ['listAuditLogs', 'getSystemOverview', 'updateTenantSystemConfig',
|
||||||
|
'安全审计日志', '导出当前页', 'Schema 迁移', '脱敏元数据']) {
|
||||||
|
assert.match(systemPanel, new RegExp(pattern));
|
||||||
|
}
|
||||||
|
const systemRoutes = read('backend/src/routes/system-operations.ts');
|
||||||
|
assert.match(systemRoutes, /'\/admin-api\/audit-logs'/);
|
||||||
|
assert.match(systemRoutes, /'\/admin-api\/system\/overview'/);
|
||||||
|
assert.match(systemRoutes, /'\/admin-api\/system\/tenant'/);
|
||||||
|
const systemRepository = read('backend/src/operations/system-operations-repository.ts');
|
||||||
|
assert.match(systemRepository, /sanitizeMetadata/);
|
||||||
|
assert.match(systemRepository, /TENANT_SYSTEM_CONFIG_UPDATED/);
|
||||||
|
|
||||||
const routes = read('backend/src/routes/business-statistics.ts');
|
const routes = read('backend/src/routes/business-statistics.ts');
|
||||||
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
||||||
|
|
||||||
@@ -261,6 +276,8 @@ for (const pattern of [
|
|||||||
'.asset-gallery',
|
'.asset-gallery',
|
||||||
'.component-editor',
|
'.component-editor',
|
||||||
'.franchise-metrics',
|
'.franchise-metrics',
|
||||||
|
'.system-metrics',
|
||||||
|
'.audit-filters',
|
||||||
'@media (max-width: 980px)',
|
'@media (max-width: 980px)',
|
||||||
'@media (max-width: 560px)'
|
'@media (max-width: 560px)'
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user