Files
gongxue-base/scripts/pb-import-business-sample.js
T

1186 lines
45 KiB
JavaScript

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