fix(order): 统一 expert_id 为大神档案 id,统计兼容历史接单;修复提现卡单告警误报

- claimOrder 写入 expert_id=companion.id、claimed_by=user.id;启动时幂等回填历史 expert_id=claimed_by 的数据

- getExpertStats / getExpertRevenueTrends 按 (expert_id=? OR claimed_by=?) 双口径统计,解决工作台与接单大厅口径不一致导致漏单

- withdrawals 新增 updated_at(ON UPDATE),提现卡单巡检改用最后更新时间,避免审核旧工单立即误报

- 115 套件/752 用例全绿;线上实测抢单写入 expert_id=9/claimed_by=2010,改后统计两口径一致
这个提交包含在:
xcbyy
2026-10-22 10:31:21 +08:00
父节点 abf1f5afbb
当前提交 81368f870f
共有 5 个文件被更改,包括 76 次插入 和 33 次删除

查看文件

@@ -234,13 +234,17 @@ export async function initDatabase(): Promise<void> {
status VARCHAR(32) DEFAULT '待审核',
transfer_bill_no VARCHAR(64) DEFAULT '',
transfer_status VARCHAR(32) DEFAULT '',
apply_time DATETIME DEFAULT CURRENT_TIMESTAMP
apply_time DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
try {
await conn.query('ALTER TABLE withdrawals ADD COLUMN reason VARCHAR(255) DEFAULT ""');
} catch (_) {}
try {
await conn.query("ALTER TABLE withdrawals ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP");
} catch (_) {}
try {
await conn.query("ALTER TABLE withdrawals ADD COLUMN transfer_bill_no VARCHAR(64) DEFAULT ''");
} catch (_) {}
@@ -803,6 +807,19 @@ export async function initDatabase(): Promise<void> {
}
}
// 数据修复:历史抢单订单的 expert_id 曾被写成用户 id,统一回填为大神档案 id
// (幂等:回填后 expert_id=c.id,通常不再等于 claimed_by)
try {
await conn.query(`
UPDATE orders o
JOIN companions c ON c.user_id = o.expert_id
SET o.expert_id = c.id
WHERE o.claimed_by > 0 AND o.expert_id = o.claimed_by
`);
} catch (_) {
// 表不存在或数据不满足条件:忽略
}
// 检查并插入初始种子数据(若表为空)
await seedInitialDataIfEmpty(conn);

查看文件

@@ -2021,27 +2021,27 @@ export class MysqlRepository {
async claimOrder(orderId: string, expertData: { expertId: number; companionName: string; expertAvatar?: string }): Promise<any> {
const expertId = Number(expertData.expertId) || 0;
if (expertId > 0) {
const comp = await this.resolveCompanionRow(expertId);
if (comp) {
if (comp.audit_status && comp.audit_status !== '已通过') {
throw new Error('大神档案未通过实名认证审核,无法接单');
}
const [uRows]: any = await pool.query('SELECT status FROM users WHERE id = ?', [comp.user_id || comp.id]);
if (uRows && uRows.length > 0 && (uRows[0].status === '已封禁' || uRows[0].status === '封禁')) {
const err: any = new Error('您的账号已被管理员封禁,无法接单');
err.status = 403;
err.code = 40300;
throw err;
}
}
}
if (!expertId) {
const err: any = new Error('缺少接单大神身份,无法接单');
err.status = 400;
err.code = 40000;
throw err;
}
const comp = await this.resolveCompanionRow(expertId);
if (comp) {
if (comp.audit_status && comp.audit_status !== '已通过') {
throw new Error('大神档案未通过实名认证审核,无法接单');
}
const [uRows]: any = await pool.query('SELECT status FROM users WHERE id = ?', [comp.user_id || comp.id]);
if (uRows && uRows.length > 0 && (uRows[0].status === '已封禁' || uRows[0].status === '封禁')) {
const err: any = new Error('您的账号已被管理员封禁,无法接单');
err.status = 403;
err.code = 40300;
throw err;
}
}
// orders.expert_id 统一存「大神档案 id」;claimed_by 存登录用户 id
const companionIdForOrder = comp ? Number(comp.id) || expertId : expertId;
const [curRows]: any = await pool.query('SELECT id, status, claimed_by FROM orders WHERE id = ? OR order_no = ? LIMIT 1', [orderId, orderId]);
const cur = curRows && curRows[0];
@@ -2075,7 +2075,7 @@ export class MysqlRepository {
WHERE (id = ? OR order_no = ?)
AND status = '待接单'
AND (claimed_by IS NULL OR claimed_by = ?)
`, [expertId, expertData.companionName || '我 (接单大神)', expertData.expertAvatar || '', expertId, orderId, orderId, expertId]);
`, [companionIdForOrder, expertData.companionName || '我 (接单大神)', expertData.expertAvatar || '', expertId, orderId, orderId, expertId]);
if (!res || res.affectedRows !== 1) {
const err: any = new Error('该订单已被其他大神抢先接单,请刷新订单池');
@@ -2088,15 +2088,30 @@ export class MysqlRepository {
return rows[0] || null;
}
/** 解析大神统计口径:兼容传入「档案 id」或「用户 id」,并覆盖历史接单数据(expert_id 曾写用户 id) */
private async resolveExpertOrderScope(expertId: number | string): Promise<{ companionId: number; userId: number }> {
const raw = Number(expertId) || 0;
if (raw <= 0) return { companionId: 0, userId: 0 };
const comp = await this.resolveCompanionRow(raw);
if (comp) {
return { companionId: Number(comp.id) || raw, userId: Number(comp.user_id) || raw };
}
return { companionId: raw, userId: raw };
}
async getExpertStats(expertId: number | string): Promise<any> {
const scope = await this.resolveExpertOrderScope(expertId);
if (!scope.companionId && !scope.userId) {
return { income: '0.00', orders: 0, waitingVerify: 0 };
}
const [rows]: any = await pool.query(`
SELECT
COUNT(*) as totalOrders,
COALESCE(SUM(CASE WHEN status IN ('已完成', '已到场核销') THEN amount ELSE 0 END), 0) as totalIncome,
COALESCE(SUM(CASE WHEN status IN ('履约中', '服务中') THEN 1 ELSE 0 END), 0) as waitingVerify
FROM orders
WHERE expert_id = ?
`, [Number(expertId) || 101]);
WHERE (expert_id = ? OR claimed_by = ?)
`, [scope.companionId || scope.userId, scope.userId || scope.companionId]);
const stat = rows[0] || {};
return {
@@ -2112,7 +2127,9 @@ export class MysqlRepository {
weekly: { label: string; income: number; orders: number; avgPrice: number }[];
monthly: { label: string; income: number; orders: number; avgPrice: number }[];
}> {
const eId = Number(expertId) || 101;
const scope = await this.resolveExpertOrderScope(expertId);
const eId = scope.companionId || scope.userId;
const eUid = scope.userId || scope.companionId;
const now = new Date();
// 1. 近 7 天走势
@@ -2130,10 +2147,10 @@ export class MysqlRepository {
COUNT(*) as orders,
COALESCE(SUM(amount), 0) as income
FROM orders
WHERE expert_id = ?
WHERE (expert_id = ? OR claimed_by = ?)
AND DATE(created_at) = ?
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
`, [eId, dateStr]);
`, [eId, eUid, dateStr]);
const count = Number(dayRows[0]?.orders || 0);
const inc = Number(Number(dayRows[0]?.income || 0).toFixed(2));
const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0;
@@ -2155,10 +2172,10 @@ export class MysqlRepository {
COUNT(*) as orders,
COALESCE(SUM(amount), 0) as income
FROM orders
WHERE expert_id = ?
WHERE (expert_id = ? OR claimed_by = ?)
AND created_at >= ? AND created_at <= ?
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
`, [eId, startD, endD]);
`, [eId, eUid, startD, endD]);
const count = Number(wRows[0]?.orders || 0);
const inc = Number(Number(wRows[0]?.income || 0).toFixed(2));
const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0;
@@ -2180,10 +2197,10 @@ export class MysqlRepository {
COUNT(*) as orders,
COALESCE(SUM(amount), 0) as income
FROM orders
WHERE expert_id = ?
WHERE (expert_id = ? OR claimed_by = ?)
AND DATE_FORMAT(created_at, '%Y-%m') = ?
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
`, [eId, yearMonth]);
`, [eId, eUid, yearMonth]);
const count = Number(mRows[0]?.orders || 0);
const inc = Number(Number(mRows[0]?.income || 0).toFixed(2));
const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0;

查看文件

@@ -5406,9 +5406,9 @@ 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
SELECT id, companion_id as userId, amount, transfer_status, updated_at
FROM withdrawals
WHERE status = '处理中' AND created_at <= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
WHERE status = '处理中' AND updated_at <= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
LIMIT 50
`);
for (const w of stuckWithdrawals || []) {
@@ -5416,7 +5416,7 @@ async function runSystemAlertScan(): Promise<void> {
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}`,
detail: `工单 ${w.id} · 用户 ${w.userId} · ¥${Number(w.amount || 0).toFixed(2)} · 转账状态 ${w.transfer_status || '-'} · 最后更新 ${w.updated_at}`,
});
}

查看文件

@@ -14,10 +14,12 @@ describe('第五十批大神收益统计范围闸门', () => {
expect(repoCode).not.toContain('OR companion_name LIKE ?');
});
it('收益汇总与走势只按 expert_id 统计', () => {
const stats = repoCode.slice(repoCode.indexOf('async getExpertStats('), repoCode.indexOf('async getExpertStats(') + 600);
expect(stats).toContain('WHERE expert_id = ?');
it('收益汇总与走势必须按档案 id / 认领用户 id 双口径统计,避免漏算', () => {
const start = repoCode.indexOf('async getExpertStats(');
const stats = repoCode.slice(start, start + 1600);
expect(stats).toContain('resolveExpertOrderScope');
expect(stats).toContain('(expert_id = ? OR claimed_by = ?)');
const trends = repoCode.slice(repoCode.indexOf('async getExpertRevenueTrends('));
expect(trends.slice(0, 4000)).toContain('WHERE expert_id = ?');
expect(trends.slice(0, 6000)).toContain('(expert_id = ? OR claimed_by = ?)');
});
});

查看文件

@@ -60,4 +60,11 @@ describe('第一百批系统告警与后台审计', () => {
expect(indexCode).toContain('openidValid');
expect(indexCode).toContain('请改用真实微信登录用户测试');
});
it('订单 expert_id 必须统一为大神档案 id,统计口径兼容用户 id', () => {
expect(repo).toContain('const companionIdForOrder = comp ? Number(comp.id) || expertId : expertId;');
expect(repo).toContain('resolveExpertOrderScope');
expect(repo).toContain('(expert_id = ? OR claimed_by = ?)');
expect(mysql).toContain('SET o.expert_id = c.id');
});
});