feat: consolidate skin policy, furniture and world NPC work

This commit is contained in:
2026-09-18 01:47:44 +08:00
parent dc188ed03d
commit aaae09399e
17 changed files with 502 additions and 217 deletions

View File

@@ -0,0 +1,56 @@
// Run inside the backend runtime: node scripts/migrate_default_skins.cjs
// Apply only with --apply /protected/backup.json. Unrelated accounts/assets are unchanged.
const mysql = require('mysql2/promise');
const fs = require('node:fs');
const BOY = 'human_whale_directional_v2_8x4';
const RETIRED = 'classic_whale';
async function migrate(connection, backupPath) {
await connection.beginTransaction();
try {
const [profiles] = await connection.query(
"SELECT id,user_id,skin_id FROM user_profiles WHERE skin_id IN ('classic_whale','pending_initial_skin','') OR skin_id IS NULL FOR UPDATE");
const [assets] = await connection.query(
"SELECT * FROM user_assets WHERE asset_type='skin' AND asset_id='classic_whale' FOR UPDATE");
const userIds = [...new Set([...profiles, ...assets].map(row=>String(row.user_id)))];
const existingBoyAssets = userIds.length ? (await connection.query(
"SELECT id,user_id FROM user_assets WHERE asset_type='skin' AND asset_id=? AND user_id IN (?) FOR UPDATE",[BOY,userIds]))[0] : [];
const summary = {profiles:profiles.length,classicAssets:assets.length,affectedAccounts:userIds.length};
if (!backupPath) { await connection.rollback(); return {...summary,dryRun:true}; }
fs.writeFileSync(backupPath, JSON.stringify({createdAt:new Date().toISOString(),profiles,assets,existingBoyAssets},null,2)+'\n',{mode:0o600,flag:'wx'});
for (const userId of userIds) {
await connection.execute("INSERT INTO user_assets (user_id,asset_type,asset_id,source) VALUES (?,'skin',?,'default_skin_migration') ON DUPLICATE KEY UPDATE asset_id=VALUES(asset_id)",[userId,BOY]);
}
await connection.execute("UPDATE user_profiles SET skin_id=? WHERE skin_id IN ('classic_whale','pending_initial_skin','') OR skin_id IS NULL",[BOY]);
await connection.execute("DELETE FROM user_assets WHERE asset_type='skin' AND asset_id=?",[RETIRED]);
const [[check]] = await connection.query("SELECT (SELECT COUNT(*) FROM user_profiles WHERE skin_id='classic_whale') AS profiles, (SELECT COUNT(*) FROM user_assets WHERE asset_type='skin' AND asset_id='classic_whale') AS assets");
if (Number(check.profiles) || Number(check.assets)) throw new Error('Retired skin references remain');
await connection.commit();
return {...summary,dryRun:false,remainingClassicProfiles:0,remainingClassicAssets:0};
} catch (error) { await connection.rollback(); throw error; }
}
async function rollback(connection, backupPath) {
const backup=JSON.parse(fs.readFileSync(backupPath,'utf8'));
const hadBoy=new Set(backup.existingBoyAssets.map(row=>String(row.user_id)));
const users=new Set([...backup.profiles,...backup.assets].map(row=>String(row.user_id)));
await connection.beginTransaction();
try {
for(const row of backup.profiles) await connection.execute('UPDATE user_profiles SET skin_id=? WHERE id=? AND skin_id=?',[row.skin_id,row.id,BOY]);
for(const row of backup.assets) await connection.execute('INSERT INTO user_assets (id,user_id,asset_type,asset_id,source,metadata,acquired_at) VALUES (?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE asset_id=VALUES(asset_id)',[row.id,row.user_id,row.asset_type,row.asset_id,row.source,row.metadata?JSON.stringify(row.metadata):null,new Date(row.acquired_at)]);
for(const userId of users) if(!hadBoy.has(userId)) await connection.execute("DELETE FROM user_assets WHERE user_id=? AND asset_type='skin' AND asset_id=? AND source='default_skin_migration'",[userId,BOY]);
await connection.commit();
return {rolledBack:true,profiles:backup.profiles.length,assets:backup.assets.length};
}catch(e){await connection.rollback();throw e;}
}
async function main() {
const args=process.argv.slice(2);
if (args.length && (!['--apply','--rollback'].includes(args[0]) || args.length!==2)) throw new Error('Use --apply /protected/backup.json or no arguments for a dry run');
for(const key of ['DB_HOST','DB_USERNAME','DB_PASSWORD','DB_NAME']) if(!process.env[key]) throw new Error('Missing '+key);
const connection=await mysql.createConnection({host:process.env.DB_HOST,port:Number(process.env.DB_PORT||3306),user:process.env.DB_USERNAME,password:process.env.DB_PASSWORD,database:process.env.DB_NAME,charset:'utf8mb4',supportBigNumbers:true,bigNumberStrings:true});
try { console.log(JSON.stringify(await (args[0]==='--rollback'?rollback(connection,args[1]):migrate(connection,args[1])))); }
finally { await connection.end(); }
}
module.exports={migrate,rollback};
if(require.main===module) main().catch(e=>{console.error(e.message);process.exitCode=1;});