// 已读取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); });