import fs from 'node:fs/promises'; import path from 'node:path'; import pg from 'pg'; const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001'; const DATABASE_URL = process.env.DATABASE_URL || DEFAULT_DATABASE_URL; const TENANT_ID = process.env.PB_SAMPLE_TENANT_ID || process.env.TENANT_ID || DEFAULT_TENANT_ID; const TENANT_SLUG = process.env.PB_SAMPLE_TENANT_SLUG || ''; const OUTPUT_DIR = process.env.PB_SAMPLE_OUTPUT_DIR || 'docs/refactor/migration-reports'; const WRITE_REPORT = envBool('PB_SAMPLE_WRITE_REPORT', false); const FAIL_ON_WARNINGS = envBool('PB_SAMPLE_FAIL_ON_WARNINGS', false); const SAMPLE_LIMIT = envNumber('PB_SAMPLE_LIMIT', 5); const ALLOWED_CRITICAL_IMPORT_ISSUES = new Set([ 'orders:order_user_not_resolved', 'handbook_chapters:handbook_chapter_subject_missing', ]); const TABLES = [ 'tenant_memberships', 'content_entries', 'content_nodes', 'question_collections', 'question_collection_items', 'practice_blueprints', 'questions', 'question_versions', 'answer_records', 'wrong_questions', 'favorite_questions', 'vocabulary_units', 'vocabulary_words', 'handbook_subjects', 'handbook_chapters', 'handbook_entries', 'scoreline_fields', 'scoreline_schools', 'scoreline_majors', 'scoreline_records', 'orders', 'payments', 'entitlements', 'activation_codes', 'content_assets', 'video_explanations', 'question_videos', 'pb_raw_records', 'pb_import_issues', ]; function envBool(key, fallback) { const value = process.env[key]; if (value === undefined || value === '') return fallback; return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); } function envNumber(key, fallback) { const raw = process.env[key]; if (!raw) return fallback; const value = Number(raw); return Number.isFinite(value) && value > 0 ? value : fallback; } function intValue(value) { return Number(value || 0); } function shanghaiTimestampForFile(date = new Date()) { const parts = Object.fromEntries( new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }).formatToParts(date).map(part => [part.type, part.value]), ); return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`; } class BusinessSampler { constructor(client) { this.client = client; this.checks = []; this.samples = {}; this.counts = {}; this.rawCounts = {}; this.tenant = null; this.latestRun = null; } add(status, name, message, details = {}) { this.checks.push({ status, name, message, ...details, }); } pass(name, message, details = {}) { this.add('pass', name, message, details); } warn(name, message, details = {}) { this.add('warn', name, message, details); } fail(name, message, details = {}) { this.add('fail', name, message, details); } skip(name, message, details = {}) { this.add('skip', name, message, details); } async one(sql, params = []) { const result = await this.client.query(sql, params); return result.rows[0] || null; } async many(sql, params = []) { const result = await this.client.query(sql, params); return result.rows; } async scalar(sql, params = []) { const row = await this.one(sql, params); if (!row) return 0; return intValue(row.count ?? row.value ?? Object.values(row)[0]); } async run() { await this.discoverTenant(); await this.captureCounts(); await this.checkTenantAndImportRun(); await this.checkTenantIsolationInvariants(); await this.checkPracticeNavigation(); await this.checkLearningRecords(); await this.checkVocabulary(); await this.checkHandbook(); await this.checkScoreline(); await this.checkCommerce(); await this.checkAssetsAndVideos(); await this.checkImportIssues(); await this.checkSensitiveDataLeaks(); return this.buildReport(); } async discoverTenant() { const tenant = TENANT_SLUG ? await this.one( ` select id, slug::text, name, status, mode, billing_status as "billingStatus" from public.tenants where slug = $1 limit 1 `, [TENANT_SLUG], ) : await this.one( ` select id, slug::text, name, status, mode, billing_status as "billingStatus" from public.tenants where id = $1 limit 1 `, [TENANT_ID], ); if (!tenant) { const hint = TENANT_SLUG ? `slug=${TENANT_SLUG}` : `id=${TENANT_ID}`; throw new Error(`Tenant not found for ${hint}. Run PocketBase import or set PB_SAMPLE_TENANT_ID/PB_SAMPLE_TENANT_SLUG.`); } this.tenant = tenant; this.latestRun = await this.one( ` select id, source_name as "sourceName", source_kind as "sourceKind", status, started_at as "startedAt", finished_at as "finishedAt" from public.pb_import_runs where tenant_id = $1 order by started_at desc limit 1 `, [tenant.id], ); } async captureCounts() { for (const table of TABLES) { const tableRef = `public.${table}`; if (table === 'pb_raw_records') { this.counts[table] = await this.scalar(`select count(*) from ${tableRef} where tenant_id = $1`, [this.tenant.id]); } else if (table === 'tenant_memberships') { this.counts[table] = await this.scalar(`select count(*) from ${tableRef} where tenant_id = $1`, [this.tenant.id]); } else { this.counts[table] = await this.scalar(`select count(*) from ${tableRef} where tenant_id = $1`, [this.tenant.id]); } } if (!this.latestRun) return; const rows = await this.many( ` select collection_name as "collectionName", count(*)::integer as count from public.pb_raw_records where tenant_id = $1 and run_id = $2 group by collection_name order by collection_name asc `, [this.tenant.id, this.latestRun.id], ); this.rawCounts = Object.fromEntries(rows.map(row => [row.collectionName, intValue(row.count)])); } rawCount(...collectionNames) { return collectionNames.reduce((sum, name) => sum + intValue(this.rawCounts[name]), 0); } hasRaw(...collectionNames) { return this.rawCount(...collectionNames) > 0; } async checkTenantAndImportRun() { if (this.tenant.status === 'active') { this.pass('tenant.active', `租户 ${this.tenant.slug} 是 active 状态。`); } else { this.warn('tenant.not_active', `租户 ${this.tenant.slug} 当前状态为 ${this.tenant.status},联调前需确认是否允许前端访问。`, { tenantStatus: this.tenant.status, }); } if (!this.latestRun) { this.fail('import.latest_run_missing', '未找到 PocketBase 导入 run,无法证明真实旧数据已经导入。'); return; } if (this.latestRun.status === 'completed') { this.pass('import.latest_run_completed', '最新 PocketBase 导入 run 已完成。', { runId: this.latestRun.id, sourceName: this.latestRun.sourceName, }); } else { this.fail('import.latest_run_not_completed', `最新 PocketBase 导入 run 状态为 ${this.latestRun.status}。`, { runId: this.latestRun.id, }); } const activeStudent = await this.one( ` select pu.id from public.tenant_memberships tm join public.platform_users pu on pu.id = tm.user_id where tm.tenant_id = $1 and tm.role = 'student' and tm.status = 'active' and coalesce(pu.status, 'active') = 'active' order by pu.created_at asc limit 1 `, [this.tenant.id], ); if (activeStudent) { this.samples.studentId = activeStudent.id; this.pass('identity.active_student_exists', '存在可用于学生端联调的 active 学生账号。'); } else { this.fail('identity.active_student_missing', '未找到 active 学生账号,学生端登录后无法抽样验证业务链路。'); } const adminMember = await this.one( ` select tm.user_id as "userId", tm.role from public.tenant_memberships tm where tm.tenant_id = $1 and tm.status = 'active' and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator') order by case tm.role when 'tenant_owner' then 0 when 'tenant_admin' then 1 else 2 end limit 1 `, [this.tenant.id], ); if (adminMember) { this.samples.tenantAdminUserId = adminMember.userId; this.pass('identity.tenant_admin_exists', '存在可用于租户后台联调的 active 管理成员。', { role: adminMember.role }); } else { this.warn('identity.tenant_admin_missing', '未找到 active 租户后台成员,后续需要创建管理员账号再做后台联调。'); } } async checkTenantIsolationInvariants() { const nodeEntryMismatch = await this.scalar( ` select count(*) from public.content_nodes cn join public.content_entries ce on ce.id = cn.entry_id where cn.tenant_id = $1 and ce.tenant_id <> cn.tenant_id `, [this.tenant.id], ); nodeEntryMismatch === 0 ? this.pass('isolation.content_node_entry_tenant_match', '内容节点与入口 tenant_id 一致。') : this.fail('isolation.content_node_entry_tenant_mismatch', '存在内容节点引用其它租户入口。', { count: nodeEntryMismatch }); const collectionItemMismatch = await this.scalar( ` select count(*) from public.question_collection_items qci join public.question_collections qc on qc.id = qci.collection_id join public.questions q on q.id = qci.question_id where qci.tenant_id = $1 and (qc.tenant_id <> qci.tenant_id or q.tenant_id <> qci.tenant_id) `, [this.tenant.id], ); collectionItemMismatch === 0 ? this.pass('isolation.collection_item_tenant_match', '题目合集条目与合集/题目 tenant_id 一致。') : this.fail('isolation.collection_item_tenant_mismatch', '存在题目合集条目跨租户引用。', { count: collectionItemMismatch }); const learningMismatch = await this.scalar( ` select count(*) from ( select fq.tenant_id, fq.question_id from public.favorite_questions fq where fq.tenant_id = $1 union all select wq.tenant_id, wq.question_id from public.wrong_questions wq where wq.tenant_id = $1 ) refs left join public.questions q on q.id = refs.question_id and q.tenant_id = refs.tenant_id where q.id is null `, [this.tenant.id], ); learningMismatch === 0 ? this.pass('isolation.learning_question_refs_valid', '错题/收藏均引用本租户有效题目。') : this.fail('isolation.learning_question_refs_invalid', '错题或收藏存在无效/跨租户题目引用。', { count: learningMismatch }); const handbookMismatch = await this.scalar( ` select count(*) from public.handbook_chapters hc join public.handbook_subjects hs on hs.id = hc.subject_id where hc.tenant_id = $1 and hs.tenant_id <> hc.tenant_id `, [this.tenant.id], ); handbookMismatch === 0 ? this.pass('isolation.handbook_refs_valid', '知识手册章节与手册科目 tenant_id 一致。') : this.fail('isolation.handbook_refs_invalid', '知识手册章节存在跨租户科目引用。', { count: handbookMismatch }); const vocabularyMismatch = await this.scalar( ` select count(*) from public.vocabulary_words vw join public.vocabulary_units vu on vu.id = vw.unit_id where vw.tenant_id = $1 and vu.tenant_id <> vw.tenant_id `, [this.tenant.id], ); vocabularyMismatch === 0 ? this.pass('isolation.vocabulary_refs_valid', '单词与单元 tenant_id 一致。') : this.fail('isolation.vocabulary_refs_invalid', '单词存在跨租户单元引用。', { count: vocabularyMismatch }); } async checkPracticeNavigation() { const legacyQuestionCount = this.counts.questions; if (legacyQuestionCount === 0) { this.fail('practice.questions_missing', '当前租户没有题目,无法支撑刷题业务。'); return; } const entries = await this.many( ` select id, name, entry_type as "entryType", visibility from public.content_entries where tenant_id = $1 and entry_type = 'question_practice' and is_active = true and visibility <> 'hidden' order by sort_order asc, created_at asc limit $2 `, [this.tenant.id, SAMPLE_LIMIT], ); if (entries.length) { this.samples.practiceEntryId = entries[0].id; this.pass('practice.entries_available', `存在 ${entries.length} 个可见题库入口样本。`, { sampleCount: entries.length }); } else { this.fail('practice.entries_missing', '没有 active 且可见的 question_practice 入口,学生端首页无法进入题库。'); } const nodeStats = await this.one( ` select count(*)::integer as count, coalesce(max(depth), 0)::integer as "maxDepth" from public.content_nodes where tenant_id = $1 and is_active = true `, [this.tenant.id], ); if (intValue(nodeStats?.count) > 0) { this.pass('practice.nodes_available', `存在 ${nodeStats.count} 个 active 内容节点,最大深度 ${nodeStats.maxDepth}。`, { count: intValue(nodeStats.count), maxDepth: intValue(nodeStats.maxDepth), }); } else { this.fail('practice.nodes_missing', '没有 active 内容节点,任意深度分类树不可用。'); } const collections = await this.many( ` select qc.id, qc.name, qc.question_count as "questionCount", count(qci.id)::integer as "itemCount", count(q.id)::integer as "resolvedQuestions", count(qv.id)::integer as "resolvedVersions", count(nullif(qv.content, ''))::integer as "contentQuestions" from public.question_collections qc left join public.question_collection_items qci on qci.tenant_id = qc.tenant_id and qci.collection_id = qc.id left join public.questions q on q.tenant_id = qc.tenant_id and q.id = qci.question_id and q.status = 'published' left join public.question_versions qv on qv.tenant_id = q.tenant_id and qv.id = q.current_version_id where qc.tenant_id = $1 and qc.status = 'active' group by qc.id, qc.name, qc.question_count having count(qci.id) > 0 order by count(qci.id) desc, qc.created_at asc limit $2 `, [this.tenant.id, SAMPLE_LIMIT], ); if (collections.length) { this.samples.collectionId = collections[0].id; const usable = collections.some(item => intValue(item.resolvedQuestions) > 0 && intValue(item.contentQuestions) > 0); usable ? this.pass('practice.collections_usable', '题目合集可以解析到已发布题目和当前题目版本。', { sampleCount: collections.length }) : this.fail('practice.collections_without_question_content', '题目合集有条目,但无法解析到带内容的当前题目版本。', { sampleCount: collections.length }); } else { this.fail('practice.collections_missing', '没有含题目的 active 题目合集,顺序/随机刷题无法组卷。'); } const blueprintRows = await this.many( ` select mode, count(*)::integer as count from public.practice_blueprints where tenant_id = $1 and status = 'active' group by mode order by mode asc `, [this.tenant.id], ); const blueprintCounts = Object.fromEntries(blueprintRows.map(row => [row.mode, intValue(row.count)])); if (blueprintCounts.sequential > 0 && blueprintCounts.random > 0) { this.pass('practice.blueprints_core_modes', '顺序刷题和随机刷题蓝图均可用。', blueprintCounts); } else { this.fail('practice.blueprints_core_modes_missing', '缺少顺序刷题或随机刷题蓝图。', blueprintCounts); } if (blueprintCounts.mock_exam > 0) { this.pass('practice.blueprints_mock_exam', '存在全真模拟蓝图。', { count: blueprintCounts.mock_exam }); } else { this.warn('practice.blueprints_mock_exam_missing', '未发现全真模拟蓝图;如果旧库没有 mock_exam_configs 可忽略,否则需补导入。'); } const blueprintBrokenRefs = await this.scalar( ` select count(*) from public.practice_blueprints pb left join public.question_collections qc on qc.tenant_id = pb.tenant_id and qc.id = pb.collection_id where pb.tenant_id = $1 and pb.status = 'active' and pb.assembly_type = 'collection' and (pb.collection_id is null or qc.id is null or qc.status <> 'active' or qc.question_count = 0) `, [this.tenant.id], ); blueprintBrokenRefs === 0 ? this.pass('practice.blueprints_collection_refs_valid', 'active 练习蓝图均指向可用合集。') : this.fail('practice.blueprints_collection_refs_invalid', '存在 active 练习蓝图无法指向可用合集。', { count: blueprintBrokenRefs }); const questionsWithoutNavigation = await this.scalar( ` select count(*) from public.questions where tenant_id = $1 and legacy_id is not null and status = 'published' and (entry_id is null or content_node_id is null or primary_collection_id is null) `, [this.tenant.id], ); questionsWithoutNavigation === 0 ? this.pass('practice.legacy_questions_navigation_complete', '已发布旧题均已挂接入口、分类节点和主合集。') : this.fail('practice.legacy_questions_navigation_incomplete', '存在已发布旧题未挂接新题库导航。', { count: questionsWithoutNavigation }); const hiddenReviewQuestions = await this.scalar( ` select count(*) from public.questions q join public.question_collections qc on qc.tenant_id = q.tenant_id and qc.id = q.primary_collection_id where q.tenant_id = $1 and qc.status = 'draft' and qc.access_rules->>'requiresReview' = 'true' `, [this.tenant.id], ); if (hiddenReviewQuestions > 0) { this.warn('practice.migration_review_questions', '有旧题被隔离到迁移待复核合集,正式上线前需要运营确认。', { count: hiddenReviewQuestions, }); } else { this.pass('practice.no_migration_review_questions', '没有题目停留在迁移待复核合集。'); } const sampleQuestions = await this.many( ` select q.id, q.type, q.type_label as "typeLabel", q.entry_id as "entryId", q.content_node_id as "contentNodeId", q.primary_collection_id as "collectionId", length(coalesce(qv.content, '')) as "contentLength", jsonb_array_length(coalesce(qv.options, '[]'::jsonb)) as "optionCount", jsonb_array_length(coalesce(qv.sub_questions, '[]'::jsonb)) as "subQuestionCount", q.has_video_explanation as "hasVideoExplanation" from public.questions q join public.question_versions qv on qv.id = q.current_version_id and qv.tenant_id = q.tenant_id where q.tenant_id = $1 and q.status = 'published' and q.entry_id is not null and q.content_node_id is not null and q.primary_collection_id is not null order by q.created_at asc limit $2 `, [this.tenant.id, SAMPLE_LIMIT], ); this.samples.questions = sampleQuestions; if (sampleQuestions.length) { this.pass('practice.sample_questions_queryable', '学生端题目列表可抽样查询到题干、选项/子题和导航字段。', { sampleCount: sampleQuestions.length, }); } else { this.fail('practice.sample_questions_missing', '无法抽样查询到可用于前端展示的已发布题目。'); } } async checkLearningRecords() { const answerCount = this.counts.answer_records; if (answerCount > 0) { const resolvedAnswerCount = await this.scalar( ` select count(*) from public.answer_records ar join public.questions q on q.id = ar.question_id and q.tenant_id = ar.tenant_id join public.platform_users pu on pu.id = ar.user_id where ar.tenant_id = $1 `, [this.tenant.id], ); resolvedAnswerCount > 0 ? this.pass('learning.answer_records_queryable', '旧答题记录可解析到新用户和新题目。', { resolvedCount: resolvedAnswerCount, totalCount: answerCount, }) : this.warn('learning.answer_records_not_queryable', '存在答题记录,但没有任何记录可同时解析到用户和题目。', { totalCount: answerCount, }); const unresolvedAnswers = await this.scalar( ` select count(*) from public.answer_records where tenant_id = $1 and legacy_question_id is not null and question_id is null `, [this.tenant.id], ); if (unresolvedAnswers > 0) { this.warn('learning.answer_records_unresolved_questions', '部分旧答题记录引用已删除或未迁移题目,个人历史中应隐藏或标记不可复盘。', { count: unresolvedAnswers, }); } else { this.pass('learning.answer_records_question_refs_complete', '旧答题记录题目引用均已解析。'); } } else { this.warn('learning.answer_records_empty', '没有旧答题记录,学习统计只能从新系统开始累计。'); } const wrongCount = this.counts.wrong_questions; wrongCount > 0 ? this.pass('learning.wrong_questions_available', '错题本有可迁移数据。', { count: wrongCount }) : this.warn('learning.wrong_questions_empty', '错题本没有迁移数据。'); const favoriteCount = this.counts.favorite_questions; favoriteCount > 0 ? this.pass('learning.favorite_questions_available', '收藏夹有可迁移数据。', { count: favoriteCount }) : this.warn('learning.favorite_questions_empty', '收藏夹没有迁移数据。'); const sampleWrong = await this.many( ` select wq.question_id as "questionId", wq.wrong_count as "wrongCount" from public.wrong_questions wq join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id where wq.tenant_id = $1 order by wq.last_wrong_at desc limit $2 `, [this.tenant.id, SAMPLE_LIMIT], ); this.samples.wrongQuestions = sampleWrong; const sampleFavorite = await this.many( ` select fq.question_id as "questionId" from public.favorite_questions fq join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id where fq.tenant_id = $1 order by fq.created_at desc limit $2 `, [this.tenant.id, SAMPLE_LIMIT], ); this.samples.favoriteQuestions = sampleFavorite; } async checkVocabulary() { const expected = this.hasRaw('vocabulary', 'vocabulary_words', 'vocabulary_units') || this.counts.vocabulary_words > 0; if (!expected) { this.skip('vocabulary.not_present', '旧库或当前租户没有单词数据。'); return; } if (this.counts.vocabulary_units > 0 && this.counts.vocabulary_words > 0) { this.pass('vocabulary.units_words_available', '单词单元和单词均已落库。', { units: this.counts.vocabulary_units, words: this.counts.vocabulary_words, }); } else { this.fail('vocabulary.units_or_words_missing', '旧库有单词数据,但新系统缺少单词单元或单词。', { units: this.counts.vocabulary_units, words: this.counts.vocabulary_words, }); } const orphanWords = await this.scalar( ` select count(*) from public.vocabulary_words vw left join public.vocabulary_units vu on vu.tenant_id = vw.tenant_id and vu.id = vw.unit_id where vw.tenant_id = $1 and vw.is_active = true and vu.id is null `, [this.tenant.id], ); orphanWords === 0 ? this.pass('vocabulary.words_have_units', 'active 单词均挂接到单词单元。') : this.fail('vocabulary.words_without_units', '存在 active 单词没有可用单元。', { count: orphanWords }); const sampleWords = await this.many( ` select vw.id, vw.unit_id as "unitId", length(vw.word) as "wordLength", length(coalesce(vw.meaning, '')) as "meaningLength", vw.difficulty from public.vocabulary_words vw where vw.tenant_id = $1 and vw.is_active = true order by vw.sort_order asc, vw.created_at asc limit $2 `, [this.tenant.id, SAMPLE_LIMIT], ); this.samples.vocabularyWords = sampleWords; sampleWords.some(word => intValue(word.wordLength) > 0 && intValue(word.meaningLength) > 0) ? this.pass('vocabulary.sample_words_queryable', '学生端背单词可抽样查询到单词和释义。', { sampleCount: sampleWords.length }) : this.fail('vocabulary.sample_words_missing_content', '单词样本缺少单词或释义,前端无法正常展示。', { sampleCount: sampleWords.length }); } async checkHandbook() { const expected = this.hasRaw('handbook_subjects', 'handbook_chapters', 'handbook_entries') || this.counts.handbook_entries > 0; if (!expected) { this.skip('handbook.not_present', '旧库或当前租户没有知识手册数据。'); return; } if (this.counts.handbook_subjects > 0 && this.counts.handbook_chapters > 0 && this.counts.handbook_entries > 0) { this.pass('handbook.subjects_chapters_entries_available', '知识手册科目、章节和条目均已落库。', { subjects: this.counts.handbook_subjects, chapters: this.counts.handbook_chapters, entries: this.counts.handbook_entries, }); } else { this.fail('handbook.structure_missing', '旧库有知识手册数据,但新系统手册科目/章节/条目不完整。', { subjects: this.counts.handbook_subjects, chapters: this.counts.handbook_chapters, entries: this.counts.handbook_entries, }); } const orphanEntries = await this.scalar( ` select count(*) from public.handbook_entries he left join public.handbook_chapters hc on hc.tenant_id = he.tenant_id and hc.id = he.chapter_id left join public.handbook_subjects hs on hs.tenant_id = hc.tenant_id and hs.id = hc.subject_id where he.tenant_id = $1 and he.is_active = true and (hc.id is null or hs.id is null) `, [this.tenant.id], ); if (orphanEntries > 0) { this.warn('handbook.entries_without_public_subject', '部分手册条目缺少可公开导航的章节/科目,已进入迁移复核或需人工归类。', { count: orphanEntries, }); } else { this.pass('handbook.entries_have_navigation', 'active 手册条目均可通过科目/章节导航访问。'); } const sampleEntries = await this.many( ` select he.id, he.chapter_id as "chapterId", length(he.title) as "titleLength", length(coalesce(he.content, '')) as "contentLength" from public.handbook_entries he join public.handbook_chapters hc on hc.id = he.chapter_id and hc.tenant_id = he.tenant_id join public.handbook_subjects hs on hs.id = hc.subject_id and hs.tenant_id = he.tenant_id where he.tenant_id = $1 and he.is_active = true order by he.sort_order asc, he.created_at asc limit $2 `, [this.tenant.id, SAMPLE_LIMIT], ); this.samples.handbookEntries = sampleEntries; sampleEntries.some(entry => intValue(entry.titleLength) > 0 && intValue(entry.contentLength) > 0) ? this.pass('handbook.sample_entries_queryable', '学生端知识手册可抽样查询到标题和内容。', { sampleCount: sampleEntries.length }) : this.fail('handbook.sample_entries_missing_content', '手册样本缺少标题或正文,前端无法正常展示。', { sampleCount: sampleEntries.length }); } async checkScoreline() { const expected = this.hasRaw('scoreline_fields', 'scoreline_schools', 'scoreline_majors', 'scoreline_records') || this.counts.scoreline_records > 0; if (!expected) { this.skip('scoreline.not_present', '旧库或当前租户没有分数线数据。'); return; } if (this.counts.scoreline_fields > 0 && this.counts.scoreline_records > 0) { this.pass('scoreline.fields_records_available', '分数线动态字段和记录均已落库。', { fields: this.counts.scoreline_fields, records: this.counts.scoreline_records, }); } else { this.fail('scoreline.fields_or_records_missing', '旧库有分数线数据,但新系统缺少动态字段或记录。', { fields: this.counts.scoreline_fields, records: this.counts.scoreline_records, }); } const recordWithFieldValues = await this.scalar( ` select count(*) from public.scoreline_records where tenant_id = $1 and jsonb_typeof(field_values) = 'object' and field_values <> '{}'::jsonb `, [this.tenant.id], ); recordWithFieldValues > 0 ? this.pass('scoreline.records_have_dynamic_values', '分数线记录包含动态字段值,可支撑地区差异化展示。', { count: recordWithFieldValues }) : this.warn('scoreline.records_without_dynamic_values', '分数线记录未发现动态字段值,前端只能展示院校/专业/年份基础信息。'); const trendFields = await this.scalar( ` select count(*) from public.scoreline_fields where tenant_id = $1 and is_trend = true and is_visible = true `, [this.tenant.id], ); trendFields > 0 ? this.pass('scoreline.trend_fields_available', '存在可用于趋势图的分数线字段。', { count: trendFields }) : this.warn('scoreline.trend_fields_missing', '没有标记趋势字段,分数线趋势图需要前端选择默认数值字段或后台补配置。'); const sampleRecords = await this.many( ` select id, year, school_id as "schoolId", major_id as "majorId", ( select count(*)::integer from jsonb_object_keys(field_values) ) as "fieldCount" from public.scoreline_records where tenant_id = $1 order by year desc, created_at asc limit $2 `, [this.tenant.id, SAMPLE_LIMIT], ); this.samples.scorelineRecords = sampleRecords; } async checkCommerce() { if (this.counts.orders === 0) { this.warn('commerce.orders_empty', '没有订单迁移数据;如果旧库确实没有订单可忽略。'); return; } this.pass('commerce.orders_available', '订单数据已迁移。', { count: this.counts.orders }); const paidOrders = await this.scalar( ` select count(*) from public.orders where tenant_id = $1 and status = 'paid' `, [this.tenant.id], ); const paidWithoutPayment = await this.scalar( ` select count(*) from public.orders o where o.tenant_id = $1 and o.status = 'paid' and not exists ( select 1 from public.payments p where p.tenant_id = o.tenant_id and p.order_id = o.id ) `, [this.tenant.id], ); paidWithoutPayment === 0 ? this.pass('commerce.paid_orders_have_payment_rows', '已支付订单均有 payment 台账。', { paidOrders }) : this.fail('commerce.paid_orders_without_payment_rows', '存在已支付订单没有 payment 台账。', { count: paidWithoutPayment, paidOrders, }); const paidWithoutUser = await this.scalar( ` select count(*) from public.orders where tenant_id = $1 and status = 'paid' and user_id is null `, [this.tenant.id], ); if (paidWithoutUser > 0) { this.warn('commerce.paid_orders_without_user', '存在已支付但缺用户的旧订单,导入器不会自动开权益,需要财务/运营人工复核。', { count: paidWithoutUser, }); } else { this.pass('commerce.paid_orders_user_refs_resolved', '已支付订单均已解析到用户。'); } const entitlementInvalid = await this.scalar( ` select count(*) from public.entitlements e left join public.platform_users pu on pu.id = e.user_id where e.tenant_id = $1 and pu.id is null `, [this.tenant.id], ); entitlementInvalid === 0 ? this.pass('commerce.entitlements_user_refs_valid', '权益均引用有效用户。', { entitlements: this.counts.entitlements }) : this.fail('commerce.entitlements_user_refs_invalid', '存在权益引用无效用户。', { count: entitlementInvalid }); if (this.counts.entitlements > 0) { const activeEntitlement = await this.one( ` select id, entitlement_type as "entitlementType", scope_type as "scopeType", status from public.entitlements where tenant_id = $1 and status = 'active' and (expires_at is null or expires_at > now()) order by created_at desc limit 1 `, [this.tenant.id], ); if (activeEntitlement) { this.samples.entitlementId = activeEntitlement.id; this.pass('commerce.active_entitlement_available', '存在可用于会员权限联调的 active 权益。', { entitlementType: activeEntitlement.entitlementType, scopeType: activeEntitlement.scopeType, }); } else { this.warn('commerce.no_current_active_entitlement', '权益数据已迁移,但当前没有未过期 active 权益;学生会员态联调需新开通测试权益。'); } } else { this.warn('commerce.entitlements_empty', '没有迁移权益数据,会员态需要通过新订单/激活码测试。'); } if (this.counts.activation_codes > 0) { const usedCodeWithoutUser = await this.scalar( ` select count(*) from public.activation_codes where tenant_id = $1 and is_used = true and used_by is null `, [this.tenant.id], ); usedCodeWithoutUser === 0 ? this.pass('commerce.activation_codes_user_refs_valid', '已使用激活码均保留使用人引用。', { count: this.counts.activation_codes }) : this.warn('commerce.activation_codes_used_without_user', '部分旧激活码标记已使用但缺使用人,需要运营复核。', { count: usedCodeWithoutUser, }); } else { this.warn('commerce.activation_codes_empty', '没有激活码迁移数据。'); } } async checkAssetsAndVideos() { if (this.counts.content_assets > 0) { this.pass('assets.content_assets_available', '内容资源台账已迁移。', { count: this.counts.content_assets }); const rawPathLeaks = await this.scalar( ` select count(*) from public.content_assets where tenant_id = $1 and ( coalesce(cdn_url, '') ~* '([a-z]:\\\\|/users/|/home/|/root/|/var/)' or coalesce(preview_url, '') ~* '([a-z]:\\\\|/users/|/home/|/root/|/var/)' or coalesce(object_key, '') ~* '(^[a-z]:\\\\|^/users/|^/home/|^/root/|^/var/)' ) `, [this.tenant.id], ); rawPathLeaks === 0 ? this.pass('assets.no_raw_local_path_leak', '资源公开字段未发现本地磁盘路径泄露。') : this.fail('assets.raw_local_path_leak', '资源公开字段疑似包含本地磁盘路径,必须改为对象存储 key 或受控 URL。', { count: rawPathLeaks, }); const lockedExternalAssets = await this.scalar( ` select count(*) from public.content_assets where tenant_id = $1 and status = 'active' and visibility in ('members', 'svip', 'private') and storage_provider = 'external_url' and coalesce(metadata->>'providerManagedAccess', 'false') <> 'true' and coalesce(access_rules->>'cdnAccessMode', '') <> 'signed_by_provider' `, [this.tenant.id], ); if (lockedExternalAssets > 0) { this.warn('assets.locked_external_urls_need_provider_policy', '部分会员/私有资源仍是外部 URL,下载预览会被后端安全策略限制;上线前应迁入 OSS/COS/Supabase Storage 或配置 provider-managed 签名。', { count: lockedExternalAssets, }); } else { this.pass('assets.locked_assets_access_policy_ok', '会员/私有资源未发现不受控外部 URL。'); } } else { this.warn('assets.content_assets_empty', '没有内容资源台账;题图/PDF/视频后续必须通过 content_assets 管理。'); } if (this.counts.video_explanations === 0 && this.counts.question_videos === 0) { this.skip('videos.not_present', '当前真实旧库没有题目视频数据,视频会员链路保留为后续导入/配置测试。'); return; } const brokenVideoLinks = await this.scalar( ` select count(*) from public.question_videos qv left join public.questions q on q.id = qv.question_id and q.tenant_id = qv.tenant_id left join public.video_explanations ve on ve.id = qv.video_id and ve.tenant_id = qv.tenant_id where qv.tenant_id = $1 and (q.id is null or ve.id is null) `, [this.tenant.id], ); brokenVideoLinks === 0 ? this.pass('videos.question_video_refs_valid', '题目视频绑定均可解析到题目和视频。', { videos: this.counts.video_explanations, bindings: this.counts.question_videos, }) : this.fail('videos.question_video_refs_invalid', '存在题目视频绑定引用无效题目或视频。', { count: brokenVideoLinks }); } async checkImportIssues() { if (!this.latestRun) return; const criticalRows = await this.many( ` select collection_name as "collectionName", issue_code as "issueCode", count(*)::integer as count from public.pb_import_issues where tenant_id = $1 and run_id = $2 and severity = 'critical' group by collection_name, issue_code order by collection_name asc, issue_code asc `, [this.tenant.id, this.latestRun.id], ); const unexpected = criticalRows.filter(row => !ALLOWED_CRITICAL_IMPORT_ISSUES.has(`${row.collectionName}:${row.issueCode}`)); if (unexpected.length > 0) { this.fail('import.unexpected_critical_issues', '最新导入存在未列入隔离策略的 critical issue。', { groups: unexpected, }); } else if (criticalRows.length > 0) { this.warn('import.allowed_critical_issues_need_manual_review', '最新导入仅剩已知人工复核类 critical issue,正式切换前仍必须处理。', { groups: criticalRows, }); } else { this.pass('import.no_critical_issues', '最新导入没有 critical issue。'); } const warningCount = await this.scalar( ` select count(*) from public.pb_import_issues where tenant_id = $1 and run_id = $2 and severity in ('warning', 'error') `, [this.tenant.id, this.latestRun.id], ); if (warningCount > 0) { this.warn('import.warning_or_error_issues_present', '最新导入仍有 warning/error issue,需要在上线复核表中留处理结论。', { count: warningCount, }); } else { this.pass('import.no_warning_or_error_issues', '最新导入没有 warning/error issue。'); } } async checkSensitiveDataLeaks() { const publicProfileSensitive = await this.scalar( ` select count(*) from public.platform_users where raw_profile::text ~* '"(password|tokenKey|secret|sessionKey|openId|unionId|wechatSessionKey|qqOpenId|wechatOpenId|wechatUnionId)"' `, ); publicProfileSensitive === 0 ? this.pass('security.public_profile_sensitive_keys_absent', 'platform_users.raw_profile 未发现敏感身份字段。') : this.fail('security.public_profile_sensitive_keys_present', 'platform_users.raw_profile 仍包含敏感身份/凭据字段。', { count: publicProfileSensitive, }); const publicSettingsSensitive = await this.scalar( ` select count(*) from public.tenant_settings where tenant_id = $1 and public_config::text ~* '(secret|privatekey|sessionkey|accesskey|appkey|apikey|api_v3_key|notifytoken|aeskey)' `, [this.tenant.id], ); publicSettingsSensitive === 0 ? this.pass('security.tenant_public_settings_no_secret_keys', '租户 public_config 未发现敏感 key。') : this.fail('security.tenant_public_settings_secret_keys', '租户 public_config 疑似包含密钥字段,应迁入 app_private.tenant_secrets。', { count: publicSettingsSensitive, }); const insecureCrmSecretRef = await this.scalar( ` select count(*) from public.crm_config where tenant_id = $1 and secret_ref is not null and secret_ref !~ '^app_private\\.tenant_secrets:' `, [this.tenant.id], ); insecureCrmSecretRef === 0 ? this.pass('security.crm_secret_refs_private', 'CRM secret_ref 均指向私密密钥引用。') : this.fail('security.crm_secret_refs_unsafe', 'CRM secret_ref 存在不安全引用。', { count: insecureCrmSecretRef }); } buildReport() { const summary = this.checks.reduce( (acc, check) => { acc[check.status] += 1; return acc; }, { pass: 0, warn: 0, fail: 0, skip: 0 }, ); return { generatedAt: new Date().toISOString(), config: { tenantId: this.tenant.id, tenantSlug: this.tenant.slug, sampleLimit: SAMPLE_LIMIT, failOnWarnings: FAIL_ON_WARNINGS, }, tenant: this.tenant, latestRun: this.latestRun, counts: this.counts, rawCounts: this.rawCounts, samples: this.samples, summary, checks: this.checks, }; } } function markdownReport(report) { const lines = []; lines.push('# PocketBase 真实迁移业务抽样验收报告'); lines.push(''); lines.push(`生成时间:${new Date(report.generatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`); lines.push(''); lines.push('## 目标'); lines.push(''); lines.push(`- 租户:${report.tenant.slug} / ${report.tenant.name}`); lines.push(`- 最新导入:${report.latestRun ? `${report.latestRun.status} / ${report.latestRun.sourceName}` : '未找到'}`); lines.push(`- 抽样条数:${report.config.sampleLimit}`); lines.push(''); lines.push('## 结果'); lines.push(''); lines.push('| 状态 | 数量 |'); lines.push('| --- | ---: |'); lines.push(`| PASS | ${report.summary.pass} |`); lines.push(`| WARN | ${report.summary.warn} |`); lines.push(`| FAIL | ${report.summary.fail} |`); lines.push(`| SKIP | ${report.summary.skip} |`); lines.push(''); lines.push('## 租户数据规模'); lines.push(''); lines.push('| 表 | 记录数 |'); lines.push('| --- | ---: |'); for (const [table, count] of Object.entries(report.counts).sort(([a], [b]) => a.localeCompare(b))) { lines.push(`| ${table} | ${count} |`); } lines.push(''); lines.push('## 检查项'); lines.push(''); lines.push('| 状态 | 检查项 | 说明 |'); lines.push('| --- | --- | --- |'); for (const check of report.checks) { lines.push(`| ${check.status.toUpperCase()} | ${check.name} | ${String(check.message).replaceAll('|', '\\|')} |`); } lines.push(''); lines.push('## 说明'); lines.push(''); lines.push('- 该脚本只读数据库,不修复数据,不生成测试数据。'); lines.push('- WARN 代表旧数据或生产配置需要人工复核;FAIL 代表当前新业务模型无法安全消费该部分迁移数据。'); lines.push('- 报告目录已被 `.gitignore` 忽略,不应提交含真实业务数据的报告。'); lines.push(''); return `${lines.join('\n')}\n`; } async function writeReport(report) { await fs.mkdir(OUTPUT_DIR, { recursive: true }); const stamp = shanghaiTimestampForFile(new Date(report.generatedAt)); const jsonPath = path.join(OUTPUT_DIR, `pb-business-sample-${stamp}.json`); const mdPath = path.join(OUTPUT_DIR, `pb-business-sample-${stamp}.md`); await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); await fs.writeFile(mdPath, markdownReport(report), 'utf8'); return { jsonPath, mdPath }; } function printConsole(report) { for (const check of report.checks) { const prefix = check.status.toUpperCase(); console.log(`[${prefix}] ${check.name}: ${check.message}`); } console.log( `Business sample complete: ${report.summary.fail} failures, ${report.summary.warn} warnings, ${report.summary.skip} skipped, ${report.summary.pass} passed.`, ); } async function main() { const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 1 }); const client = await pool.connect(); try { await client.query('set default_transaction_read_only = on'); const sampler = new BusinessSampler(client); const report = await sampler.run(); printConsole(report); if (WRITE_REPORT) { const files = await writeReport(report); console.log(`[sample] wrote ${files.jsonPath}`); console.log(`[sample] wrote ${files.mdPath}`); } if (report.summary.fail > 0 || (FAIL_ON_WARNINGS && report.summary.warn > 0)) { process.exitCode = 1; } } finally { client.release(); await pool.end(); } } main().catch(error => { console.error(error); process.exitCode = 1; });