feat: normalize legacy migration learning data

This commit is contained in:
Codex
2026-06-30 10:04:48 +08:00
parent 2510d736a4
commit 3303426806
7 changed files with 584 additions and 22 deletions

View File

@@ -439,7 +439,7 @@ dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失
默认 dry-run 使用 `development` profile正式迁移、预生产验收和 CI 应使用 `production` profile。生产 profile 会额外输出 `migrationReadiness`,检查用户、题目、科目、分类、订单、套餐、激活码、单词和知识手册等必需集合,以及用户手机号、题目归属、订单套餐、激活码、单词和手册归属等关键字段覆盖率。`--profile` 只接受 `development``production`,拼写错误会按 blocker 失败。
当前真实 SQLite 基线已经跑通只读导出58 个业务 collection、248555 条记录、9 个 storage 原始资源文件。production dry-run 目前仍有 2 个真实数据 blocker30 个订单缺用户、22 个知识手册章节缺所属手册;还有 `user_answer_records``mock_exam_configs``referral_qrcodes``commission_settings` 等 mapper gap,详见:
当前真实 SQLite 基线已经跑通只读导出58 个业务 collection、248555 条记录、9 个 storage 原始资源文件。`user_answer_records``mock_exam_configs``referral_qrcodes``commission_settings` 已进入标准化导入。production dry-run 目前仍有 2 个真实数据 blocker30 个订单缺用户、22 个知识手册章节缺所属手册;导入器会把它们隔离到财务复核/迁移待复核手册并写入 `pb_import_issues`,但正式切换前仍必须人工确认,详见:
```text
docs/refactor/pocketbase-real-data-migration-runbook.md

View File

@@ -172,6 +172,7 @@
| --- | --- | --- |
| PocketBase schema/导出分析 | 可联调 | `scripts/import-pocketbase` 支持 schema summary/risk、`npm run pb:import:dry-run` 导出目录静态迁移报告 |
| PocketBase JSON dry-run | 可联调 | 不写数据库检查导出目录、JSON 形态、核心集合、旧 ID、敏感字段、schema relation、未映射集合和关键业务计数`--profile=production` 会额外检查生产迁移必需集合和关键字段覆盖率,正式切换建议配合 `--fail-on-warnings` |
| PocketBase SQLite 标准化导入 | 迁移期 | 已支持真实 SQLite 只读导出后的核心集合标准化,新增覆盖 `user_answer_records -> answer_records/wrong_questions``mock_exam_configs -> practice_blueprints``referral_qrcodes -> referral_qrcodes/referral_codes``commission_settings -> tenant_commission_settings`;缺用户订单会进入财务复核且不自动开权益,缺归属手册章节会进入“迁移待复核手册”并写 `pb_import_issues`;全量真实导入仍需继续做批量化性能优化 |
| 题目 JSON preview/import | 可联调 | 后端负责规范化、issue、幂等、审计 |
| 公共题库采纳、手动同步和自动同步 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;同步 API 和 `public-banks` worker 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录、租户内容通知和租户自改冲突保护;冲突处理 API 已支持单条/批量采纳平台版本和保留租户本地版本worker 失败会写 `public_question_bank_sync_failed` 租户通知并保留稳定错误码,恢复成功会自动关闭失败通知;平台可通过 `/api/platform-admin/question-bank-sync-status` 查看跨租户同步运营状态;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、冲突不覆盖、单条/批量冲突处理、worker 自动同步/失败通知/恢复关闭、starter 单地区不可见/不可采纳第二地区题库、pro 全国套餐可见第二地区题库等测试 |
| 单词 JSON preview/import | 可联调 | 兼容旧模板 |

View File

@@ -76,8 +76,9 @@
- 从 PocketBase 导出现有用户、题库、单词、知识手册、分数线、订单、权益数据。
- 已补 `npm run pb:export:sqlite` 只读 SQLite 导出工具,默认从 `F:\project\参考\旧题库数据库文件\data.db` 导出到 `.gitignore` 覆盖的 `pb_export/`,并生成 `sqlite-export-manifest.json``storage-manifest.json` 和脱敏统计。
- 已补 `npm run pb:import:dry-run` 静态迁移报告工具、`--profile=production` 生产迁移门禁、关键集合/关键字段覆盖率检查、strict warning 门禁测试和真实数据迁移验收 runbook真实 SQLite 已导出 58 个业务 collection、248555 条记录,并跑过 production dry-run。
- 当前真实 dry-run 剩余 blocker30 个订单缺 `userId`,其中 7 个 paid22 个知识手册章节缺 `subjectId`正式导入前必须补自动修复/隔离 mapper 和人工复核报告
- 当前真实 dry-run 剩余 mapper gap`user_answer_records` 85442 条`mock_exam_configs` 48 条、`referral_qrcodes` 79 条、`commission_settings` 1 条。它们分别对应学习历史/错题归因、全真模拟蓝图、推广码/小程序码、分佣设置,后续要进入标准化导入
- 当前真实 dry-run 剩余 blocker30 个订单缺 `userId`,其中 7 个 paid22 个知识手册章节缺 `subjectId`导入器已补隔离策略:缺用户订单进入财务复核且不自动开权益,缺归属手册章节进入“迁移待复核手册”。正式切换前仍必须人工找回/确认这些记录
- 已补真实 mapper gap`user_answer_records` 85442 条标准化到 `answer_records/wrong_questions``mock_exam_configs` 48 条标准化到 `practice_blueprints``referral_qrcodes` 79 条标准化到 `referral_qrcodes/referral_codes``commission_settings` 1 条标准化到 `tenant_commission_settings`
- 真实 `pb:import:json` 试跑已验证 raw 导入和新 mapper 开始落库,但 248555 条真实记录的逐条标准化仍超过 10 分钟,需要下一阶段继续做批量化/事务分批优化,重点是 `questions``question_versions``user_answer_records``pb_raw_records`
- 处理完 blocker 后再跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,再跑 `pb:import:json``pb:import:validate` 和业务抽样。
- 对题目 JSON、单词、知识手册、分数线、视频走后端 preview/import API 做二次验证。

View File

@@ -136,18 +136,29 @@ storage-manifest: 9 个原始资源文件
auxiliary.db: _logs 121715 条,仅摘要
```
最近一次 production dry-run 仍有 2 个 blocker需要在正式导入前处理:
最近一次 production dry-run 仍有 2 个真实数据 blocker需要在正式切换前处理:
- `orders.userId` 覆盖率 95.3%30 个订单缺用户,其中 7 个为已支付订单。处理策略应优先从支付通知、手机号、订单号或人工售后记录找回用户;找不回的已支付订单必须进入人工异常台账,不允许静默开权益。
- `handbook_chapters.subjectId` 覆盖率 94.4%22 个章节缺所属手册。处理策略应按章节标题和条目归属归并到正确 `handbook_subjects`,无法归并的放入迁移隔离手册并标记待人工复核。
production dry-run 还有这些 warning属于后续 mapper 或运营处理项
当前导入器已经补齐下面 4 个旧集合的标准化 mapper
- `user_answer_records` 85442 条会导入到 `answer_records`,并按错误记录重建 `wrong_questions`
- `mock_exam_configs` 48 条会导入到 `practice_blueprints(mode='mock_exam', assembly_type='filters')`
- `referral_qrcodes` 79 条会导入到 `referral_qrcodes`,并同步补 `referral_codes`
- `commission_settings` 1 条会导入到 `tenant_commission_settings`
导入器对两个真实 blocker 采用“隔离 + 审计”策略,不会静默丢数据:
- 缺用户订单仍导入 `orders/payments/order_items``raw_payload.migration.reviewRequired=true``entitlementBlocked=true`,并写入 `pb_import_issues`。缺用户订单不会自动开通权益。
-`subjectId` 的手册章节会挂到 `handbook_subjects.legacy_id='__migration_orphan_handbook_subject__'` 的“迁移待复核手册”,章节 `metadata.reviewRequired=true`,并写入 `pb_import_issues`
production dry-run 还有这些 warning属于运营处理项
- `user_answer_records` 85442 条尚未规范化到新练习历史/答题记录模型。
- `mock_exam_configs` 48 条尚未规范化到 `practice_blueprints`
- `referral_qrcodes` 79 条尚未规范化到新推广码/小程序码模型。
- `commission_settings` 1 条需要迁移到新分佣设置。
- `dashboard_cache`、空 `customer_messages/smscodes/tenant_config` 可不作为正式数据源,必要时只保留审计摘要。
- `recent_practices` 和少量 `user_answer_records` 有旧用户引用断裂,需要在导入后复核 `pb_import_issues`
性能注意:真实 `pb:import:json` 试跑已经证明 248555 条真实记录会超过 10 分钟。正式迁移前需要继续优化 importer 的批量写入和分批事务,重点是 `pb_raw_records``questions/question_versions``user_answer_records`。迁移演练必须记录总耗时、每阶段耗时和失败恢复策略。
## 阶段 1静态 Dry-Run

View File

@@ -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',

View File

@@ -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);

View File

@@ -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',