Files
gongxue-base/scripts/import-pocketbase/src/import-json.ts
2026-06-30 10:50:31 +08:00

3599 lines
124 KiB
TypeScript

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<string, unknown> & { id?: string };
type CollectionMap = Record<string, JsonRecord[]>;
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<string, unknown> = {};
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
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<string, unknown> = {};
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
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<string, unknown>);
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<string, unknown>)) {
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<string, string | null>();
const userLookupCache = new Map<string, string | null>();
const questionVersionLookupCache = new Map<string, string | null>();
async function legacyId(tableName: string, legacyValue: unknown): Promise<string | null> {
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<string | null> {
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<string | null> {
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<Map<string, string>> {
const legacyValues = [...new Set(values.map(value => text(value)).filter((value): value is string => Boolean(value)))];
const result = new Map<string, string>();
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<Map<string, string>> {
const legacyValues = [...new Set(values.map(value => text(value)).filter((value): value is string => Boolean(value)))];
const result = new Map<string, string>();
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<string | null>): Promise<Map<string, string>> {
const values = [...new Set(questionIds.filter((value): value is string => Boolean(value)))];
const result = new Map<string, string>();
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<string | null> = [];
const questionVersionIds: Array<string | null> = [];
const legacyIds: string[] = [];
const legacyQuestionIds: Array<string | null> = [];
const legacyCategoryIds: Array<string | null> = [];
const selectedOptionsPayloads: string[] = [];
const answerPayloads: string[] = [];
const isCorrectValues: Array<boolean | null> = [];
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<string, unknown> = {};
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<string, unknown>) {
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<string, unknown>),
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<string> {
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<void>,
) {
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<CollectionMap> {
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<string, number> = {};
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();
});