diff --git a/README.md b/README.md index 6c3397cd..6ce29681 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,25 @@ dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失 当前真实 SQLite 基线已经跑通只读导出和干净库全量导入:58 个业务 collection、248555 条记录、9 个 storage 原始资源文件;本地 `npx supabase db reset` 后执行 `npm run pb:import:json` 最近用时约 10 分 11 秒,`npm run pb:import:validate` 结果为 0 failures、3 warnings。`user_answer_records`、`mock_exam_configs`、`referral_qrcodes`、`commission_settings` 已进入标准化导入,导入后核心计数包括 3670 用户、74102 题目、85199 条旧答题记录、38205 条错题、636 订单、447 权益、79 个推广码和 1 条租户分佣设置。导入器现在还会从旧 `region_modules/module_nodes/subjects/categories/questions.nodeId` 生成新架构 `content_entries/content_nodes/question_collections/practice_blueprints`,最新导入计数为 11 个题库入口、2830 个内容节点、1597 个题目合集、82106 条合集题目关系、3102 个顺序/随机练习蓝图;所有已发布旧题都会写入 `entry_id/content_node_id/primary_collection_id`,供 Taro 前端按新模型直接消费。 +导入结构校验后,还要运行真实业务抽样: + +```powershell +$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" +npm run pb:import:sample +``` + +`pb:import:sample` 是只读脚本,用来证明迁移数据能被新 SaaS 业务模型消费:题库入口、分类节点、题目合集、顺序/随机/全真模拟蓝图、题目当前版本、答题记录、错题、收藏、单词、知识手册、分数线、订单、支付、权益、激活码、资源台账和敏感字段泄露都会被抽样检查。当前真实迁移库最近结果为 `0 failures, 6 warnings, 1 skipped, 39 passed`;warning 均为旧数据人工复核项或上线前留档项,详见 runbook。 + +如需生成本地报告: + +```powershell +$env:PB_SAMPLE_WRITE_REPORT="true" +npm run pb:import:sample +Remove-Item Env:\PB_SAMPLE_WRITE_REPORT +``` + +报告输出到已忽略的 `docs/refactor/migration-reports/`,不要提交真实用户、订单或学习数据样本。 + production dry-run 目前仍有 2 个真实数据 blocker:30 个订单缺用户、22 个知识手册章节缺所属手册;导入器会把它们隔离到财务复核/迁移待复核手册并写入 `pb_import_issues`,其中最新导入 run 的 critical issue 剩余 29 个:7 个已支付订单缺用户、22 个手册章节缺所属手册。正式切换前仍必须人工确认,详见: ```text @@ -559,7 +578,7 @@ npm run audit:runtime git diff --check ``` -结果:通过。最近一轮真实迁移专项验证已通过 `npx supabase db reset`、`npm run pb:import:json`、`npm run pb:import:validate` 和 `npm run check:importer`;production dry-run 仍按预期返回非 0,因为旧数据本身还剩订单缺用户和手册章节缺归属两个 blocker。`npm run test:auth:remote-smoke` 覆盖远程 Auth/JWKS 验收脚本自身。`npm run test:rls` 覆盖 75 条运行时 RLS 断言,包含主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝。`npm run test:api` 覆盖平台细粒度权限、公共题库跨租户同步运营状态、资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、平台账单逾期 dry-run/催缴记录、平台审计告警查询/状态更新/越权拒绝/敏感 details 脱敏、平台审计告警通知渠道/事件查询和密钥不回显、平台催缴通知渠道/事件查询和密钥不回显、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:platform-billing` 覆盖平台 SaaS 订阅自动计费、重复开票保护、账单明细和审计。`npm run test:worker:platform-dunning` 覆盖平台 SaaS 逾期账单标记、内部催缴记录、租户 `past_due` 状态和每日催缴幂等。`npm run test:worker:platform-dunning-notifications` 覆盖平台 SaaS 催缴外部通知入队、generic webhook 发送、幂等、防重复、联系方式掩码、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:platform-audit-alerts` 覆盖平台审计告警生成、规则匹配、幂等、防重复和告警 details 脱敏。`npm run test:worker:platform-audit-notifications` 覆盖平台审计告警外部通知入队、generic webhook 发送、幂等、防重复、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:public-banks` 覆盖公共题库自动同步、失败通知和恢复自动关闭。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 当前为 0 vulnerabilities;Excel 解析已从 `exceljs` 切换为 `read-excel-file`,避免生产运行时携带 `exceljs -> uuid` 的已知中危依赖。 +结果:通过。最近一轮真实迁移专项验证已通过 `npx supabase db reset`、`npm run pb:import:json`、`npm run pb:import:validate`、`npm run pb:import:sample` 和 `npm run check:importer`;`pb:import:sample` 当前为 0 failures、6 warnings、1 skipped、39 passed;production dry-run 仍按预期返回非 0,因为旧数据本身还剩订单缺用户和手册章节缺归属两个 blocker。`npm run test:auth:remote-smoke` 覆盖远程 Auth/JWKS 验收脚本自身。`npm run test:rls` 覆盖 75 条运行时 RLS 断言,包含主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝。`npm run test:api` 覆盖平台细粒度权限、公共题库跨租户同步运营状态、资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、平台账单逾期 dry-run/催缴记录、平台审计告警查询/状态更新/越权拒绝/敏感 details 脱敏、平台审计告警通知渠道/事件查询和密钥不回显、平台催缴通知渠道/事件查询和密钥不回显、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:platform-billing` 覆盖平台 SaaS 订阅自动计费、重复开票保护、账单明细和审计。`npm run test:worker:platform-dunning` 覆盖平台 SaaS 逾期账单标记、内部催缴记录、租户 `past_due` 状态和每日催缴幂等。`npm run test:worker:platform-dunning-notifications` 覆盖平台 SaaS 催缴外部通知入队、generic webhook 发送、幂等、防重复、联系方式掩码、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:platform-audit-alerts` 覆盖平台审计告警生成、规则匹配、幂等、防重复和告警 details 脱敏。`npm run test:worker:platform-audit-notifications` 覆盖平台审计告警外部通知入队、generic webhook 发送、幂等、防重复、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:public-banks` 覆盖公共题库自动同步、失败通知和恢复自动关闭。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 当前为 0 vulnerabilities;Excel 解析已从 `exceljs` 切换为 `read-excel-file`,避免生产运行时携带 `exceljs -> uuid` 的已知中危依赖。 注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 756373b0..99bf608f 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -172,7 +172,7 @@ | --- | --- | --- | | PocketBase schema/导出分析 | 可联调 | `scripts/import-pocketbase` 支持 schema summary/risk、`npm run pb:import:dry-run` 导出目录静态迁移报告 | | PocketBase JSON dry-run | 可联调 | 不写数据库,检查导出目录、JSON 形态、核心集合、旧 ID、敏感字段、schema relation、未映射集合和关键业务计数;`--profile=production` 会额外检查生产迁移必需集合和关键字段覆盖率,正式切换建议配合 `--fail-on-warnings` | -| PocketBase SQLite 标准化导入 | 可联调 | 已支持真实 SQLite 只读导出后的核心集合标准化,新增覆盖 `user_answer_records -> answer_records/wrong_questions`、`mock_exam_configs -> practice_blueprints`、`referral_qrcodes -> referral_qrcodes/referral_codes`、`commission_settings -> tenant_commission_settings`;干净本地 Supabase 全量真实导入 248555 条记录最近约 10 分 11 秒,`pb:import:validate` 为 0 failures、3 warnings;导入后会生成 11 个题库入口、2830 个内容节点、1597 个题目合集、82106 条合集题目关系和 3102 个顺序/随机练习蓝图,并回填全部已发布旧题的 `entry_id/content_node_id/primary_collection_id`;缺用户订单会进入财务复核且不自动开权益,缺归属手册章节会进入“迁移待复核手册”并写 `pb_import_issues`;正式切换前仍需人工处理 7 个已支付缺用户订单和 22 个待复核手册章节 | +| PocketBase SQLite 标准化导入 | 可联调 | 已支持真实 SQLite 只读导出后的核心集合标准化,新增覆盖 `user_answer_records -> answer_records/wrong_questions`、`mock_exam_configs -> practice_blueprints`、`referral_qrcodes -> referral_qrcodes/referral_codes`、`commission_settings -> tenant_commission_settings`;干净本地 Supabase 全量真实导入 248555 条记录最近约 10 分 11 秒,`pb:import:validate` 为 0 failures、3 warnings;已补 `npm run pb:import:sample` 只读业务抽样,当前真实迁移库为 0 failures、6 warnings、1 skipped、39 passed,覆盖题库入口、内容节点、题目合集、练习蓝图、题目版本、学习记录、单词、手册、分数线、订单、支付、权益、激活码、资源和敏感字段泄露;导入后会生成 11 个题库入口、2830 个内容节点、1597 个题目合集、82106 条合集题目关系和 3102 个顺序/随机练习蓝图,并回填全部已发布旧题的 `entry_id/content_node_id/primary_collection_id`;缺用户订单会进入财务复核且不自动开权益,缺归属手册章节会进入“迁移待复核手册”并写 `pb_import_issues`;正式切换前仍需人工处理 7 个已支付缺用户订单和 22 个待复核手册章节 | | 题目 JSON preview/import | 可联调 | 后端负责规范化、issue、幂等、审计 | | 公共题库采纳、手动同步和自动同步 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;同步 API 和 `public-banks` worker 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录、租户内容通知和租户自改冲突保护;冲突处理 API 已支持单条/批量采纳平台版本和保留租户本地版本;worker 失败会写 `public_question_bank_sync_failed` 租户通知并保留稳定错误码,恢复成功会自动关闭失败通知;平台可通过 `/api/platform-admin/question-bank-sync-status` 查看跨租户同步运营状态;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、冲突不覆盖、单条/批量冲突处理、worker 自动同步/失败通知/恢复关闭、starter 单地区不可见/不可采纳第二地区题库、pro 全国套餐可见第二地区题库等测试 | | 单词 JSON preview/import | 可联调 | 兼容旧模板 | @@ -228,7 +228,8 @@ npx supabase db reset $env:DATABASE_URL='postgresql://postgres:postgres@127.0.0.1:54322/postgres' npm run pb:import:json npm run pb:import:validate +npm run pb:import:sample npm run check:importer ``` -结果:`pb:import:json` 用时约 10 分 11 秒,`pb:import:validate` 为 0 failures、3 warnings。新题库导航计数:11 个 `content_entries`、2830 个 `content_nodes`、1597 个 `question_collections`、82106 条 `question_collection_items`、3102 个 `practice_blueprints`,已发布旧题缺入口/节点/合集数为 0。`npm run pb:import:dry-run -- --profile=production --json` 仍按预期返回非 0,因为真实旧数据还有 30 个订单缺 `userId` 和 22 个手册章节缺 `subjectId` 两个上线前 blocker。 +结果:`pb:import:json` 用时约 10 分 11 秒,`pb:import:validate` 为 0 failures、3 warnings,`pb:import:sample` 为 0 failures、6 warnings、1 skipped、39 passed。新题库导航计数:11 个 `content_entries`、2830 个 `content_nodes`、1597 个 `question_collections`、82106 条 `question_collection_items`、3102 个 `practice_blueprints`,已发布旧题缺入口/节点/合集数为 0。`npm run pb:import:dry-run -- --profile=production --json` 仍按预期返回非 0,因为真实旧数据还有 30 个订单缺 `userId` 和 22 个手册章节缺 `subjectId` 两个上线前 blocker。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index cacaa419..dc7c9dc3 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -79,8 +79,9 @@ - 当前真实 dry-run 剩余 blocker:30 个订单缺 `userId`,其中 7 个 paid;22 个知识手册章节缺 `subjectId`。导入器已补隔离策略:缺用户订单进入财务复核且不自动开权益,缺归属手册章节进入“迁移待复核手册”。正式切换前仍必须人工找回/确认这些记录。 - 已补真实 mapper gap:`user_answer_records` 85442 条标准化到 `answer_records/wrong_questions`,`mock_exam_configs` 48 条标准化到 `practice_blueprints`,`referral_qrcodes` 79 条标准化到 `referral_qrcodes/referral_codes`,`commission_settings` 1 条标准化到 `tenant_commission_settings`。 - 真实 `pb:import:json` 已在干净本地 Supabase 上跑通,248555 条真实记录最近导入约 10 分 11 秒;`pb:import:validate` 为 0 failures、3 warnings。核心落库计数:3670 用户、74102 题目、85199 旧答题记录、38205 错题、636 订单、447 权益、79 个推广码、1 条租户分佣设置;同时生成 11 个题库入口、2830 个内容节点、1597 个题目合集、82106 条合集题目关系、3102 个顺序/随机练习蓝图,已发布旧题缺入口/节点/合集数为 0。 + - 已补 `npm run pb:import:sample` 真实业务抽样验收,只读检查题库入口、内容节点、题目合集、练习蓝图、题目当前版本、答题记录、错题、收藏、单词、手册、分数线、订单、支付、权益、激活码、资源台账和敏感字段泄露。当前真实迁移库结果为 0 failures、6 warnings、1 skipped、39 passed;warning 均为旧数据人工复核或上线留档项。 - 最新导入 run 剩余 29 个 critical import issues:7 个已支付订单缺用户、22 个手册章节缺所属手册,均为正式切换前人工复核项;旧答题记录空用户或旧用户已删除已降为 warning。 - - 处理完 blocker 后再跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,再跑 `pb:import:json`、`pb:import:validate` 和业务抽样。 + - 处理完 blocker 后再跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,再跑 `pb:import:json`、`pb:import:validate` 和 `pb:import:sample`。 - 对题目 JSON、单词、知识手册、分数线、视频走后端 preview/import API 做二次验证。 4. 部署配置 diff --git a/docs/refactor/pocketbase-real-data-migration-runbook.md b/docs/refactor/pocketbase-real-data-migration-runbook.md index 466c8869..63da2018 100644 --- a/docs/refactor/pocketbase-real-data-migration-runbook.md +++ b/docs/refactor/pocketbase-real-data-migration-runbook.md @@ -329,6 +329,39 @@ Remove-Item Env:\FAIL_ON_WARNINGS ## 阶段 4:抽样验收 +结构校验通过后,先运行只读业务抽样脚本: + +```bash +npm run pb:import:sample +``` + +它和 `pb:import:validate` 的边界不同: + +- `pb:import:validate` 证明核心表关系、敏感字段和迁移隔离策略没有断。 +- `pb:import:sample` 证明真实迁移数据能被新 SaaS 业务模型消费,覆盖题库入口、内容节点、合集、练习蓝图、题目当前版本、答题记录、错题、收藏、单词、知识手册、分数线、订单、支付、权益、激活码、内容资源、视频和敏感字段泄露。 +- 脚本只读数据库,不修复数据,不生成测试数据;FAIL 表示当前业务模型不能安全消费该部分迁移数据,WARN 表示旧数据或上线配置需要人工复核。 + +常用参数: + +```powershell +$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" +$env:PB_SAMPLE_TENANT_SLUG="master" +npm run pb:import:sample + +# 需要保存本地报告时开启;报告目录已被 .gitignore 忽略 +$env:PB_SAMPLE_WRITE_REPORT="true" +npm run pb:import:sample +Remove-Item Env:\PB_SAMPLE_WRITE_REPORT +``` + +当前真实迁移库最近一次抽样结果: + +```text +npm run pb:import:sample => 0 failures, 6 warnings, 1 skipped, 39 passed +``` + +其中 6 个 warning 分别对应:迁移待复核题目、部分旧答题记录引用已删除题、已支付缺用户订单、已使用但缺使用人的激活码、已知 critical import issues 人工复核项、最新导入 warning/error issue 留档项;`videos.not_present` 为 SKIP,因为当前旧库没有题目视频记录。 + 每次演练都应至少抽样下面数据: | 范围 | 抽样建议 | 验收点 | diff --git a/package.json b/package.json index 7a403247..497eb612 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "pb:export:sqlite": "npm --workspace @tiku-saas/import-pocketbase run export:sqlite --", "pb:import:dry-run": "npm --workspace @tiku-saas/import-pocketbase run import:dry-run --", "pb:import:json": "npm --workspace @tiku-saas/import-pocketbase run import:json", - "pb:import:validate": "npm --workspace @tiku-saas/import-pocketbase run import:validate" + "pb:import:validate": "npm --workspace @tiku-saas/import-pocketbase run import:validate", + "pb:import:sample": "node scripts/pb-import-business-sample.js" } } diff --git a/scripts/pb-import-business-sample.js b/scripts/pb-import-business-sample.js new file mode 100644 index 00000000..59ed1c16 --- /dev/null +++ b/scripts/pb-import-business-sample.js @@ -0,0 +1,1185 @@ +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; +});