Files
gongxue-base/scripts/pb-import-business-sample.js
2026-06-30 13:03:38 +08:00

1186 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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