forked from wangziqi/gongxue-base
perf: stabilize real pocketbase import
This commit is contained in:
14
README.md
14
README.md
@@ -23,13 +23,13 @@
|
||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。
|
||||
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
||||
- 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。
|
||||
- PocketBase schema/数据导入器雏形和导入后校验脚本。
|
||||
- PocketBase SQLite 只读导出、JSON dry-run、标准化导入和导入后校验脚本;真实旧库 248555 条业务记录已能在干净本地 Supabase 中完成全量导入。
|
||||
- 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。
|
||||
|
||||
还没有达到生产交付的部分:
|
||||
|
||||
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入、微信/支付宝官方账单下载任务、provider-bills worker 自动导入比对、差错工单处理、异常订单运营台和人工调整凭证复核报表;真实生产账号、真实回调域名和真实生产账单抽样验收还没接完。
|
||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;本地阶段使用 mock/fake provider 和回调后业务链路验收,不要求真实平台密钥。资金对账已支持手工/API 账单导入、微信/支付宝官方账单下载任务、provider-bills worker 自动导入比对、差错工单处理、异常订单运营台和人工调整凭证复核报表;真实生产账号、真实回调域名和真实生产账单抽样验收等上线后密钥联调还没接完。
|
||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、动态水印上下文、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成;Taro 学生资料页已按短期签名和 `watermark.traceId` 渲染可见水印确认/预览第一版。生产还要配置真实扫描服务 endpoint/token,并继续补转码/CDN 级水印、CDN 刷新和对象生命周期策略。
|
||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载、字段映射 API 和 Taro 租户内容页第一版导入操作台已完成。
|
||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||
@@ -439,7 +439,9 @@ dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失
|
||||
|
||||
默认 dry-run 使用 `development` profile;正式迁移、预生产验收和 CI 应使用 `production` profile。生产 profile 会额外输出 `migrationReadiness`,检查用户、题目、科目、分类、订单、套餐、激活码、单词和知识手册等必需集合,以及用户手机号、题目归属、订单套餐、激活码、单词和手册归属等关键字段覆盖率。`--profile` 只接受 `development` 或 `production`,拼写错误会按 blocker 失败。
|
||||
|
||||
当前真实 SQLite 基线已经跑通只读导出:58 个业务 collection、248555 条记录、9 个 storage 原始资源文件。`user_answer_records`、`mock_exam_configs`、`referral_qrcodes`、`commission_settings` 已进入标准化导入。production dry-run 目前仍有 2 个真实数据 blocker:30 个订单缺用户、22 个知识手册章节缺所属手册;导入器会把它们隔离到财务复核/迁移待复核手册并写入 `pb_import_issues`,但正式切换前仍必须人工确认,详见:
|
||||
当前真实 SQLite 基线已经跑通只读导出和干净库全量导入:58 个业务 collection、248555 条记录、9 个 storage 原始资源文件;本地 `npx supabase db reset` 后执行 `npm run pb:import:json` 用时约 8 分 58 秒,`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 权益、48 个模考蓝图、79 个推广码和 1 条租户分佣设置。
|
||||
|
||||
production dry-run 目前仍有 2 个真实数据 blocker:30 个订单缺用户、22 个知识手册章节缺所属手册;导入器会把它们隔离到财务复核/迁移待复核手册并写入 `pb_import_issues`,其中最新导入 run 的 critical issue 剩余 29 个:7 个已支付订单缺用户、22 个手册章节缺所属手册。正式切换前仍必须人工确认,详见:
|
||||
|
||||
```text
|
||||
docs/refactor/pocketbase-real-data-migration-runbook.md
|
||||
@@ -541,7 +543,7 @@ npm run audit:runtime
|
||||
git diff --check
|
||||
```
|
||||
|
||||
结果:通过。`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 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` 的已知中危依赖。
|
||||
|
||||
注意:`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 官方修复。
|
||||
|
||||
@@ -552,7 +554,7 @@ git diff --check
|
||||
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
||||
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。
|
||||
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
||||
4. 题库导出模板精排、导出操作台、使用 `F:\project\参考\旧题库数据库文件` 做真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。
|
||||
4. 题库导出模板精排、导出操作台、导入字段映射 UI 和复检结果操作台;继续对真实迁移数据做题目、订单、权益、错题、资料和视频抽样验收。
|
||||
5. 上云后接真实 OAuth/短信/支付生产账号、回调域名和真实生产账单抽样验收;本地阶段继续用 mock/fake provider 验证回调后业务链路、幂等、审计、密钥不泄露和权益开通/撤销。后续还要补真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。
|
||||
|
||||
旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro H5/小程序补体验时只作为页面状态、微信平台能力和交互参考,不继承旧 PocketBase 直连和旧鉴权逻辑。
|
||||
|
||||
@@ -13,6 +13,17 @@ Supabase 适合承担 PostgreSQL、RLS、Auth、Edge Functions、Webhook/Hooks
|
||||
|
||||
Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先用 `app_private.auth_sessions` 签发 `tk_` session,保证 Web/Taro 能跑通端到端流程。
|
||||
|
||||
## 本地验收边界
|
||||
|
||||
当前阶段没有真实阿里云/腾讯云/微信/QQ/支付宝密钥时,不阻塞后端编码和本地联调。验收重点放在 provider adapter 之外的商用关键链路:
|
||||
|
||||
- 短信:使用 `AUTH_SMS_PROVIDER=mock` 或本地 fake endpoint 验证验证码生成、HMAC 存储、冷却、过期、登录事件和 session 签发。生产环境仍为 mock 会 fail-fast。
|
||||
- 微信小程序/微信网页/QQ 登录:使用本地 fake endpoint 或测试桩覆盖 code 换身份之后的账号创建/合并、`user_identities`、登录审计和 session 签发;真实 `code2Session`、授权回调域名和开放平台错误码到云服务器解析完成后再验收。
|
||||
- 微信/支付宝支付:本地优先验证下单参数生成、回调验签/解密的错误处理、幂等键、`payment_events`、订单状态、权益开通、退款状态机、补偿 worker 和对账工单。真实商户号、API v3 key、证书、支付宝公私钥和回调域名上线后再做官方联调。
|
||||
- 密钥:本地可以只写测试密钥或 fake secret,但必须走 `app_private.tenant_secrets` / `app_private.platform_secrets` 引用链;前端和 public schema 不允许出现明文密钥。
|
||||
|
||||
因此本地“跑通”不等于真实平台验收完成。上线门禁必须再补真实生产账号、真实回调域名、官方账单抽样和生产 provider 配置证据。
|
||||
|
||||
## 当前已实现
|
||||
|
||||
- `POST /api/auth/sms/send`:手机号验证码发送,验证码只保存 HMAC hash。
|
||||
@@ -321,6 +332,8 @@ B 端合作商年费、服务费、服务器资源费不走学生端 `orders`,
|
||||
|
||||
真实接入前需要先明确微信支付、支付宝或聚合支付是否允许你们销售的题库会员形态,以及小程序端是否涉及虚拟支付限制。
|
||||
|
||||
本地开发可以先把这些配置留空或配置为 inactive。租户后台保存 provider 配置时只写非敏感公开字段和 `secretRef`;等云服务器域名、备案、官方应用/商户申请完成后,再在租户后台或平台后台写入真实密钥并执行生产联调。
|
||||
|
||||
## 需要准备的资料
|
||||
|
||||
- 阿里云或腾讯云短信:签名、模板 ID、AccessKey/SecretKey、短信用途文案。
|
||||
|
||||
@@ -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`;缺用户订单会进入财务复核且不自动开权益,缺归属手册章节会进入“迁移待复核手册”并写 `pb_import_issues`;全量真实导入仍需继续做批量化性能优化 |
|
||||
| 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 条记录约 8 分 58 秒,`pb:import:validate` 为 0 failures、3 warnings;缺用户订单会进入财务复核且不自动开权益,缺归属手册章节会进入“迁移待复核手册”并写 `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 | 可联调 | 兼容旧模板 |
|
||||
@@ -220,3 +220,15 @@ git diff --check
|
||||
- smoke seed
|
||||
- API build
|
||||
- API integration tests
|
||||
|
||||
真实迁移专项最近已通过:
|
||||
|
||||
```bash
|
||||
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 check:importer
|
||||
```
|
||||
|
||||
结果:`pb:import:json` 用时约 8 分 58 秒,`pb:import:validate` 为 0 failures、3 warnings。`npm run pb:import:dry-run -- --profile=production --json` 仍按预期返回非 0,因为真实旧数据还有 30 个订单缺 `userId` 和 22 个手册章节缺 `subjectId` 两个上线前 blocker。
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
- 已补 `npm run pb:import:dry-run` 静态迁移报告工具、`--profile=production` 生产迁移门禁、关键集合/关键字段覆盖率检查、strict warning 门禁测试和真实数据迁移验收 runbook;真实 SQLite 已导出 58 个业务 collection、248555 条记录,并跑过 production dry-run。
|
||||
- 当前真实 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` 试跑已验证 raw 导入和新 mapper 开始落库,但 248555 条真实记录的逐条标准化仍超过 10 分钟,需要下一阶段继续做批量化/事务分批优化,重点是 `questions`、`question_versions`、`user_answer_records`、`pb_raw_records`。
|
||||
- 真实 `pb:import:json` 已在干净本地 Supabase 上跑通,248555 条真实记录导入约 8 分 58 秒;`pb:import:validate` 为 0 failures、3 warnings。核心落库计数:3670 用户、74102 题目、85199 旧答题记录、38205 错题、636 订单、447 权益、48 个模考蓝图、79 个推广码、1 条租户分佣设置。
|
||||
- 最新导入 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` 和业务抽样。
|
||||
- 对题目 JSON、单词、知识手册、分数线、视频走后端 preview/import API 做二次验证。
|
||||
|
||||
@@ -103,6 +104,7 @@
|
||||
- 已完成阿里云短信、腾讯云短信 adapter 的后端实现和本地 fake endpoint 测试。
|
||||
- 已完成微信小程序 `code2Session` 登录主链路。
|
||||
- 已完成手机号绑定/换绑、微信网页登录、QQ 登录基础 API;继续补真实生产账号、回调域名和开放平台联调。
|
||||
- 本地阶段不等待真实密钥,继续用 mock/fake provider 验证验证码、账号合并、登录审计、session 签发和错误处理;真实密钥、合法域名和开放平台错误码等上云后再联调。
|
||||
- 旧 PocketBase 用户账号和新身份体系的映射/补绑。
|
||||
|
||||
3. 导入体系扩展
|
||||
@@ -136,7 +138,7 @@
|
||||
|
||||
8. 订单和营销体验
|
||||
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算、优惠券复杂规则、核销明细/报表和内部退款状态机。
|
||||
- 已完成支付/退款补偿 worker、官方账单下载 worker、资金对账导入比对、差错工单、异常订单运营台和人工调整凭证复核;Taro 财务运营台第一版已接入;继续补学生端售后入口深化、营销活动 UI、真实生产账单格式抽样验收和活动效果看板。
|
||||
- 已完成支付/退款补偿 worker、官方账单下载 worker、资金对账导入比对、差错工单、异常订单运营台和人工调整凭证复核;Taro 财务运营台第一版已接入;本地继续验证回调后业务链路、幂等、审计、权益开通/撤销和密钥不泄露,真实生产账号、回调域名和账单格式抽样验收上云后补齐;继续补学生端售后入口深化、营销活动 UI 和活动效果看板。
|
||||
|
||||
9. 积分和反馈增强
|
||||
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
||||
@@ -252,6 +254,6 @@
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。
|
||||
3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 使用 `F:\project\参考\旧题库数据库文件` 中的真实 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收;导入过程中发现的字段污染、跨集合引用断裂、敏感字段和旧权限问题都要沉淀到 importer mapper 或修复脚本,不手工临时修库。
|
||||
5. 使用 `F:\project\参考\旧题库数据库文件` 中的真实 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 继续做人工复核和抽样验收;当前 dry-run/导入/校验链路已跑通,下一步重点是 7 个已支付缺用户订单、22 个待复核手册章节、2533 道旧分类缺失题目和 5298 条引用已删除题目的答题记录的运营处理结论。导入过程中发现的字段污染、跨集合引用断裂、敏感字段和旧权限问题都要沉淀到 importer mapper 或修复脚本,不手工临时修库。
|
||||
6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
|
||||
@@ -143,7 +143,7 @@ auxiliary.db: _logs 121715 条,仅摘要
|
||||
|
||||
当前导入器已经补齐下面 4 个旧集合的标准化 mapper:
|
||||
|
||||
- `user_answer_records` 85442 条会导入到 `answer_records`,并按错误记录重建 `wrong_questions`。
|
||||
- `user_answer_records` 85442 条会导入到 `answer_records`,并按错误记录重建 `wrong_questions`。旧用户为空或旧用户已不存在的答题记录会跳过并写 warning,不作为财务/权益级 blocker。
|
||||
- `mock_exam_configs` 48 条会导入到 `practice_blueprints(mode='mock_exam', assembly_type='filters')`。
|
||||
- `referral_qrcodes` 79 条会导入到 `referral_qrcodes`,并同步补 `referral_codes`。
|
||||
- `commission_settings` 1 条会导入到 `tenant_commission_settings`。
|
||||
@@ -158,7 +158,38 @@ production dry-run 还有这些 warning,属于运营处理项:
|
||||
- `dashboard_cache`、空 `customer_messages/smscodes/tenant_config` 可不作为正式数据源,必要时只保留审计摘要。
|
||||
- `recent_practices` 和少量 `user_answer_records` 有旧用户引用断裂,需要在导入后复核 `pb_import_issues`。
|
||||
|
||||
性能注意:真实 `pb:import:json` 试跑已经证明 248555 条真实记录会超过 10 分钟。正式迁移前需要继续优化 importer 的批量写入和分批事务,重点是 `pb_raw_records`、`questions/question_versions`、`user_answer_records`。迁移演练必须记录总耗时、每阶段耗时和失败恢复策略。
|
||||
最新真实导入演练结果:
|
||||
|
||||
```text
|
||||
环境:本地 Supabase,先执行 npx supabase db reset
|
||||
命令:npm run pb:import:json
|
||||
耗时:约 8 分 58 秒
|
||||
导入后校验:npm run pb:import:validate => 0 failures, 3 warnings
|
||||
|
||||
核心计数:
|
||||
platform_users: 3670
|
||||
questions: 74102
|
||||
answer_records: 85199
|
||||
wrong_questions: 38205
|
||||
orders: 636
|
||||
entitlements: 447
|
||||
practice_blueprints(mode=mock_exam): 48
|
||||
referral_qrcodes: 79
|
||||
tenant_commission_settings: 1
|
||||
```
|
||||
|
||||
最新导入 run 的 `pb_import_issues` 仍有 29 个 critical,需要正式切换前人工复核:
|
||||
|
||||
- 7 个 `orders` 已支付订单缺用户:订单会进入财务复核,不自动开通权益。
|
||||
- 22 个 `handbook_chapters` 缺所属手册:章节会挂到“迁移待复核手册”。
|
||||
|
||||
导入后校验的 3 个 warnings 当前含义:
|
||||
|
||||
- 2533 道题保留 `legacy_category_id` 但无法解析到旧 `categories/module_nodes`,原始 `categoryId` 与 `nodeId` 相同,且旧导出中已没有对应分类或节点。不能由导入器硬绑到错误分类,需后续按科目/题型人工归档或在内容后台批量挂载。
|
||||
- 5298 条旧答题记录保留 `legacy_question_id` 但无法解析到题目,说明旧学习日志引用了已删除/未导出的题目;这些记录不会进入错题本有效组卷。
|
||||
- 最新 run 存在 29 个 critical import issues,即上面的付费订单和手册章节人工复核项。
|
||||
|
||||
性能注意:真实 `pb:import:json` 已能在 9 分钟左右完成 248555 条真实记录导入。后续若题库规模继续增长,仍建议继续批量化 `questions/question_versions` 并补阶段耗时统计、失败恢复和断点重跑策略。
|
||||
|
||||
## 阶段 1:静态 Dry-Run
|
||||
|
||||
@@ -265,6 +296,12 @@ Remove-Item Env:\FAIL_ON_WARNINGS
|
||||
- `tenant_settings.public_config` 不含密钥。
|
||||
- `crm_config.secret_ref` 只引用 `app_private.tenant_secrets`。
|
||||
|
||||
当前演练可接受但上线前必须确认的 warning:
|
||||
|
||||
- 已支付订单缺用户不能自动开权益,必须由财务/运营确认是否补绑用户、退款、作废或保留售后台账。
|
||||
- 手册章节缺归属需要内容负责人确认归并到正确手册,或保留在迁移待复核手册并在前端隐藏。
|
||||
- 已删除分类下的题目和已删除题目对应的学习记录,需要按运营口径决定是否建立“迁移待复核分类”、按科目批量归档,或仅保留 legacy trace。
|
||||
|
||||
## 阶段 4:抽样验收
|
||||
|
||||
每次演练都应至少抽样下面数据:
|
||||
|
||||
@@ -61,6 +61,14 @@ const nonCollectionJsonFiles = new Set([
|
||||
'storage-manifest.json',
|
||||
]);
|
||||
|
||||
const importBatchSize = Math.max(100, intFromEnv(process.env.PB_IMPORT_BATCH_SIZE, 1000));
|
||||
|
||||
function intFromEnv(value: unknown, fallback: number): number {
|
||||
if (value === null || value === undefined || value === '') return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback;
|
||||
}
|
||||
|
||||
function asArray(input: unknown): JsonRecord[] {
|
||||
if (Array.isArray(input)) return input as JsonRecord[];
|
||||
if (input && typeof input === 'object' && Array.isArray((input as { items?: unknown[] }).items)) {
|
||||
@@ -358,47 +366,84 @@ async function createRun() {
|
||||
|
||||
async function importRaw(runId: string, collection: string, records: JsonRecord[]) {
|
||||
let count = 0;
|
||||
for (const record of records) {
|
||||
const legacyId = text(record.id);
|
||||
if (!legacyId) continue;
|
||||
const sanitized = sanitizeRecord(record);
|
||||
const issueRows: Array<{
|
||||
legacyId: string;
|
||||
issue: Issue;
|
||||
}> = [];
|
||||
|
||||
for (let offset = 0; offset < records.length; offset += importBatchSize) {
|
||||
const batch = records.slice(offset, offset + importBatchSize);
|
||||
const legacyIds: string[] = [];
|
||||
const recordPayloads: string[] = [];
|
||||
|
||||
for (const record of batch) {
|
||||
const legacyId = text(record.id);
|
||||
if (!legacyId) continue;
|
||||
legacyIds.push(legacyId);
|
||||
recordPayloads.push(JSON.stringify(sanitizeRecord(record)));
|
||||
for (const detectedIssue of detectIssues(collection, record)) {
|
||||
issueRows.push({ legacyId, issue: detectedIssue });
|
||||
}
|
||||
}
|
||||
|
||||
if (!legacyIds.length) continue;
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.pb_raw_records (run_id, tenant_id, collection_name, legacy_id, record)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
select $1::uuid, $2::uuid, $3::text, raw.legacy_id, raw.record_payload::jsonb
|
||||
from unnest($4::text[], $5::text[]) as raw(legacy_id, record_payload)
|
||||
on conflict (run_id, collection_name, legacy_id)
|
||||
do update set record = excluded.record, imported_at = now()
|
||||
`,
|
||||
[runId, tenantId, collection, legacyId, JSON.stringify(sanitized)],
|
||||
[runId, tenantId, collection, legacyIds, recordPayloads],
|
||||
);
|
||||
|
||||
for (const issue of detectIssues(collection, record)) {
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.pb_import_issues (
|
||||
run_id, tenant_id, collection_name, legacy_id, severity,
|
||||
issue_code, message, field_path, raw_value_sample
|
||||
)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
`,
|
||||
[
|
||||
runId,
|
||||
tenantId,
|
||||
collection,
|
||||
legacyId,
|
||||
issue.severity,
|
||||
issue.issueCode,
|
||||
issue.message,
|
||||
issue.fieldPath || null,
|
||||
issue.rawValueSample || null,
|
||||
],
|
||||
);
|
||||
}
|
||||
count += 1;
|
||||
count += legacyIds.length;
|
||||
}
|
||||
|
||||
await insertImportIssueRows(runId, collection, issueRows);
|
||||
return count;
|
||||
}
|
||||
|
||||
async function insertImportIssueRows(
|
||||
runId: string,
|
||||
collection: string,
|
||||
rows: Array<{ legacyId: string | null; issue: Issue }>,
|
||||
) {
|
||||
for (let offset = 0; offset < rows.length; offset += importBatchSize) {
|
||||
const batch = rows.slice(offset, offset + importBatchSize);
|
||||
if (!batch.length) continue;
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.pb_import_issues (
|
||||
run_id, tenant_id, collection_name, legacy_id, severity,
|
||||
issue_code, message, field_path, raw_value_sample
|
||||
)
|
||||
select $1::uuid, $2::uuid, $3::text, issue.legacy_id, issue.severity,
|
||||
issue.issue_code, issue.message, issue.field_path, issue.raw_value_sample
|
||||
from unnest(
|
||||
$4::text[],
|
||||
$5::text[],
|
||||
$6::text[],
|
||||
$7::text[],
|
||||
$8::text[],
|
||||
$9::text[]
|
||||
) as issue(legacy_id, severity, issue_code, message, field_path, raw_value_sample)
|
||||
`,
|
||||
[
|
||||
runId,
|
||||
tenantId,
|
||||
collection,
|
||||
batch.map(row => row.legacyId),
|
||||
batch.map(row => row.issue.severity),
|
||||
batch.map(row => row.issue.issueCode),
|
||||
batch.map(row => row.issue.message),
|
||||
batch.map(row => row.issue.fieldPath || null),
|
||||
batch.map(row => row.issue.rawValueSample || null),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function issue(runId: string, collection: string, legacyId: unknown, issueCode: string, message: string, severity: Issue['severity'] = 'warning') {
|
||||
await pool.query(
|
||||
`
|
||||
@@ -463,6 +508,117 @@ async function currentQuestionVersionId(questionId: string | null): Promise<stri
|
||||
return result;
|
||||
}
|
||||
|
||||
async function bulkUserIdsByLegacy(values: unknown[]): Promise<Map<string, string>> {
|
||||
const legacyValues = [...new Set(values.map(value => text(value)).filter((value): value is string => Boolean(value)))];
|
||||
const result = new Map<string, string>();
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const legacyValue of legacyValues) {
|
||||
if (userLookupCache.has(legacyValue)) {
|
||||
const cached = userLookupCache.get(legacyValue);
|
||||
if (cached) result.set(legacyValue, cached);
|
||||
} else {
|
||||
missing.push(legacyValue);
|
||||
}
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < missing.length; offset += importBatchSize) {
|
||||
const batch = missing.slice(offset, offset + importBatchSize);
|
||||
if (!batch.length) continue;
|
||||
const rows = await pool.query<{ legacy_id: string; id: string }>(
|
||||
`
|
||||
select legacy_id, id
|
||||
from public.platform_users
|
||||
where legacy_id = any($1::text[])
|
||||
`,
|
||||
[batch],
|
||||
);
|
||||
const found = new Map(rows.rows.map(row => [row.legacy_id, row.id]));
|
||||
for (const legacyValue of batch) {
|
||||
const id = found.get(legacyValue) || null;
|
||||
userLookupCache.set(legacyValue, id);
|
||||
if (id) result.set(legacyValue, id);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function bulkLegacyIds(tableName: string, values: unknown[]): Promise<Map<string, string>> {
|
||||
const legacyValues = [...new Set(values.map(value => text(value)).filter((value): value is string => Boolean(value)))];
|
||||
const result = new Map<string, string>();
|
||||
const missing: string[] = [];
|
||||
|
||||
if (!legacyLookupTables.has(tableName)) throw new Error(`Unsafe legacy lookup table: ${tableName}`);
|
||||
|
||||
for (const legacyValue of legacyValues) {
|
||||
const cacheKey = `${tableName}:${legacyValue}`;
|
||||
if (legacyLookupCache.has(cacheKey)) {
|
||||
const cached = legacyLookupCache.get(cacheKey);
|
||||
if (cached) result.set(legacyValue, cached);
|
||||
} else {
|
||||
missing.push(legacyValue);
|
||||
}
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < missing.length; offset += importBatchSize) {
|
||||
const batch = missing.slice(offset, offset + importBatchSize);
|
||||
if (!batch.length) continue;
|
||||
const rows = await pool.query<{ legacy_id: string; id: string }>(
|
||||
`
|
||||
select legacy_id, id
|
||||
from public.${tableName}
|
||||
where tenant_id = $1 and legacy_id = any($2::text[])
|
||||
`,
|
||||
[tenantId, batch],
|
||||
);
|
||||
const found = new Map(rows.rows.map(row => [row.legacy_id, row.id]));
|
||||
for (const legacyValue of batch) {
|
||||
const id = found.get(legacyValue) || null;
|
||||
legacyLookupCache.set(`${tableName}:${legacyValue}`, id);
|
||||
if (id) result.set(legacyValue, id);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function bulkQuestionVersionIds(questionIds: Array<string | null>): Promise<Map<string, string>> {
|
||||
const values = [...new Set(questionIds.filter((value): value is string => Boolean(value)))];
|
||||
const result = new Map<string, string>();
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const questionId of values) {
|
||||
if (questionVersionLookupCache.has(questionId)) {
|
||||
const cached = questionVersionLookupCache.get(questionId);
|
||||
if (cached) result.set(questionId, cached);
|
||||
} else {
|
||||
missing.push(questionId);
|
||||
}
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < missing.length; offset += importBatchSize) {
|
||||
const batch = missing.slice(offset, offset + importBatchSize);
|
||||
if (!batch.length) continue;
|
||||
const rows = await pool.query<{ id: string; current_version_id: string | null }>(
|
||||
`
|
||||
select id, current_version_id
|
||||
from public.questions
|
||||
where tenant_id = $1 and id = any($2::uuid[])
|
||||
`,
|
||||
[tenantId, batch],
|
||||
);
|
||||
const found = new Map(rows.rows.map(row => [row.id, row.current_version_id]));
|
||||
for (const questionId of batch) {
|
||||
const versionId = found.get(questionId) || null;
|
||||
questionVersionLookupCache.set(questionId, versionId);
|
||||
if (versionId) result.set(questionId, versionId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function upsertSecret(secretScope: string, secretKey: string, secretValue: unknown, provider: string | null = null) {
|
||||
if (!importSecretValues || secretValue === null || secretValue === undefined || secretValue === '') return;
|
||||
await pool.query(
|
||||
@@ -876,45 +1032,86 @@ async function normalizeQuestions(records: JsonRecord[]) {
|
||||
}
|
||||
|
||||
async function normalizeUserAnswerRecords(runId: string, records: JsonRecord[]) {
|
||||
const affectedWrongPairs = new Set<string>();
|
||||
const userIdsByLegacy = await bulkUserIdsByLegacy(records.map(record => record.userId));
|
||||
const questionIdsByLegacy = await bulkLegacyIds('questions', records.map(record => record.questionId));
|
||||
const questionVersionIdsByQuestion = await bulkQuestionVersionIds([...questionIdsByLegacy.values()]);
|
||||
const issueRows: Array<{ legacyId: string | null; issue: Issue }> = [];
|
||||
|
||||
for (const r of records) {
|
||||
const userId = await userIdByLegacy(r.userId);
|
||||
if (!userId) {
|
||||
await issue(
|
||||
runId,
|
||||
'user_answer_records',
|
||||
r.id,
|
||||
'answer_user_not_found',
|
||||
`Answer record cannot be imported because userId was not resolved: ${text(r.userId) || '(empty)'}`,
|
||||
'critical',
|
||||
);
|
||||
continue;
|
||||
for (let offset = 0; offset < records.length; offset += importBatchSize) {
|
||||
const batch = records.slice(offset, offset + importBatchSize);
|
||||
const userIds: string[] = [];
|
||||
const questionIds: Array<string | null> = [];
|
||||
const questionVersionIds: Array<string | null> = [];
|
||||
const legacyIds: string[] = [];
|
||||
const legacyQuestionIds: Array<string | null> = [];
|
||||
const legacyCategoryIds: Array<string | null> = [];
|
||||
const selectedOptionsPayloads: string[] = [];
|
||||
const answerPayloads: string[] = [];
|
||||
const isCorrectValues: Array<boolean | null> = [];
|
||||
const answeredAtValues: string[] = [];
|
||||
const createdAtValues: string[] = [];
|
||||
|
||||
for (const r of batch) {
|
||||
const legacyIdValue = text(r.id);
|
||||
if (!legacyIdValue) continue;
|
||||
|
||||
const legacyUserId = text(r.userId);
|
||||
const userId = legacyUserId ? userIdsByLegacy.get(legacyUserId) || null : null;
|
||||
if (!userId) {
|
||||
const hasLegacyUserId = Boolean(legacyUserId);
|
||||
issueRows.push({
|
||||
legacyId: legacyIdValue,
|
||||
issue: {
|
||||
severity: 'warning',
|
||||
issueCode: hasLegacyUserId ? 'answer_user_not_found' : 'answer_user_empty',
|
||||
message: hasLegacyUserId
|
||||
? `Answer record skipped because legacy userId no longer exists in exported users: ${legacyUserId}`
|
||||
: 'Answer record skipped because legacy userId is empty.',
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const legacyQuestionId = text(r.questionId);
|
||||
const questionId = legacyQuestionId ? questionIdsByLegacy.get(legacyQuestionId) || null : null;
|
||||
if (!questionId) {
|
||||
issueRows.push({
|
||||
legacyId: legacyIdValue,
|
||||
issue: {
|
||||
severity: 'warning',
|
||||
issueCode: 'answer_question_not_found',
|
||||
message: `Answer record kept with legacy_question_id only because questionId was not resolved: ${legacyQuestionId || '(empty)'}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const isCorrect = r.isCorrect === null || r.isCorrect === undefined || r.isCorrect === ''
|
||||
? null
|
||||
: boolValue(r.isCorrect);
|
||||
const answeredAt = nullableDateText(r.answeredAt, r.updated, r.created);
|
||||
const selectedOptions = arrayValue(r.selectedOptions);
|
||||
const answerPayload = {
|
||||
source: 'pocketbase.user_answer_records',
|
||||
selectedOptions,
|
||||
legacyCategoryId: text(r.categoryId),
|
||||
legacyQuestionId,
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
userIds.push(userId);
|
||||
questionIds.push(questionId);
|
||||
questionVersionIds.push(questionId ? questionVersionIdsByQuestion.get(questionId) || null : null);
|
||||
legacyIds.push(legacyIdValue);
|
||||
legacyQuestionIds.push(legacyQuestionId);
|
||||
legacyCategoryIds.push(text(r.categoryId));
|
||||
selectedOptionsPayloads.push(JSON.stringify(selectedOptions));
|
||||
answerPayloads.push(JSON.stringify(answerPayload));
|
||||
isCorrectValues.push(isCorrect);
|
||||
answeredAtValues.push(answeredAt);
|
||||
createdAtValues.push(nullableDateText(r.created, answeredAt));
|
||||
}
|
||||
|
||||
const questionId = await legacyId('questions', r.questionId);
|
||||
if (!questionId) {
|
||||
await issue(
|
||||
runId,
|
||||
'user_answer_records',
|
||||
r.id,
|
||||
'answer_question_not_found',
|
||||
`Answer record kept with legacy_question_id only because questionId was not resolved: ${text(r.questionId) || '(empty)'}`,
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
|
||||
const isCorrect = r.isCorrect === null || r.isCorrect === undefined || r.isCorrect === ''
|
||||
? null
|
||||
: boolValue(r.isCorrect);
|
||||
const answeredAt = nullableDateText(r.answeredAt, r.updated, r.created);
|
||||
const answerPayload = {
|
||||
source: 'pocketbase.user_answer_records',
|
||||
selectedOptions: arrayValue(r.selectedOptions),
|
||||
legacyCategoryId: text(r.categoryId),
|
||||
legacyQuestionId: text(r.questionId),
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
if (!legacyIds.length) continue;
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
@@ -923,9 +1120,29 @@ async function normalizeUserAnswerRecords(runId: string, records: JsonRecord[])
|
||||
legacy_question_id, legacy_category_id, selected_options,
|
||||
answer_payload, is_correct, answered_at, created_at
|
||||
)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9::jsonb,$10,
|
||||
coalesce(nullif($11::text,'')::timestamptz, now()),
|
||||
coalesce(nullif($12::text,'')::timestamptz, now())
|
||||
select $1::uuid, answer.user_id::uuid, answer.question_id::uuid,
|
||||
answer.question_version_id::uuid, answer.legacy_id,
|
||||
answer.legacy_question_id, answer.legacy_category_id,
|
||||
answer.selected_options::jsonb, answer.answer_payload::jsonb,
|
||||
answer.is_correct::boolean,
|
||||
coalesce(nullif(answer.answered_at::text,'')::timestamptz, now()),
|
||||
coalesce(nullif(answer.created_at::text,'')::timestamptz, now())
|
||||
from unnest(
|
||||
$2::text[],
|
||||
$3::text[],
|
||||
$4::text[],
|
||||
$5::text[],
|
||||
$6::text[],
|
||||
$7::text[],
|
||||
$8::text[],
|
||||
$9::text[],
|
||||
$10::boolean[],
|
||||
$11::text[],
|
||||
$12::text[]
|
||||
) as answer(
|
||||
user_id, question_id, question_version_id, legacy_id,
|
||||
legacy_question_id, legacy_category_id, selected_options,
|
||||
answer_payload, is_correct, answered_at, created_at
|
||||
)
|
||||
on conflict (tenant_id, legacy_id) do update set
|
||||
user_id = excluded.user_id,
|
||||
@@ -940,42 +1157,40 @@ async function normalizeUserAnswerRecords(runId: string, records: JsonRecord[])
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
questionId,
|
||||
await currentQuestionVersionId(questionId),
|
||||
r.id,
|
||||
text(r.questionId),
|
||||
text(r.categoryId),
|
||||
JSON.stringify(arrayValue(r.selectedOptions)),
|
||||
JSON.stringify(answerPayload),
|
||||
isCorrect,
|
||||
answeredAt,
|
||||
nullableDateText(r.created, answeredAt),
|
||||
userIds,
|
||||
questionIds,
|
||||
questionVersionIds,
|
||||
legacyIds,
|
||||
legacyQuestionIds,
|
||||
legacyCategoryIds,
|
||||
selectedOptionsPayloads,
|
||||
answerPayloads,
|
||||
isCorrectValues,
|
||||
answeredAtValues,
|
||||
createdAtValues,
|
||||
],
|
||||
);
|
||||
|
||||
if (isCorrect === false && questionId) affectedWrongPairs.add(`${userId}:${questionId}`);
|
||||
}
|
||||
|
||||
for (const pair of affectedWrongPairs) {
|
||||
const [userId, questionId] = pair.split(':');
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count, last_wrong_at, resolved_at)
|
||||
select $1, $2, $3, count(*)::integer, max(answered_at), null
|
||||
from public.answer_records
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and question_id = $3
|
||||
and is_correct = false
|
||||
on conflict (tenant_id, user_id, question_id)
|
||||
do update set wrong_count = excluded.wrong_count,
|
||||
last_wrong_at = excluded.last_wrong_at,
|
||||
resolved_at = null
|
||||
`,
|
||||
[tenantId, userId, questionId],
|
||||
);
|
||||
}
|
||||
await insertImportIssueRows(runId, 'user_answer_records', issueRows);
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.wrong_questions (tenant_id, user_id, question_id, wrong_count, last_wrong_at, resolved_at)
|
||||
select tenant_id, user_id, question_id, count(*)::integer, max(answered_at), null
|
||||
from public.answer_records
|
||||
where tenant_id = $1
|
||||
and legacy_id is not null
|
||||
and question_id is not null
|
||||
and is_correct = false
|
||||
group by tenant_id, user_id, question_id
|
||||
on conflict (tenant_id, user_id, question_id)
|
||||
do update set wrong_count = excluded.wrong_count,
|
||||
last_wrong_at = excluded.last_wrong_at,
|
||||
resolved_at = null
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
}
|
||||
|
||||
async function normalizeUsers(records: JsonRecord[]) {
|
||||
@@ -2336,17 +2551,35 @@ async function normalizeQuestionVideos(records: JsonRecord[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeReportType(value: unknown): string {
|
||||
const type = text(value)?.toLowerCase();
|
||||
if (!type) return 'other';
|
||||
if (['question_error', 'answer_error', 'explanation_bad', 'typo'].includes(type)) return 'question_error';
|
||||
if (type === 'content_error') return 'content_error';
|
||||
if (type === 'video_error') return 'video_error';
|
||||
if (type === 'asset_error') return 'asset_error';
|
||||
if (type === 'system_bug') return 'system_bug';
|
||||
if (type === 'suggestion') return 'suggestion';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function normalizeReportStatus(value: unknown): string {
|
||||
const status = text(value)?.toLowerCase();
|
||||
if (['pending', 'accepted', 'rejected', 'resolved', 'closed'].includes(status || '')) return status || 'pending';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
async function normalizeReports(records: JsonRecord[]) {
|
||||
for (const r of records) {
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.reports (
|
||||
tenant_id, legacy_id, question_id, user_id, type, description,
|
||||
status, created_at, updated_at
|
||||
status, metadata, created_at, updated_at
|
||||
)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,
|
||||
coalesce(nullif($8::text,'')::timestamptz, now()),
|
||||
coalesce(nullif($9::text,'')::timestamptz, now())
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,
|
||||
coalesce(nullif($9::text,'')::timestamptz, now()),
|
||||
coalesce(nullif($10::text,'')::timestamptz, now())
|
||||
)
|
||||
on conflict (tenant_id, legacy_id) do update set
|
||||
question_id = excluded.question_id,
|
||||
@@ -2354,6 +2587,7 @@ async function normalizeReports(records: JsonRecord[]) {
|
||||
type = excluded.type,
|
||||
description = excluded.description,
|
||||
status = excluded.status,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
[
|
||||
@@ -2361,9 +2595,14 @@ async function normalizeReports(records: JsonRecord[]) {
|
||||
r.id,
|
||||
await legacyId('questions', r.questionId),
|
||||
await userIdByLegacy(r.userId),
|
||||
text(r.type),
|
||||
normalizeReportType(r.type),
|
||||
text(r.description),
|
||||
text(r.status) || 'pending',
|
||||
normalizeReportStatus(r.status),
|
||||
JSON.stringify({
|
||||
source: 'pocketbase.reports',
|
||||
legacyType: text(r.type),
|
||||
legacyStatus: text(r.status),
|
||||
}),
|
||||
dateText(r.created),
|
||||
dateText(r.updated),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user