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