feat(data): 新增历史数据归属订正脚本(默认 dry-run,--apply 写库)
- companions.user_id:按昵称唯一匹配用户回填(避免重复占用) - finance_records.user_id:按充值/提现/订单/对手方规则回填,无法唯一确定则保留不填 - 默认仅预览,幂等可重复执行;新增 npm 脚本 backfill:ownership 与回归护栏 - 文档补充订正说明(79 套件/611 用例全绿)
这个提交包含在:
157
server/scripts/backfill-ownership.ts
普通文件
157
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);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户