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 次删除

查看文件

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