From 81368f870f147681d08a70d6786d96061315d405 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Thu, 22 Oct 2026 10:31:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(order):=20=E7=BB=9F=E4=B8=80=20expert=5Fid?= =?UTF-8?q?=20=E4=B8=BA=E5=A4=A7=E7=A5=9E=E6=A1=A3=E6=A1=88=20id=EF=BC=8C?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=85=BC=E5=AE=B9=E5=8E=86=E5=8F=B2=E6=8E=A5?= =?UTF-8?q?=E5=8D=95=EF=BC=9B=E4=BF=AE=E5=A4=8D=E6=8F=90=E7=8E=B0=E5=8D=A1?= =?UTF-8?q?=E5=8D=95=E5=91=8A=E8=AD=A6=E8=AF=AF=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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,改后统计两口径一致 --- server/src/db/mysql.ts | 19 ++++++- server/src/db/repository.ts | 67 +++++++++++++++--------- server/src/index.ts | 6 +-- server/tests/expert-stats-scope.spec.ts | 10 ++-- server/tests/system-alerts-audit.spec.ts | 7 +++ 5 files changed, 76 insertions(+), 33 deletions(-) diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 52dc785..b4de38d 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -234,13 +234,17 @@ export async function initDatabase(): Promise { 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 { } } + // 数据修复:历史抢单订单的 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); diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index b2aeb5b..ac53c40 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -2021,27 +2021,27 @@ export class MysqlRepository { async claimOrder(orderId: string, expertData: { expertId: number; companionName: string; expertAvatar?: string }): Promise { 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 { + 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; diff --git a/server/src/index.ts b/server/src/index.ts index 4ef7d6c..eb6b1dd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5406,9 +5406,9 @@ async function runSystemAlertScan(): Promise { 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 { 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}`, }); } diff --git a/server/tests/expert-stats-scope.spec.ts b/server/tests/expert-stats-scope.spec.ts index 74da986..67cd005 100644 --- a/server/tests/expert-stats-scope.spec.ts +++ b/server/tests/expert-stats-scope.spec.ts @@ -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 = ?)'); }); }); diff --git a/server/tests/system-alerts-audit.spec.ts b/server/tests/system-alerts-audit.spec.ts index c109df1..532946c 100644 --- a/server/tests/system-alerts-audit.spec.ts +++ b/server/tests/system-alerts-audit.spec.ts @@ -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'); + }); });