diff --git a/admin/arco-design-pro-vite/src/locale/en-US.ts b/admin/arco-design-pro-vite/src/locale/en-US.ts
index 1aeb75a..80c8e1b 100644
--- a/admin/arco-design-pro-vite/src/locale/en-US.ts
+++ b/admin/arco-design-pro-vite/src/locale/en-US.ts
@@ -33,6 +33,7 @@ export default {
'menu.safety.reports': 'Violation Reports',
'menu.safety.auditLog': 'Risk Intercept Log',
'menu.safety.platformConfig': 'Platform & Support Config',
+ 'menu.safety.systemAlerts': 'Alerts & Admin Audit',
'menu.banner': 'Banner & Operations',
'menu.banner.manage': 'Banner Management',
'menu.banner.navCategory': 'Category Navigation',
diff --git a/admin/arco-design-pro-vite/src/locale/zh-CN.ts b/admin/arco-design-pro-vite/src/locale/zh-CN.ts
index 66adb53..6ecb2d1 100644
--- a/admin/arco-design-pro-vite/src/locale/zh-CN.ts
+++ b/admin/arco-design-pro-vite/src/locale/zh-CN.ts
@@ -33,6 +33,7 @@ export default {
'menu.safety.reports': '违规举报工单处置',
'menu.safety.auditLog': '场所与内容拦截日志',
'menu.safety.platformConfig': '平台配置与协议规范 (用户协议/隐私/客服)',
+ 'menu.safety.systemAlerts': '系统告警与操作审计',
'menu.banner': '首页轮播与运营',
'menu.banner.manage': '轮播图管理',
'menu.banner.navCategory': '金刚区导航品类',
diff --git a/admin/arco-design-pro-vite/src/router/routes/modules/safety.ts b/admin/arco-design-pro-vite/src/router/routes/modules/safety.ts
index 928c12c..c924700 100644
--- a/admin/arco-design-pro-vite/src/router/routes/modules/safety.ts
+++ b/admin/arco-design-pro-vite/src/router/routes/modules/safety.ts
@@ -52,6 +52,16 @@ const SAFETY: AppRouteRecordRaw = {
roles: ['*'],
},
},
+ {
+ path: 'system-alerts',
+ name: 'SystemAlerts',
+ component: () => import('@/views/safety/system-alerts/index.vue'),
+ meta: {
+ locale: 'menu.safety.systemAlerts',
+ requiresAuth: true,
+ roles: ['*'],
+ },
+ },
],
};
diff --git a/admin/arco-design-pro-vite/src/views/safety/system-alerts/index.vue b/admin/arco-design-pro-vite/src/views/safety/system-alerts/index.vue
new file mode 100644
index 0000000..815ff09
--- /dev/null
+++ b/admin/arco-design-pro-vite/src/views/safety/system-alerts/index.vue
@@ -0,0 +1,161 @@
+
+
+
+ 履约安全与风控
+ 系统告警与操作审计
+
+
+
+
+
+
+
+
+ 刷新告警
+
+
+ 每 5 分钟自动巡检:提现卡单 / 支付成功但订单未流转 / 待接单超时未退款
+
+
+
+
+
+
+
+
+ {{ record.level }}
+
+
+
+
+ {{ record.title }}
+
+
+
+
+
+ 标记处理
+
+
+
+
+
+
+
+
+
+
+ 刷新审计
+
+ 余额调整、封禁/解封、删除用户、大神审核、提现打款等敏感操作留痕
+
+
+
+
+
+
+
+ {{ record.action }}
+
+
+
+
+ {{ record.targetType }} / {{ record.targetId }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/运维备份与告警审计.md b/docs/运维备份与告警审计.md
new file mode 100644
index 0000000..dda8d8c
--- /dev/null
+++ b/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;
+```
diff --git a/server/scripts/backup-db.sh b/server/scripts/backup-db.sh
new file mode 100644
index 0000000..c8c3e10
--- /dev/null
+++ b/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))"
diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts
index d845541..52dc785 100644
--- a/server/src/db/mysql.ts
+++ b/server/src/db/mysql.ts
@@ -681,6 +681,36 @@ export async function initDatabase(): Promise {
) 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(`
CREATE TABLE IF NOT EXISTS admin_message_reads (
@@ -745,6 +775,9 @@ export async function initDatabase(): Promise {
['withdrawals', 'idx_wd_status', 'status'],
['activities', 'idx_act_creator', 'creator_id'],
['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_status', 'status'],
['users', 'idx_users_phone', 'phone'],
diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts
index 1c2b74a..b2aeb5b 100644
--- a/server/src/db/repository.ts
+++ b/server/src/db/repository.ts
@@ -3787,6 +3787,68 @@ export class MysqlRepository {
};
}
+ // ===== 系统告警与后台操作审计 =====
+ async createSystemAlert(data: { key: string; level?: string; title: string; detail?: string }): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
const [auditRows]: any = await pool.query('SELECT * FROM companions WHERE audit_status = "待审核"');
const [arbRows]: any = await pool.query('SELECT * FROM arbitrations WHERE status = "待平台仲裁"');
@@ -3807,6 +3869,14 @@ export class MysqlRepository {
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);
return {
@@ -3817,6 +3887,7 @@ export class MysqlRepository {
pendingOrdersCount,
pendingReportsCount: rptRows.length,
riskInterceptionsCount: secRows.length,
+ systemAlertsCount,
pendingAudits: auditRows.map((c: any) => ({
id: c.id,
name: c.name,
diff --git a/server/src/index.ts b/server/src/index.ts
index 0f46959..e358254 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -832,6 +832,7 @@ function buildAdminTodoItems(todos: any, readMap: Record): any[]
{ 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_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
.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: '权限不足:买家与大神不可越权执行审核' });
}
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 });
} catch (err: any) {
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));
}
+/** 后台敏感操作审计:余额调整、封禁、删除、审核、打款等 */
+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 精确推送。
@@ -3626,6 +3642,7 @@ app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Respon
// 驳回:退还预扣佣金
if (action === 'reject') {
const result = await repo.reviewWithdrawal(wdId, 'reject', rejectReason);
+ logAdminAudit(req, '驳回提现', 'withdrawal', wdId, 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);
if (state === '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');
void notifyBySubscribe('withdrawSuccess', {
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: '提现打款成功,款项已进入大神微信零钱' });
}
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') {
pushUserNotification(
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}。`,
'/pages/user/recharge'
);
+ logAdminAudit(
+ req,
+ direction === 'IN' ? '余额充值' : '余额扣减',
+ 'user',
+ userId,
+ `¥${result.amount}${reason ? ` · ${reason}` : ''} → 余额 ¥${result.after}`
+ );
res.json({
code: 20000,
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) => {
try {
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: '无效的状态参数' });
}
const updated = await repo.updateUserStatus(userId, status);
+ logAdminAudit(req, status === '正常' ? '解除封禁' : '封禁用户', 'user', userId, `状态 → ${status}`);
res.json({
code: 20000,
data: updated,
@@ -3895,6 +3954,7 @@ app.delete('/api/users/:id', requireAdmin, async (req: Request, res: Response) =
if (!success) {
return res.status(404).json({ code: 40400, message: '用户不存在或已被删除' });
}
+ logAdminAudit(req, '删除用户', 'user', userId, '永久删除用户档案及关联数据');
res.json({
code: 20000,
data: { id: userId },
@@ -5321,6 +5381,91 @@ function startOrderTimeoutScheduler(): void {
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 {
+ 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 只记日志、对外通用提示
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
const status = Number(err && err.status) || 500;
@@ -5349,6 +5494,7 @@ async function bootstrap() {
await logStartupConfigSelfCheck();
startOrderTimeoutScheduler();
+ startSystemAlertScheduler();
// 载入后台登录态版本号,并周期同步(支持多实例下的"吊销所有会话")
await loadAdminTokenEpoch();
diff --git a/server/tests/system-alerts-audit.spec.ts b/server/tests/system-alerts-audit.spec.ts
new file mode 100644
index 0000000..1ddf275
--- /dev/null
+++ b/server/tests/system-alerts-audit.spec.ts
@@ -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");
+ });
+});