From 65ddc7301a6eac94c2f9d8badb2d586ef685eb8b Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Thu, 22 Oct 2026 09:54:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E6=AF=8F=E6=97=A5=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=A4=87=E4=BB=BD=20+=20=E8=B5=84=E9=87=91?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E5=B7=A1=E6=A3=80=E5=91=8A=E8=AD=A6=20+=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=93=8D=E4=BD=9C=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 system_alerts/admin_audit_logs 表与 (status,created_at) 索引;启动即巡检、每 5 分钟扫描提现卡单/支付与订单不一致/待接单超时 - 后台新增「系统告警与操作审计」页(告警处理 + 审计列表),并接入消息铃铛待办 - 余额调整/封禁/删除/大神审核/提现打款/告警处理等敏感操作写入审计日志(含 IP) - 新增 server/scripts/backup-db.sh 并已在生产配置每日 03:30 cron(校验+保留 14 天) - 清理空的 order_messages 表;115 套件/750 用例全绿;线上实测告警接口/审计落库/备份均通过 --- .../arco-design-pro-vite/src/locale/en-US.ts | 1 + .../arco-design-pro-vite/src/locale/zh-CN.ts | 1 + .../src/router/routes/modules/safety.ts | 10 ++ .../src/views/safety/system-alerts/index.vue | 161 ++++++++++++++++++ docs/运维备份与告警审计.md | 56 ++++++ server/scripts/backup-db.sh | 27 +++ server/src/db/mysql.ts | 33 ++++ server/src/db/repository.ts | 71 ++++++++ server/src/index.ts | 146 ++++++++++++++++ server/tests/system-alerts-audit.spec.ts | 56 ++++++ 10 files changed, 562 insertions(+) create mode 100644 admin/arco-design-pro-vite/src/views/safety/system-alerts/index.vue create mode 100644 docs/运维备份与告警审计.md create mode 100644 server/scripts/backup-db.sh create mode 100644 server/tests/system-alerts-audit.spec.ts 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 @@ + + + + + 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"); + }); +});