fix: prevent reused account ownership for orphan works
This commit is contained in:
177
scripts/rebind-orphan-work-owners.mjs
Normal file
177
scripts/rebind-orphan-work-owners.mjs
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const DEFAULT_ORPHAN_WORK_OWNER_USER_ID = 'wx-openid-placeholder';
|
||||
|
||||
export const WORK_OWNER_TABLES = [
|
||||
'custom_world_profile',
|
||||
'custom_world_gallery_entry',
|
||||
'custom_world_session',
|
||||
'custom_world_agent_session',
|
||||
'custom_world_draft_card',
|
||||
'puzzle_agent_session',
|
||||
'puzzle_work_profile',
|
||||
'bark_battle_draft_config',
|
||||
'bark_battle_published_config',
|
||||
'match3d_agent_session',
|
||||
'match3d_work_profile',
|
||||
'jump_hop_agent_session',
|
||||
'jump_hop_work_profile',
|
||||
'wooden_fish_agent_session',
|
||||
'wooden_fish_work_profile',
|
||||
'square_hole_agent_session',
|
||||
'square_hole_work_profile',
|
||||
'visual_novel_agent_session',
|
||||
'visual_novel_work_profile',
|
||||
'big_fish_creation_session',
|
||||
];
|
||||
|
||||
const ROW_KEY_FIELDS = ['profile_id', 'work_id', 'session_id', 'draft_id', 'gallery_entry_id', 'id'];
|
||||
|
||||
if (isCliEntry()) {
|
||||
runCli(process.argv.slice(2)).catch((error) => {
|
||||
console.error(
|
||||
`[rebind-orphan-work-owners] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export function rebindOrphanWorkOwnersInMigration(
|
||||
migration,
|
||||
{ placeholderUserId = DEFAULT_ORPHAN_WORK_OWNER_USER_ID, validUserIds = [] } = {},
|
||||
) {
|
||||
if (!migration || !Array.isArray(migration.tables)) {
|
||||
throw new Error('迁移 JSON 必须包含 tables 数组。');
|
||||
}
|
||||
|
||||
const normalizedPlaceholderUserId = placeholderUserId.trim();
|
||||
const validUserIdSet = new Set(
|
||||
(Array.isArray(validUserIds) ? validUserIds : [])
|
||||
.map((value) => String(value).trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
validUserIdSet.add(normalizedPlaceholderUserId);
|
||||
|
||||
const reboundRows = [];
|
||||
for (const table of migration.tables) {
|
||||
if (!table || !WORK_OWNER_TABLES.includes(table.name) || !Array.isArray(table.rows)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const row of table.rows) {
|
||||
if (!row || typeof row !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const currentOwner = typeof row.owner_user_id === 'string' ? row.owner_user_id.trim() : '';
|
||||
if (currentOwner === normalizedPlaceholderUserId || validUserIdSet.has(currentOwner)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalOwner = typeof row.owner_user_id === 'string' ? row.owner_user_id : '';
|
||||
row.owner_user_id = normalizedPlaceholderUserId;
|
||||
reboundRows.push({
|
||||
table: table.name,
|
||||
rowKey: resolveRowKey(row),
|
||||
from: originalOwner,
|
||||
to: normalizedPlaceholderUserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { reboundRows, validUserCount: validUserIdSet.size };
|
||||
}
|
||||
|
||||
function resolveRowKey(row) {
|
||||
for (const field of ROW_KEY_FIELDS) {
|
||||
const value = row[field];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return '<unknown>';
|
||||
}
|
||||
|
||||
async function runCli(argv) {
|
||||
const options = parseCliArgs(argv);
|
||||
const inputPath = path.resolve(options.in);
|
||||
const outputPath = path.resolve(options.out);
|
||||
const migration = JSON.parse(await readFile(inputPath, 'utf8'));
|
||||
const result = rebindOrphanWorkOwnersInMigration(migration, {
|
||||
placeholderUserId: options.placeholderUserId,
|
||||
validUserIds: collectValidUserIds(migration),
|
||||
});
|
||||
|
||||
if (!options.dryRun) {
|
||||
await writeFile(outputPath, `${JSON.stringify(migration, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[rebind-orphan-work-owners] ${options.dryRun ? 'dry-run' : `已写入 ${outputPath}`},回填 ${result.reboundRows.length} 行`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const options = {
|
||||
in: '',
|
||||
out: '',
|
||||
placeholderUserId: DEFAULT_ORPHAN_WORK_OWNER_USER_ID,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = (name) => {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${name} 缺少参数值。`);
|
||||
}
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
if (arg === '--in') {
|
||||
options.in = readValue(arg);
|
||||
} else if (arg === '--out') {
|
||||
options.out = readValue(arg);
|
||||
} else if (arg === '--placeholder-user-id') {
|
||||
options.placeholderUserId = readValue(arg);
|
||||
} else if (arg === '--dry-run') {
|
||||
options.dryRun = true;
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.in) {
|
||||
throw new Error('必须传入 --in。');
|
||||
}
|
||||
if (!options.out && !options.dryRun) {
|
||||
throw new Error('非 dry-run 必须传入 --out。');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function collectValidUserIds(migration) {
|
||||
const result = new Set();
|
||||
for (const table of migration.tables ?? []) {
|
||||
if (!table || !Array.isArray(table.rows)) {
|
||||
continue;
|
||||
}
|
||||
if (table.name === 'user_account') {
|
||||
for (const row of table.rows) {
|
||||
if (typeof row?.user_id === 'string' && row.user_id.trim()) {
|
||||
result.add(row.user_id.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isCliEntry() {
|
||||
const entry = process.argv[1];
|
||||
return entry ? import.meta.url === `file://${entry.replace(/\\/gu, '/')}` : false;
|
||||
}
|
||||
42
scripts/rebind-orphan-work-owners.test.ts
Normal file
42
scripts/rebind-orphan-work-owners.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { rebindOrphanWorkOwnersInMigration } from './rebind-orphan-work-owners.mjs';
|
||||
|
||||
const placeholderUserId = 'wx-openid-placeholder';
|
||||
|
||||
function table(name, rows) {
|
||||
return { name, rows };
|
||||
}
|
||||
|
||||
describe('rebindOrphanWorkOwnersInMigration', () => {
|
||||
it('把作品表里认证表不存在的 owner_user_id 回填到占位用户', () => {
|
||||
const migration = {
|
||||
schema_version: 1,
|
||||
exported_at_micros: 1,
|
||||
tables: [
|
||||
table('user_account', [{ user_id: 'user_alive' }, { user_id: placeholderUserId }]),
|
||||
table('puzzle_work_profile', [
|
||||
{ profile_id: 'p1', owner_user_id: 'user_missing' },
|
||||
{ profile_id: 'p2', owner_user_id: 'user_alive' },
|
||||
{ profile_id: 'p3', owner_user_id: placeholderUserId },
|
||||
]),
|
||||
table('puzzle_agent_session', [{ session_id: 'draft-1', owner_user_id: '' }]),
|
||||
table('tracking_event', [{ event_id: 't1', owner_user_id: 'user_missing' }]),
|
||||
],
|
||||
};
|
||||
|
||||
const result = rebindOrphanWorkOwnersInMigration(migration, {
|
||||
placeholderUserId,
|
||||
validUserIds: ['user_alive'],
|
||||
});
|
||||
|
||||
expect(result.reboundRows).toEqual([
|
||||
{ table: 'puzzle_work_profile', rowKey: 'p1', from: 'user_missing', to: placeholderUserId },
|
||||
{ table: 'puzzle_agent_session', rowKey: 'draft-1', from: '', to: placeholderUserId },
|
||||
]);
|
||||
expect(migration.tables[1].rows[0].owner_user_id).toBe(placeholderUserId);
|
||||
expect(migration.tables[1].rows[1].owner_user_id).toBe('user_alive');
|
||||
expect(migration.tables[1].rows[2].owner_user_id).toBe(placeholderUserId);
|
||||
expect(migration.tables[2].rows[0].owner_user_id).toBe(placeholderUserId);
|
||||
expect(migration.tables[3].rows[0].owner_user_id).toBe('user_missing');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user