forked from wangziqi/gongxue-base
1186 lines
45 KiB
JavaScript
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;
|
||
});
|