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 用例全绿;线上实测告警接口/审计落库/备份均通过
这个提交包含在:
@@ -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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户