diff --git a/docs/部署验收与1分钱支付闭环.md b/docs/部署验收与1分钱支付闭环.md index 918faa0..04f9dac 100644 --- a/docs/部署验收与1分钱支付闭环.md +++ b/docs/部署验收与1分钱支付闭环.md @@ -130,3 +130,19 @@ MYSQL_DATABASE=peiwan ## 九、回滚 本轮改动遵循"边界内最小改动",无破坏性数据库结构变更。如需回滚,按部署前的文件/库备份还原即可。 + +## 附:历史数据归属订正(可选,一次性) + +用于修复历史数据(新数据已自动带归属,无需执行): +- `companions.user_id` 为空 → 按"昵称唯一匹配用户"回填; +- `finance_records.user_id` 缺失(0/NULL) → 按充值/提现/订单/对手方规则回填。 + +默认只**预览**,确认无误后再 `--apply`(脚本幂等,可重复执行): + +```bash +cd server +npx tsx scripts/backfill-ownership.ts # 预览(不写库) +npx tsx scripts/backfill-ownership.ts --apply # 写库 +``` + +输出会给出 `total / linked / ambiguous / noUser / filled / skipped` 统计与样例,便于核对。 diff --git a/server/package.json b/server/package.json index a073ad3..fb54921 100644 --- a/server/package.json +++ b/server/package.json @@ -8,7 +8,8 @@ "test": "jest --verbose", "test:watch": "jest --watch", "build": "tsc", - "start": "node dist/src/index.js" + "start": "node dist/src/index.js", + "backfill:ownership": "tsx scripts/backfill-ownership.ts" }, "keywords": [ "peiwan", diff --git a/server/scripts/backfill-ownership.ts b/server/scripts/backfill-ownership.ts new file mode 100644 index 0000000..813fba0 --- /dev/null +++ b/server/scripts/backfill-ownership.ts @@ -0,0 +1,157 @@ +// 已读取AGENTS.md +/** + * 历史数据归属订正脚本(一次性) + * + * 用途: + * 1) companions.user_id 为 NULL 的历史大神:按"昵称唯一匹配用户"回填绑定; + * 2) finance_records.user_id 缺失(0/NULL) 的历史资金流水:按充值/提现/订单/对手方规则回填归属。 + * + * 安全:默认 **只预览(dry-run)**,不写库;确认无误后加 `--apply` 执行。 + * + * 用法(在 server 目录): + * npx tsx scripts/backfill-ownership.ts # 预览 + * npx tsx scripts/backfill-ownership.ts --apply # 执行 + */ +import { initDatabase, pool } from '../src/db/mysql'; + +const APPLY = process.argv.includes('--apply'); + +async function backfillCompanions() { + const [rows]: any = await pool.query( + 'SELECT id, name FROM companions WHERE user_id IS NULL ORDER BY id ASC' + ); + let linked = 0; + let ambiguous = 0; + let noUser = 0; + const examples: any[] = []; + + for (const c of rows || []) { + const [users]: any = await pool.query('SELECT id FROM users WHERE nickname = ? LIMIT 2', [c.name]); + if (!users || users.length === 0) { + noUser += 1; + continue; + } + if (users.length > 1) { + ambiguous += 1; + continue; + } + const uid = Number(users[0].id) || 0; + if (!uid) { + noUser += 1; + continue; + } + // 一个用户只允许绑定一个历史大神档案,避免重复占用 + const [occupied]: any = await pool.query( + 'SELECT id FROM companions WHERE user_id = ? AND id <> ? LIMIT 1', + [uid, c.id] + ); + if (occupied && occupied.length > 0) { + ambiguous += 1; + continue; + } + if (APPLY) { + await pool.query('UPDATE companions SET user_id = ? WHERE id = ? AND user_id IS NULL', [uid, c.id]); + } + linked += 1; + if (examples.length < 10) examples.push({ companionId: c.id, name: c.name, userId: uid }); + } + + return { total: (rows || []).length, linked, ambiguous, noUser, examples }; +} + +async function backfillFinanceRecords() { + const [rows]: any = await pool.query( + 'SELECT id, type, related_id, counterparty FROM finance_records WHERE (user_id IS NULL OR user_id = 0) ORDER BY id ASC' + ); + let filled = 0; + let skipped = 0; + const examples: any[] = []; + + for (const r of rows || []) { + const type = String(r.type || ''); + const rel = String(r.related_id || ''); + let uid = 0; + + try { + if (/充值/.test(type) && rel) { + // 充值流水:related_id = 支付意图 out_trade_no + const [pi]: any = await pool.query( + 'SELECT user_id FROM payment_intents WHERE out_trade_no = ? LIMIT 1', + [rel] + ); + uid = Number((pi && pi[0] && pi[0].user_id) || 0); + } else if (/提现/.test(type) && rel) { + // 提现流水:related_id = 提现单 id + const [wd]: any = await pool.query( + 'SELECT companion_id FROM withdrawals WHERE id = ? LIMIT 1', + [rel] + ); + uid = Number((wd && wd[0] && wd[0].companion_id) || 0); + } else if (rel) { + // 订单相关流水:related_id = 订单 id / order_no + const [ordRows]: any = await pool.query( + 'SELECT id, buyer_id, expert_id FROM orders WHERE id = ? OR order_no = ? LIMIT 1', + [rel, rel] + ); + const ord = ordRows && ordRows[0]; + if (ord) { + if (/退款|支付/.test(type)) { + uid = Number(ord.buyer_id) || 0; + } else if (/结算|完单/.test(type)) { + const [cp]: any = await pool.query( + 'SELECT user_id FROM companions WHERE id = ? LIMIT 1', + [ord.expert_id] + ); + uid = Number((cp && cp[0] && cp[0].user_id) || 0) || Number(ord.expert_id) || 0; + } + } + } + + // 兜底:从对手方文本"用户 1001"中解析用户ID + if (!uid) { + const m = String(r.counterparty || '').match(/用户\s*(\d+)/); + if (m) uid = Number(m[1]) || 0; + } + } catch (e) { + uid = 0; + } + + if (uid > 0) { + if (APPLY) { + await pool.query( + 'UPDATE finance_records SET user_id = ? WHERE id = ? AND (user_id IS NULL OR user_id = 0)', + [uid, r.id] + ); + } + filled += 1; + if (examples.length < 10) examples.push({ id: r.id, type, userId: uid }); + } else { + skipped += 1; + } + } + + return { total: (rows || []).length, filled, skipped, examples }; +} + +async function main() { + await initDatabase(); + console.log(`[Backfill] 模式:${APPLY ? 'APPLY(写库)' : 'DRY-RUN(仅预览,不写库)'}`); + + const companions = await backfillCompanions(); + console.log('[Backfill] companions.user_id:', JSON.stringify(companions)); + + const finance = await backfillFinanceRecords(); + console.log('[Backfill] finance_records.user_id:', JSON.stringify(finance)); + + if (!APPLY) { + console.log('\n[Backfill] 以上为预览结果;确认无误后执行:npx tsx scripts/backfill-ownership.ts --apply'); + } else { + console.log('\n[Backfill] 已完成写库。'); + } + await pool.end(); +} + +main().catch((e) => { + console.error('[Backfill] 执行失败:', e); + process.exit(1); +}); diff --git a/server/tests/backfill-ownership.spec.ts b/server/tests/backfill-ownership.spec.ts new file mode 100644 index 0000000..4efdf9a --- /dev/null +++ b/server/tests/backfill-ownership.spec.ts @@ -0,0 +1,30 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第六十三批回归闸门:历史数据归属订正脚本(默认 dry-run,需 --apply 才写库) + */ +describe('第六十三批历史数据订正脚本闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const scriptPath = path.join(rootDir, 'server/scripts/backfill-ownership.ts'); + const script = fs.readFileSync(scriptPath, 'utf-8'); + const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, 'server/package.json'), 'utf-8')); + + it('脚本默认只预览,需 --apply 才写库', () => { + expect(script).toContain("process.argv.includes('--apply')"); + expect(script).toContain('DRY-RUN'); + expect(script).toContain('APPLY(写库)'); + }); + + it('覆盖 companions.user_id 与 finance_records.user_id 的订正', () => { + expect(script).toContain('backfillCompanions'); + expect(script).toContain('UPDATE companions SET user_id = ? WHERE id = ? AND user_id IS NULL'); + expect(script).toContain('backfillFinanceRecords'); + expect(script).toContain('UPDATE finance_records SET user_id = ? WHERE id = ?'); + }); + + it('提供 npm 脚本入口', () => { + expect(pkg.scripts['backfill:ownership']).toContain('scripts/backfill-ownership.ts'); + }); +});