Files
miaojingAI/scripts/migration-integrity-check.mjs
2026-05-20 15:25:44 +08:00

161 lines
8.9 KiB
JavaScript

#!/usr/bin/env node
import fs from 'fs';
import { Pool } from 'pg';
import {
checkStorageUrl,
getMigrationCheckBaseUrl,
getMigrationStorageUrlConcurrency,
getMigrationStorageUrlTimeoutMs,
} from './migration-integrity-check-helpers.mjs';
loadEnvFile('.env.local');
const connectionString = process.env.LOCAL_DB_URL;
if (!connectionString) {
console.error('LOCAL_DB_URL is required');
process.exit(1);
}
const baseUrl = getMigrationCheckBaseUrl();
const maxStorageUrls = Number(process.env.MIGRATION_CHECK_STORAGE_URL_LIMIT || 200);
const storageUrlTimeoutMs = getMigrationStorageUrlTimeoutMs();
const storageUrlConcurrency = getMigrationStorageUrlConcurrency();
const pool = new Pool({ connectionString, max: 2 });
const checks = [];
try {
await collectChecks();
const blockers = checks.filter(check => check.severity === 'blocker' && check.value > 0);
const warnings = checks.filter(check => check.severity === 'warning' && check.value > 0);
console.log(JSON.stringify({
ok: blockers.length === 0,
baseUrl,
checkedAt: new Date().toISOString(),
blockers,
warnings,
checks,
}, null, 2));
process.exit(blockers.length === 0 ? 0 : 1);
} finally {
await pool.end().catch(() => undefined);
}
async function collectChecks() {
await scalar('profiles_total', 'info', 'select count(*) from profiles');
await scalar('auth_users_total', 'info', 'select count(*) from auth.users');
await scalar('works_total', 'info', 'select count(*) from works');
await scalar('private_works_total', 'info', 'select count(*) from works where is_public = false');
await scalar('profiles_without_auth', 'blocker', 'select count(*) from profiles p left join auth.users au on au.id = p.id where au.id is null');
await scalar('auth_without_profile', 'blocker', 'select count(*) from auth.users au left join profiles p on p.id = au.id where p.id is null');
await scalar('missing_password_hash', 'blocker', "select count(*) from auth.users where coalesce(password_hash, '') = ''");
await scalar('works_missing_profile', 'blocker', 'select count(*) from works w left join profiles p on p.id = w.user_id where w.user_id is not null and p.id is null');
await scalar('works_missing_user_id', 'blocker', 'select count(*) from works where user_id is null');
await scalar('credit_tx_missing_profile', 'blocker', 'select count(*) from credit_transactions ct left join profiles p on p.id = ct.user_id where ct.user_id is not null and p.id is null');
await scalar('credit_tx_missing_work', 'blocker', 'select count(*) from credit_transactions ct left join works w on w.id = ct.related_work_id where ct.related_work_id is not null and w.id is null');
await scalar('credit_tx_user_work_mismatch', 'blocker', 'select count(*) from credit_transactions ct join works w on w.id = ct.related_work_id where ct.user_id is not null and w.user_id is not null and ct.user_id <> w.user_id');
await scalar('orders_missing_profile', 'blocker', 'select count(*) from orders o left join profiles p on p.id = o.user_id where o.user_id is not null and p.id is null');
await scalar('redeem_codes_created_by_missing_profile', 'blocker', "select case when to_regclass('public.redeem_codes') is null then 0 else (select count(*) from redeem_codes rc left join profiles p on p.id = rc.created_by where rc.created_by is not null and p.id is null) end");
await scalar('redeem_codes_used_by_missing_profile', 'blocker', "select case when to_regclass('public.redeem_codes') is null then 0 else (select count(*) from redeem_codes rc left join profiles p on p.id = rc.used_by where rc.used_by is not null and p.id is null) end");
await scalar('invitation_referrals_missing_inviter', 'blocker', "select case when to_regclass('public.invitation_referrals') is null then 0 else (select count(*) from invitation_referrals ir left join profiles p on p.id = ir.inviter_user_id where p.id is null) end");
await scalar('invitation_referrals_missing_invitee', 'blocker', "select case when to_regclass('public.invitation_referrals') is null then 0 else (select count(*) from invitation_referrals ir left join profiles p on p.id = ir.invitee_user_id where p.id is null) end");
await scalar('user_api_keys_missing_profile', 'blocker', 'select count(*) from user_api_keys k left join profiles p on p.id = k.user_id where k.user_id is not null and p.id is null');
await scalar('user_api_keys_missing_preview', 'blocker', "select count(*) from user_api_keys where coalesce(api_key_encrypted, '') <> '' and coalesce(api_key_preview, '') = ''");
await scalar('system_api_missing_preview', 'blocker', "select count(*) from system_api_configs where coalesce(api_key_encrypted, '') <> '' and coalesce(api_key_preview, '') = ''");
await scalar('work_likes_missing_profile', 'blocker', 'select count(*) from work_likes wl left join profiles p on p.id = wl.user_id where wl.user_id is not null and p.id is null');
await scalar('work_likes_missing_work', 'blocker', 'select count(*) from work_likes wl left join works w on w.id = wl.work_id where wl.work_id is not null and w.id is null');
await scalar('generation_jobs_missing_profile', 'blocker', 'select count(*) from generation_jobs gj left join profiles p on p.id = gj.user_id where gj.user_id is not null and p.id is null');
await scalar('same_url_different_users', 'info', "select count(*) from (select result_url from works where coalesce(result_url, '') <> '' group by result_url having count(distinct user_id) > 1) t");
await scalar('duplicate_url_same_user', 'warning', "select count(*) from (select user_id, result_url from works where coalesce(result_url, '') <> '' group by user_id, result_url having count(*) > 1) t");
for (const [table, column] of [
['user_api_keys', 'manifest_path'],
['system_api_configs', 'manifest_path'],
['system_api_configs', 'billing_mode'],
['system_api_configs', 'fixed_price'],
['system_api_configs', 'duration_price_per_second'],
['system_api_configs', 'input_price_per_1k'],
['system_api_configs', 'output_price_per_1k'],
['system_api_configs', 'is_default'],
['system_api_configs', 'allowed_membership_tiers'],
['system_api_configs', 'polling_mode'],
['system_api_configs', 'polling_order'],
['profiles', 'invite_code'],
['profiles', 'referred_by_user_id'],
['invitation_referrals', 'invite_code'],
['invitation_referrals', 'inviter_user_id'],
['invitation_referrals', 'invitee_user_id'],
]) {
await requiredColumn(table, column);
}
await checkLocalStorageUrls();
}
async function scalar(name, severity, sql, params = []) {
const res = await pool.query(sql, params);
checks.push({
name,
severity,
value: Number(res.rows[0]?.count ?? res.rows[0]?.value ?? 0),
});
}
async function requiredColumn(table, column) {
const [schema, tableName] = table.includes('.') ? table.split('.', 2) : ['public', table];
const res = await pool.query(
'select count(*)::int as count from information_schema.columns where table_schema = $1 and table_name = $2 and column_name = $3',
[schema, tableName, column],
);
checks.push({
name: `column_${table}_${column}`,
severity: 'blocker',
value: Number(res.rows[0]?.count || 0) === 1 ? 0 : 1,
});
}
async function checkLocalStorageUrls() {
const res = await pool.query(`
with urls as (
select result_url as url from works where result_url like '/api/local-storage/%'
union select thumbnail_url as url from works where thumbnail_url like '/api/local-storage/%'
union select logo_url as url from site_config where logo_url like '/api/local-storage/%'
union select favicon_url as url from site_config where favicon_url like '/api/local-storage/%'
)
select url from urls where url is not null limit $1
`, [Number.isFinite(maxStorageUrls) && maxStorageUrls > 0 ? maxStorageUrls : 200]);
let missing = 0;
let checked = 0;
let cursor = 0;
const workers = Array.from({ length: Math.min(storageUrlConcurrency, Math.max(1, res.rows.length)) }, async () => {
while (cursor < res.rows.length) {
const row = res.rows[cursor++];
const result = await checkStorageUrl(baseUrl, row.url, { timeoutMs: storageUrlTimeoutMs });
checked += 1;
if (!result.ok) missing += 1;
}
});
await Promise.all(workers);
checks.push({ name: 'local_storage_urls_checked', severity: 'info', value: res.rows.length });
checks.push({ name: 'local_storage_urls_probe_completed', severity: 'info', value: checked });
checks.push({ name: 'local_storage_urls_missing', severity: 'blocker', value: missing });
}
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
const raw = fs.readFileSync(filePath, 'utf8');
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
const [, key, value] = match;
if (process.env[key] !== undefined) continue;
process.env[key] = value.replace(/^['"]|['"]$/g, '');
}
}