import fs from 'node:fs'; import path from 'node:path'; import { closeDb, pool, queryOne } from './db.js'; import { loadEnv } from './env.js'; loadEnv(); type JsonRecord = Record & { id?: string }; type CollectionMap = Record; type Issue = { severity: 'info' | 'warning' | 'error' | 'critical'; issueCode: string; message: string; fieldPath?: string; rawValueSample?: string; }; const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001'; const tenantSlug = process.env.TENANT_SLUG || 'master'; const tenantName = process.env.TENANT_NAME || '升本刷题通主租户'; const exportDir = path.resolve(process.cwd(), process.env.PB_EXPORT_DIR || '../../pb_export'); const sourceName = process.env.PB_SOURCE_NAME || path.basename(exportDir); const importSecretValues = process.env.IMPORT_SECRET_VALUES === 'true'; const sensitiveKeyPattern = /(password|token|secret|privatekey|sessionkey|accesskey|appkey|apikey|api_v3_key|notifytoken|aeskey|openid|unionid|wxaccesstoken|wechatsessionkey|smscode|verifycode|verificationcode|captcha)/i; const identityKeyPattern = /(phone|email|openid|unionid|sessionkey)/i; const legacyLookupTables = new Set([ 'activation_codes', 'badges', 'categories', 'code_batches', 'coupons', 'coupon_redemptions', 'handbook_chapters', 'handbook_entries', 'handbook_subjects', 'majors', 'module_nodes', 'orders', 'practice_blueprints', 'products', 'questions', 'region_modules', 'regions', 'reports', 'schools', 'scoreline_majors', 'scoreline_schools', 'subjects', 'svip_plans', 'video_explanations', 'vocabulary_units', 'vocabulary_words', ]); const nonCollectionJsonFiles = new Set([ 'pb_schema.sqlite.json', 'sqlite-export-manifest.json', 'storage-manifest.json', ]); const importBatchSize = Math.max(100, intFromEnv(process.env.PB_IMPORT_BATCH_SIZE, 1000)); function intFromEnv(value: unknown, fallback: number): number { if (value === null || value === undefined || value === '') return fallback; const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback; } function asArray(input: unknown): JsonRecord[] { if (Array.isArray(input)) return input as JsonRecord[]; if (input && typeof input === 'object' && Array.isArray((input as { items?: unknown[] }).items)) { return (input as { items: JsonRecord[] }).items; } if (input && typeof input === 'object' && Array.isArray((input as { records?: unknown[] }).records)) { return (input as { records: JsonRecord[] }).records; } return []; } function collectionNameFromFile(fileName: string) { return fileName.replace(/\.json$/i, ''); } function redactValue(value: unknown) { if (value === null || value === undefined || value === '') return value; return '[REDACTED]'; } function sanitizeValue(value: unknown, keyPath = ''): unknown { const key = keyPath.split('.').pop() || keyPath; if (sensitiveKeyPattern.test(key)) return redactValue(value); if (Array.isArray(value)) return value.map((item, index) => sanitizeValue(item, `${keyPath}.${index}`)); if (value && typeof value === 'object') { const copy: Record = {}; for (const [childKey, childValue] of Object.entries(value as Record)) { copy[childKey] = sanitizeValue(childValue, keyPath ? `${keyPath}.${childKey}` : childKey); } return copy; } return value; } function sanitizeRecord(record: JsonRecord): JsonRecord { return sanitizeValue(record) as JsonRecord; } function stripSensitiveKeys(value: unknown, keyPath = ''): unknown { const key = keyPath.split('.').pop() || keyPath; if (sensitiveKeyPattern.test(key)) return undefined; if (Array.isArray(value)) { return value.map((item, index) => stripSensitiveKeys(item, `${keyPath}.${index}`)).filter(item => item !== undefined); } if (value && typeof value === 'object') { const copy: Record = {}; for (const [childKey, childValue] of Object.entries(value as Record)) { const stripped = stripSensitiveKeys(childValue, keyPath ? `${keyPath}.${childKey}` : childKey); if (stripped !== undefined) copy[childKey] = stripped; } return copy; } return value; } function publicProfile(record: JsonRecord): JsonRecord { return stripSensitiveKeys(record) as JsonRecord; } function learningStats(value: unknown): JsonRecord { const stats = parseJsonish(value, {}) as JsonRecord; const { favorites: _favorites, wrongBook: _wrongBook, ...rest } = stats; return rest; } function text(value: unknown): string | null { if (value === null || value === undefined) return null; const result = String(value).trim(); return result ? result : null; } function intValue(value: unknown, fallback = 0): number { if (value === null || value === undefined || value === '') return fallback; const parsed = Number(value); return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; } function numberValue(value: unknown, fallback: number | null = null): number | null { if (value === null || value === undefined || value === '') return fallback; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function cents(value: unknown, fallback = 0): number { const parsed = numberValue(value, null); return parsed === null ? fallback : Math.round(parsed * 100); } function boolValue(value: unknown, fallback = false): boolean { if (value === null || value === undefined || value === '') return fallback; if (typeof value === 'boolean') return value; if (typeof value === 'number') return value !== 0; const normalized = String(value).trim().toLowerCase(); if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true; if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false; return fallback; } function parseJsonish(value: unknown, fallback: unknown): unknown { if (value === null || value === undefined || value === '') return fallback; if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) return fallback; if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { return JSON.parse(trimmed); } catch { return fallback; } } } return value; } function json(value: unknown, fallback: unknown) { return JSON.stringify(parseJsonish(value, fallback)); } function arrayValue(value: unknown): unknown[] { const parsed = parseJsonish(value, []); if (Array.isArray(parsed)) return parsed; if (typeof parsed === 'string' && parsed.trim()) return parsed.split(',').map(item => item.trim()).filter(Boolean); return []; } function objectValue(value: unknown): JsonRecord { const parsed = parseJsonish(value, {}); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as JsonRecord; return {}; } function entriesValue(value: unknown): Array<[string, unknown]> { const parsed = parseJsonish(value, {}); if (Array.isArray(parsed)) return parsed.map((item, index) => [String(index), item]); if (parsed && typeof parsed === 'object') return Object.entries(parsed as Record); return []; } function dateText(value: unknown): string { return text(value) || ''; } function nullableDateText(...values: unknown[]): string { for (const value of values) { const result = dateText(value); if (result) return result; } return ''; } function clampRate(value: unknown, fallback = 0.2): number { const parsed = numberValue(value, fallback); if (parsed === null || !Number.isFinite(parsed)) return fallback; return Math.min(1, Math.max(0, parsed)); } function normalizeTenantRole(roleValue: unknown): string { const role = text(roleValue)?.toLowerCase(); if (role === 'superadmin') return 'platform_admin'; if (role === 'admin') return 'tenant_admin'; if (role === 'operator') return 'tenant_operator'; if (role === 'teacher') return 'teacher'; if (role === 'sales') return 'sales'; if (role === 'agent') return 'agent'; return 'student'; } function normalizeOrderStatus(value: unknown): string { const status = text(value)?.toLowerCase(); if (status === 'paid' || status === 'success') return 'paid'; if (status === 'failed' || status === 'fail') return 'failed'; if (status === 'refunded' || status === 'refund') return 'refunded'; if (status === 'closed' || status === 'cancelled' || status === 'canceled') return 'closed'; return 'pending'; } function normalizePaymentStatus(value: unknown): string { const status = normalizeOrderStatus(value); if (status === 'closed') return 'cancelled'; return status; } function normalizeCouponType(value: unknown): string | null { const type = text(value)?.toLowerCase(); if (!type) return null; if (['percent', 'percentage', 'rate'].includes(type)) return 'percent'; return 'fixed'; } function walkIssues(collection: string, value: unknown, pathParts: string[] = []): Issue[] { const issues: Issue[] = []; if (!value || typeof value !== 'object') return issues; for (const [key, childValue] of Object.entries(value as Record)) { const fieldPath = [...pathParts, key].join('.'); if (sensitiveKeyPattern.test(key) && childValue !== null && childValue !== undefined && childValue !== '') { issues.push({ severity: collection === 'settings' || collection === 'crm_config' ? 'critical' : 'warning', issueCode: 'sensitive_field_in_source', message: `Sensitive source field "${fieldPath}" must not be copied into public business tables.`, fieldPath, rawValueSample: String(childValue).slice(0, 6) + '...', }); } if (childValue && typeof childValue === 'object') issues.push(...walkIssues(collection, childValue, [...pathParts, key])); } return issues; } function detectIssues(collection: string, record: JsonRecord): Issue[] { const issues = walkIssues(collection, record); if (collection === 'settings') { issues.push({ severity: 'warning', issueCode: 'settings_monolith', message: 'Legacy settings is a monolithic config table. Values must be split into public config, private secrets, payment accounts and storage config.', }); } if (collection === 'users') { if (record.stats && typeof record.stats === 'object') { issues.push({ severity: 'info', issueCode: 'json_stats_requires_normalization', message: 'users.stats is legacy JSON and should be normalized into learning/favorite/wrong-book tables.', fieldPath: 'stats', }); } if (record.isSvip !== undefined || record.svipRegions !== undefined || record.svipExpiry !== undefined) { issues.push({ severity: 'warning', issueCode: 'legacy_membership_state', message: 'Legacy SVIP fields should be converted into entitlements instead of copied as mutable user flags.', }); } } if (['smscodes', 'customer_messages'].includes(collection)) { issues.push({ severity: 'warning', issueCode: 'privacy_sensitive_collection', message: `${collection} contains personal or verification data and is intentionally not imported into public business tables by default.`, }); } return issues; } async function ensureTenant() { await pool.query( ` insert into public.tenants (id, slug, name, status, mode) values ($1, $2, $3, 'active', 'platform_owned') on conflict (id) do update set slug = excluded.slug, name = excluded.name, updated_at = now() `, [tenantId, tenantSlug, tenantName], ); await pool.query( ` insert into public.tenant_branding (tenant_id, brand_name, short_name) values ($1, $2, $3) on conflict (tenant_id) do nothing `, [tenantId, tenantName, tenantName], ); await pool.query( ` insert into public.tenant_settings (tenant_id) values ($1) on conflict (tenant_id) do nothing `, [tenantId], ); } async function createRun() { const row = await queryOne<{ id: string }>( ` insert into public.pb_import_runs (tenant_id, source_name, source_kind) values ($1, $2, 'json') returning id `, [tenantId, sourceName], ); if (!row) throw new Error('Failed to create import run'); return row.id; } async function importRaw(runId: string, collection: string, records: JsonRecord[]) { let count = 0; const issueRows: Array<{ legacyId: string; issue: Issue; }> = []; for (let offset = 0; offset < records.length; offset += importBatchSize) { const batch = records.slice(offset, offset + importBatchSize); const legacyIds: string[] = []; const recordPayloads: string[] = []; for (const record of batch) { const legacyId = text(record.id); if (!legacyId) continue; legacyIds.push(legacyId); recordPayloads.push(JSON.stringify(sanitizeRecord(record))); for (const detectedIssue of detectIssues(collection, record)) { issueRows.push({ legacyId, issue: detectedIssue }); } } if (!legacyIds.length) continue; await pool.query( ` insert into public.pb_raw_records (run_id, tenant_id, collection_name, legacy_id, record) select $1::uuid, $2::uuid, $3::text, raw.legacy_id, raw.record_payload::jsonb from unnest($4::text[], $5::text[]) as raw(legacy_id, record_payload) on conflict (run_id, collection_name, legacy_id) do update set record = excluded.record, imported_at = now() `, [runId, tenantId, collection, legacyIds, recordPayloads], ); count += legacyIds.length; } await insertImportIssueRows(runId, collection, issueRows); return count; } async function insertImportIssueRows( runId: string, collection: string, rows: Array<{ legacyId: string | null; issue: Issue }>, ) { for (let offset = 0; offset < rows.length; offset += importBatchSize) { const batch = rows.slice(offset, offset + importBatchSize); if (!batch.length) continue; await pool.query( ` insert into public.pb_import_issues ( run_id, tenant_id, collection_name, legacy_id, severity, issue_code, message, field_path, raw_value_sample ) select $1::uuid, $2::uuid, $3::text, issue.legacy_id, issue.severity, issue.issue_code, issue.message, issue.field_path, issue.raw_value_sample from unnest( $4::text[], $5::text[], $6::text[], $7::text[], $8::text[], $9::text[] ) as issue(legacy_id, severity, issue_code, message, field_path, raw_value_sample) `, [ runId, tenantId, collection, batch.map(row => row.legacyId), batch.map(row => row.issue.severity), batch.map(row => row.issue.issueCode), batch.map(row => row.issue.message), batch.map(row => row.issue.fieldPath || null), batch.map(row => row.issue.rawValueSample || null), ], ); } } async function issue(runId: string, collection: string, legacyId: unknown, issueCode: string, message: string, severity: Issue['severity'] = 'warning') { await pool.query( ` insert into public.pb_import_issues ( run_id, tenant_id, collection_name, legacy_id, severity, issue_code, message ) values ($1,$2,$3,$4,$5,$6,$7) `, [runId, tenantId, collection, text(legacyId), severity, issueCode, message], ); } async function markNormalized(runId: string, collection: string) { await pool.query( ` update public.pb_raw_records set normalized = true where run_id = $1 and collection_name = $2 `, [runId, collection], ); } const legacyLookupCache = new Map(); const userLookupCache = new Map(); const questionVersionLookupCache = new Map(); async function legacyId(tableName: string, legacyValue: unknown): Promise { const value = text(legacyValue); if (!value) return null; if (!legacyLookupTables.has(tableName)) throw new Error(`Unsafe legacy lookup table: ${tableName}`); const cacheKey = `${tableName}:${value}`; if (legacyLookupCache.has(cacheKey)) return legacyLookupCache.get(cacheKey) || null; const row = await queryOne<{ id: string }>( `select id from public.${tableName} where tenant_id = $1 and legacy_id = $2 limit 1`, [tenantId, value], ); const result = row?.id || null; legacyLookupCache.set(cacheKey, result); return result; } async function userIdByLegacy(value: unknown): Promise { const legacyValue = text(value); if (!legacyValue) return null; if (userLookupCache.has(legacyValue)) return userLookupCache.get(legacyValue) || null; const row = await queryOne<{ id: string }>('select id from public.platform_users where legacy_id = $1 limit 1', [legacyValue]); const result = row?.id || null; userLookupCache.set(legacyValue, result); return result; } async function currentQuestionVersionId(questionId: string | null): Promise { if (!questionId) return null; if (questionVersionLookupCache.has(questionId)) return questionVersionLookupCache.get(questionId) || null; const row = await queryOne<{ current_version_id: string | null }>( `select current_version_id from public.questions where tenant_id = $1 and id = $2 limit 1`, [tenantId, questionId], ); const result = row?.current_version_id || null; questionVersionLookupCache.set(questionId, result); return result; } async function bulkUserIdsByLegacy(values: unknown[]): Promise> { const legacyValues = [...new Set(values.map(value => text(value)).filter((value): value is string => Boolean(value)))]; const result = new Map(); const missing: string[] = []; for (const legacyValue of legacyValues) { if (userLookupCache.has(legacyValue)) { const cached = userLookupCache.get(legacyValue); if (cached) result.set(legacyValue, cached); } else { missing.push(legacyValue); } } for (let offset = 0; offset < missing.length; offset += importBatchSize) { const batch = missing.slice(offset, offset + importBatchSize); if (!batch.length) continue; const rows = await pool.query<{ legacy_id: string; id: string }>( ` select legacy_id, id from public.platform_users where legacy_id = any($1::text[]) `, [batch], ); const found = new Map(rows.rows.map(row => [row.legacy_id, row.id])); for (const legacyValue of batch) { const id = found.get(legacyValue) || null; userLookupCache.set(legacyValue, id); if (id) result.set(legacyValue, id); } } return result; } async function bulkLegacyIds(tableName: string, values: unknown[]): Promise> { const legacyValues = [...new Set(values.map(value => text(value)).filter((value): value is string => Boolean(value)))]; const result = new Map(); const missing: string[] = []; if (!legacyLookupTables.has(tableName)) throw new Error(`Unsafe legacy lookup table: ${tableName}`); for (const legacyValue of legacyValues) { const cacheKey = `${tableName}:${legacyValue}`; if (legacyLookupCache.has(cacheKey)) { const cached = legacyLookupCache.get(cacheKey); if (cached) result.set(legacyValue, cached); } else { missing.push(legacyValue); } } for (let offset = 0; offset < missing.length; offset += importBatchSize) { const batch = missing.slice(offset, offset + importBatchSize); if (!batch.length) continue; const rows = await pool.query<{ legacy_id: string; id: string }>( ` select legacy_id, id from public.${tableName} where tenant_id = $1 and legacy_id = any($2::text[]) `, [tenantId, batch], ); const found = new Map(rows.rows.map(row => [row.legacy_id, row.id])); for (const legacyValue of batch) { const id = found.get(legacyValue) || null; legacyLookupCache.set(`${tableName}:${legacyValue}`, id); if (id) result.set(legacyValue, id); } } return result; } async function bulkQuestionVersionIds(questionIds: Array): Promise> { const values = [...new Set(questionIds.filter((value): value is string => Boolean(value)))]; const result = new Map(); const missing: string[] = []; for (const questionId of values) { if (questionVersionLookupCache.has(questionId)) { const cached = questionVersionLookupCache.get(questionId); if (cached) result.set(questionId, cached); } else { missing.push(questionId); } } for (let offset = 0; offset < missing.length; offset += importBatchSize) { const batch = missing.slice(offset, offset + importBatchSize); if (!batch.length) continue; const rows = await pool.query<{ id: string; current_version_id: string | null }>( ` select id, current_version_id from public.questions where tenant_id = $1 and id = any($2::uuid[]) `, [tenantId, batch], ); const found = new Map(rows.rows.map(row => [row.id, row.current_version_id])); for (const questionId of batch) { const versionId = found.get(questionId) || null; questionVersionLookupCache.set(questionId, versionId); if (versionId) result.set(questionId, versionId); } } return result; } async function upsertSecret(secretScope: string, secretKey: string, secretValue: unknown, provider: string | null = null) { if (!importSecretValues || secretValue === null || secretValue === undefined || secretValue === '') return; await pool.query( ` insert into app_private.tenant_secrets (tenant_id, secret_scope, secret_key, secret_value, provider) values ($1,$2,$3,$4,$5) on conflict (tenant_id, secret_scope, secret_key) do update set secret_value = excluded.secret_value, provider = excluded.provider, updated_at = now() `, [tenantId, secretScope, secretKey, String(secretValue), provider], ); } async function normalizeRegions(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.regions ( tenant_id, legacy_id, name, code, short_name, full_name, icon, pinyin, sort_order, is_hot, is_active, config, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, coalesce(nullif($13::text,'')::timestamptz, now()), coalesce(nullif($14::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set name = excluded.name, code = excluded.code, short_name = excluded.short_name, full_name = excluded.full_name, icon = excluded.icon, pinyin = excluded.pinyin, sort_order = excluded.sort_order, is_hot = excluded.is_hot, is_active = excluded.is_active, config = excluded.config, updated_at = excluded.updated_at `, [ tenantId, r.id, text(r.name) || '未命名地区', text(r.code), text(r.shortName), text(r.fullName), text(r.icon), text(r.pinyin), intValue(r.order), boolValue(r.isHot), boolValue(r.isActive, true), json(r.config, {}), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeRegionModules(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.region_modules ( tenant_id, region_id, legacy_id, name, type, icon, color, text_color, description, route, sort_order, is_primary_school_module, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, coalesce(nullif($14::text,'')::timestamptz, now()), coalesce(nullif($15::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, name = excluded.name, type = excluded.type, icon = excluded.icon, color = excluded.color, text_color = excluded.text_color, description = excluded.description, route = excluded.route, sort_order = excluded.sort_order, is_primary_school_module = excluded.is_primary_school_module, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), r.id, text(r.name) || '未命名模块', text(r.type), text(r.icon), text(r.color), text(r.textColor), text(r.description), text(r.route), intValue(r.order), boolValue(r.isPrimarySchoolModule), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeModuleNodes(records: JsonRecord[]) { for (const r of records) { const legacyParent = text(r.parentId); await pool.query( ` insert into public.module_nodes ( tenant_id, region_id, module_id, parent_id, legacy_id, legacy_parent_id, legacy_module_id, type, name, sort_order, is_active, metadata, created_at, updated_at ) values ($1,$2,$3,null,$4,$5,$6,$7,$8,$9,$10,$11, coalesce(nullif($12::text,'')::timestamptz, now()), coalesce(nullif($13::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, module_id = excluded.module_id, legacy_parent_id = excluded.legacy_parent_id, legacy_module_id = excluded.legacy_module_id, type = excluded.type, name = excluded.name, sort_order = excluded.sort_order, is_active = excluded.is_active, metadata = excluded.metadata, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), await legacyId('region_modules', r.moduleId), r.id, legacyParent, text(r.moduleId), ['category', 'subject', 'chapter', 'paper', 'school', 'major', 'custom'].includes(text(r.type) || '') ? text(r.type) : 'custom', text(r.name) || '未命名节点', intValue(r.order), boolValue(r.isActive, true), json(r.metadata, {}), dateText(r.created), dateText(r.updated), ], ); } await pool.query( ` update public.module_nodes child set parent_id = parent.id from public.module_nodes parent where child.tenant_id = $1 and parent.tenant_id = child.tenant_id and child.legacy_parent_id is not null and parent.legacy_id = child.legacy_parent_id `, [tenantId], ); } async function normalizeSchools(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.schools ( tenant_id, region_id, module_id, legacy_id, name, professional_exam_date, metadata, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7, coalesce(nullif($8::text,'')::timestamptz, now()), coalesce(nullif($9::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, module_id = excluded.module_id, name = excluded.name, professional_exam_date = excluded.professional_exam_date, metadata = excluded.metadata, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), await legacyId('region_modules', r.moduleId), r.id, text(r.name) || '未命名院校', text(r.professionalExamDate), json({ isActive: boolValue(r.isActive, true) }, {}), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeMajors(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.majors ( tenant_id, region_id, school_id, legacy_id, name, description, study_tips, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9, coalesce(nullif($10::text,'')::timestamptz, now()), coalesce(nullif($11::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, school_id = excluded.school_id, name = excluded.name, description = excluded.description, study_tips = excluded.study_tips, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), await legacyId('schools', r.schoolId), r.id, text(r.name) || '未命名专业', text(r.description), text(r.studyTips), intValue(r.order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeSubjects(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.subjects ( tenant_id, region_id, module_id, school_id, major_id, node_id, legacy_id, name, type, major_legacy_ids, icon, description, stats, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,null,$6,$7,$8,$9,$10,$11,$12,$13,true, coalesce(nullif($14::text,'')::timestamptz, now()), coalesce(nullif($15::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, module_id = excluded.module_id, school_id = excluded.school_id, major_id = excluded.major_id, name = excluded.name, type = excluded.type, major_legacy_ids = excluded.major_legacy_ids, icon = excluded.icon, description = excluded.description, stats = excluded.stats, sort_order = excluded.sort_order, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), await legacyId('region_modules', r.moduleId), await legacyId('schools', r.schoolId), await legacyId('majors', r.majorId), r.id, text(r.name) || '未命名科目', text(r.type) === 'professional' ? 'professional' : text(r.type) === 'cultural' ? 'cultural' : null, json(arrayValue(r.majorIds), []), text(r.icon), text(r.description), json(r.stats, {}), intValue(r.order), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeCategories(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.categories ( tenant_id, subject_id, node_id, legacy_id, name, category_type, sort_order, svip_question_limit, is_active, created_at, updated_at ) values ($1,$2,null,$3,$4,$5,$6,$7,true, coalesce(nullif($8::text,'')::timestamptz, now()), coalesce(nullif($9::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set subject_id = excluded.subject_id, name = excluded.name, category_type = excluded.category_type, sort_order = excluded.sort_order, svip_question_limit = excluded.svip_question_limit, updated_at = excluded.updated_at `, [ tenantId, await legacyId('subjects', r.subjectId), r.id, text(r.name) || '未命名章节', text(r.categoryType) === 'paper' ? 'paper' : 'chapter', intValue(r.order), numberValue(r.svipQuestionLimit, null), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeQuestions(records: JsonRecord[]) { for (const r of records) { const inserted = await queryOne<{ id: string }>( ` insert into public.questions ( tenant_id, subject_id, category_id, node_id, legacy_id, legacy_subject_id, legacy_category_id, legacy_node_id, type, type_label, difficulty, tags, media_url, status, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'published', coalesce(nullif($14::text,'')::timestamptz, now()), coalesce(nullif($15::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set subject_id = excluded.subject_id, category_id = excluded.category_id, node_id = excluded.node_id, legacy_subject_id = excluded.legacy_subject_id, legacy_category_id = excluded.legacy_category_id, legacy_node_id = excluded.legacy_node_id, type = excluded.type, type_label = excluded.type_label, difficulty = excluded.difficulty, tags = excluded.tags, media_url = excluded.media_url, updated_at = excluded.updated_at returning id `, [ tenantId, await legacyId('subjects', r.subjectId), await legacyId('categories', r.categoryId), await legacyId('module_nodes', r.nodeId), r.id, text(r.subjectId), text(r.categoryId), text(r.nodeId), text(r.type) || 'choice', text(r.typeLabel), numberValue(r.difficulty, null), json(r.tags, []), text(r.media), dateText(r.created), dateText(r.updated), ], ); if (!inserted) continue; const version = await queryOne<{ id: string }>( ` insert into public.question_versions ( tenant_id, question_id, version_no, content, options, correct_option_index, correct_option_indices, answer_text, explanation, sub_questions, code_lang, code_template ) values ($1,$2,1,$3,$4,$5,$6,$7,$8,$9,$10,$11) on conflict (question_id, version_no) do update set content = excluded.content, options = excluded.options, correct_option_index = excluded.correct_option_index, correct_option_indices = excluded.correct_option_indices, answer_text = excluded.answer_text, explanation = excluded.explanation, sub_questions = excluded.sub_questions, code_lang = excluded.code_lang, code_template = excluded.code_template returning id `, [ tenantId, inserted.id, text(r.content), json(r.options, []), numberValue(r.correctOptionIndex, null), json(r.correctOptionIndices, []), text(r.answerText), text(r.explanation), json(r.sub_questions || r.subQuestions, []), text(r.code_lang), text(r.code_template), ], ); if (version) { await pool.query('update public.questions set current_version_id = $1 where id = $2', [version.id, inserted.id]); } } } async function normalizeUserAnswerRecords(runId: string, records: JsonRecord[]) { const userIdsByLegacy = await bulkUserIdsByLegacy(records.map(record => record.userId)); const questionIdsByLegacy = await bulkLegacyIds('questions', records.map(record => record.questionId)); const questionVersionIdsByQuestion = await bulkQuestionVersionIds([...questionIdsByLegacy.values()]); const issueRows: Array<{ legacyId: string | null; issue: Issue }> = []; for (let offset = 0; offset < records.length; offset += importBatchSize) { const batch = records.slice(offset, offset + importBatchSize); const userIds: string[] = []; const questionIds: Array = []; const questionVersionIds: Array = []; const legacyIds: string[] = []; const legacyQuestionIds: Array = []; const legacyCategoryIds: Array = []; const selectedOptionsPayloads: string[] = []; const answerPayloads: string[] = []; const isCorrectValues: Array = []; const answeredAtValues: string[] = []; const createdAtValues: string[] = []; for (const r of batch) { const legacyIdValue = text(r.id); if (!legacyIdValue) continue; const legacyUserId = text(r.userId); const userId = legacyUserId ? userIdsByLegacy.get(legacyUserId) || null : null; if (!userId) { const hasLegacyUserId = Boolean(legacyUserId); issueRows.push({ legacyId: legacyIdValue, issue: { severity: 'warning', issueCode: hasLegacyUserId ? 'answer_user_not_found' : 'answer_user_empty', message: hasLegacyUserId ? `Answer record skipped because legacy userId no longer exists in exported users: ${legacyUserId}` : 'Answer record skipped because legacy userId is empty.', }, }); continue; } const legacyQuestionId = text(r.questionId); const questionId = legacyQuestionId ? questionIdsByLegacy.get(legacyQuestionId) || null : null; if (!questionId) { issueRows.push({ legacyId: legacyIdValue, issue: { severity: 'warning', issueCode: 'answer_question_not_found', message: `Answer record kept with legacy_question_id only because questionId was not resolved: ${legacyQuestionId || '(empty)'}`, }, }); } const isCorrect = r.isCorrect === null || r.isCorrect === undefined || r.isCorrect === '' ? null : boolValue(r.isCorrect); const answeredAt = nullableDateText(r.answeredAt, r.updated, r.created); const selectedOptions = arrayValue(r.selectedOptions); const answerPayload = { source: 'pocketbase.user_answer_records', selectedOptions, legacyCategoryId: text(r.categoryId), legacyQuestionId, importedAt: new Date().toISOString(), }; userIds.push(userId); questionIds.push(questionId); questionVersionIds.push(questionId ? questionVersionIdsByQuestion.get(questionId) || null : null); legacyIds.push(legacyIdValue); legacyQuestionIds.push(legacyQuestionId); legacyCategoryIds.push(text(r.categoryId)); selectedOptionsPayloads.push(JSON.stringify(selectedOptions)); answerPayloads.push(JSON.stringify(answerPayload)); isCorrectValues.push(isCorrect); answeredAtValues.push(answeredAt); createdAtValues.push(nullableDateText(r.created, answeredAt)); } if (!legacyIds.length) continue; await pool.query( ` insert into public.answer_records ( tenant_id, user_id, question_id, question_version_id, legacy_id, legacy_question_id, legacy_category_id, selected_options, answer_payload, is_correct, answered_at, created_at ) select $1::uuid, answer.user_id::uuid, answer.question_id::uuid, answer.question_version_id::uuid, answer.legacy_id, answer.legacy_question_id, answer.legacy_category_id, answer.selected_options::jsonb, answer.answer_payload::jsonb, answer.is_correct::boolean, coalesce(nullif(answer.answered_at::text,'')::timestamptz, now()), coalesce(nullif(answer.created_at::text,'')::timestamptz, now()) from unnest( $2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[], $8::text[], $9::text[], $10::boolean[], $11::text[], $12::text[] ) as answer( user_id, question_id, question_version_id, legacy_id, legacy_question_id, legacy_category_id, selected_options, answer_payload, is_correct, answered_at, created_at ) on conflict (tenant_id, legacy_id) do update set user_id = excluded.user_id, question_id = excluded.question_id, question_version_id = excluded.question_version_id, legacy_question_id = excluded.legacy_question_id, legacy_category_id = excluded.legacy_category_id, selected_options = excluded.selected_options, answer_payload = excluded.answer_payload, is_correct = excluded.is_correct, answered_at = excluded.answered_at `, [ tenantId, userIds, questionIds, questionVersionIds, legacyIds, legacyQuestionIds, legacyCategoryIds, selectedOptionsPayloads, answerPayloads, isCorrectValues, answeredAtValues, createdAtValues, ], ); } await insertImportIssueRows(runId, 'user_answer_records', issueRows); await pool.query( ` insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count, last_wrong_at, resolved_at) select tenant_id, user_id, question_id, count(*)::integer, max(answered_at), null from public.answer_records where tenant_id = $1 and legacy_id is not null and question_id is not null and is_correct = false group by tenant_id, user_id, question_id on conflict (tenant_id, user_id, question_id) do update set wrong_count = excluded.wrong_count, last_wrong_at = excluded.last_wrong_at, resolved_at = null `, [tenantId], ); } async function normalizeUsers(records: JsonRecord[]) { for (const r of records) { const safeProfile = publicProfile(r); await pool.query( ` insert into public.platform_users ( legacy_id, username, email, phone, name, avatar_url, primary_role, score, last_seen_at, password_migration_required, platform_permissions, raw_profile, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::timestamptz,true,$10::jsonb,$11, coalesce(nullif($12::text,'')::timestamptz, now()), coalesce(nullif($13::text,'')::timestamptz, now()) ) on conflict (legacy_id) do update set username = excluded.username, email = excluded.email, phone = excluded.phone, name = excluded.name, avatar_url = excluded.avatar_url, primary_role = excluded.primary_role, score = excluded.score, platform_permissions = excluded.platform_permissions, raw_profile = excluded.raw_profile, updated_at = excluded.updated_at `, [ r.id, text(r.username), text(r.email), text(r.phone), text(r.name) || text(r.username), text(r.avatar), normalizeTenantRole(r.role), intValue(r.score), dateText(r.lastSeenAt), normalizeTenantRole(r.role) === 'platform_admin' ? JSON.stringify({ '*': true }) : JSON.stringify({}), JSON.stringify(safeProfile), dateText(r.created), dateText(r.updated), ], ); const user = await queryOne<{ id: string }>('select id from public.platform_users where legacy_id = $1', [r.id]); if (!user) continue; await pool.query( ` insert into public.tenant_memberships (tenant_id, user_id, role, legacy_role) values ($1, $2, $3, $4) on conflict (tenant_id, user_id, role) do update set legacy_role = excluded.legacy_role `, [tenantId, user.id, normalizeTenantRole(r.role), text(r.role) || 'student'], ); await pool.query( ` insert into public.student_profiles ( tenant_id, user_id, legacy_user_id, region_id, selected_school_id, selected_major_id, questions_answered_today, mastered_words_count, last_check_in_date, stats, progress, module_selections, recent_activities, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::date,$10,$11,$12,$13, coalesce(nullif($14::text,'')::timestamptz, now()), coalesce(nullif($15::text,'')::timestamptz, now()) ) on conflict (tenant_id, user_id) do update set region_id = excluded.region_id, selected_school_id = excluded.selected_school_id, selected_major_id = excluded.selected_major_id, questions_answered_today = excluded.questions_answered_today, mastered_words_count = excluded.mastered_words_count, last_check_in_date = excluded.last_check_in_date, stats = excluded.stats, progress = excluded.progress, module_selections = excluded.module_selections, recent_activities = excluded.recent_activities, updated_at = excluded.updated_at `, [ tenantId, user.id, r.id, await legacyId('regions', r.regionId), await legacyId('schools', r.selectedSchoolId), await legacyId('majors', r.selectedMajorId), intValue(r.questionsAnswered), intValue(r.masteredWordsCount), dateText(r.lastCheckInDate), JSON.stringify(learningStats(r.stats)), json(r.progress, {}), json(r.moduleSelections, {}), json(r.recentActivities, []), dateText(r.created), dateText(r.updated), ], ); for (const [key, provider] of [ ['email', 'email'], ['phone', 'phone'], ['wechatUnionId', 'wechat_unionid'], ['wechatOpenId', 'wechat_openid'], ['qqOpenId', 'qq_openid'], ] as const) { const subject = text(r[key]); if (!subject) continue; await pool.query( ` insert into public.user_identities (user_id, provider, provider_subject, union_id, open_id, phone, email) values ($1,$2,$3,$4,$5,$6,$7) on conflict (provider, provider_subject) do update set user_id = excluded.user_id `, [ user.id, provider, subject, key === 'wechatUnionId' ? subject : null, key === 'wechatOpenId' || key === 'qqOpenId' ? subject : null, key === 'phone' ? subject : null, key === 'email' ? subject : null, ], ); } } } async function normalizeUserEntitlementsAndStats(runId: string, records: JsonRecord[]) { for (const r of records) { const userId = await userIdByLegacy(r.id); if (!userId) continue; await pool.query( ` delete from public.entitlements where tenant_id = $1 and user_id = $2 and source_type = 'migration' and legacy_source_id = $3 `, [tenantId, userId, text(r.id)], ); if (boolValue(r.isSvip) || text(r.svipExpiry) || r.svipRegions) { const svipRegionEntries = entriesValue(r.svipRegions); const scopes = svipRegionEntries.length > 0 ? svipRegionEntries.map(([regionLegacyId, expiresAt]) => ({ regionLegacyId, expiresAt })) : [{ regionLegacyId: r.svipRegionId || r.regionId || null, expiresAt: r.svipExpiry }]; for (const scope of scopes) { const scopeLegacyId = scope.regionLegacyId; const regionId = await legacyId('regions', scopeLegacyId); await pool.query( ` insert into public.entitlements ( tenant_id, user_id, entitlement_type, scope_type, scope_id, source_type, legacy_source_id, starts_at, expires_at, status, metadata ) values ($1,$2,'svip',$3,$4,'migration',$5, coalesce(nullif($6::text,'')::timestamptz, now()), nullif($7::text,'')::timestamptz, 'active', $8 ) `, [ tenantId, userId, regionId ? 'region' : 'tenant', regionId, text(r.id), dateText(r.created), dateText(scope.expiresAt || r.svipExpiry), json({ legacyRegion: scopeLegacyId || null, source: 'users.svip' }, {}), ], ); } } const stats = parseJsonish(r.stats, {}) as JsonRecord; for (const legacyQuestionId of arrayValue(stats.favorites)) { const questionId = await legacyId('questions', legacyQuestionId); if (!questionId) { await issue(runId, 'users', r.id, 'favorite_question_not_found', `Favorite question not found: ${text(legacyQuestionId)}`); continue; } await pool.query( ` insert into public.favorite_questions (tenant_id, user_id, question_id, source) values ($1,$2,$3,'migration') on conflict (tenant_id, user_id, question_id) do nothing `, [tenantId, userId, questionId], ); } for (const legacyQuestionId of arrayValue(stats.wrongBook)) { const questionId = await legacyId('questions', legacyQuestionId); if (!questionId) { await issue(runId, 'users', r.id, 'wrong_question_not_found', `Wrong-book question not found: ${text(legacyQuestionId)}`); continue; } await pool.query( ` insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count) values ($1,$2,$3,1) on conflict (tenant_id, user_id, question_id) do update set wrong_count = greatest(public.wrong_questions.wrong_count, excluded.wrong_count) `, [tenantId, userId, questionId], ); } } } async function normalizeSettings(records: JsonRecord[]) { for (const r of records) { const publicConfig: Record = {}; for (const [key, value] of Object.entries(r)) { if (['id', 'created', 'updated'].includes(key)) continue; if (sensitiveKeyPattern.test(key)) continue; if (identityKeyPattern.test(key)) continue; publicConfig[key] = value; } await pool.query( ` insert into public.tenant_settings (tenant_id, public_config) values ($1, $2) on conflict (tenant_id) do update set public_config = public.tenant_settings.public_config || excluded.public_config, updated_at = now() `, [tenantId, JSON.stringify(publicConfig)], ); await upsertPaymentAccount('xunhu_alipay', { appId: text(r.xunhuAlipayAppId), notifyDomain: text(r.xunhuNotifyDomain), returnUrl: text(r.xunhuReturnUrl), }); await upsertPaymentAccount('xunhu_wechat', { appId: text(r.xunhuWechatAppId), notifyDomain: text(r.xunhuNotifyDomain), returnUrl: text(r.xunhuReturnUrl), }); await upsertPaymentAccount('wechat_pay', { appId: text(r.wxMiniAppId), mchId: text(r.wxMchId), notifyUrl: text(r.wxNotifyUrl), serialNo: text(r.wxSerialNo), }); await upsertPaymentAccount('xpay', { offerId: text(r.xpayOfferId), env: text(r.xpayEnv), enabled: boolValue(r.xpayEnabled), }); for (const [key, value] of Object.entries(r)) { if (!sensitiveKeyPattern.test(key) || value === null || value === undefined || value === '') continue; const lower = key.toLowerCase(); const scope = lower.includes('sms') ? 'sms' : lower.includes('pay') || lower.includes('xpay') || lower.includes('mchid') || lower.includes('serial') ? 'payment' : lower.includes('s3') ? 'storage' : lower.includes('wechat') || lower.includes('qq') || lower.includes('wx') ? 'oauth' : 'system'; await upsertSecret(scope, key, value); } } } async function upsertPaymentAccount(provider: string, publicConfig: Record) { const hasAnyValue = Object.values(publicConfig).some(value => value !== null && value !== undefined && value !== ''); if (!hasAnyValue) return; await pool.query( ` insert into public.tenant_payment_accounts (tenant_id, provider, mode, display_name, status, config_public) values ($1,$2,'tenant_collect',$3,'pending',$4) on conflict (tenant_id, provider) do update set config_public = public.tenant_payment_accounts.config_public || excluded.config_public, updated_at = now() `, [tenantId, provider, provider, JSON.stringify(publicConfig)], ); } async function normalizeSvipPlans(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.svip_plans ( tenant_id, region_id, legacy_id, name, price_cents, original_price_cents, days, description, per_day_label, badge, recommended, coupon_only, vp_product_id, vp_enabled, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true, coalesce(nullif($16::text,'')::timestamptz, now()), coalesce(nullif($17::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, name = excluded.name, price_cents = excluded.price_cents, original_price_cents = excluded.original_price_cents, days = excluded.days, description = excluded.description, per_day_label = excluded.per_day_label, badge = excluded.badge, recommended = excluded.recommended, coupon_only = excluded.coupon_only, vp_product_id = excluded.vp_product_id, vp_enabled = excluded.vp_enabled, sort_order = excluded.sort_order, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), r.id, text(r.name) || '未命名套餐', cents(r.price), r.originalPrice === undefined ? null : cents(r.originalPrice), intValue(r.days), text(r.desc), text(r.perDay), text(r.badge), boolValue(r.recommended), boolValue(r.couponOnly), text(r.vpProductId), boolValue(r.vpEnabled), intValue(r.order), dateText(r.created), dateText(r.updated), ], ); } } function normalizeMockExamSections(questionTypes: unknown) { return arrayValue(questionTypes) .map((item, index) => { const object = objectValue(item); const questionType = text(object.type) || text(object.questionType) || `section_${index + 1}`; const count = intValue(object.count, 0); return { key: text(object.key) || questionType, title: text(object.title) || text(object.name) || questionType, questionType, count, sortOrder: intValue(object.order, index + 1) - 1, scoreEach: numberValue(object.scoreEach ?? object.score, null), }; }) .filter(section => section.count > 0) .sort((a, b) => a.sortOrder - b.sortOrder); } async function normalizeMockExamConfigs(runId: string, records: JsonRecord[]) { for (const r of records) { const subjectId = await legacyId('subjects', r.subjectId); const sections = normalizeMockExamSections(r.questionTypes); const questionLimit = intValue(r.totalQuestions, sections.reduce((sum, section) => sum + section.count, 0)); if (!subjectId) { await issue( runId, 'mock_exam_configs', r.id, 'mock_exam_subject_not_found', `Mock exam blueprint imported without resolved subject because subjectId was not found: ${text(r.subjectId) || '(empty)'}`, 'warning', ); } await pool.query( ` insert into public.practice_blueprints ( tenant_id, legacy_id, name, mode, assembly_type, question_limit, duration_minutes, sections, rules, status, sort_order, created_at, updated_at ) values ($1,$2,$3,'mock_exam','filters',$4,$5,$6::jsonb,$7::jsonb,$8,$9, coalesce(nullif($10::text,'')::timestamptz, now()), coalesce(nullif($11::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set name = excluded.name, question_limit = excluded.question_limit, duration_minutes = excluded.duration_minutes, sections = excluded.sections, rules = excluded.rules, status = excluded.status, sort_order = excluded.sort_order, updated_at = excluded.updated_at `, [ tenantId, r.id, text(r.name) || `全真模拟-${text(r.subjectId) || text(r.id) || '未命名'}`, questionLimit > 0 ? questionLimit : null, intValue(r.duration, 45), JSON.stringify(sections), JSON.stringify({ source: 'pocketbase.mock_exam_configs', subjectId, legacySubjectId: text(r.subjectId), legacyQuestionTypes: arrayValue(r.questionTypes), randomize: true, }), boolValue(r.isActive, true) ? 'active' : 'archived', intValue(r.order), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeCodeBatches(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.code_batches ( tenant_id, legacy_id, name, sale_type, channel, campaign_name, default_unit_price_cents, cost_price_cents, total_count, days, region_id, legacy_region_id, issued_at, created_by, remark, commission_rate, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,nullif($13::text,'')::timestamptz,$14,$15,$16, coalesce(nullif($17::text,'')::timestamptz, now()), coalesce(nullif($18::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set name = excluded.name, sale_type = excluded.sale_type, channel = excluded.channel, campaign_name = excluded.campaign_name, default_unit_price_cents = excluded.default_unit_price_cents, cost_price_cents = excluded.cost_price_cents, total_count = excluded.total_count, days = excluded.days, region_id = excluded.region_id, legacy_region_id = excluded.legacy_region_id, issued_at = excluded.issued_at, created_by = excluded.created_by, remark = excluded.remark, commission_rate = excluded.commission_rate, updated_at = excluded.updated_at `, [ tenantId, r.id, text(r.name) || '未命名批次', text(r.saleType), text(r.channel), text(r.campaignName), cents(r.defaultUnitPrice), cents(r.costPrice), intValue(r.totalCount), numberValue(r.days, null), await legacyId('regions', r.regionId), text(r.regionId), dateText(r.issuedAt), await userIdByLegacy(r.createdBy), text(r.remark), numberValue(r.commissionRate, null), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeActivationCodes(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.activation_codes ( tenant_id, legacy_id, code, days, is_used, used_by, used_at, agent_user_id, batch_id, sale_type, unit_price_cents, sold_to, used_region_id, coupon_code, coupon_redemption_id, remark, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,nullif($7::text,'')::timestamptz,$8,$9,$10,$11,$12,$13,$14,$15,$16, coalesce(nullif($17::text,'')::timestamptz, now()), coalesce(nullif($18::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set code = excluded.code, days = excluded.days, is_used = excluded.is_used, used_by = excluded.used_by, used_at = excluded.used_at, agent_user_id = excluded.agent_user_id, batch_id = excluded.batch_id, sale_type = excluded.sale_type, unit_price_cents = excluded.unit_price_cents, sold_to = excluded.sold_to, used_region_id = excluded.used_region_id, coupon_code = excluded.coupon_code, coupon_redemption_id = excluded.coupon_redemption_id, remark = excluded.remark, updated_at = excluded.updated_at `, [ tenantId, r.id, text(r.code) || `legacy-${text(r.id)}`, intValue(r.days), boolValue(r.isUsed), await userIdByLegacy(r.usedBy), dateText(r.usedAt), await userIdByLegacy(r.agentId), await legacyId('code_batches', r.batchId), text(r.saleType), r.unitPrice === undefined ? null : cents(r.unitPrice), text(r.soldTo), await legacyId('regions', r.usedRegionId), text(r.couponCode), await legacyId('coupon_redemptions', r.couponRedemptionId), text(r.remark), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeCoupons(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.coupons ( tenant_id, legacy_id, code, plan_id, discount_type, discount_value, valid_from, valid_to, max_uses, used_count, source, remark, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,nullif($7::text,'')::timestamptz,nullif($8::text,'')::timestamptz,$9,$10,$11,$12, coalesce(nullif($13::text,'')::timestamptz, now()), coalesce(nullif($14::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set code = excluded.code, plan_id = excluded.plan_id, discount_type = excluded.discount_type, discount_value = excluded.discount_value, valid_from = excluded.valid_from, valid_to = excluded.valid_to, max_uses = excluded.max_uses, used_count = excluded.used_count, source = excluded.source, remark = excluded.remark, updated_at = excluded.updated_at `, [ tenantId, r.id, text(r.code) || `legacy-${text(r.id)}`, await legacyId('svip_plans', r.planId), normalizeCouponType(r.discountType), numberValue(r.discountValue, null), dateText(r.validFrom), dateText(r.validTo), numberValue(r.maxUses, null), intValue(r.usedCount), text(r.source), text(r.remark), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeCouponRedemptions(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.coupon_redemptions ( tenant_id, legacy_id, coupon_id, coupon_code, user_id, plan_id, order_id, status, discount_applied_cents, region_id, source, remark, claimed_at, used_at, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, nullif($13::text,'')::timestamptz, nullif($14::text,'')::timestamptz, coalesce(nullif($15::text,'')::timestamptz, now()), coalesce(nullif($16::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set coupon_id = excluded.coupon_id, coupon_code = excluded.coupon_code, user_id = excluded.user_id, plan_id = excluded.plan_id, order_id = excluded.order_id, status = excluded.status, discount_applied_cents = excluded.discount_applied_cents, region_id = excluded.region_id, source = excluded.source, remark = excluded.remark, claimed_at = excluded.claimed_at, used_at = excluded.used_at, updated_at = excluded.updated_at `, [ tenantId, r.id, await legacyId('coupons', r.couponId), text(r.couponCode), await userIdByLegacy(r.userId), await legacyId('svip_plans', r.planId), await legacyId('orders', r.orderId), text(r.status) || 'claimed', r.discountApplied === undefined ? null : cents(r.discountApplied), await legacyId('regions', r.regionId), text(r.source), text(r.remark), dateText(r.claimedAt), dateText(r.usedAt), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeOrders(runId: string, records: JsonRecord[]) { for (const r of records) { const resolvedUserId = await userIdByLegacy(r.userId); const sanitized = sanitizeRecord(r); const rawPayload = { ...(sanitized as Record), migration: { source: 'pocketbase.orders', reviewRequired: !resolvedUserId, reviewReason: !resolvedUserId ? 'legacy_user_not_resolved' : null, entitlementBlocked: !resolvedUserId, }, }; const order = await queryOne<{ id: string }>( ` insert into public.orders ( tenant_id, user_id, legacy_id, legacy_user_id, order_no, status, product_type, product_name, amount_cents, pay_method, pay_provider, trade_no, plan_id, legacy_plan_id, days, region_id, legacy_region_id, paid_at, raw_payload, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,'svip',$7,$8,$9,$10,$11,$12,$13,$14,$15,$16, nullif($17::text,'')::timestamptz,$18, coalesce(nullif($19::text,'')::timestamptz, now()), coalesce(nullif($20::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set user_id = excluded.user_id, legacy_user_id = excluded.legacy_user_id, order_no = excluded.order_no, status = excluded.status, product_name = excluded.product_name, amount_cents = excluded.amount_cents, pay_method = excluded.pay_method, pay_provider = excluded.pay_provider, trade_no = excluded.trade_no, plan_id = excluded.plan_id, legacy_plan_id = excluded.legacy_plan_id, days = excluded.days, region_id = excluded.region_id, legacy_region_id = excluded.legacy_region_id, paid_at = excluded.paid_at, raw_payload = excluded.raw_payload, updated_at = excluded.updated_at returning id `, [ tenantId, resolvedUserId, r.id, text(r.userId), text(r.orderNo) || `legacy-${text(r.id)}`, normalizeOrderStatus(r.status), text(r.planName), cents(r.amount), text(r.payMethod), text(r.payProvider), text(r.tradeNo) || text(r.xpayWxOrderId) || text(r.xpayChannelOrderId), await legacyId('svip_plans', r.planId), text(r.planId), numberValue(r.days, null), await legacyId('regions', r.regionId), text(r.regionId), dateText(r.paidAt), JSON.stringify(rawPayload), dateText(r.created), dateText(r.updated), ], ); if (!order) continue; if (!resolvedUserId) { await issue( runId, 'orders', r.id, 'order_user_not_resolved', `Order imported for finance review only because userId was not resolved: ${text(r.userId) || '(empty)'}`, normalizeOrderStatus(r.status) === 'paid' ? 'critical' : 'warning', ); } await pool.query( ` insert into public.order_items ( tenant_id, order_id, legacy_id, item_type, item_id, name, quantity, unit_amount_cents, total_amount_cents, metadata ) values ($1,$2,$3,'svip_plan',$4,$5,1,$6,$6,$7) on conflict (tenant_id, legacy_id) do update set order_id = excluded.order_id, item_id = excluded.item_id, name = excluded.name, unit_amount_cents = excluded.unit_amount_cents, total_amount_cents = excluded.total_amount_cents, metadata = excluded.metadata `, [ tenantId, order.id, r.id, await legacyId('svip_plans', r.planId), text(r.planName) || 'SVIP会员', cents(r.amount), json({ legacyPlanId: text(r.planId), days: numberValue(r.days, null) }, {}), ], ); await pool.query( ` insert into public.payments ( tenant_id, order_id, legacy_id, legacy_order_id, provider, method, status, amount_cents, provider_trade_no, paid_at, raw_payload, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,nullif($10::text,'')::timestamptz,$11, coalesce(nullif($12::text,'')::timestamptz, now()), coalesce(nullif($13::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set provider = excluded.provider, method = excluded.method, status = excluded.status, amount_cents = excluded.amount_cents, provider_trade_no = excluded.provider_trade_no, paid_at = excluded.paid_at, raw_payload = excluded.raw_payload, updated_at = excluded.updated_at `, [ tenantId, order.id, r.id, text(r.id), text(r.payProvider) || text(r.payMethod) || 'legacy', text(r.payMethod), normalizePaymentStatus(r.status), cents(r.amount), text(r.tradeNo) || text(r.xpayWxOrderId) || text(r.xpayChannelOrderId), dateText(r.paidAt), JSON.stringify(sanitizeRecord(r)), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeVocabularyUnits(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.vocabulary_units ( tenant_id, region_id, legacy_id, name, description, word_count, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8, coalesce(nullif($9::text,'')::timestamptz, now()), coalesce(nullif($10::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, name = excluded.name, description = excluded.description, word_count = excluded.word_count, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), r.id, text(r.name) || '未命名单元', text(r.description), numberValue(r.wordCount, null), intValue(r.order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeVocabularyWords(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.vocabulary_words ( tenant_id, unit_id, legacy_id, word, phonetic, meaning, example, example_translation, difficulty, tags, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, coalesce(nullif($13::text,'')::timestamptz, now()), coalesce(nullif($14::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set unit_id = excluded.unit_id, word = excluded.word, phonetic = excluded.phonetic, meaning = excluded.meaning, example = excluded.example, example_translation = excluded.example_translation, difficulty = excluded.difficulty, tags = excluded.tags, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, await legacyId('vocabulary_units', r.unit), r.id, text(r.word) || 'unknown', text(r.phonetic), text(r.meaning), text(r.example), text(r.exampleTranslation), numberValue(r.difficulty, null), json(r.tags, []), intValue(r.order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeHandbookSubjects(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.handbook_subjects ( tenant_id, region_id, legacy_id, name, type, icon, color, description, sort_order, is_active, metadata, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11, coalesce(nullif($12::text,'')::timestamptz, now()), coalesce(nullif($13::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, name = excluded.name, type = excluded.type, icon = excluded.icon, color = excluded.color, description = excluded.description, sort_order = excluded.sort_order, is_active = excluded.is_active, metadata = excluded.metadata, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), r.id, text(r.name) || '未命名手册科目', text(r.type), text(r.icon), text(r.color), text(r.description), intValue(r.order), boolValue(r.isActive, true), json({ legacySchoolId: text(r.schoolId), legacyMajorId: text(r.majorId), legacyMajorIds: arrayValue(r.majorIds) }, {}), dateText(r.created), dateText(r.updated), ], ); } } async function migrationOrphanHandbookSubjectId(): Promise { const legacyIdValue = '__migration_orphan_handbook_subject__'; const row = await queryOne<{ id: string }>( ` insert into public.handbook_subjects ( tenant_id, legacy_id, name, type, description, sort_order, is_active, metadata ) values ($1,$2,'迁移待复核手册','migration_review','旧 PocketBase 章节缺少 subjectId 时自动挂载到这里,需人工归并。',999999,true,$3::jsonb) on conflict (tenant_id, legacy_id) do update set name = excluded.name, description = excluded.description, metadata = public.handbook_subjects.metadata || excluded.metadata, updated_at = now() returning id `, [ tenantId, legacyIdValue, JSON.stringify({ source: 'pocketbase.handbook_chapters', reviewRequired: true, reviewReason: 'legacy_chapter_subject_missing', }), ], ); if (!row) throw new Error('Failed to create migration orphan handbook subject'); return row.id; } async function normalizeHandbookChapters(runId: string, records: JsonRecord[]) { for (const r of records) { let subjectId = await legacyId('handbook_subjects', r.subjectId); const missingSubject = !subjectId; if (missingSubject) { subjectId = await migrationOrphanHandbookSubjectId(); await issue( runId, 'handbook_chapters', r.id, 'handbook_chapter_subject_missing', `Handbook chapter was attached to migration review subject because subjectId was missing or unresolved: ${text(r.subjectId) || '(empty)'}`, 'critical', ); } await pool.query( ` insert into public.handbook_chapters ( tenant_id, subject_id, legacy_id, name, description, sort_order, is_active, metadata, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8::jsonb, coalesce(nullif($9::text,'')::timestamptz, now()), coalesce(nullif($10::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set subject_id = excluded.subject_id, name = excluded.name, description = excluded.description, sort_order = excluded.sort_order, is_active = excluded.is_active, metadata = excluded.metadata, updated_at = excluded.updated_at `, [ tenantId, subjectId, r.id, text(r.name) || '未命名手册章节', text(r.description), intValue(r.order), boolValue(r.isActive, true), JSON.stringify({ source: 'pocketbase.handbook_chapters', legacySubjectId: text(r.subjectId), reviewRequired: missingSubject, reviewReason: missingSubject ? 'legacy_subject_missing_or_unresolved' : null, }), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeHandbookEntries(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.handbook_entries ( tenant_id, chapter_id, legacy_id, title, summary, content, tags, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9, coalesce(nullif($10::text,'')::timestamptz, now()), coalesce(nullif($11::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set chapter_id = excluded.chapter_id, title = excluded.title, summary = excluded.summary, content = excluded.content, tags = excluded.tags, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, await legacyId('handbook_chapters', r.chapterId), r.id, text(r.title) || '未命名知识点', text(r.summary), text(r.content), json(r.tags, []), intValue(r.order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeSimpleContent(records: JsonRecord[], table: 'banners' | 'faqs' | 'announcements') { for (const r of records) { if (table === 'banners') { await pool.query( ` insert into public.banners ( tenant_id, region_id, legacy_id, title, subtitle, content, button_text, button_link, bg_color, border_color, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, coalesce(nullif($13::text,'')::timestamptz, now()), coalesce(nullif($14::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, title = excluded.title, subtitle = excluded.subtitle, content = excluded.content, button_text = excluded.button_text, button_link = excluded.button_link, bg_color = excluded.bg_color, border_color = excluded.border_color, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), r.id, text(r.title), text(r.subtitle), text(r.content), text(r.buttonText), text(r.buttonLink), text(r.bgColor), text(r.borderColor), intValue(r.order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } else if (table === 'faqs') { await pool.query( ` insert into public.faqs ( tenant_id, region_id, legacy_id, question, answer, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7, coalesce(nullif($8::text,'')::timestamptz, now()), coalesce(nullif($9::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, question = excluded.question, answer = excluded.answer, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), r.id, text(r.question), text(r.answer), intValue(r.order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } else { await pool.query( ` insert into public.announcements ( tenant_id, legacy_id, content, link, bg_color, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7, coalesce(nullif($8::text,'')::timestamptz, now()), coalesce(nullif($9::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set content = excluded.content, link = excluded.link, bg_color = excluded.bg_color, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, r.id, text(r.content), text(r.link), text(r.bgColor), intValue(r.order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } } } async function normalizeProducts(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.products ( tenant_id, region_id, legacy_id, title, price_label, link, type, tags, cover, preview_iframe, detail_images, sort_order, status, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'active', coalesce(nullif($13::text,'')::timestamptz, now()), coalesce(nullif($14::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, title = excluded.title, price_label = excluded.price_label, link = excluded.link, type = excluded.type, tags = excluded.tags, cover = excluded.cover, preview_iframe = excluded.preview_iframe, detail_images = excluded.detail_images, sort_order = excluded.sort_order, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), r.id, text(r.title) || '未命名商品', text(r.price), text(r.link), text(r.type), json(r.tags, []), text(r.cover), text(r.previewIframe), json(r.detailImages, []), intValue(r.order), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeTimelines(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.timelines ( tenant_id, region_id, school_id, legacy_id, type, title, description, event_date, link, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,nullif($8::text,'')::date,$9,$10,$11, coalesce(nullif($12::text,'')::timestamptz, now()), coalesce(nullif($13::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, school_id = excluded.school_id, type = excluded.type, title = excluded.title, description = excluded.description, event_date = excluded.event_date, link = excluded.link, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), await legacyId('schools', r.schoolId), r.id, text(r.type), text(r.title) || '未命名时间线', text(r.description), dateText(r.eventDate), text(r.link), intValue(r.order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeExamDates(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.exam_dates ( tenant_id, region_id, school_id, legacy_id, exam_name, exam_date, exam_type, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,nullif($6::text,'')::date,$7,$8,$9, coalesce(nullif($10::text,'')::timestamptz, now()), coalesce(nullif($11::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, school_id = excluded.school_id, exam_name = excluded.exam_name, exam_date = excluded.exam_date, exam_type = excluded.exam_type, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), await legacyId('schools', r.schoolId), r.id, text(r.examName) || '考试日期', dateText(r.examDate), text(r.examType), intValue(r.order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeVideoExplanations(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.video_explanations ( tenant_id, legacy_id, title, description, video_url, thumbnail_url, duration_seconds, knowledge_tags, is_general, subject_id, legacy_subject_id, difficulty, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, coalesce(nullif($15::text,'')::timestamptz, now()), coalesce(nullif($16::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set title = excluded.title, description = excluded.description, video_url = excluded.video_url, thumbnail_url = excluded.thumbnail_url, duration_seconds = excluded.duration_seconds, knowledge_tags = excluded.knowledge_tags, is_general = excluded.is_general, subject_id = excluded.subject_id, legacy_subject_id = excluded.legacy_subject_id, difficulty = excluded.difficulty, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, r.id, text(r.title) || '未命名视频解析', text(r.description), text(r.videoUrl), text(r.thumbnailUrl), numberValue(r.duration, null), json(r.knowledgeTags, []), boolValue(r.isGeneral), await legacyId('subjects', r.subjectId), text(r.subjectId), numberValue(r.difficulty, null), intValue(r.order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeQuestionVideos(records: JsonRecord[]) { for (const r of records) { const questionId = await legacyId('questions', r.questionId); const videoId = await legacyId('video_explanations', r.videoId); if (!questionId || !videoId) continue; await pool.query( ` insert into public.question_videos ( tenant_id, question_id, video_id, legacy_id, legacy_question_id, legacy_video_id, video_type, sort_order, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8, coalesce(nullif($9::text,'')::timestamptz, now()), coalesce(nullif($10::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set question_id = excluded.question_id, video_id = excluded.video_id, video_type = excluded.video_type, sort_order = excluded.sort_order, updated_at = excluded.updated_at `, [ tenantId, questionId, videoId, r.id, text(r.questionId), text(r.videoId), text(r.videoType) || 'specific', intValue(r.order), dateText(r.created), dateText(r.updated), ], ); } await pool.query( ` update public.questions q set has_video_explanation = true where tenant_id = $1 and exists ( select 1 from public.question_videos qv where qv.tenant_id = q.tenant_id and qv.question_id = q.id ) `, [tenantId], ); } function normalizeReportType(value: unknown): string { const type = text(value)?.toLowerCase(); if (!type) return 'other'; if (['question_error', 'answer_error', 'explanation_bad', 'typo'].includes(type)) return 'question_error'; if (type === 'content_error') return 'content_error'; if (type === 'video_error') return 'video_error'; if (type === 'asset_error') return 'asset_error'; if (type === 'system_bug') return 'system_bug'; if (type === 'suggestion') return 'suggestion'; return 'other'; } function normalizeReportStatus(value: unknown): string { const status = text(value)?.toLowerCase(); if (['pending', 'accepted', 'rejected', 'resolved', 'closed'].includes(status || '')) return status || 'pending'; return 'pending'; } async function normalizeReports(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.reports ( tenant_id, legacy_id, question_id, user_id, type, description, status, metadata, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8::jsonb, coalesce(nullif($9::text,'')::timestamptz, now()), coalesce(nullif($10::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set question_id = excluded.question_id, user_id = excluded.user_id, type = excluded.type, description = excluded.description, status = excluded.status, metadata = excluded.metadata, updated_at = excluded.updated_at `, [ tenantId, r.id, await legacyId('questions', r.questionId), await userIdByLegacy(r.userId), normalizeReportType(r.type), text(r.description), normalizeReportStatus(r.status), JSON.stringify({ source: 'pocketbase.reports', legacyType: text(r.type), legacyStatus: text(r.status), }), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeScorelineSchools(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.scoreline_schools ( tenant_id, region_id, legacy_id, name, short_name, type, is_hot, sort_order, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8, coalesce(nullif($9::text,'')::timestamptz, now()), coalesce(nullif($10::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, name = excluded.name, short_name = excluded.short_name, type = excluded.type, is_hot = excluded.is_hot, sort_order = excluded.sort_order, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), r.id, text(r.name) || '未命名分数线院校', text(r.shortName), text(r.type), boolValue(r.isHot), intValue(r.order), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeScorelineMajors(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.scoreline_majors ( tenant_id, region_id, school_id, legacy_id, name, sort_order, has_restriction, restriction_desc, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8, coalesce(nullif($9::text,'')::timestamptz, now()), coalesce(nullif($10::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, school_id = excluded.school_id, name = excluded.name, sort_order = excluded.sort_order, has_restriction = excluded.has_restriction, restriction_desc = excluded.restriction_desc, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), await legacyId('scoreline_schools', r.schoolId), r.id, text(r.name) || '未命名分数线专业', intValue(r.order), boolValue(r.hasRestriction), text(r.restrictionDesc), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeScorelineFields(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.scoreline_fields ( tenant_id, region_id, legacy_id, field_key, field_name, field_type, unit, is_filter, is_required, is_visible, is_trend, options, placeholder, description, sort_order, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15, coalesce(nullif($16::text,'')::timestamptz, now()), coalesce(nullif($17::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, field_key = excluded.field_key, field_name = excluded.field_name, field_type = excluded.field_type, unit = excluded.unit, is_filter = excluded.is_filter, is_required = excluded.is_required, is_visible = excluded.is_visible, is_trend = excluded.is_trend, options = excluded.options, placeholder = excluded.placeholder, description = excluded.description, sort_order = excluded.sort_order, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), r.id, text(r.fieldKey) || `field_${text(r.id)}`, text(r.fieldName) || '未命名字段', text(r.fieldType), text(r.unit), boolValue(r.isFilter), boolValue(r.isRequired), boolValue(r.isVisible, true), boolValue(r.isTrend), json(r.options, []), text(r.placeholder), text(r.description), intValue(r.sortOrder), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeScorelineRecords(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.scoreline_records ( tenant_id, region_id, school_id, major_id, legacy_id, year, school_name, major_name, field_values, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9, coalesce(nullif($10::text,'')::timestamptz, now()), coalesce(nullif($11::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set region_id = excluded.region_id, school_id = excluded.school_id, major_id = excluded.major_id, year = excluded.year, school_name = excluded.school_name, major_name = excluded.major_name, field_values = excluded.field_values, updated_at = excluded.updated_at `, [ tenantId, await legacyId('regions', r.regionId), await legacyId('scoreline_schools', r.schoolId), await legacyId('scoreline_majors', r.majorId), r.id, intValue(r.year), text(r.schoolName), text(r.majorName), json(r.fieldValues, {}), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeReferralTracks(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.referral_tracks ( tenant_id, legacy_id, event_type, ref_code, ref_user_id, target_user_id, source, ip_address, user_agent, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9, coalesce(nullif($10::text,'')::timestamptz, now()), coalesce(nullif($11::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set event_type = excluded.event_type, ref_code = excluded.ref_code, ref_user_id = excluded.ref_user_id, target_user_id = excluded.target_user_id, source = excluded.source, ip_address = excluded.ip_address, user_agent = excluded.user_agent, updated_at = excluded.updated_at `, [ tenantId, r.id, text(r.eventType) || 'unknown', text(r.refCode), await userIdByLegacy(r.refUserId), await userIdByLegacy(r.targetUserId), text(r.source), text(r.ip), text(r.userAgent), dateText(r.created), dateText(r.updated), ], ); } } function refCodeFromScene(sceneValue: unknown, fallback: unknown): string { const scene = text(sceneValue); if (scene) { const match = /^ref[_=-]?(.+)$/i.exec(scene); const code = (match?.[1] || scene).replace(/[^a-z0-9]/gi, '').toUpperCase(); if (code) return code; } const fallbackText = text(fallback)?.replace(/[^a-z0-9]/gi, '').toUpperCase(); return fallbackText || 'LEGACY'; } async function normalizeReferralQrcodes(runId: string, records: JsonRecord[]) { for (const r of records) { const userId = await userIdByLegacy(r.userId); const scene = text(r.scene) || `legacy_${text(r.id) || refCodeFromScene(null, r.userId)}`; const page = text(r.page) || 'pages/index/index'; const refCode = refCodeFromScene(scene, r.id); const qrcodeUrl = text(r.qrcodeUrl) || text(r.image); const metadata = { source: 'pocketbase.referral_qrcodes', legacyId: text(r.id), legacyUserId: text(r.userId), image: text(r.image), originalQrcodeUrl: text(r.qrcodeUrl), }; if (!userId) { await issue( runId, 'referral_qrcodes', r.id, 'referral_qrcode_user_not_found', `Referral qrcode cannot be attached to a user because userId was not resolved: ${text(r.userId) || '(empty)'}`, 'critical', ); continue; } await pool.query( ` insert into public.referral_codes (tenant_id, user_id, code, status, channel, landing_path, metadata, created_at, updated_at) values ($1,$2,$3::citext,'active','wechat-miniapp',$4,$5::jsonb, coalesce(nullif($6::text,'')::timestamptz, now()), coalesce(nullif($7::text,'')::timestamptz, now()) ) on conflict (tenant_id, user_id) do update set code = excluded.code, channel = excluded.channel, landing_path = excluded.landing_path, metadata = public.referral_codes.metadata || excluded.metadata, updated_at = excluded.updated_at `, [tenantId, userId, refCode, page, JSON.stringify(metadata), dateText(r.created), dateText(r.updated)], ); await pool.query( ` insert into public.referral_qrcodes ( tenant_id, user_id, ref_code, scene, page, provider, qrcode_url, status, metadata, created_at, updated_at ) values ($1,$2,$3::citext,$4,$5,'wechat-miniapp',$6,$7,$8::jsonb, coalesce(nullif($9::text,'')::timestamptz, now()), coalesce(nullif($10::text,'')::timestamptz, now()) ) on conflict (tenant_id, provider, scene, page) do update set user_id = excluded.user_id, ref_code = excluded.ref_code, qrcode_url = excluded.qrcode_url, status = excluded.status, metadata = public.referral_qrcodes.metadata || excluded.metadata, updated_at = excluded.updated_at `, [ tenantId, userId, refCode, scene, page, qrcodeUrl, qrcodeUrl ? 'ready' : 'pending', JSON.stringify(metadata), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeCommissionSettings(records: JsonRecord[]) { for (const r of records) { const config = { source: 'pocketbase.commission_settings', legacyId: text(r.id), remark: text(r.remark), raw: sanitizeRecord(r), }; await pool.query( ` insert into public.tenant_commission_settings ( tenant_id, default_rate, min_settlement_cents, settlement_cycle, config, created_at, updated_at ) values ($1,$2,0,'monthly',$3::jsonb, coalesce(nullif($4::text,'')::timestamptz, now()), coalesce(nullif($5::text,'')::timestamptz, now()) ) on conflict (tenant_id) do update set default_rate = excluded.default_rate, config = public.tenant_commission_settings.config || excluded.config, updated_at = excluded.updated_at `, [ tenantId, clampRate(r.defaultRate, 0.2), JSON.stringify(config), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeBadges(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.badges ( tenant_id, legacy_id, name, description, category, icon_url, level, unlock_type, condition_field, condition_operator, condition_value, condition_extra, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, coalesce(nullif($15::text,'')::timestamptz, now()), coalesce(nullif($16::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set name = excluded.name, description = excluded.description, category = excluded.category, icon_url = excluded.icon_url, level = excluded.level, unlock_type = excluded.unlock_type, condition_field = excluded.condition_field, condition_operator = excluded.condition_operator, condition_value = excluded.condition_value, condition_extra = excluded.condition_extra, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, r.id, text(r.name) || '未命名徽章', text(r.description), text(r.category), text(r.icon_url), numberValue(r.level, null), text(r.unlock_type), text(r.condition_field), text(r.condition_operator), numberValue(r.condition_value, null), json(r.condition_extra, {}), intValue(r.sort_order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeUserBadges(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.user_badges ( tenant_id, user_id, badge_id, granted_by, legacy_id, note, granted_at, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,nullif($7::text,'')::timestamptz, coalesce(nullif($8::text,'')::timestamptz, now()), coalesce(nullif($9::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set user_id = excluded.user_id, badge_id = excluded.badge_id, granted_by = excluded.granted_by, note = excluded.note, granted_at = excluded.granted_at, updated_at = excluded.updated_at `, [ tenantId, await userIdByLegacy(r.user), await legacyId('badges', r.badge), await userIdByLegacy(r.granted_by), r.id, text(r.note), dateText(r.granted_at), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeWordUserData(progressRecords: JsonRecord[], favoriteRecords: JsonRecord[]) { for (const r of progressRecords) { await pool.query( ` insert into public.user_word_progress ( tenant_id, user_id, word_id, legacy_id, legacy_user_id, legacy_word_id, status, correct_count, wrong_count, last_review_date, next_review_date, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,nullif($10::text,'')::timestamptz,nullif($11::text,'')::timestamptz, coalesce(nullif($12::text,'')::timestamptz, now()), coalesce(nullif($13::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set user_id = excluded.user_id, word_id = excluded.word_id, status = excluded.status, correct_count = excluded.correct_count, wrong_count = excluded.wrong_count, last_review_date = excluded.last_review_date, next_review_date = excluded.next_review_date, updated_at = excluded.updated_at `, [ tenantId, await userIdByLegacy(r.userId), await legacyId('vocabulary_words', r.wordId), r.id, text(r.userId), text(r.wordId), ['new', 'learning', 'mastered', 'reviewing'].includes(text(r.status) || '') ? text(r.status) : 'new', intValue(r.correctCount), intValue(r.wrongCount), dateText(r.lastReviewDate), dateText(r.nextReviewDate), dateText(r.created), dateText(r.updated), ], ); } for (const r of favoriteRecords) { await pool.query( ` insert into public.user_word_favorites ( tenant_id, user_id, word_id, legacy_id, legacy_user_id, legacy_word_id, note, favorited_at, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,nullif($8::text,'')::timestamptz, coalesce(nullif($9::text,'')::timestamptz, now()), coalesce(nullif($10::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set user_id = excluded.user_id, word_id = excluded.word_id, note = excluded.note, favorited_at = excluded.favorited_at, updated_at = excluded.updated_at `, [ tenantId, await userIdByLegacy(r.userId), await legacyId('vocabulary_words', r.wordId), r.id, text(r.userId), text(r.wordId), text(r.note), dateText(r.createdAt), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeRecentPractices(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.recent_practices ( tenant_id, user_id, legacy_id, practice_type, target_legacy_id, target_name, progress, color, last_access_at, last_practice_at, metadata, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::timestamptz,nullif($10::text,'')::timestamptz,$11, coalesce(nullif($12::text,'')::timestamptz, now()), coalesce(nullif($13::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set user_id = excluded.user_id, practice_type = excluded.practice_type, target_legacy_id = excluded.target_legacy_id, target_name = excluded.target_name, progress = excluded.progress, color = excluded.color, last_access_at = excluded.last_access_at, last_practice_at = excluded.last_practice_at, metadata = excluded.metadata, updated_at = excluded.updated_at `, [ tenantId, await userIdByLegacy(r.userId), r.id, text(r.type), text(r.targetId), text(r.targetName), intValue(r.progress), text(r.color), dateText(r.lastAccessTime), dateText(r.lastPracticeAt), json({ legacyTargetId: text(r.targetId) }, {}), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeAuxiliary(records: JsonRecord[], collection: string) { for (const r of records) { if (collection === 'audit_logs') { await pool.query( ` insert into public.audit_logs ( tenant_id, actor_user_id, action, target_type, target_id, details, ip_address, user_agent, created_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,coalesce(nullif($9::text,'')::timestamptz, now())) `, [ tenantId, await userIdByLegacy(r.userId), text(r.action) || 'legacy_audit', text(r.targetType), text(r.targetId), json({ detail: text(r.detail), metadata: text(r.metadata) }, {}), text(r.ip), text(r.userAgent), dateText(r.created), ], ); } if (collection === 'crm_config') { await pool.query( ` insert into public.crm_config ( tenant_id, enabled, url, secret_ref, form_name, exam_type, timeout_sec, delay_sec, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8, coalesce(nullif($9::text,'')::timestamptz, now()), coalesce(nullif($10::text,'')::timestamptz, now()) ) on conflict (tenant_id) do update set enabled = excluded.enabled, url = excluded.url, secret_ref = excluded.secret_ref, form_name = excluded.form_name, exam_type = excluded.exam_type, timeout_sec = excluded.timeout_sec, delay_sec = excluded.delay_sec, updated_at = excluded.updated_at `, [ tenantId, boolValue(r.enabled), text(r.url), text(r.secret) ? 'app_private.tenant_secrets:crm:legacy_crm_secret' : null, text(r.formName), text(r.examType), numberValue(r.timeoutSec, null), numberValue(r.delaySec, null), dateText(r.created), dateText(r.updated), ], ); await upsertSecret('crm', 'legacy_crm_secret', r.secret, 'legacy_crm'); } if (collection === 'crm_webhook_queue') { await pool.query( ` insert into public.crm_webhook_queue ( tenant_id, legacy_id, record_id, status, scheduled_at, attempts, next_attempt_at, last_error, last_http_code, lead_id, sent_at, created_at, updated_at ) values ($1,$2,$3,$4,nullif($5::text,'')::timestamptz,$6,nullif($7::text,'')::timestamptz,$8,$9,$10,nullif($11::text,'')::timestamptz, coalesce(nullif($12::text,'')::timestamptz, now()), coalesce(nullif($13::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set record_id = excluded.record_id, status = excluded.status, scheduled_at = excluded.scheduled_at, attempts = excluded.attempts, next_attempt_at = excluded.next_attempt_at, last_error = excluded.last_error, last_http_code = excluded.last_http_code, lead_id = excluded.lead_id, sent_at = excluded.sent_at, updated_at = excluded.updated_at `, [ tenantId, r.id, text(r.recordId), text(r.status) || 'pending', dateText(r.scheduledAt), intValue(r.attempts), dateText(r.nextAttemptAt), text(r.lastError), numberValue(r.lastHttpCode, null), text(r.leadId), dateText(r.sentAt), dateText(r.created), dateText(r.updated), ], ); } if (collection === 'crm_webhook_log') { await pool.query( ` insert into public.crm_webhook_log ( tenant_id, legacy_id, record_id, http_code, outcome, error_message, lead_id, request_body, response_summary, signed_at, attempt, created_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,nullif($10::text,'')::timestamptz,$11, coalesce(nullif($12::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set record_id = excluded.record_id, http_code = excluded.http_code, outcome = excluded.outcome, error_message = excluded.error_message, lead_id = excluded.lead_id, request_body = excluded.request_body, response_summary = excluded.response_summary, signed_at = excluded.signed_at, attempt = excluded.attempt `, [ tenantId, r.id, text(r.recordId), numberValue(r.httpCode, null), text(r.outcome), text(r.errorMessage), text(r.leadId), text(r.requestBody), text(r.responseSummary), dateText(r.signedAt), numberValue(r.attempt, null), dateText(r.created), ], ); } } } async function normalizeContentAssets(records: JsonRecord[], collection: 'app_assets' | 'images') { for (const r of records) { await pool.query( ` insert into public.content_assets ( tenant_id, legacy_id, asset_key, title, category, description, file_name, cdn_url, is_public, metadata, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, coalesce(nullif($11::text,'')::timestamptz, now()), coalesce(nullif($12::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set asset_key = excluded.asset_key, title = excluded.title, category = excluded.category, description = excluded.description, file_name = excluded.file_name, cdn_url = excluded.cdn_url, is_public = excluded.is_public, metadata = excluded.metadata, updated_at = excluded.updated_at `, [ tenantId, `${collection}:${text(r.id)}`, text(r.key), text(r.title), text(r.category), text(r.desc), text(r.image), text(r.cdnUrl), boolValue(r.isPublic), json({ sourceCollection: collection }, {}), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeDailyStats(records: JsonRecord[], collection: 'dashboard_daily_stats' | 'revenue_daily_stats') { for (const r of records) { if (collection === 'dashboard_daily_stats') { await pool.query( ` insert into public.dashboard_daily_stats ( tenant_id, legacy_id, stat_date, region_id, legacy_region_id, new_users, new_questions, new_orders, new_revenue_cents, active_users, rebuilt_at ) values ($1,$2,nullif($3::text,'')::date,$4,$5,$6,$7,$8,$9,$10,nullif($11::text,'')::timestamptz) on conflict (tenant_id, stat_date, legacy_region_id) do update set new_users = excluded.new_users, new_questions = excluded.new_questions, new_orders = excluded.new_orders, new_revenue_cents = excluded.new_revenue_cents, active_users = excluded.active_users, rebuilt_at = excluded.rebuilt_at `, [ tenantId, r.id, dateText(r.statDate), await legacyId('regions', r.regionId), text(r.regionId), intValue(r.newUsers), intValue(r.newQuestions), intValue(r.newOrders), cents(r.newRevenue), intValue(r.activeUsers), dateText(r.rebuiltAt), ], ); } else { await pool.query( ` insert into public.revenue_daily_stats ( tenant_id, legacy_id, stat_date, region_id, legacy_region_id, sale_type, real_revenue_cents, order_count, code_count, code_used, code_estimated_cents, estimated_revenue_cents, rebuilt_at ) values ($1,$2,nullif($3::text,'')::date,$4,$5,$6,$7,$8,$9,$10,$11,$12,nullif($13::text,'')::timestamptz) on conflict (tenant_id, stat_date, legacy_region_id, sale_type) do update set real_revenue_cents = excluded.real_revenue_cents, order_count = excluded.order_count, code_count = excluded.code_count, code_used = excluded.code_used, code_estimated_cents = excluded.code_estimated_cents, estimated_revenue_cents = excluded.estimated_revenue_cents, rebuilt_at = excluded.rebuilt_at `, [ tenantId, r.id, dateText(r.statDate), await legacyId('regions', r.regionId), text(r.regionId), text(r.saleType) || '', cents(r.realRevenue), intValue(r.orderCount), intValue(r.codeCount), intValue(r.codeUsed), cents(r.codeEstimated), cents(r.estimatedRevenue), dateText(r.rebuiltAt), ], ); } } } async function normalizeQuestionTypeGroups(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.question_type_groups ( tenant_id, subject_id, legacy_id, legacy_subject_id, display_name, types, sort_order, is_active, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6,$7,$8, coalesce(nullif($9::text,'')::timestamptz, now()), coalesce(nullif($10::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set subject_id = excluded.subject_id, legacy_subject_id = excluded.legacy_subject_id, display_name = excluded.display_name, types = excluded.types, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = excluded.updated_at `, [ tenantId, await legacyId('subjects', r.subjectId), r.id, text(r.subjectId), text(r.displayName) || '题型分组', json(r.types, []), intValue(r.order), boolValue(r.isActive, true), dateText(r.created), dateText(r.updated), ], ); } } async function normalizeSubjectShares(records: JsonRecord[]) { for (const r of records) { await pool.query( ` insert into public.subject_shares ( tenant_id, source_subject_id, target_subject_id, legacy_id, legacy_source_subject_id, legacy_target_subject_id, created_at, updated_at ) values ($1,$2,$3,$4,$5,$6, coalesce(nullif($7::text,'')::timestamptz, now()), coalesce(nullif($8::text,'')::timestamptz, now()) ) on conflict (tenant_id, legacy_id) do update set source_subject_id = excluded.source_subject_id, target_subject_id = excluded.target_subject_id, legacy_source_subject_id = excluded.legacy_source_subject_id, legacy_target_subject_id = excluded.legacy_target_subject_id, updated_at = excluded.updated_at `, [ tenantId, await legacyId('subjects', r.sourceSubjectId), await legacyId('subjects', r.targetSubjectId), r.id, text(r.sourceSubjectId), text(r.targetSubjectId), dateText(r.created), dateText(r.updated), ], ); } } async function runNormalizer( runId: string, collections: CollectionMap, collection: string, normalizer: (records: JsonRecord[]) => Promise, ) { const records = collections[collection] || []; if (records.length === 0) return; await normalizer(records); await markNormalized(runId, collection); console.log(`${collection}: ${records.length} records normalized`); } async function loadCollections(): Promise { const files = fs.readdirSync(exportDir).filter(file => file.toLowerCase().endsWith('.json')); const collections: CollectionMap = {}; for (const file of files) { if (nonCollectionJsonFiles.has(file)) continue; const collection = collectionNameFromFile(file); collections[collection] = asArray(JSON.parse(fs.readFileSync(path.join(exportDir, file), 'utf8'))); } return collections; } async function normalizeAll(runId: string, collections: CollectionMap) { await runNormalizer(runId, collections, 'regions', normalizeRegions); await runNormalizer(runId, collections, 'region_modules', normalizeRegionModules); await runNormalizer(runId, collections, 'module_nodes', normalizeModuleNodes); await runNormalizer(runId, collections, 'schools', normalizeSchools); await runNormalizer(runId, collections, 'majors', normalizeMajors); await runNormalizer(runId, collections, 'subjects', normalizeSubjects); await runNormalizer(runId, collections, 'categories', normalizeCategories); await runNormalizer(runId, collections, 'question_type_groups', normalizeQuestionTypeGroups); await runNormalizer(runId, collections, 'subject_shares', normalizeSubjectShares); await runNormalizer(runId, collections, 'questions', normalizeQuestions); await runNormalizer(runId, collections, 'users', normalizeUsers); await normalizeUserEntitlementsAndStats(runId, collections.users || []); if ((collections.users || []).length > 0) await markNormalized(runId, 'users'); await runNormalizer(runId, collections, 'user_answer_records', records => normalizeUserAnswerRecords(runId, records)); await runNormalizer(runId, collections, 'settings', normalizeSettings); await runNormalizer(runId, collections, 'svip_plans', normalizeSvipPlans); await runNormalizer(runId, collections, 'mock_exam_configs', records => normalizeMockExamConfigs(runId, records)); await runNormalizer(runId, collections, 'code_batches', normalizeCodeBatches); await runNormalizer(runId, collections, 'coupons', normalizeCoupons); await runNormalizer(runId, collections, 'coupon_redemptions', normalizeCouponRedemptions); await runNormalizer(runId, collections, 'codes', normalizeActivationCodes); await runNormalizer(runId, collections, 'orders', records => normalizeOrders(runId, records)); await runNormalizer(runId, collections, 'vocabulary_units', normalizeVocabularyUnits); await runNormalizer(runId, collections, 'vocabulary', normalizeVocabularyWords); await normalizeWordUserData(collections.user_word_progress || [], collections.user_word_favorites || []); if ((collections.user_word_progress || []).length > 0) { await markNormalized(runId, 'user_word_progress'); console.log(`user_word_progress: ${collections.user_word_progress.length} records normalized`); } if ((collections.user_word_favorites || []).length > 0) { await markNormalized(runId, 'user_word_favorites'); console.log(`user_word_favorites: ${collections.user_word_favorites.length} records normalized`); } await runNormalizer(runId, collections, 'handbook_subjects', normalizeHandbookSubjects); await runNormalizer(runId, collections, 'handbook_chapters', records => normalizeHandbookChapters(runId, records)); await runNormalizer(runId, collections, 'handbook_entries', normalizeHandbookEntries); await runNormalizer(runId, collections, 'banners', records => normalizeSimpleContent(records, 'banners')); await runNormalizer(runId, collections, 'faqs', records => normalizeSimpleContent(records, 'faqs')); await runNormalizer(runId, collections, 'announcements', records => normalizeSimpleContent(records, 'announcements')); await runNormalizer(runId, collections, 'products', normalizeProducts); await runNormalizer(runId, collections, 'timelines', normalizeTimelines); await runNormalizer(runId, collections, 'exam_dates', normalizeExamDates); await runNormalizer(runId, collections, 'reports', normalizeReports); await runNormalizer(runId, collections, 'video_explanations', normalizeVideoExplanations); await runNormalizer(runId, collections, 'question_videos', normalizeQuestionVideos); await runNormalizer(runId, collections, 'scoreline_schools', normalizeScorelineSchools); await runNormalizer(runId, collections, 'scoreline_majors', normalizeScorelineMajors); await runNormalizer(runId, collections, 'scoreline_fields', normalizeScorelineFields); await runNormalizer(runId, collections, 'scoreline_records', normalizeScorelineRecords); await runNormalizer(runId, collections, 'referral_tracks', normalizeReferralTracks); await runNormalizer(runId, collections, 'referral_qrcodes', records => normalizeReferralQrcodes(runId, records)); await runNormalizer(runId, collections, 'commission_settings', normalizeCommissionSettings); await runNormalizer(runId, collections, 'badges', normalizeBadges); await runNormalizer(runId, collections, 'user_badges', normalizeUserBadges); await runNormalizer(runId, collections, 'recent_practices', normalizeRecentPractices); await runNormalizer(runId, collections, 'audit_logs', records => normalizeAuxiliary(records, 'audit_logs')); await runNormalizer(runId, collections, 'crm_config', records => normalizeAuxiliary(records, 'crm_config')); await runNormalizer(runId, collections, 'crm_webhook_queue', records => normalizeAuxiliary(records, 'crm_webhook_queue')); await runNormalizer(runId, collections, 'crm_webhook_log', records => normalizeAuxiliary(records, 'crm_webhook_log')); await runNormalizer(runId, collections, 'app_assets', records => normalizeContentAssets(records, 'app_assets')); await runNormalizer(runId, collections, 'images', records => normalizeContentAssets(records, 'images')); await runNormalizer(runId, collections, 'dashboard_daily_stats', records => normalizeDailyStats(records, 'dashboard_daily_stats')); await runNormalizer(runId, collections, 'revenue_daily_stats', records => normalizeDailyStats(records, 'revenue_daily_stats')); } async function main() { if (!fs.existsSync(exportDir)) { throw new Error(`Export dir not found: ${exportDir}. Put collection JSON files there, e.g. users.json and questions.json.`); } await ensureTenant(); const runId = await createRun(); const collections = await loadCollections(); const stats: Record = {}; for (const [collection, records] of Object.entries(collections)) { stats[collection] = await importRaw(runId, collection, records); console.log(`${collection}: ${stats[collection]} raw records imported`); } await normalizeAll(runId, collections); await pool.query( `update public.pb_import_runs set status = 'completed', stats = $2, finished_at = now() where id = $1`, [runId, JSON.stringify(stats)], ); console.log(`Import completed. runId=${runId}`); } main() .catch(async error => { console.error(error); process.exitCode = 1; }) .finally(async () => { await closeDb(); });