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 用例全绿;线上实测告警接口/审计落库/备份均通过
这个提交包含在:
@@ -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',
|
||||
|
||||
@@ -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': '金刚区导航品类',
|
||||
|
||||
@@ -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: ['*'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<a-breadcrumb class="breadcrumb">
|
||||
<a-breadcrumb-item>履约安全与风控</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>系统告警与操作审计</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
|
||||
<a-card class="general-card">
|
||||
<a-tabs v-model:active-key="activeTab" @change="onTabChange">
|
||||
<a-tab-pane key="alerts" :title="`系统告警 (${alerts.length})`">
|
||||
<a-space style="margin-bottom: 14px">
|
||||
<a-button :loading="loadingAlerts" @click="fetchAlerts">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新告警
|
||||
</a-button>
|
||||
<span class="hint">
|
||||
每 5 分钟自动巡检:提现卡单 / 支付成功但订单未流转 / 待接单超时未退款
|
||||
</span>
|
||||
</a-space>
|
||||
|
||||
<a-table
|
||||
:data="alerts"
|
||||
:loading="loadingAlerts"
|
||||
row-key="id"
|
||||
:pagination="{ pageSize: 10, showTotal: true }"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="发现时间" data-index="createdAt" :width="175" />
|
||||
<a-table-column title="级别" :width="90">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="record.level === 'ERROR' ? 'red' : 'orange'">{{ record.level }}</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="告警项" :width="250">
|
||||
<template #cell="{ record }">
|
||||
<strong>{{ record.title }}</strong>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="详情" data-index="detail" />
|
||||
<a-table-column title="操作" :width="110" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-button type="text" size="small" @click="resolveAlert(record)">标记处理</a-button>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="audit" title="后台操作审计">
|
||||
<a-space style="margin-bottom: 14px">
|
||||
<a-button :loading="loadingAudit" @click="fetchAudit">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新审计
|
||||
</a-button>
|
||||
<span class="hint">余额调整、封禁/解封、删除用户、大神审核、提现打款等敏感操作留痕</span>
|
||||
</a-space>
|
||||
|
||||
<a-table
|
||||
:data="audits"
|
||||
:loading="loadingAudit"
|
||||
row-key="id"
|
||||
:pagination="{ pageSize: 10, showTotal: true }"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="操作时间" data-index="createdAt" :width="175" />
|
||||
<a-table-column title="操作" :width="160">
|
||||
<template #cell="{ record }">
|
||||
<a-tag color="arcoblue">{{ record.action }}</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="对象" :width="200">
|
||||
<template #cell="{ record }">
|
||||
<span class="code-font">{{ record.targetType }} / {{ record.targetId }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="详情" data-index="detail" />
|
||||
<a-table-column title="IP" data-index="ip" :width="150" />
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
|
||||
const activeTab = ref('alerts');
|
||||
const alerts = ref<any[]>([]);
|
||||
const audits = ref<any[]>([]);
|
||||
const loadingAlerts = ref(false);
|
||||
const loadingAudit = ref(false);
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
loadingAlerts.value = true;
|
||||
try {
|
||||
const res: any = await axios.get('/api/admin/system/alerts', { params: { status: 'OPEN' } });
|
||||
alerts.value = Array.isArray(res?.data) ? res.data : [];
|
||||
} catch (err: any) {
|
||||
Message.error(err?.message || '获取系统告警失败');
|
||||
} finally {
|
||||
loadingAlerts.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAudit = async () => {
|
||||
loadingAudit.value = true;
|
||||
try {
|
||||
const res: any = await axios.get('/api/admin/audit-logs');
|
||||
audits.value = Array.isArray(res?.data) ? res.data : [];
|
||||
} catch (err: any) {
|
||||
Message.error(err?.message || '获取操作审计失败');
|
||||
} finally {
|
||||
loadingAudit.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveAlert = async (record: any) => {
|
||||
try {
|
||||
const res: any = await axios.post(`/api/admin/system/alerts/${record.id}/resolve`);
|
||||
if (res && res.code === 20000) {
|
||||
Message.success('已标记处理');
|
||||
fetchAlerts();
|
||||
} else {
|
||||
Message.error(res?.message || '操作失败');
|
||||
}
|
||||
} catch (err: any) {
|
||||
Message.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const onTabChange = (key: string) => {
|
||||
if (key === 'audit' && audits.value.length === 0) fetchAudit();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlerts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-font {
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
}
|
||||
</style>
|
||||
56
docs/运维备份与告警审计.md
普通文件
56
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;
|
||||
```
|
||||
27
server/scripts/backup-db.sh
普通文件
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");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户