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 用例全绿;线上实测告警接口/审计落库/备份均通过
这个提交包含在:
xcbyy
2026-10-22 09:54:10 +08:00
父节点 d941a16d28
当前提交 65ddc7301a
共有 10 个文件被更改,包括 562 次插入 和 0 次删除

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;
`);
// 系统告警与后台操作审计表
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<void> {
['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'],

查看文件

@@ -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> {
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,

查看文件

@@ -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_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<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 只记日志、对外通用提示
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();

查看文件

@@ -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");
});
});