feat(data): 新增历史数据归属订正脚本(默认 dry-run,--apply 写库)
- companions.user_id:按昵称唯一匹配用户回填(避免重复占用) - finance_records.user_id:按充值/提现/订单/对手方规则回填,无法唯一确定则保留不填 - 默认仅预览,幂等可重复执行;新增 npm 脚本 backfill:ownership 与回归护栏 - 文档补充订正说明(79 套件/611 用例全绿)
这个提交包含在:
@@ -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` 统计与样例,便于核对。
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"test": "jest --verbose",
|
"test": "jest --verbose",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/src/index.js"
|
"start": "node dist/src/index.js",
|
||||||
|
"backfill:ownership": "tsx scripts/backfill-ownership.ts"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"peiwan",
|
"peiwan",
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户