feat(ops): 每日数据库备份 + 资金异常巡检告警 + 后台操作审计
- 新增 system_alerts/admin_audit_logs 表与 (status,created_at) 索引;启动即巡检、每 5 分钟扫描提现卡单/支付与订单不一致/待接单超时 - 后台新增「系统告警与操作审计」页(告警处理 + 审计列表),并接入消息铃铛待办 - 余额调整/封禁/删除/大神审核/提现打款/告警处理等敏感操作写入审计日志(含 IP) - 新增 server/scripts/backup-db.sh 并已在生产配置每日 03:30 cron(校验+保留 14 天) - 清理空的 order_messages 表;115 套件/750 用例全绿;线上实测告警接口/审计落库/备份均通过
这个提交包含在:
@@ -33,6 +33,7 @@ export default {
|
|||||||
'menu.safety.reports': 'Violation Reports',
|
'menu.safety.reports': 'Violation Reports',
|
||||||
'menu.safety.auditLog': 'Risk Intercept Log',
|
'menu.safety.auditLog': 'Risk Intercept Log',
|
||||||
'menu.safety.platformConfig': 'Platform & Support Config',
|
'menu.safety.platformConfig': 'Platform & Support Config',
|
||||||
|
'menu.safety.systemAlerts': 'Alerts & Admin Audit',
|
||||||
'menu.banner': 'Banner & Operations',
|
'menu.banner': 'Banner & Operations',
|
||||||
'menu.banner.manage': 'Banner Management',
|
'menu.banner.manage': 'Banner Management',
|
||||||
'menu.banner.navCategory': 'Category Navigation',
|
'menu.banner.navCategory': 'Category Navigation',
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default {
|
|||||||
'menu.safety.reports': '违规举报工单处置',
|
'menu.safety.reports': '违规举报工单处置',
|
||||||
'menu.safety.auditLog': '场所与内容拦截日志',
|
'menu.safety.auditLog': '场所与内容拦截日志',
|
||||||
'menu.safety.platformConfig': '平台配置与协议规范 (用户协议/隐私/客服)',
|
'menu.safety.platformConfig': '平台配置与协议规范 (用户协议/隐私/客服)',
|
||||||
|
'menu.safety.systemAlerts': '系统告警与操作审计',
|
||||||
'menu.banner': '首页轮播与运营',
|
'menu.banner': '首页轮播与运营',
|
||||||
'menu.banner.manage': '轮播图管理',
|
'menu.banner.manage': '轮播图管理',
|
||||||
'menu.banner.navCategory': '金刚区导航品类',
|
'menu.banner.navCategory': '金刚区导航品类',
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ const SAFETY: AppRouteRecordRaw = {
|
|||||||
roles: ['*'],
|
roles: ['*'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'system-alerts',
|
||||||
|
name: 'SystemAlerts',
|
||||||
|
component: () => import('@/views/safety/system-alerts/index.vue'),
|
||||||
|
meta: {
|
||||||
|
locale: 'menu.safety.systemAlerts',
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ['*'],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<a-breadcrumb class="breadcrumb">
|
||||||
|
<a-breadcrumb-item>履约安全与风控</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item>系统告警与操作审计</a-breadcrumb-item>
|
||||||
|
</a-breadcrumb>
|
||||||
|
|
||||||
|
<a-card class="general-card">
|
||||||
|
<a-tabs v-model:active-key="activeTab" @change="onTabChange">
|
||||||
|
<a-tab-pane key="alerts" :title="`系统告警 (${alerts.length})`">
|
||||||
|
<a-space style="margin-bottom: 14px">
|
||||||
|
<a-button :loading="loadingAlerts" @click="fetchAlerts">
|
||||||
|
<template #icon><icon-refresh /></template>
|
||||||
|
刷新告警
|
||||||
|
</a-button>
|
||||||
|
<span class="hint">
|
||||||
|
每 5 分钟自动巡检:提现卡单 / 支付成功但订单未流转 / 待接单超时未退款
|
||||||
|
</span>
|
||||||
|
</a-space>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:data="alerts"
|
||||||
|
:loading="loadingAlerts"
|
||||||
|
row-key="id"
|
||||||
|
:pagination="{ pageSize: 10, showTotal: true }"
|
||||||
|
>
|
||||||
|
<template #columns>
|
||||||
|
<a-table-column title="发现时间" data-index="createdAt" :width="175" />
|
||||||
|
<a-table-column title="级别" :width="90">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-tag :color="record.level === 'ERROR' ? 'red' : 'orange'">{{ record.level }}</a-tag>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="告警项" :width="250">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<strong>{{ record.title }}</strong>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="详情" data-index="detail" />
|
||||||
|
<a-table-column title="操作" :width="110" fixed="right">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-button type="text" size="small" @click="resolveAlert(record)">标记处理</a-button>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="audit" title="后台操作审计">
|
||||||
|
<a-space style="margin-bottom: 14px">
|
||||||
|
<a-button :loading="loadingAudit" @click="fetchAudit">
|
||||||
|
<template #icon><icon-refresh /></template>
|
||||||
|
刷新审计
|
||||||
|
</a-button>
|
||||||
|
<span class="hint">余额调整、封禁/解封、删除用户、大神审核、提现打款等敏感操作留痕</span>
|
||||||
|
</a-space>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:data="audits"
|
||||||
|
:loading="loadingAudit"
|
||||||
|
row-key="id"
|
||||||
|
:pagination="{ pageSize: 10, showTotal: true }"
|
||||||
|
>
|
||||||
|
<template #columns>
|
||||||
|
<a-table-column title="操作时间" data-index="createdAt" :width="175" />
|
||||||
|
<a-table-column title="操作" :width="160">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-tag color="arcoblue">{{ record.action }}</a-tag>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="对象" :width="200">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<span class="code-font">{{ record.targetType }} / {{ record.targetId }}</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column title="详情" data-index="detail" />
|
||||||
|
<a-table-column title="IP" data-index="ip" :width="150" />
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Message } from '@arco-design/web-vue';
|
||||||
|
|
||||||
|
const activeTab = ref('alerts');
|
||||||
|
const alerts = ref<any[]>([]);
|
||||||
|
const audits = ref<any[]>([]);
|
||||||
|
const loadingAlerts = ref(false);
|
||||||
|
const loadingAudit = ref(false);
|
||||||
|
|
||||||
|
const fetchAlerts = async () => {
|
||||||
|
loadingAlerts.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await axios.get('/api/admin/system/alerts', { params: { status: 'OPEN' } });
|
||||||
|
alerts.value = Array.isArray(res?.data) ? res.data : [];
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error(err?.message || '获取系统告警失败');
|
||||||
|
} finally {
|
||||||
|
loadingAlerts.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAudit = async () => {
|
||||||
|
loadingAudit.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await axios.get('/api/admin/audit-logs');
|
||||||
|
audits.value = Array.isArray(res?.data) ? res.data : [];
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error(err?.message || '获取操作审计失败');
|
||||||
|
} finally {
|
||||||
|
loadingAudit.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAlert = async (record: any) => {
|
||||||
|
try {
|
||||||
|
const res: any = await axios.post(`/api/admin/system/alerts/${record.id}/resolve`);
|
||||||
|
if (res && res.code === 20000) {
|
||||||
|
Message.success('已标记处理');
|
||||||
|
fetchAlerts();
|
||||||
|
} else {
|
||||||
|
Message.error(res?.message || '操作失败');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error(err?.message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTabChange = (key: string) => {
|
||||||
|
if (key === 'audit' && audits.value.length === 0) fetchAudit();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchAlerts();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-font {
|
||||||
|
font-family: Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
56
docs/运维备份与告警审计.md
普通文件
56
docs/运维备份与告警审计.md
普通文件
@@ -0,0 +1,56 @@
|
|||||||
|
# 运维备份、系统告警与操作审计
|
||||||
|
|
||||||
|
## 一、每日数据库备份
|
||||||
|
|
||||||
|
- 脚本:`/www/backup/peiwan/backup_db.sh`(仓库副本:`server/scripts/backup-db.sh`)
|
||||||
|
- 计划任务:`30 3 * * * /bin/bash /www/backup/peiwan/backup_db.sh >> /www/backup/peiwan/backup.log 2>&1`
|
||||||
|
- 行为:
|
||||||
|
1. 从 `server/.env` 读取数据库凭据(不落盘明文);
|
||||||
|
2. `mysqldump --single-transaction --quick --no-tablespaces` 导出并 gzip;
|
||||||
|
3. `gzip -t` 校验压缩包完整性;
|
||||||
|
4. 只清理 `db_*.sql.gz` 中超过 14 天的文件。
|
||||||
|
- 手动执行:`/bin/bash /www/backup/peiwan/backup_db.sh`
|
||||||
|
- 恢复:`gunzip -c db_xxx.sql.gz | mysql -h... -u... database`
|
||||||
|
|
||||||
|
## 二、系统异常巡检(每 5 分钟)
|
||||||
|
|
||||||
|
服务端内置 `runSystemAlertScan()`,启动即执行一次,之后每 300 秒一次(`SYSTEM_ALERT_SCAN_MS` 可调,`DISABLE_SYSTEM_ALERT_SCAN=1` 可关)。
|
||||||
|
|
||||||
|
检查项:
|
||||||
|
|
||||||
|
| 告警 | 判定 | 级别 |
|
||||||
|
|---|---|---|
|
||||||
|
| 提现打款卡在「处理中」 | `withdrawals.status='处理中'` 且创建超过 30 分钟 | ERROR |
|
||||||
|
| 支付成功但订单未流转 | `payment_intents.status='SUCCESS'` 且订单仍为待付款超过 10 分钟 | ERROR |
|
||||||
|
| 待接单订单超时未退款 | `orders.status='待接单'` 超过 40 分钟 | WARN |
|
||||||
|
|
||||||
|
告警写入 `system_alerts` 表,按业务主键(如 `withdraw_stuck_<工单号>`)唯一去重,不会重复刷屏。
|
||||||
|
|
||||||
|
查看与处理:管理后台 → **履约安全与风控 → 系统告警与操作审计**。
|
||||||
|
|
||||||
|
同时会进入后台右上角消息铃铛的「系统告警」待办。
|
||||||
|
|
||||||
|
## 三、后台操作审计
|
||||||
|
|
||||||
|
敏感操作写入 `admin_audit_logs`,当前覆盖:
|
||||||
|
|
||||||
|
- 余额充值 / 扣减(含金额、原因、操作后余额)
|
||||||
|
- 封禁 / 解封用户
|
||||||
|
- 删除用户
|
||||||
|
- 通过 / 驳回大神入驻
|
||||||
|
- 提现发起转账、提现打款、驳回提现
|
||||||
|
- 处理系统告警
|
||||||
|
|
||||||
|
记录字段:操作时间、操作类型、对象类型/ID、详情、操作者 IP。
|
||||||
|
|
||||||
|
查看:管理后台 → **系统告警与操作审计 → 后台操作审计**。
|
||||||
|
|
||||||
|
## 四、手动查询
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 未处理告警
|
||||||
|
SELECT * FROM system_alerts WHERE status='OPEN' ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
# 最近后台操作
|
||||||
|
SELECT * FROM admin_audit_logs ORDER BY created_at DESC LIMIT 50;
|
||||||
|
```
|
||||||
27
server/scripts/backup-db.sh
普通文件
27
server/scripts/backup-db.sh
普通文件
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 陪玩生产库每日备份:mysqldump -> gzip -> 校验 -> 保留 14 天
|
||||||
|
# 由 crontab 每天 03:30 调用;凭据从 server/.env 读取,不落盘明文。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR="/www/wwwroot/peiww.gynhc.com/server"
|
||||||
|
BACKUP_DIR="/www/backup/peiwan"
|
||||||
|
TS="$(date +%Y%m%d_%H%M%S)"
|
||||||
|
OUT="$BACKUP_DIR/db_$TS.sql.gz"
|
||||||
|
|
||||||
|
cd "$APP_DIR"
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
export MYSQL_PWD="$MYSQL_PASSWORD"
|
||||||
|
mysqldump --single-transaction --quick --no-tablespaces \
|
||||||
|
-h"$MYSQL_HOST" -P"${MYSQL_PORT:-3306}" -u"$MYSQL_USER" "$MYSQL_DATABASE" \
|
||||||
|
| gzip > "$OUT"
|
||||||
|
unset MYSQL_PWD
|
||||||
|
|
||||||
|
gzip -t "$OUT"
|
||||||
|
|
||||||
|
# 只清理本脚本生成的 db_*.sql.gz,保留 14 天
|
||||||
|
find "$BACKUP_DIR" -maxdepth 1 -name 'db_*.sql.gz' -type f -mtime +14 -delete
|
||||||
|
|
||||||
|
echo "[$(date '+%F %T')] backup ok: $OUT ($(du -h "$OUT" | cut -f1))"
|
||||||
@@ -681,6 +681,36 @@ export async function initDatabase(): Promise<void> {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// 系统告警与后台操作审计表
|
||||||
|
await conn.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS system_alerts (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
alert_key VARCHAR(160) NOT NULL,
|
||||||
|
level VARCHAR(16) NOT NULL DEFAULT 'WARN',
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
detail TEXT,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
resolved_at DATETIME NULL,
|
||||||
|
UNIQUE KEY uq_alert_key (alert_key),
|
||||||
|
INDEX idx_alert_status (status, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await conn.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_audit_logs (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
operator VARCHAR(64) NOT NULL DEFAULT 'admin',
|
||||||
|
action VARCHAR(64) NOT NULL,
|
||||||
|
target_type VARCHAR(32) DEFAULT '',
|
||||||
|
target_id VARCHAR(64) DEFAULT '',
|
||||||
|
detail TEXT,
|
||||||
|
ip VARCHAR(64) DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_audit_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
`);
|
||||||
|
|
||||||
// 管理端消息铃铛:待办已读状态(记录已读时数量,新增待办会重新变未读)
|
// 管理端消息铃铛:待办已读状态(记录已读时数量,新增待办会重新变未读)
|
||||||
await conn.query(`
|
await conn.query(`
|
||||||
CREATE TABLE IF NOT EXISTS admin_message_reads (
|
CREATE TABLE IF NOT EXISTS admin_message_reads (
|
||||||
@@ -745,6 +775,9 @@ export async function initDatabase(): Promise<void> {
|
|||||||
['withdrawals', 'idx_wd_status', 'status'],
|
['withdrawals', 'idx_wd_status', 'status'],
|
||||||
['activities', 'idx_act_creator', 'creator_id'],
|
['activities', 'idx_act_creator', 'creator_id'],
|
||||||
['activities', 'idx_act_status', 'status'],
|
['activities', 'idx_act_status', 'status'],
|
||||||
|
['activities', 'idx_act_status_created', 'status, created_at'],
|
||||||
|
['withdrawals', 'idx_wd_status_created', 'status, created_at'],
|
||||||
|
['payment_intents', 'idx_pi_status_created', 'status, created_at'],
|
||||||
['payment_intents', 'idx_pi_order', 'order_id'],
|
['payment_intents', 'idx_pi_order', 'order_id'],
|
||||||
['payment_intents', 'idx_pi_status', 'status'],
|
['payment_intents', 'idx_pi_status', 'status'],
|
||||||
['users', 'idx_users_phone', 'phone'],
|
['users', 'idx_users_phone', 'phone'],
|
||||||
|
|||||||
@@ -3787,6 +3787,68 @@ export class MysqlRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 系统告警与后台操作审计 =====
|
||||||
|
async createSystemAlert(data: { key: string; level?: string; title: string; detail?: string }): Promise<boolean> {
|
||||||
|
const id = `alert_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`;
|
||||||
|
const [res]: any = await pool.query(
|
||||||
|
`INSERT IGNORE INTO system_alerts (id, alert_key, level, title, detail) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[id, data.key, data.level || 'WARN', data.title, data.detail || '']
|
||||||
|
);
|
||||||
|
return Boolean(res && res.affectedRows === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSystemAlerts(status = 'OPEN', limit = 100): Promise<any[]> {
|
||||||
|
const safeLimit = Math.min(200, Math.max(1, Math.round(Number(limit) || 100)));
|
||||||
|
const [rows]: any = await pool.query(
|
||||||
|
`SELECT id, alert_key as alertKey, level, title, detail, status, created_at as createdAt, resolved_at as resolvedAt
|
||||||
|
FROM system_alerts WHERE status = ? ORDER BY created_at DESC LIMIT ?`,
|
||||||
|
[status, safeLimit]
|
||||||
|
);
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveSystemAlert(id: string): Promise<boolean> {
|
||||||
|
const [res]: any = await pool.query(
|
||||||
|
`UPDATE system_alerts SET status = 'RESOLVED', resolved_at = NOW() WHERE id = ? AND status = 'OPEN'`,
|
||||||
|
[String(id)]
|
||||||
|
);
|
||||||
|
return Boolean(res && res.affectedRows === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAdminAuditLog(data: {
|
||||||
|
operator?: string;
|
||||||
|
action: string;
|
||||||
|
targetType?: string;
|
||||||
|
targetId?: string;
|
||||||
|
detail?: string;
|
||||||
|
ip?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const id = `audit_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`;
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO admin_audit_logs (id, operator, action, target_type, target_id, detail, ip)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
String(data.operator || 'admin').slice(0, 64),
|
||||||
|
String(data.action || '').slice(0, 64),
|
||||||
|
String(data.targetType || '').slice(0, 32),
|
||||||
|
String(data.targetId || '').slice(0, 64),
|
||||||
|
String(data.detail || ''),
|
||||||
|
String(data.ip || '').slice(0, 64),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAdminAuditLogs(limit = 200): Promise<any[]> {
|
||||||
|
const safeLimit = Math.min(500, Math.max(1, Math.round(Number(limit) || 200)));
|
||||||
|
const [rows]: any = await pool.query(
|
||||||
|
`SELECT id, operator, action, target_type as targetType, target_id as targetId, detail, ip, created_at as createdAt
|
||||||
|
FROM admin_audit_logs ORDER BY created_at DESC LIMIT ?`,
|
||||||
|
[safeLimit]
|
||||||
|
);
|
||||||
|
return rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
async getDashboardTodos(): Promise<any> {
|
async getDashboardTodos(): Promise<any> {
|
||||||
const [auditRows]: any = await pool.query('SELECT * FROM companions WHERE audit_status = "待审核"');
|
const [auditRows]: any = await pool.query('SELECT * FROM companions WHERE audit_status = "待审核"');
|
||||||
const [arbRows]: any = await pool.query('SELECT * FROM arbitrations WHERE status = "待平台仲裁"');
|
const [arbRows]: any = await pool.query('SELECT * FROM arbitrations WHERE status = "待平台仲裁"');
|
||||||
@@ -3807,6 +3869,14 @@ export class MysqlRepository {
|
|||||||
logServerError('getDashboardTodos.orders', e);
|
logServerError('getDashboardTodos.orders', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let systemAlertsCount = 0;
|
||||||
|
try {
|
||||||
|
const [aRows]: any = await pool.query('SELECT COUNT(*) as c FROM system_alerts WHERE status = "OPEN"');
|
||||||
|
systemAlertsCount = Number((aRows && aRows[0] && aRows[0].c) || 0);
|
||||||
|
} catch (e) {
|
||||||
|
logServerError('getDashboardTodos.alerts', e);
|
||||||
|
}
|
||||||
|
|
||||||
const pendingWithdrawalAmount = wdRows.reduce((sum: number, w: any) => sum + Number(w.amount), 0);
|
const pendingWithdrawalAmount = wdRows.reduce((sum: number, w: any) => sum + Number(w.amount), 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -3817,6 +3887,7 @@ export class MysqlRepository {
|
|||||||
pendingOrdersCount,
|
pendingOrdersCount,
|
||||||
pendingReportsCount: rptRows.length,
|
pendingReportsCount: rptRows.length,
|
||||||
riskInterceptionsCount: secRows.length,
|
riskInterceptionsCount: secRows.length,
|
||||||
|
systemAlertsCount,
|
||||||
pendingAudits: auditRows.map((c: any) => ({
|
pendingAudits: auditRows.map((c: any) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
|
|||||||
@@ -832,6 +832,7 @@ function buildAdminTodoItems(todos: any, readMap: Record<string, number>): any[]
|
|||||||
{ key: 'pending_withdrawals', count: Number(todos?.pendingWithdrawalsCount || 0), title: '待审核提现', content: `待审核提现合计 ¥${Number(todos?.pendingWithdrawalAmount || 0).toFixed(2)}`, link: '/finance/withdraw' },
|
{ key: 'pending_withdrawals', count: Number(todos?.pendingWithdrawalsCount || 0), title: '待审核提现', content: `待审核提现合计 ¥${Number(todos?.pendingWithdrawalAmount || 0).toFixed(2)}`, link: '/finance/withdraw' },
|
||||||
{ key: 'pending_reports', count: Number(todos?.pendingReportsCount || 0), title: '待处理举报', content: '有违规举报工单待处置', link: '/safety/reports' },
|
{ key: 'pending_reports', count: Number(todos?.pendingReportsCount || 0), title: '待处理举报', content: '有违规举报工单待处置', link: '/safety/reports' },
|
||||||
{ key: 'pending_arbitrations', count: Number(todos?.pendingArbitrationsCount || 0), title: '待仲裁工单', content: '有争议仲裁待平台介入', link: '/order/arbitration' },
|
{ key: 'pending_arbitrations', count: Number(todos?.pendingArbitrationsCount || 0), title: '待仲裁工单', content: '有争议仲裁待平台介入', link: '/order/arbitration' },
|
||||||
|
{ key: 'system_alerts', count: Number(todos?.systemAlertsCount || 0), title: '系统告警', content: '资金/订单状态异常待处理', link: '/safety/system-alerts' },
|
||||||
];
|
];
|
||||||
return defs
|
return defs
|
||||||
.filter((d) => d.count > 0)
|
.filter((d) => d.count > 0)
|
||||||
@@ -1328,6 +1329,7 @@ app.post('/api/companions/:id/audit', requireAdmin, async (req: Request, res: Re
|
|||||||
return res.status(403).json({ code: 40300, message: '权限不足:买家与大神不可越权执行审核' });
|
return res.status(403).json({ code: 40300, message: '权限不足:买家与大神不可越权执行审核' });
|
||||||
}
|
}
|
||||||
const result = await repo.auditCompanion(companionId, action, reason);
|
const result = await repo.auditCompanion(companionId, action, reason);
|
||||||
|
logAdminAudit(req, action === 'approve' ? '通过大神入驻' : '驳回大神入驻', 'companion', companionId, reason ? `原因:${reason}` : '');
|
||||||
res.json({ code: 20000, data: result, message: result.message });
|
res.json({ code: 20000, data: result, message: result.message });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(400).json({ code: 40000, message: err.message });
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
@@ -2709,6 +2711,20 @@ function pushUserNotification(userId: number, type: string, title: string, conte
|
|||||||
.catch((e: any) => logServerError('pushUserNotification', e));
|
.catch((e: any) => logServerError('pushUserNotification', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 后台敏感操作审计:余额调整、封禁、删除、审核、打款等 */
|
||||||
|
function logAdminAudit(req: Request, action: string, targetType: string, targetId: string | number, detail: string): void {
|
||||||
|
void repo
|
||||||
|
.createAdminAuditLog({
|
||||||
|
operator: 'admin',
|
||||||
|
action,
|
||||||
|
targetType,
|
||||||
|
targetId: String(targetId || ''),
|
||||||
|
detail,
|
||||||
|
ip: clientIpOf(req),
|
||||||
|
})
|
||||||
|
.catch((e: any) => logServerError('logAdminAudit', e));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新派单通知:给当前在线、审核通过的认证大神推送站内消息(含红点)。
|
* 新派单通知:给当前在线、审核通过的认证大神推送站内消息(含红点)。
|
||||||
* 说明:大神档案暂无实时坐标,先按"在线接单中"广播;后续可结合经纬度做 5km 精确推送。
|
* 说明:大神档案暂无实时坐标,先按"在线接单中"广播;后续可结合经纬度做 5km 精确推送。
|
||||||
@@ -3626,6 +3642,7 @@ app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Respon
|
|||||||
// 驳回:退还预扣佣金
|
// 驳回:退还预扣佣金
|
||||||
if (action === 'reject') {
|
if (action === 'reject') {
|
||||||
const result = await repo.reviewWithdrawal(wdId, 'reject', rejectReason);
|
const result = await repo.reviewWithdrawal(wdId, 'reject', rejectReason);
|
||||||
|
logAdminAudit(req, '驳回提现', 'withdrawal', wdId, rejectReason || '风控核验未通过');
|
||||||
return res.json({ code: 20000, data: result, message: `提现驳回: ${rejectReason || '风控核验未通过'}` });
|
return res.json({ code: 20000, data: result, message: `提现驳回: ${rejectReason || '风控核验未通过'}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3668,6 +3685,7 @@ app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Respon
|
|||||||
const billNo = String(bill.out_bill_no || outBillNo);
|
const billNo = String(bill.out_bill_no || outBillNo);
|
||||||
if (state === 'SUCCESS') {
|
if (state === 'SUCCESS') {
|
||||||
const r = await repo.markWithdrawalTransfer(wdId, billNo, '已打款', 'SUCCESS');
|
const r = await repo.markWithdrawalTransfer(wdId, billNo, '已打款', 'SUCCESS');
|
||||||
|
logAdminAudit(req, '提现打款', 'withdrawal', wdId, `¥${Number(wd.amount || 0).toFixed(2)} · 转账单 ${billNo} · SUCCESS`);
|
||||||
pushUserNotification(Number(wd.companion_id) || 0, 'WITHDRAW', '提现已到账', `提现金额 ¥${Number(wd.amount || 0).toFixed(2)} 已打款到你的微信零钱。`, '/pages/user/withdraw-records');
|
pushUserNotification(Number(wd.companion_id) || 0, 'WITHDRAW', '提现已到账', `提现金额 ¥${Number(wd.amount || 0).toFixed(2)} 已打款到你的微信零钱。`, '/pages/user/withdraw-records');
|
||||||
void notifyBySubscribe('withdrawSuccess', {
|
void notifyBySubscribe('withdrawSuccess', {
|
||||||
userId: Number(wd.companion_id) || 0,
|
userId: Number(wd.companion_id) || 0,
|
||||||
@@ -3682,6 +3700,7 @@ app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Respon
|
|||||||
return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款成功,款项已进入大神微信零钱' });
|
return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款成功,款项已进入大神微信零钱' });
|
||||||
}
|
}
|
||||||
const r = await repo.markWithdrawalTransfer(wdId, billNo, '处理中', state);
|
const r = await repo.markWithdrawalTransfer(wdId, billNo, '处理中', state);
|
||||||
|
logAdminAudit(req, '提现发起转账', 'withdrawal', wdId, `¥${Number(wd.amount || 0).toFixed(2)} · 转账单 ${billNo} · 状态 ${state}`);
|
||||||
if (state === 'WAIT_USER_CONFIRM') {
|
if (state === 'WAIT_USER_CONFIRM') {
|
||||||
pushUserNotification(
|
pushUserNotification(
|
||||||
Number(wd.companion_id) || 0,
|
Number(wd.companion_id) || 0,
|
||||||
@@ -3857,6 +3876,13 @@ app.post('/api/admin/users/:id/balance', requireAdmin, async (req: Request, res:
|
|||||||
`平台${direction === 'IN' ? '充值' : '扣减'} ¥${result.amount}${reason ? `(${reason})` : ''},当前余额 ¥${result.after}。`,
|
`平台${direction === 'IN' ? '充值' : '扣减'} ¥${result.amount}${reason ? `(${reason})` : ''},当前余额 ¥${result.after}。`,
|
||||||
'/pages/user/recharge'
|
'/pages/user/recharge'
|
||||||
);
|
);
|
||||||
|
logAdminAudit(
|
||||||
|
req,
|
||||||
|
direction === 'IN' ? '余额充值' : '余额扣减',
|
||||||
|
'user',
|
||||||
|
userId,
|
||||||
|
`¥${result.amount}${reason ? ` · ${reason}` : ''} → 余额 ¥${result.after}`
|
||||||
|
);
|
||||||
res.json({
|
res.json({
|
||||||
code: 20000,
|
code: 20000,
|
||||||
data: result,
|
data: result,
|
||||||
@@ -3867,6 +3893,38 @@ app.post('/api/admin/users/:id/balance', requireAdmin, async (req: Request, res:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 管理后台:系统告警与操作审计查询
|
||||||
|
app.get('/api/admin/system/alerts', requireAdmin, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const raw = String(req.query.status || 'OPEN').toUpperCase();
|
||||||
|
const status = raw === 'RESOLVED' ? 'RESOLVED' : 'OPEN';
|
||||||
|
const list = await repo.listSystemAlerts(status, Number(req.query.limit) || 100);
|
||||||
|
res.json({ code: 20000, data: list });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ code: 50000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/system/alerts/:id/resolve', requireAdmin, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const ok = await repo.resolveSystemAlert(String(req.params.id));
|
||||||
|
if (!ok) return res.status(404).json({ code: 40400, message: '告警不存在或已处理' });
|
||||||
|
logAdminAudit(req, '处理系统告警', 'system_alert', String(req.params.id), '标记为已处理');
|
||||||
|
res.json({ code: 20000, data: { ok: true }, message: '已标记处理' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/audit-logs', requireAdmin, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const list = await repo.listAdminAuditLogs(Number(req.query.limit) || 200);
|
||||||
|
res.json({ code: 20000, data: list });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ code: 50000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/users/:id/status', requireAdmin, async (req: Request, res: Response) => {
|
app.post('/api/users/:id/status', requireAdmin, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = Number(req.params.id);
|
const userId = Number(req.params.id);
|
||||||
@@ -3875,6 +3933,7 @@ app.post('/api/users/:id/status', requireAdmin, async (req: Request, res: Respon
|
|||||||
return res.status(400).json({ code: 40000, message: '无效的状态参数' });
|
return res.status(400).json({ code: 40000, message: '无效的状态参数' });
|
||||||
}
|
}
|
||||||
const updated = await repo.updateUserStatus(userId, status);
|
const updated = await repo.updateUserStatus(userId, status);
|
||||||
|
logAdminAudit(req, status === '正常' ? '解除封禁' : '封禁用户', 'user', userId, `状态 → ${status}`);
|
||||||
res.json({
|
res.json({
|
||||||
code: 20000,
|
code: 20000,
|
||||||
data: updated,
|
data: updated,
|
||||||
@@ -3895,6 +3954,7 @@ app.delete('/api/users/:id', requireAdmin, async (req: Request, res: Response) =
|
|||||||
if (!success) {
|
if (!success) {
|
||||||
return res.status(404).json({ code: 40400, message: '用户不存在或已被删除' });
|
return res.status(404).json({ code: 40400, message: '用户不存在或已被删除' });
|
||||||
}
|
}
|
||||||
|
logAdminAudit(req, '删除用户', 'user', userId, '永久删除用户档案及关联数据');
|
||||||
res.json({
|
res.json({
|
||||||
code: 20000,
|
code: 20000,
|
||||||
data: { id: userId },
|
data: { id: userId },
|
||||||
@@ -5321,6 +5381,91 @@ function startOrderTimeoutScheduler(): void {
|
|||||||
console.log(`[Peiwan Scheduler] 订单超时扫描已启动,间隔 ${Math.round(intervalMs / 1000)}s`);
|
console.log(`[Peiwan Scheduler] 订单超时扫描已启动,间隔 ${Math.round(intervalMs / 1000)}s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 资金/状态异常巡检:每 5 分钟扫描一次,异常写入 system_alerts(按业务主键去重)
|
||||||
|
const SYSTEM_ALERT_SCAN_MS = Number(process.env.SYSTEM_ALERT_SCAN_MS || 5 * 60 * 1000);
|
||||||
|
let systemAlertTimer: NodeJS.Timeout | null = null;
|
||||||
|
let systemAlertRunning = false;
|
||||||
|
|
||||||
|
async function runSystemAlertScan(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 1) 提现打款卡在「处理中」超过 30 分钟
|
||||||
|
const [stuckWithdrawals]: any = await pool.query(`
|
||||||
|
SELECT id, companion_id as userId, amount, transfer_status, created_at
|
||||||
|
FROM withdrawals
|
||||||
|
WHERE status = '处理中' AND created_at <= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
|
||||||
|
LIMIT 50
|
||||||
|
`);
|
||||||
|
for (const w of stuckWithdrawals || []) {
|
||||||
|
await repo.createSystemAlert({
|
||||||
|
key: `withdraw_stuck_${w.id}`,
|
||||||
|
level: 'ERROR',
|
||||||
|
title: '提现打款卡在「处理中」',
|
||||||
|
detail: `工单 ${w.id} · 用户 ${w.userId} · ¥${Number(w.amount || 0).toFixed(2)} · 转账状态 ${w.transfer_status || '-'} · 创建于 ${w.created_at}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 支付已确认但订单仍是待付款(回调/状态流转异常)
|
||||||
|
const [paidUnpaid]: any = await pool.query(`
|
||||||
|
SELECT o.id, o.order_no, o.status, COALESCE(pi.paid_at, pi.created_at) as paidAt
|
||||||
|
FROM payment_intents pi
|
||||||
|
JOIN orders o ON o.id = pi.order_id
|
||||||
|
WHERE pi.status = 'SUCCESS'
|
||||||
|
AND o.status IN ('待付款', '待支付')
|
||||||
|
AND COALESCE(pi.paid_at, pi.created_at) <= DATE_SUB(NOW(), INTERVAL 10 MINUTE)
|
||||||
|
LIMIT 50
|
||||||
|
`);
|
||||||
|
for (const o of paidUnpaid || []) {
|
||||||
|
await repo.createSystemAlert({
|
||||||
|
key: `paid_unpaid_${o.id}`,
|
||||||
|
level: 'ERROR',
|
||||||
|
title: '支付已成功但订单仍为待付款',
|
||||||
|
detail: `订单 ${o.order_no || o.id} · 状态 ${o.status} · 支付时间 ${o.paidAt},请人工核对支付意图与订单状态`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 待接单超时未自动退款(调度器异常)
|
||||||
|
const [stalePool]: any = await pool.query(`
|
||||||
|
SELECT id, order_no, created_at
|
||||||
|
FROM orders
|
||||||
|
WHERE status = '待接单' AND created_at <= DATE_SUB(NOW(), INTERVAL 40 MINUTE)
|
||||||
|
LIMIT 50
|
||||||
|
`);
|
||||||
|
for (const o of stalePool || []) {
|
||||||
|
await repo.createSystemAlert({
|
||||||
|
key: `pool_stale_${o.id}`,
|
||||||
|
level: 'WARN',
|
||||||
|
title: '待接单订单超时未退款',
|
||||||
|
detail: `订单 ${o.order_no || o.id} · 创建于 ${o.created_at},已超过 40 分钟仍在派单池,请检查超时退款任务`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logServerError('runSystemAlertScan', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSystemAlertScheduler(): void {
|
||||||
|
if (process.env.DISABLE_SYSTEM_ALERT_SCAN === '1') {
|
||||||
|
console.log('[Peiwan Scheduler] 系统异常巡检已禁用 (DISABLE_SYSTEM_ALERT_SCAN=1)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (systemAlertTimer) return;
|
||||||
|
const intervalMs = Number.isFinite(SYSTEM_ALERT_SCAN_MS) ? Math.max(60000, SYSTEM_ALERT_SCAN_MS) : 300000;
|
||||||
|
void runSystemAlertScan();
|
||||||
|
systemAlertTimer = setInterval(async () => {
|
||||||
|
if (systemAlertRunning) return;
|
||||||
|
systemAlertRunning = true;
|
||||||
|
try {
|
||||||
|
await runSystemAlertScan();
|
||||||
|
} finally {
|
||||||
|
systemAlertRunning = false;
|
||||||
|
}
|
||||||
|
}, intervalMs);
|
||||||
|
if (typeof (systemAlertTimer as any).unref === 'function') {
|
||||||
|
(systemAlertTimer as any).unref();
|
||||||
|
}
|
||||||
|
console.log(`[Peiwan Scheduler] 系统异常巡检已启动,间隔 ${Math.round(intervalMs / 1000)}s`);
|
||||||
|
}
|
||||||
|
|
||||||
// 兜底错误处理:统一返回 JSON(避免 Express 默认 HTML 堆栈泄露);5xx 只记日志、对外通用提示
|
// 兜底错误处理:统一返回 JSON(避免 Express 默认 HTML 堆栈泄露);5xx 只记日志、对外通用提示
|
||||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
const status = Number(err && err.status) || 500;
|
const status = Number(err && err.status) || 500;
|
||||||
@@ -5349,6 +5494,7 @@ async function bootstrap() {
|
|||||||
|
|
||||||
await logStartupConfigSelfCheck();
|
await logStartupConfigSelfCheck();
|
||||||
startOrderTimeoutScheduler();
|
startOrderTimeoutScheduler();
|
||||||
|
startSystemAlertScheduler();
|
||||||
|
|
||||||
// 载入后台登录态版本号,并周期同步(支持多实例下的"吊销所有会话")
|
// 载入后台登录态版本号,并周期同步(支持多实例下的"吊销所有会话")
|
||||||
await loadAdminTokenEpoch();
|
await loadAdminTokenEpoch();
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// 已读取AGENTS.md
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第一百批:资金巡检告警 + 后台操作审计 + 每日备份。
|
||||||
|
*/
|
||||||
|
describe('第一百批系统告警与后台审计', () => {
|
||||||
|
const root = path.resolve(__dirname, '../../');
|
||||||
|
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8');
|
||||||
|
|
||||||
|
const mysql = read('server/src/db/mysql.ts');
|
||||||
|
const repo = read('server/src/db/repository.ts');
|
||||||
|
const indexCode = read('server/src/index.ts');
|
||||||
|
const adminPage = read('admin/arco-design-pro-vite/src/views/safety/system-alerts/index.vue');
|
||||||
|
const safetyRoute = read('admin/arco-design-pro-vite/src/router/routes/modules/safety.ts');
|
||||||
|
const zhLocale = read('admin/arco-design-pro-vite/src/locale/zh-CN.ts');
|
||||||
|
const backupScript = read('server/scripts/backup-db.sh');
|
||||||
|
|
||||||
|
it('告警与审计表必须落库,并补充巡检所需索引', () => {
|
||||||
|
expect(mysql).toContain('CREATE TABLE IF NOT EXISTS system_alerts');
|
||||||
|
expect(mysql).toContain('CREATE TABLE IF NOT EXISTS admin_audit_logs');
|
||||||
|
expect(mysql).toContain("['withdrawals', 'idx_wd_status_created'");
|
||||||
|
expect(mysql).toContain("['payment_intents', 'idx_pi_status_created'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('巡检必须覆盖提现卡单、支付与订单不一致、待接单超时', () => {
|
||||||
|
expect(indexCode).toContain('runSystemAlertScan');
|
||||||
|
expect(indexCode).toContain('提现打款卡在「处理中」');
|
||||||
|
expect(indexCode).toContain('支付已成功但订单仍为待付款');
|
||||||
|
expect(indexCode).toContain('待接单订单超时未退款');
|
||||||
|
expect(indexCode).toContain('startSystemAlertScheduler');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('敏感后台操作必须写审计日志', () => {
|
||||||
|
expect(repo).toContain('async createAdminAuditLog');
|
||||||
|
expect(indexCode).toContain('function logAdminAudit');
|
||||||
|
const actions = ['余额充值', '封禁用户', '删除用户', '通过大神入驻', '提现打款'];
|
||||||
|
for (const a of actions) expect(indexCode).toContain(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('后台必须提供告警/审计查询与处理入口', () => {
|
||||||
|
expect(indexCode).toContain("app.get('/api/admin/system/alerts'");
|
||||||
|
expect(indexCode).toContain("app.get('/api/admin/audit-logs'");
|
||||||
|
expect(safetyRoute).toContain("path: 'system-alerts'");
|
||||||
|
expect(zhLocale).toContain('menu.safety.systemAlerts');
|
||||||
|
expect(adminPage).toContain('/api/admin/system/alerts');
|
||||||
|
expect(adminPage).toContain('/api/admin/audit-logs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('每日备份脚本必须校验压缩包并保留 14 天', () => {
|
||||||
|
expect(backupScript).toContain('mysqldump --single-transaction --quick --no-tablespaces');
|
||||||
|
expect(backupScript).toContain('gzip -t');
|
||||||
|
expect(backupScript).toContain("-mtime +14 -delete");
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户