forked from wangziqi/gongxue-base
feat: normalize legacy migration learning data
This commit is contained in:
@@ -96,6 +96,7 @@ const supportedCollections = new Set([
|
||||
'categories',
|
||||
'code_batches',
|
||||
'codes',
|
||||
'commission_settings',
|
||||
'coupons',
|
||||
'coupon_redemptions',
|
||||
'crm_config',
|
||||
@@ -109,6 +110,7 @@ const supportedCollections = new Set([
|
||||
'handbook_subjects',
|
||||
'images',
|
||||
'majors',
|
||||
'mock_exam_configs',
|
||||
'module_nodes',
|
||||
'orders',
|
||||
'products',
|
||||
@@ -117,6 +119,7 @@ const supportedCollections = new Set([
|
||||
'questions',
|
||||
'recent_practices',
|
||||
'referral_tracks',
|
||||
'referral_qrcodes',
|
||||
'region_modules',
|
||||
'regions',
|
||||
'reports',
|
||||
@@ -132,6 +135,7 @@ const supportedCollections = new Set([
|
||||
'svip_plans',
|
||||
'timelines',
|
||||
'user_badges',
|
||||
'user_answer_records',
|
||||
'user_word_favorites',
|
||||
'user_word_progress',
|
||||
'users',
|
||||
|
||||
@@ -39,6 +39,7 @@ const legacyLookupTables = new Set([
|
||||
'majors',
|
||||
'module_nodes',
|
||||
'orders',
|
||||
'practice_blueprints',
|
||||
'products',
|
||||
'questions',
|
||||
'region_modules',
|
||||
@@ -54,6 +55,12 @@ const legacyLookupTables = new Set([
|
||||
'vocabulary_words',
|
||||
]);
|
||||
|
||||
const nonCollectionJsonFiles = new Set([
|
||||
'pb_schema.sqlite.json',
|
||||
'sqlite-export-manifest.json',
|
||||
'storage-manifest.json',
|
||||
]);
|
||||
|
||||
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)) {
|
||||
@@ -179,6 +186,12 @@ function arrayValue(value: unknown): unknown[] {
|
||||
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]);
|
||||
@@ -190,6 +203,20 @@ 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';
|
||||
@@ -395,22 +422,45 @@ async function markNormalized(runId: string, collection: string) {
|
||||
);
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
return row?.id || null;
|
||||
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]);
|
||||
return row?.id || null;
|
||||
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 upsertSecret(secretScope: string, secretKey: string, secretValue: unknown, provider: string | null = null) {
|
||||
@@ -825,6 +875,109 @@ async function normalizeQuestions(records: JsonRecord[]) {
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeUserAnswerRecords(runId: string, records: JsonRecord[]) {
|
||||
const affectedWrongPairs = new Set<string>();
|
||||
|
||||
for (const r of records) {
|
||||
const userId = await userIdByLegacy(r.userId);
|
||||
if (!userId) {
|
||||
await issue(
|
||||
runId,
|
||||
'user_answer_records',
|
||||
r.id,
|
||||
'answer_user_not_found',
|
||||
`Answer record cannot be imported because userId was not resolved: ${text(r.userId) || '(empty)'}`,
|
||||
'critical',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const questionId = await legacyId('questions', r.questionId);
|
||||
if (!questionId) {
|
||||
await issue(
|
||||
runId,
|
||||
'user_answer_records',
|
||||
r.id,
|
||||
'answer_question_not_found',
|
||||
`Answer record kept with legacy_question_id only because questionId was not resolved: ${text(r.questionId) || '(empty)'}`,
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
|
||||
const isCorrect = r.isCorrect === null || r.isCorrect === undefined || r.isCorrect === ''
|
||||
? null
|
||||
: boolValue(r.isCorrect);
|
||||
const answeredAt = nullableDateText(r.answeredAt, r.updated, r.created);
|
||||
const answerPayload = {
|
||||
source: 'pocketbase.user_answer_records',
|
||||
selectedOptions: arrayValue(r.selectedOptions),
|
||||
legacyCategoryId: text(r.categoryId),
|
||||
legacyQuestionId: text(r.questionId),
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
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
|
||||
)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9::jsonb,$10,
|
||||
coalesce(nullif($11::text,'')::timestamptz, now()),
|
||||
coalesce(nullif($12::text,'')::timestamptz, now())
|
||||
)
|
||||
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,
|
||||
userId,
|
||||
questionId,
|
||||
await currentQuestionVersionId(questionId),
|
||||
r.id,
|
||||
text(r.questionId),
|
||||
text(r.categoryId),
|
||||
JSON.stringify(arrayValue(r.selectedOptions)),
|
||||
JSON.stringify(answerPayload),
|
||||
isCorrect,
|
||||
answeredAt,
|
||||
nullableDateText(r.created, answeredAt),
|
||||
],
|
||||
);
|
||||
|
||||
if (isCorrect === false && questionId) affectedWrongPairs.add(`${userId}:${questionId}`);
|
||||
}
|
||||
|
||||
for (const pair of affectedWrongPairs) {
|
||||
const [userId, questionId] = pair.split(':');
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count, last_wrong_at, resolved_at)
|
||||
select $1, $2, $3, count(*)::integer, max(answered_at), null
|
||||
from public.answer_records
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and question_id = $3
|
||||
and is_correct = false
|
||||
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, userId, questionId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeUsers(records: JsonRecord[]) {
|
||||
for (const r of records) {
|
||||
const safeProfile = publicProfile(r);
|
||||
@@ -1166,6 +1319,84 @@ async function normalizeSvipPlans(records: JsonRecord[]) {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -1373,8 +1604,19 @@ async function normalizeCouponRedemptions(records: JsonRecord[]) {
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeOrders(records: JsonRecord[]) {
|
||||
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 (
|
||||
@@ -1410,7 +1652,7 @@ async function normalizeOrders(records: JsonRecord[]) {
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
await userIdByLegacy(r.userId),
|
||||
resolvedUserId,
|
||||
r.id,
|
||||
text(r.userId),
|
||||
text(r.orderNo) || `legacy-${text(r.id)}`,
|
||||
@@ -1426,13 +1668,23 @@ async function normalizeOrders(records: JsonRecord[]) {
|
||||
await legacyId('regions', r.regionId),
|
||||
text(r.regionId),
|
||||
dateText(r.paidAt),
|
||||
JSON.stringify(sanitizeRecord(r)),
|
||||
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(
|
||||
`
|
||||
@@ -1625,17 +1877,60 @@ async function normalizeHandbookSubjects(records: JsonRecord[]) {
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeHandbookChapters(records: JsonRecord[]) {
|
||||
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, created_at, updated_at
|
||||
is_active, 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())
|
||||
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,
|
||||
@@ -1643,16 +1938,23 @@ async function normalizeHandbookChapters(records: JsonRecord[]) {
|
||||
description = excluded.description,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
await legacyId('handbook_subjects', r.subjectId),
|
||||
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),
|
||||
],
|
||||
@@ -2273,6 +2575,130 @@ async function normalizeReferralTracks(records: JsonRecord[]) {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -2820,6 +3246,7 @@ 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')));
|
||||
}
|
||||
@@ -2841,14 +3268,16 @@ async function normalizeAll(runId: string, collections: CollectionMap) {
|
||||
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', normalizeOrders);
|
||||
await runNormalizer(runId, collections, 'orders', records => normalizeOrders(runId, records));
|
||||
|
||||
await runNormalizer(runId, collections, 'vocabulary_units', normalizeVocabularyUnits);
|
||||
await runNormalizer(runId, collections, 'vocabulary', normalizeVocabularyWords);
|
||||
@@ -2863,7 +3292,7 @@ async function normalizeAll(runId: string, collections: CollectionMap) {
|
||||
}
|
||||
|
||||
await runNormalizer(runId, collections, 'handbook_subjects', normalizeHandbookSubjects);
|
||||
await runNormalizer(runId, collections, 'handbook_chapters', normalizeHandbookChapters);
|
||||
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'));
|
||||
@@ -2880,6 +3309,8 @@ async function normalizeAll(runId: string, collections: CollectionMap) {
|
||||
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);
|
||||
|
||||
@@ -217,6 +217,120 @@ async function validationChecks(): Promise<CheckResult[]> {
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'legacy_answers_missing_user',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.answer_records ar
|
||||
left join public.platform_users u on u.id = ar.user_id
|
||||
where ar.tenant_id = $1
|
||||
and ar.legacy_id is not null
|
||||
and u.id is null
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Legacy answer_records exist without a valid user.',
|
||||
'Legacy answer_records reference valid users.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'legacy_answers_unresolved_questions',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.answer_records
|
||||
where tenant_id = $1
|
||||
and legacy_question_id is not null
|
||||
and question_id is null
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Some legacy answer_records kept only legacy_question_id because questions were not resolved.',
|
||||
'Legacy answer_records question references are resolved.',
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'wrong_questions_invalid_references',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.wrong_questions wq
|
||||
left join public.platform_users u on u.id = wq.user_id
|
||||
left join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
|
||||
where wq.tenant_id = $1
|
||||
and (u.id is null or q.id is null)
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Wrong-question rows reference missing users or questions.',
|
||||
'Wrong-question rows reference valid users and questions.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'legacy_mock_blueprints_incomplete',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.practice_blueprints
|
||||
where tenant_id = $1
|
||||
and mode = 'mock_exam'
|
||||
and legacy_id is not null
|
||||
and (
|
||||
question_limit is null
|
||||
or jsonb_array_length(sections) = 0
|
||||
or rules->>'legacySubjectId' is null
|
||||
)
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Legacy mock exam blueprints are missing limits, sections, or legacy subject traceability.',
|
||||
'Legacy mock exam blueprints have limits, sections, and legacy subject traceability.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'referral_qrcodes_incomplete',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.referral_qrcodes
|
||||
where tenant_id = $1
|
||||
and (ref_code is null or scene = '' or page = '')
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Referral qrcodes are missing ref_code, scene, or page.',
|
||||
'Referral qrcodes have ref_code, scene, and page.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'commission_settings_invalid_rate',
|
||||
await scalar(
|
||||
`
|
||||
select count(*)
|
||||
from public.tenant_commission_settings
|
||||
where tenant_id = $1
|
||||
and (default_rate < 0 or default_rate > 1)
|
||||
`,
|
||||
[tenantId],
|
||||
),
|
||||
'Tenant commission settings contain invalid default_rate values.',
|
||||
'Tenant commission settings default_rate values are valid.',
|
||||
),
|
||||
);
|
||||
|
||||
checks.push(
|
||||
result(
|
||||
'entitlements_unresolved_user',
|
||||
|
||||
Reference in New Issue
Block a user