文件
shanghaipeiwan/server/scripts/backfill-ownership.ts
xcbyy 1ff4da290a feat(data): 新增历史数据归属订正脚本(默认 dry-run,--apply 写库)
- companions.user_id:按昵称唯一匹配用户回填(避免重复占用)

- finance_records.user_id:按充值/提现/订单/对手方规则回填,无法唯一确定则保留不填

- 默认仅预览,幂等可重复执行;新增 npm 脚本 backfill:ownership 与回归护栏

- 文档补充订正说明(79 套件/611 用例全绿)
2026-10-19 08:58:24 +08:00

158 行
5.0 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

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