forked from wangziqi/gongxue-base
3599 lines
124 KiB
TypeScript
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();
|
|
});
|