feat: add platform usage worker

This commit is contained in:
Codex
2026-06-30 20:00:14 +08:00
parent 686b3609cf
commit ff2b0a83ee
16 changed files with 1002 additions and 17 deletions

View File

@@ -15,11 +15,11 @@
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、跟进效果统计、学习督导自动化预览/生成、督导规则模板、学生批量 CRM 推送、审计日志。 - 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、跟进效果统计、学习督导自动化预览/生成、督导规则模板、学生批量 CRM 推送、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口租户默认关闭、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口租户默认关闭、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。
- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。 - 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、平台用量自动采集 worker、用量记录、公共题库授权。
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目同步会保护租户自改题目返回冲突而不覆盖后台可查询冲突明细worker 失败会生成租户 `public_question_bank_sync_failed` 通知,恢复成功自动关闭失败通知,平台可用 `/api/platform-admin/question-bank-sync-status``platform:question_bank:ops` 查看跨租户同步运营摘要。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目同步会保护租户自改题目返回冲突而不覆盖后台可查询冲突明细worker 失败会生成租户 `public_question_bank_sync_failed` 通知,恢复成功自动关闭失败通知,平台可用 `/api/platform-admin/question-bank-sync-status``platform:question_bank:ops` 查看跨租户同步运营摘要。
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets``daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets``daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略、客资队列和学生批量 CRM 跟进推送。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略、客资队列和学生批量 CRM 跟进推送。
- `apps/worker` 后台任务进程CRM webhook 队列消费、`lead.created` 客资事件、`student.crm_push` 学生跟进事件、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志student-supervision worker 可按租户督导规则模板定时生成学习跟进任务commerce worker 可补偿查询微信/支付宝支付和退款状态provider-bills worker 可下载微信/支付宝官方账单并导入资金对账platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计platform-dunning-notifications worker 可把内部催缴记录按平台渠道推送到 generic/钉钉/飞书/企微 webhook并记录幂等发送事件platform-audit-alerts worker 可把高风险平台审计动作转换为内部告警并递归脱敏告警 detailsplatform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook并记录幂等发送事件assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源imports worker 可执行大批量导入public-banks worker 可自动同步公共题库采纳副本exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 - `apps/worker` 后台任务进程CRM webhook 队列消费、`lead.created` 客资事件、`student.crm_push` 学生跟进事件、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志student-supervision worker 可按租户督导规则模板定时生成学习跟进任务commerce worker 可补偿查询微信/支付宝支付和退款状态provider-bills worker 可下载微信/支付宝官方账单并导入资金对账platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计platform-usage worker 可按月从权威业务表自动采集学生数、活跃学生、题量、资源数、存储 GB、视频数、视频播放、视频次数消耗、已支付订单、GMV 和有效权益并写入用量台账和审计platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计platform-dunning-notifications worker 可把内部催缴记录按平台渠道推送到 generic/钉钉/飞书/企微 webhook并记录幂等发送事件platform-audit-alerts worker 可把高风险平台审计动作转换为内部告警并递归脱敏告警 detailsplatform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook并记录幂等发送事件assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源imports worker 可执行大批量导入public-banks worker 可自动同步公共题库采纳副本exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 - 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
- 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。 - 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。
@@ -63,7 +63,7 @@
```text ```text
apps/api/ Node.js 业务 API apps/api/ Node.js 业务 API
apps/taro/ Taro 4 React 跨端前端H5 三入口,后续扩展小程序 apps/taro/ Taro 4 React 跨端前端H5 三入口,后续扩展小程序
apps/worker/ 后台异步任务CRM webhook、支付/退款补偿、官方账单下载、平台审计告警/外部通知、资源复检、导入执行、公共题库同步、题库导出渲染等 apps/worker/ 后台异步任务CRM webhook、支付/退款补偿、官方账单下载、平台计费/用量/催缴、平台审计告警/外部通知、资源复检、导入执行、公共题库同步、题库导出渲染等
packages/config/ 共享配置 packages/config/ 共享配置
packages/db/ PostgreSQL 连接池和查询封装 packages/db/ PostgreSQL 连接池和查询封装
packages/domain/ 领域常量和共享类型 packages/domain/ 领域常量和共享类型
@@ -350,6 +350,7 @@ npm run test:api
npm run test:worker:crm npm run test:worker:crm
npm run test:worker:commerce npm run test:worker:commerce
npm run test:worker:platform-billing npm run test:worker:platform-billing
npm run test:worker:platform-usage
npm run test:worker:platform-dunning npm run test:worker:platform-dunning
npm run test:worker:platform-dunning-notifications npm run test:worker:platform-dunning-notifications
npm run test:worker:platform-audit-alerts npm run test:worker:platform-audit-alerts
@@ -594,7 +595,7 @@ npm run audit:runtime
git diff --check git diff --check
``` ```
结果:通过。最近一轮真实迁移专项验证已通过 `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 passedproduction 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 vulnerabilitiesExcel 解析已从 `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 passedproduction 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-usage` 覆盖平台 SaaS 月度用量自动采集、11 类指标、手工调整记录不覆盖和重复运行幂等。`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 vulnerabilitiesExcel 解析已从 `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 官方修复。 注意:`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 官方修复。
@@ -606,6 +607,6 @@ git diff --check
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。 2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
4. 题库导出模板精排、导出操作台、导入字段映射 UI 和复检结果操作台;继续对真实迁移数据做题目、订单、权益、错题、资料和视频抽样验收。 4. 题库导出模板精排、导出操作台、导入字段映射 UI 和复检结果操作台;继续对真实迁移数据做题目、订单、权益、错题、资料和视频抽样验收。
5. 上云后接真实 OAuth/短信/支付生产账号、回调域名和真实生产账单抽样验收;本地阶段继续用 mock/fake provider 验证回调后业务链路、幂等、审计、密钥不泄露和权益开通/撤销。后续还要补真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化。排行榜不是默认主线功能,仅在租户显式购买/开启活动并完成压测后,才进入防刷、日/周榜预聚合和运营看板开发。 5. 上云后接真实 OAuth/短信/支付生产账号、回调域名和真实生产账单抽样验收;本地阶段继续用 mock/fake provider 验证回调后业务链路、幂等、审计、密钥不泄露和权益开通/撤销。后续还要补真实打款 provider、发票、公共题库版本通知/冲突处理操作台、平台套餐额度/超额账单、积分活动风控和连续签到奖励深化。排行榜不是默认主线功能,仅在租户显式购买/开启活动并完成压测后,才进入防刷、日/周榜预聚合和运营看板开发。
旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro H5/小程序补体验时只作为页面状态、微信平台能力和交互参考,不继承旧 PocketBase 直连和旧鉴权逻辑。 旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro H5/小程序补体验时只作为页面状态、微信平台能力和交互参考,不继承旧 PocketBase 直连和旧鉴权逻辑。

View File

@@ -884,11 +884,23 @@ export async function platformOverviewRoute(ctx: RequestContext) {
), ),
queryOne<{ students: string; questions: string; storageGb: string }>( queryOne<{ students: string; questions: string; storageGb: string }>(
` `
with latest_usage as (
select tenant_id, metric_key, metric_value,
row_number() over (
partition by tenant_id, metric_key
order by case when metadata->>'source' = 'platform_usage_worker' then 0 else 1 end,
period_end desc,
created_at desc
) as rn
from public.tenant_usage_records
where period_end >= current_date - interval '31 days'
and metric_key in ('students', 'questions', 'storage_gb')
)
select coalesce(sum(metric_value) filter (where metric_key = 'students'), 0)::text as students, select coalesce(sum(metric_value) filter (where metric_key = 'students'), 0)::text as students,
coalesce(sum(metric_value) filter (where metric_key = 'questions'), 0)::text as questions, coalesce(sum(metric_value) filter (where metric_key = 'questions'), 0)::text as questions,
coalesce(sum(metric_value) filter (where metric_key = 'storage_gb'), 0)::text as "storageGb" coalesce(sum(metric_value) filter (where metric_key = 'storage_gb'), 0)::text as "storageGb"
from public.tenant_usage_records from latest_usage
where period_end >= current_date - interval '31 days' where rn = 1
`, `,
), ),
]); ]);

View File

@@ -12,6 +12,7 @@
"commerce:once": "tsx src/index.ts --once --job commerce", "commerce:once": "tsx src/index.ts --once --job commerce",
"provider-bills:once": "tsx src/index.ts --once --job provider-bills", "provider-bills:once": "tsx src/index.ts --once --job provider-bills",
"platform-billing:once": "tsx src/index.ts --once --job platform-billing", "platform-billing:once": "tsx src/index.ts --once --job platform-billing",
"platform-usage:once": "tsx src/index.ts --once --job platform-usage",
"platform-dunning:once": "tsx src/index.ts --once --job platform-dunning", "platform-dunning:once": "tsx src/index.ts --once --job platform-dunning",
"platform-dunning-notifications:once": "tsx src/index.ts --once --job platform-dunning-notifications", "platform-dunning-notifications:once": "tsx src/index.ts --once --job platform-dunning-notifications",
"platform-audit-alerts:once": "tsx src/index.ts --once --job platform-audit-alerts", "platform-audit-alerts:once": "tsx src/index.ts --once --job platform-audit-alerts",

View File

@@ -22,6 +22,9 @@ export interface WorkerConfig {
platformBillingDaysAhead: number; platformBillingDaysAhead: number;
platformBillingDueDays: number; platformBillingDueDays: number;
platformBillingWorkerId: string; platformBillingWorkerId: string;
platformUsageBatchSize: number;
platformUsageWorkerId: string;
platformUsageMonth: string;
platformDunningBatchSize: number; platformDunningBatchSize: number;
platformDunningWorkerId: string; platformDunningWorkerId: string;
platformDunningNotificationBatchSize: number; platformDunningNotificationBatchSize: number;
@@ -198,6 +201,9 @@ const loadedConfig: WorkerConfig = {
platformBillingDaysAhead: envNumber('WORKER_PLATFORM_BILLING_DAYS_AHEAD', 45), platformBillingDaysAhead: envNumber('WORKER_PLATFORM_BILLING_DAYS_AHEAD', 45),
platformBillingDueDays: envNumber('WORKER_PLATFORM_BILLING_DUE_DAYS', 15), platformBillingDueDays: envNumber('WORKER_PLATFORM_BILLING_DUE_DAYS', 15),
platformBillingWorkerId: envString('WORKER_PLATFORM_BILLING_ID', `platform-billing-${process.pid}`), platformBillingWorkerId: envString('WORKER_PLATFORM_BILLING_ID', `platform-billing-${process.pid}`),
platformUsageBatchSize: envNumber('WORKER_PLATFORM_USAGE_BATCH_SIZE', 100),
platformUsageWorkerId: envString('WORKER_PLATFORM_USAGE_ID', `platform-usage-${process.pid}`),
platformUsageMonth: envString('WORKER_PLATFORM_USAGE_MONTH', ''),
platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100), platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100),
platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`), platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`),
platformDunningNotificationBatchSize: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE', 50), platformDunningNotificationBatchSize: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE', 50),

View File

@@ -51,6 +51,16 @@ async function runOnce() {
); );
return; return;
} }
if (job === 'platform-usage') {
const { processPlatformUsageBatch } = await import('./jobs/platform-usage.js');
const result = await processPlatformUsageBatch();
console.log(
`[worker] platform-usage batch processed=${result.processed}`
+ ` metrics=${result.metrics} created=${result.created}`
+ ` updated=${result.updated} failed=${result.failed} skipped=${result.skipped}`,
);
return;
}
if (job === 'platform-dunning') { if (job === 'platform-dunning') {
const { processPlatformDunningBatch } = await import('./jobs/platform-dunning.js'); const { processPlatformDunningBatch } = await import('./jobs/platform-dunning.js');
const result = await processPlatformDunningBatch(); const result = await processPlatformDunningBatch();

View File

@@ -0,0 +1,509 @@
import type pg from 'pg';
import { pool } from '../db.js';
import { config } from '../config.js';
interface UsageTenant {
tenantId: string;
tenantSlug: string;
tenantName: string;
}
interface UsageMetric {
metricKey: string;
metricValue: string;
metadata: Record<string, unknown>;
}
export interface PlatformUsageWorkerResult {
processed: number;
metrics: number;
created: number;
updated: number;
failed: number;
skipped: number;
}
const MONTH_RE = /^\d{4}-\d{2}$/;
function positiveInteger(value: number, fallback: number, max: number) {
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.min(Math.trunc(value), max);
}
function shanghaiMonth(value = new Date()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
}).format(value);
}
function monthPeriod(monthText: string) {
const normalized = monthText.trim();
if (!MONTH_RE.test(normalized)) {
throw new Error(`Invalid platform usage month: ${monthText}. Expected YYYY-MM.`);
}
const [yearText, monthNumberText] = normalized.split('-');
const year = Number(yearText);
const monthNumber = Number(monthNumberText);
const periodStart = `${yearText}-${monthNumberText}-01`;
const periodEndDate = new Date(Date.UTC(year, monthNumber, 0));
const periodEnd = periodEndDate.toISOString().slice(0, 10);
return { periodStart, periodEnd };
}
function nowIso() {
return new Date().toISOString();
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function errorCode(error: unknown) {
return typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code || 'PLATFORM_USAGE_WORKER_ERROR')
: 'PLATFORM_USAGE_WORKER_ERROR';
}
function truncate(value: unknown, max = 1900) {
return String(value ?? '').slice(0, max);
}
async function loadTenants(limit: number, offset: number) {
const result = await pool.query<UsageTenant>(
`
select id as "tenantId", slug::text as "tenantSlug", name as "tenantName"
from public.tenants
where status = 'active'
and mode in ('saas', 'dedicated', 'platform_owned')
order by created_at asc
limit $1
offset $2
`,
[limit, offset],
);
return result.rows;
}
async function loadUsageMetrics(
client: pg.PoolClient,
tenant: UsageTenant,
periodStart: string,
periodEnd: string,
): Promise<UsageMetric[]> {
const result = await client.query<{
studentCount: string;
activeStudents: string;
publishedQuestions: string;
draftQuestions: string;
activeAssets: string;
storageBytes: string;
verifiedStorageBytes: string;
videoCount: string;
videoPlays: string;
videoQuotaConsumed: string;
paidOrders: string;
paidOrderAmountCents: string;
activeEntitlements: string;
}>(
`
select
(
select count(distinct tm.user_id)::text
from public.tenant_memberships tm
where tm.tenant_id = $1
and tm.role = 'student'
and tm.status = 'active'
) as "studentCount",
(
select count(distinct ar.user_id)::text
from public.answer_records ar
where ar.tenant_id = $1
and ar.answered_at >= $2::date
and ar.answered_at < ($3::date + interval '1 day')
) as "activeStudents",
(
select count(*)::text
from public.questions q
where q.tenant_id = $1
and q.status = 'published'
) as "publishedQuestions",
(
select count(*)::text
from public.questions q
where q.tenant_id = $1
and q.status = 'draft'
) as "draftQuestions",
(
select count(*)::text
from public.content_assets a
where a.tenant_id = $1
and a.status <> 'archived'
) as "activeAssets",
(
select coalesce(sum(coalesce(a.verified_size_bytes, a.file_size_bytes, 0)), 0)::text
from public.content_assets a
where a.tenant_id = $1
and a.status <> 'archived'
and (
a.upload_status in ('verified', 'not_required')
or a.storage_provider = 'external_url'
)
and a.security_scan_status in ('not_required', 'passed')
) as "storageBytes",
(
select coalesce(sum(coalesce(a.verified_size_bytes, 0)), 0)::text
from public.content_assets a
where a.tenant_id = $1
and a.status <> 'archived'
and a.upload_status = 'verified'
and a.security_scan_status in ('not_required', 'passed')
) as "verifiedStorageBytes",
(
select count(*)::text
from public.video_explanations v
where v.tenant_id = $1
and v.is_active = true
) as "videoCount",
(
select count(*)::text
from public.video_play_events e
where e.tenant_id = $1
and e.created_at >= $2::date
and e.created_at < ($3::date + interval '1 day')
) as "videoPlays",
(
select coalesce(sum(e.consumed_quota), 0)::text
from public.video_play_events e
where e.tenant_id = $1
and e.created_at >= $2::date
and e.created_at < ($3::date + interval '1 day')
) as "videoQuotaConsumed",
(
select count(*)::text
from public.orders o
where o.tenant_id = $1
and o.status = 'paid'
and coalesce(o.paid_at, o.updated_at, o.created_at) >= $2::date
and coalesce(o.paid_at, o.updated_at, o.created_at) < ($3::date + interval '1 day')
) as "paidOrders",
(
select coalesce(sum(o.amount_cents), 0)::text
from public.orders o
where o.tenant_id = $1
and o.status = 'paid'
and coalesce(o.paid_at, o.updated_at, o.created_at) >= $2::date
and coalesce(o.paid_at, o.updated_at, o.created_at) < ($3::date + interval '1 day')
) as "paidOrderAmountCents",
(
select count(*)::text
from public.entitlements e
where e.tenant_id = $1
and e.status = 'active'
and e.starts_at < ($3::date + interval '1 day')
and (e.expires_at is null or e.expires_at >= $2::date)
) as "activeEntitlements"
`,
[tenant.tenantId, periodStart, periodEnd],
);
const row = result.rows[0];
const storageBytes = Number(row?.storageBytes || 0);
const verifiedStorageBytes = Number(row?.verifiedStorageBytes || 0);
const baseMetadata = {
source: 'platform_usage_worker',
tenantSlug: tenant.tenantSlug,
tenantName: tenant.tenantName,
periodStart,
periodEnd,
collectedAt: nowIso(),
workerId: config.platformUsageWorkerId,
};
return [
{
metricKey: 'students',
metricValue: row?.studentCount || '0',
metadata: {
...baseMetadata,
unit: 'count',
scope: 'snapshot',
query: 'tenant_memberships.role=student,status=active,count_distinct_user',
},
},
{
metricKey: 'active_students',
metricValue: row?.activeStudents || '0',
metadata: {
...baseMetadata,
unit: 'count',
scope: 'period',
query: 'answer_records.answered_at,count_distinct_user',
},
},
{
metricKey: 'questions',
metricValue: row?.publishedQuestions || '0',
metadata: {
...baseMetadata,
unit: 'count',
scope: 'snapshot',
status: 'published',
draftQuestions: Number(row?.draftQuestions || 0),
},
},
{
metricKey: 'assets',
metricValue: row?.activeAssets || '0',
metadata: {
...baseMetadata,
unit: 'count',
scope: 'snapshot',
query: 'content_assets.status<>archived',
},
},
{
metricKey: 'storage_gb',
metricValue: (storageBytes / 1024 / 1024 / 1024).toFixed(6),
metadata: {
...baseMetadata,
unit: 'gb',
scope: 'snapshot',
bytes: storageBytes,
verifiedBytes: verifiedStorageBytes,
query: 'content_assets verified/not_required and security passed/not_required',
},
},
{
metricKey: 'videos',
metricValue: row?.videoCount || '0',
metadata: {
...baseMetadata,
unit: 'count',
scope: 'snapshot',
query: 'video_explanations.is_active=true',
},
},
{
metricKey: 'video_plays',
metricValue: row?.videoPlays || '0',
metadata: {
...baseMetadata,
unit: 'count',
scope: 'period',
query: 'video_play_events.created_at',
},
},
{
metricKey: 'video_quota_consumed',
metricValue: row?.videoQuotaConsumed || '0',
metadata: {
...baseMetadata,
unit: 'count',
scope: 'period',
query: 'sum(video_play_events.consumed_quota)',
},
},
{
metricKey: 'paid_orders',
metricValue: row?.paidOrders || '0',
metadata: {
...baseMetadata,
unit: 'count',
scope: 'period',
query: 'orders.status=paid',
},
},
{
metricKey: 'paid_order_amount_cents',
metricValue: row?.paidOrderAmountCents || '0',
metadata: {
...baseMetadata,
unit: 'cents',
currency: 'CNY',
scope: 'period',
query: 'sum(orders.amount_cents where status=paid)',
},
},
{
metricKey: 'active_entitlements',
metricValue: row?.activeEntitlements || '0',
metadata: {
...baseMetadata,
unit: 'count',
scope: 'period_intersection',
query: 'entitlements.status=active and overlaps period',
},
},
];
}
async function upsertUsageMetric(
client: pg.PoolClient,
tenantId: string,
metric: UsageMetric,
periodStart: string,
periodEnd: string,
) {
const existing = await client.query<{ id: string }>(
`
select id
from public.tenant_usage_records
where tenant_id = $1
and metric_key = $2
and period_start = $3::date
and period_end = $4::date
and metadata->>'source' = 'platform_usage_worker'
limit 1
for update
`,
[tenantId, metric.metricKey, periodStart, periodEnd],
);
if (existing.rows[0]) {
await client.query(
`
update public.tenant_usage_records
set metric_value = $5::numeric,
metadata = $6::jsonb
where id = $1
and tenant_id = $2
and metric_key = $3
and period_start = $4::date
and period_end = $7::date
and metadata->>'source' = 'platform_usage_worker'
`,
[
existing.rows[0].id,
tenantId,
metric.metricKey,
periodStart,
metric.metricValue,
JSON.stringify(metric.metadata),
periodEnd,
],
);
return 'updated' as const;
}
await client.query(
`
insert into public.tenant_usage_records (
tenant_id, metric_key, metric_value, period_start, period_end, metadata
)
values ($1, $2, $3::numeric, $4::date, $5::date, $6::jsonb)
`,
[
tenantId,
metric.metricKey,
metric.metricValue,
periodStart,
periodEnd,
JSON.stringify(metric.metadata),
],
);
return 'created' as const;
}
async function collectTenantUsage(
tenant: UsageTenant,
periodStart: string,
periodEnd: string,
) {
const client = await pool.connect();
try {
await client.query('begin');
await client.query('select pg_advisory_xact_lock(hashtext($1))', [
`platform_usage_worker:${tenant.tenantId}:${periodStart}:${periodEnd}`,
]);
const metrics = await loadUsageMetrics(client, tenant, periodStart, periodEnd);
let created = 0;
let updated = 0;
for (const metric of metrics) {
const status = await upsertUsageMetric(client, tenant.tenantId, metric, periodStart, periodEnd);
if (status === 'created') created += 1;
else updated += 1;
}
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, null, 'platform.usage.worker_collected', 'tenant_usage_records', $2, $3::jsonb)
`,
[
tenant.tenantId,
`${periodStart}:${periodEnd}`,
JSON.stringify({
periodStart,
periodEnd,
metricKeys: metrics.map(metric => metric.metricKey),
created,
updated,
workerId: config.platformUsageWorkerId,
}),
],
);
await client.query('commit');
return { status: 'ok' as const, metrics: metrics.length, created, updated };
} catch (error) {
await client.query('rollback').catch(() => {});
await pool.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, null, 'platform.usage.worker_failed', 'tenant_usage_records', $2, $3::jsonb)
`,
[
tenant.tenantId,
`${periodStart}:${periodEnd}`,
JSON.stringify({
periodStart,
periodEnd,
code: errorCode(error),
message: truncate(errorMessage(error)),
workerId: config.platformUsageWorkerId,
}),
],
).catch(() => {});
return { status: 'failed' as const, metrics: 0, created: 0, updated: 0 };
} finally {
client.release();
}
}
export async function processPlatformUsageBatch(options: {
limit?: number;
month?: string;
} = {}): Promise<PlatformUsageWorkerResult> {
const pageSize = positiveInteger(options.limit ?? config.platformUsageBatchSize, 100, 1000);
const { periodStart, periodEnd } = monthPeriod(options.month || config.platformUsageMonth || shanghaiMonth());
const result: PlatformUsageWorkerResult = {
processed: 0,
metrics: 0,
created: 0,
updated: 0,
failed: 0,
skipped: 0,
};
for (let offset = 0; ; offset += pageSize) {
const tenants = await loadTenants(pageSize, offset);
if (tenants.length === 0) break;
result.processed += tenants.length;
for (const tenant of tenants) {
const tenantResult = await collectTenantUsage(tenant, periodStart, periodEnd);
if (tenantResult.status === 'failed') {
result.failed += 1;
continue;
}
result.metrics += tenantResult.metrics;
result.created += tenantResult.created;
result.updated += tenantResult.updated;
}
}
return result;
}

View File

@@ -42,6 +42,7 @@
| 手机号绑定/换绑 | 可联调 | `/api/auth/phone/bind` 使用 `bind_phone` 短信验证码,后端校验当前登录态、手机号唯一性、移除旧手机号 identity并撤销其它迁移期 session | | 手机号绑定/换绑 | 可联调 | `/api/auth/phone/bind` 使用 `bind_phone` 短信验证码,后端校验当前登录态、手机号唯一性、移除旧手机号 identity并撤销其它迁移期 session |
| 微信网页/QQ OAuth | 可联调 | `/api/auth/oauth/wechat` 已完成微信网页登录 code 换 token、userinfo、unionid 合并、session 签发和审计;`/api/auth/oauth/qq` 已完成 code/token/openid/userinfo 主链路;生产前需真实开放平台账号和回调域名联调 | | 微信网页/QQ OAuth | 可联调 | `/api/auth/oauth/wechat` 已完成微信网页登录 code 换 token、userinfo、unionid 合并、session 签发和审计;`/api/auth/oauth/qq` 已完成 code/token/openid/userinfo 主链路;生产前需真实开放平台账号和回调域名联调 |
| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT平台账号以后端 `platform_users.primary_role='platform_admin'``status='active'``platform_permissions` 为准;`GET /api/platform-admin/permissions` 返回权限目录和当前账号 `effective` 能力,平台路由按 `platform:staff:*``platform:tenant:*``platform:billing:*``platform:audit:*``platform:question_bank:*` 等权限点强制校验;`GET/PUT/PATCH /api/platform-admin/staff` 可管理平台员工,禁用员工会拒绝后续 JWT 映射并撤销迁移期 session`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 | | 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT平台账号以后端 `platform_users.primary_role='platform_admin'``status='active'``platform_permissions` 为准;`GET /api/platform-admin/permissions` 返回权限目录和当前账号 `effective` 能力,平台路由按 `platform:staff:*``platform:tenant:*``platform:billing:*``platform:audit:*``platform:question_bank:*` 等权限点强制校验;`GET/PUT/PATCH /api/platform-admin/staff` 可管理平台员工,禁用员工会拒绝后续 JWT 映射并撤销迁移期 session`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 |
| 平台 SaaS 用量采集 | 可联调 | `GET/POST /api/platform-admin/usage` 保留平台用量台账和手工调整能力;`apps/worker --job platform-usage` 会按月自动从权威业务表采集学生数、活跃学生、题量、资源数、存储 GB、视频数、视频播放、视频次数消耗、已支付订单、GMV 和有效权益,写入 `tenant_usage_records.metadata.source=platform_usage_worker` 和审计日志;手工调整记录不被覆盖,平台概览优先取每租户每指标最新 worker 快照 |
| 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 | | 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 |
| 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`支持权限、菜单、模块、字段、数据范围配置Taro 租户设置页已接创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置第一版 | | 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`支持权限、菜单、模块、字段、数据范围配置Taro 租户设置页已接创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置第一版 |
| 班级/教师/学生范围权限 | 可联调 | `tenant_classes``tenant_class_members` + `/api/tenant-admin/classes``classes/members``students``teachers`;教师默认只看自己负责班级,字段权限可脱敏学生手机号 | | 班级/教师/学生范围权限 | 可联调 | `tenant_classes``tenant_class_members` + `/api/tenant-admin/classes``classes/members``students``teachers`;教师默认只看自己负责班级,字段权限可脱敏学生手机号 |
@@ -146,7 +147,7 @@
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes``classes/members``students``teachers`,支持班级范围权限和审计 | | 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes``classes/members``students``teachers`,支持班级范围权限和审计 |
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups``students/followups/report``students/supervision/preview``students/supervision/generate``students/supervision/rules``students/crm-push`;支持逐行结果、限量、防跨租户和教师范围校验;跟进报表支持 7/30/90 天或自定义日期范围、状态/类型/优先级/负责人/班级聚合、逾期待办、CRM 推送队列摘要和每日趋势;学习督导由后端读取答题、错题、单词待复习和未完成练习数据,按阈值预览候选并幂等生成 `learning` 跟进任务,也可保存手动/每日/每周规则交给 worker 定时生成,要求 `students:supervision:read/write`;批量 CRM 推送会为学生生成跟进任务并写入异步 CRM 队列,要求 `crm:write``students:read``students:followups:write`;学生导入/upsert/分班禁止 `avatarUrl/avatar_url/avatar/headimgurl/figureurl``primaryRole/primary_role`,避免绕过预设头像与租户角色体系 | | 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups``students/followups/report``students/supervision/preview``students/supervision/generate``students/supervision/rules``students/crm-push`;支持逐行结果、限量、防跨租户和教师范围校验;跟进报表支持 7/30/90 天或自定义日期范围、状态/类型/优先级/负责人/班级聚合、逾期待办、CRM 推送队列摘要和每日趋势;学习督导由后端读取答题、错题、单词待复习和未完成练习数据,按阈值预览候选并幂等生成 `learning` 跟进任务,也可保存手动/每日/每周规则交给 worker 定时生成,要求 `students:supervision:read/write`;批量 CRM 推送会为学生生成跟进任务并写入异步 CRM 队列,要求 `crm:write``students:read``students:followups:write`;学生导入/upsert/分班禁止 `avatarUrl/avatar_url/avatar/headimgurl/figureurl``primaryRole/primary_role`,避免绕过预设头像与租户角色体系 |
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 | | 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量平台 API 已拆分 `platform:staff:read/write/status``platform:tenant:read/write/status/billing_profile``platform:billing:read/write/payment/dunning/notification``platform:audit:read/export/alert/notification``platform:question_bank:read/grant/ops` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook发送成功会推进提醒状态失败会退避重试联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 | | 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量台账和平台用量自动采集 worker;平台 API 已拆分 `platform:staff:read/write/status``platform:tenant:read/write/status/billing_profile``platform:billing:read/write/payment/dunning/notification``platform:usage:read/write``platform:audit:read/export/alert/notification``platform:question_bank:read/grant/ops` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-usage` 会生成月度 SaaS 用量快照并保留手工调整记录;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook发送成功会推进提醒状态失败会退避重试联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、用量采集、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 |
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 | | 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks``question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库,并可限制授权地区和科目。平台保存 grant 时会校验 `allowedRegionIds``allowedSubjectIds` 属于源平台题库租户,且已发布题目的科目必须被授权科目覆盖 | | 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks``question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库,并可限制授权地区和科目。平台保存 grant 时会校验 `allowedRegionIds``allowedSubjectIds` 属于源平台题库租户,且已发布题目的科目必须被授权科目覆盖 |
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks``public-question-banks/adopt``public-question-banks/sync``public-question-banks/conflicts``public-question-banks/conflicts/resolve``public-question-banks/conflicts/resolve-batch``tenant-content/notifications``/api/platform-admin/question-bank-sync-status`;租户只能看到自己 `question_bank_grants`、有效 `tenant_subscriptions``platform_saas_plans.feature_flags.publicQuestionBanks` 和订阅 `metadata.publicQuestionBankAccess` 同时允许的题库。基础版默认 `limited_regions` 且需要地区 allowlist专业版默认 `national`;采纳、同步和冲突处理都会重新校验当前授权,越权 grant 返回 `QUESTION_BANK_GRANT_NOT_AVAILABLE`。采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新、冲突和 worker 失败会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会写入逐条审计,冲突全部处理后相关通知自动 resolved失败通知会在后续同步恢复成功后自动 resolved平台运营接口需 `platform:question_bank:ops`,只返回跨租户同步摘要和通知数量,不返回题目正文/答案/解析 | | 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks``public-question-banks/adopt``public-question-banks/sync``public-question-banks/conflicts``public-question-banks/conflicts/resolve``public-question-banks/conflicts/resolve-batch``tenant-content/notifications``/api/platform-admin/question-bank-sync-status`;租户只能看到自己 `question_bank_grants`、有效 `tenant_subscriptions``platform_saas_plans.feature_flags.publicQuestionBanks` 和订阅 `metadata.publicQuestionBankAccess` 同时允许的题库。基础版默认 `limited_regions` 且需要地区 allowlist专业版默认 `national`;采纳、同步和冲突处理都会重新校验当前授权,越权 grant 返回 `QUESTION_BANK_GRANT_NOT_AVAILABLE`。采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新、冲突和 worker 失败会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会写入逐条审计,冲突全部处理后相关通知自动 resolved失败通知会在后续同步恢复成功后自动 resolved平台运营接口需 `platform:question_bank:ops`,只返回跨租户同步摘要和通知数量,不返回题目正文/答案/解析 |

View File

@@ -8,7 +8,7 @@
新项目已经不是简单的 PocketBase 字段平移,而是按多租户 SaaS 重新建立了后端边界: 新项目已经不是简单的 PocketBase 字段平移,而是按多租户 SaaS 重新建立了后端边界:
- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴和用量 - 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴、用量台账和月度用量自动采集
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。 - 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API学生头像不支持上传或第三方头像落库学生写入口会拒绝头像 URL签到、积分阈值、反馈解决和积分活动可触发自动勋章发放反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知;租户后台已具备反馈运营聚合报表和积分风控只读报表。 - 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API学生头像不支持上传或第三方头像落库学生写入口会拒绝头像 URL签到、积分阈值、反馈解决和积分活动可触发自动勋章发放反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知;租户后台已具备反馈运营聚合报表和积分风控只读报表。
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。 - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。
@@ -21,7 +21,7 @@
| 模块 | 当前状态 | 已经具备 | 上线前还要补 | | 模块 | 当前状态 | 已经具备 | 上线前还要补 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 | | 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 |
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台员工列表/创建/编辑/启停、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量、公共题库授权、公共题库地区/科目授权校验、SaaS 套餐/订阅 metadata 公共题库访问边界、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、平台审计告警升级策略和更完整运营消息 | | 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台员工列表/创建/编辑/启停、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量台账、platform-usage 月度自动采集 worker、公共题库授权、公共题库地区/科目授权校验、SaaS 套餐/订阅 metadata 公共题库访问边界、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、SaaS 套餐额度/超额账单、平台审计告警升级策略和更完整运营消息 |
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、积分风控只读报表、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限、学生运营跟进、学习督导规则模板和 `student-supervision` workerTaro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注、跟进任务、学习督导预览/生成和保存每日规则第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台、积分风控摘要和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、学习督导触达联动/效果归因、真实打款/导出/凭证和完整权限菜单 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、积分风控只读报表、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限、学生运营跟进、学习督导规则模板和 `student-supervision` workerTaro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注、跟进任务、学习督导预览/生成和保存每日规则第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台、积分风控摘要和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、学习督导触达联动/效果归因、真实打款/导出/凭证和完整权限菜单 |
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜接口(租户默认关闭)、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、starter 单地区/专业版全国公共题库访问边界、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台;排行榜仅在租户显式开启活动后再补压测、防刷和预聚合 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜接口(租户默认关闭)、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、starter 单地区/专业版全国公共题库访问边界、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台;排行榜仅在租户显式开启活动后再补压测、防刷和预聚合 |
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜接口(租户默认关闭) | 更细复习参数 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜接口(租户默认关闭) | 更细复习参数 |
@@ -86,7 +86,7 @@
- 资金对账已支持手工/API 账单导入比对、微信/支付宝官方账单下载任务、异常查询和差错工单处理;继续补真实生产账单格式验收、财务复核报表和异常订单运营台。 - 资金对账已支持手工/API 账单导入比对、微信/支付宝官方账单下载任务、异常查询和差错工单处理;继续补真实生产账单格式验收、财务复核报表和异常订单运营台。
- XPay 或其它实际支付网关 adapter。 - XPay 或其它实际支付网关 adapter。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。 - 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;公共题库可见、采纳、同步、冲突处理会统一校验 SaaS 套餐、有效订阅、订阅 metadata、grant 地区和科目范围。继续补生产定时调度、失败告警,以及存储、学生数、题量、视频播放量等更多套餐用量限制和超额计费策略 - 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;公共题库可见、采纳、同步、冲突处理会统一校验 SaaS 套餐、有效订阅、订阅 metadata、grant 地区和科目范围。平台用量采集已覆盖存储、学生数、题量和视频播放等基础指标继续补生产定时调度、失败告警、SaaS 套餐额度判定和超额计费账单生成
- 导入模板、字段映射、导入任务详情和复检 API 已可用Taro 租户内容页已接模板下载、字段别名覆盖、导入执行、异步 job 轮询和复检结果面板第一版。前端继续补真实导入目标选择体验和大数据量导入验收。 - 导入模板、字段映射、导入任务详情和复检 API 已可用Taro 租户内容页已接模板下载、字段别名覆盖、导入执行、异步 job 轮询和复检结果面板第一版。前端继续补真实导入目标选择体验和大数据量导入验收。
- 视频深度防盗链、转码级水印和播放统计。 - 视频深度防盗链、转码级水印和播放统计。
- 数据看板 API收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。 - 数据看板 API收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。

View File

@@ -15,7 +15,7 @@
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 | | 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、公共题库披露策略、公共题库地区/科目授权和基础版单地区/专业版全国访问边界 | 平台侧主题模板库、平台在线收款、平台审计报表增强、套餐存储/学生数/题量超额策略 | | 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、月度用量自动采集 worker、公共题库披露策略、公共题库地区/科目授权和基础版单地区/专业版全国访问边界 | 平台侧主题模板库、平台在线收款、平台审计报表增强、套餐存储/学生数/题量/视频播放额度判定和超额账单 |
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 | | 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 |
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询 API、按 SaaS 套餐和订阅 metadata 控制公共题库地区/科目/题库范围 | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 | | 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询 API、按 SaaS 套餐和订阅 metadata 控制公共题库地区/科目/题库范围 | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |

View File

@@ -31,7 +31,7 @@
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。 - `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、跟进看板、学习督导候选预览/生成、督导规则保存和 CRM 批量推送第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表、积分任务/兑换、积分风控只读摘要和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。 - 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、跟进看板、学习督导候选预览/生成、督导规则保存和 CRM 批量推送第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表、积分任务/兑换、积分风控只读摘要和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权、平台员工;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,平台员工页已接员工列表、搜索、创建/编辑、权限点勾选、禁用和恢复,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV并可查看开放审计告警、确认或解决告警也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成催缴记录查看。 - 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权、平台员工;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,平台员工页已接员工列表、搜索、创建/编辑、权限点勾选、禁用和恢复,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV并可查看开放审计告警、确认或解决告警也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成催缴记录查看和用量台账查看。平台用量由 `platform-usage` worker 从后端权威表采集,前端不要自行统计答题记录、资源大小、订单或视频播放后计算服务费
- 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习/学习概览、商城收银台、订单详情和售后入口已经有第一版页面。 - 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习/学习概览、商城收银台、订单详情和售后入口已经有第一版页面。
- 可以按新后端主模型接入内容导航: - 可以按新后端主模型接入内容导航:
- `content_entries` - `content_entries`
@@ -111,7 +111,7 @@
| --- | --- | --- | | --- | --- | --- |
| 工作台 | `apps/taro/src/pages/platform-admin/workbench/index.tsx` | `platform-admin/permissions``platform-admin/overview``tenants``invoices``question-banks``question-bank-grants``audit-logs``audit-logs/export``audit-alerts``audit-alerts/status``audit-notification-channels/events``dunning-notification-channels/events` | | 工作台 | `apps/taro/src/pages/platform-admin/workbench/index.tsx` | `platform-admin/permissions``platform-admin/overview``tenants``invoices``question-banks``question-bank-grants``audit-logs``audit-logs/export``audit-alerts``audit-alerts/status``audit-notification-channels/events``dunning-notification-channels/events` |
| 租户管理 | `apps/taro/src/pages/platform-admin/tenants/index.tsx` | `platform-admin/tenants``POST tenants``tenants/detail``PATCH tenants/status``PUT tenants/billing-profile``audit-logs` | | 租户管理 | `apps/taro/src/pages/platform-admin/tenants/index.tsx` | `platform-admin/tenants``POST tenants``tenants/detail``PATCH tenants/status``PUT tenants/billing-profile``audit-logs` |
| 账务中心 | `apps/taro/src/pages/platform-admin/billing/index.tsx` | `platform-admin/plans``invoices``invoices/subscription-candidates``invoices/from-subscription``invoices/from-subscriptions-batch``invoices/payments/manual-confirm``usage``subscriptions``POST usage` | | 账务中心 | `apps/taro/src/pages/platform-admin/billing/index.tsx` | `platform-admin/plans``invoices``invoices/subscription-candidates``invoices/from-subscription``invoices/from-subscriptions-batch``invoices/payments/manual-confirm``usage``subscriptions``POST usage``GET usage` 会展示 `platform-usage` worker 生成的月度快照和手工调整记录,`POST usage` 仅用于人工补录/调整 |
| 公共题库 | `apps/taro/src/pages/platform-admin/question-banks/index.tsx` | `platform-admin/question-banks``question-bank-grants``PUT question-bank-grants` | | 公共题库 | `apps/taro/src/pages/platform-admin/question-banks/index.tsx` | `platform-admin/question-banks``question-bank-grants``PUT question-bank-grants` |
| 平台员工 | `apps/taro/src/pages/platform-admin/staff/index.tsx` | `platform-admin/permissions``platform-admin/staff``PUT platform-admin/staff``PATCH platform-admin/staff/status` | | 平台员工 | `apps/taro/src/pages/platform-admin/staff/index.tsx` | `platform-admin/permissions``platform-admin/staff``PUT platform-admin/staff``PATCH platform-admin/staff/status` |

View File

@@ -37,7 +37,7 @@
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分任务领取、积分兑换、优惠券兑换履约、积分风控只读报表和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换、积分风控摘要和用户通知查看第一版;营销自动化、外部订阅消息/短信和更完整活动效果看板继续补 | | 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分任务领取、积分兑换、优惠券兑换履约、积分风控只读报表和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换、积分风控摘要和用户通知查看第一版;营销自动化、外部订阅消息/短信和更完整活动效果看板继续补 |
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件、平台权限字段和平台员工状态字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台员工列表/创建/编辑/启停、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台员工创建/权限目录/JWT 访问/越权拒绝/自降级拒绝/禁用后 JWT 拒绝/审计脱敏、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露Taro 类型检查覆盖平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款和更完整平台审计报表待补 | | 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件、平台权限字段和平台员工状态字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台员工列表/创建/编辑/启停、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量台账、平台用量自动采集 worker、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台员工创建/权限目录/JWT 访问/越权拒绝/自降级拒绝/禁用后 JWT 拒绝/审计脱敏、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-usage` 覆盖月度用量自动采集、手工调整不覆盖和幂等,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露Taro 类型检查覆盖平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款、套餐额度/超额账单和更完整平台审计报表待补 |
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 | | 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 |
| 数据导入 | 已建立 importer、risk report、dry-run report、validate | 已覆盖多类旧集合 | 命令行 dry-run/导入/校验 | `pb:import:dry-run``pb:import:validate``test:pb:dry-run` 覆盖 strict warning 和关系断裂门禁 | 基础工具和真实迁移 runbook 可用,需拿真实完整数据执行多轮 dry-run、导入回归和抽样验收 | | 数据导入 | 已建立 importer、risk report、dry-run report、validate | 已覆盖多类旧集合 | 命令行 dry-run/导入/校验 | `pb:import:dry-run``pb:import:validate``test:pb:dry-run` 覆盖 strict warning 和关系断裂门禁 | 基础工具和真实迁移 runbook 可用,需拿真实完整数据执行多轮 dry-run、导入回归和抽样验收 |
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 | | 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |

View File

@@ -76,7 +76,7 @@
| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护、最近平台审计查询/导出、开放审计告警查询/确认/解决、审计告警外部通知渠道/事件、订阅账单候选预览、dry-run、批量生成、自动计费 worker、逾期标记、内部催缴台账和催缴外部通知已接真实 API/worker后续补租户基础资料编辑增强和审计告警升级策略 | | 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护、最近平台审计查询/导出、开放审计告警查询/确认/解决、审计告警外部通知渠道/事件、订阅账单候选预览、dry-run、批量生成、自动计费 worker、逾期标记、内部催缴台账和催缴外部通知已接真实 API/worker后续补租户基础资料编辑增强和审计告警升级策略 |
| SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 | | SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 |
| 年费/服务费账单 | 已覆盖 | 订阅账单候选、批量开票、自动计费、人工收款、逾期标记、租户 `past_due` 状态、内部催缴记录和平台催缴外部 webhook 通知已覆盖;真实平台在线收款、外部短信/微信订阅消息和停用策略待补 | | 年费/服务费账单 | 已覆盖 | 订阅账单候选、批量开票、自动计费、人工收款、逾期标记、租户 `past_due` 状态、内部催缴记录和平台催缴外部 webhook 通知已覆盖;真实平台在线收款、外部短信/微信订阅消息和停用策略待补 |
| 租户用量记录 | 已覆盖 | 自动采集 worker 待补 | | 租户用量记录 | 已覆盖 | 已有平台用量手工记录和 `platform-usage` worker 月度自动采集worker 会从权威业务表汇总学生数、活跃学生、题量、资源数、存储 GB、视频数、视频播放、视频次数消耗、已支付订单、GMV 和有效权益,手工调整记录保留为审计台账不被覆盖。后续补套餐额度判定和超额账单自动生成 |
| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、同步通知、冲突查询 API、单条/批量冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺生产定时调度、失败告警和更完整运营消息 | | 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、同步通知、冲突查询 API、单条/批量冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺生产定时调度、失败告警和更完整运营消息 |
| 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 | | 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 |
| 租户安全审计 | 部分覆盖 | 租户侧 audit logs 和平台侧 `/api/platform-admin/audit-logs` 第一版已有,平台侧 `/api/platform-admin/audit-logs/export` 可按租户、动作、目标、操作者、日期和关键词导出 CSV/JSON导出会递归脱敏敏感字段并写审计平台审计告警规则、开放告警查询、确认/解决/忽略和 `platform-audit-alerts` worker 第一版已覆盖,告警 details 会递归脱敏;`platform-audit-notifications` worker 已支持 generic/钉钉/飞书/企微 webhook 外部通知、发送事件台账、重试和请求 payload 脱敏;平台后台工作台/租户详情页可查看最近审计,工作台可导出 CSV、处理开放告警并查看通知渠道/事件摘要;缺更完整筛选 UI、升级策略和运营报表 | | 租户安全审计 | 部分覆盖 | 租户侧 audit logs 和平台侧 `/api/platform-admin/audit-logs` 第一版已有,平台侧 `/api/platform-admin/audit-logs/export` 可按租户、动作、目标、操作者、日期和关键词导出 CSV/JSON导出会递归脱敏敏感字段并写审计平台审计告警规则、开放告警查询、确认/解决/忽略和 `platform-audit-alerts` worker 第一版已覆盖,告警 details 会递归脱敏;`platform-audit-notifications` worker 已支持 generic/钉钉/飞书/企微 webhook 外部通知、发送事件台账、重试和请求 payload 脱敏;平台后台工作台/租户详情页可查看最近审计,工作台可导出 CSV、处理开放告警并查看通知渠道/事件摘要;缺更完整筛选 UI、升级策略和运营报表 |

View File

@@ -33,6 +33,7 @@
- 资金对账和差错工单闭环已完成:`commerce_reconciliation_batches/items``/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志;`commerce_reconciliation_issues/events` 支持异常明细创建工单、分配、开始处理、升级、解决、忽略、重开和事件留痕,且不直接修改订单/支付/退款/权益。 - 资金对账和差错工单闭环已完成:`commerce_reconciliation_batches/items``/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志;`commerce_reconciliation_issues/events` 支持异常明细创建工单、分配、开始处理、升级、解决、忽略、重开和事件留痕,且不直接修改订单/支付/退款/权益。
- 微信/支付宝官方账单下载地基已完成:`commerce_bill_download_jobs``POST /api/commerce/reconciliation/provider-bills/request``GET /api/commerce/reconciliation/provider-bills/jobs``apps/worker --job provider-bills` 已接入worker 负责后端签名申请下载 URL、hash 校验、JSON/CSV/ZIP 账单解析、复用 `provider_download` 对账导入、任务状态回写和密钥脱敏。 - 微信/支付宝官方账单下载地基已完成:`commerce_bill_download_jobs``POST /api/commerce/reconciliation/provider-bills/request``GET /api/commerce/reconciliation/provider-bills/jobs``apps/worker --job provider-bills` 已接入worker 负责后端签名申请下载 URL、hash 校验、JSON/CSV/ZIP 账单解析、复用 `provider_download` 对账导入、任务状态回写和密钥脱敏。
- 平台 SaaS 自动计费 worker 已完成:`apps/worker --job platform-billing` 会按 `WORKER_PLATFORM_BILLING_DAYS_AHEAD` 查找即将到期且未开票的订阅,生成 `tenant_invoices/tenant_invoice_items`,使用订阅行锁和账单查重防重复,写入 `platform.invoice.subscription_auto_created` 审计;`npm run test:worker:platform-billing` 覆盖自动开票、明细、审计和二次运行幂等。 - 平台 SaaS 自动计费 worker 已完成:`apps/worker --job platform-billing` 会按 `WORKER_PLATFORM_BILLING_DAYS_AHEAD` 查找即将到期且未开票的订阅,生成 `tenant_invoices/tenant_invoice_items`,使用订阅行锁和账单查重防重复,写入 `platform.invoice.subscription_auto_created` 审计;`npm run test:worker:platform-billing` 覆盖自动开票、明细、审计和二次运行幂等。
- 平台 SaaS 用量自动采集 worker 已完成:`apps/worker --job platform-usage` 默认按上海时区当前月采集,也可用 `WORKER_PLATFORM_USAGE_MONTH=YYYY-MM` 补跑指定月份;当前会生成 `students``active_students``questions``assets``storage_gb``videos``video_plays``video_quota_consumed``paid_orders``paid_order_amount_cents``active_entitlements` 11 类指标,写入 `tenant_usage_records``platform.usage.worker_collected` 审计;手工调整用量不被 worker 覆盖,`npm run test:worker:platform-usage` 覆盖幂等和指标口径。
- 平台 SaaS 逾期催缴 worker 已完成:`apps/worker --job platform-dunning` 会扫描已过 `due_date` 且未结清的服务费账单,标记 `tenant_invoices.status=overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写审计;`POST /api/platform-admin/invoices/process-overdue` 支持平台后台 dry-run/执行,`GET /api/platform-admin/invoices/reminders` 支持查看催缴台账;`npm run test:worker:platform-dunning` 覆盖逾期标记、催缴幂等和审计。 - 平台 SaaS 逾期催缴 worker 已完成:`apps/worker --job platform-dunning` 会扫描已过 `due_date` 且未结清的服务费账单,标记 `tenant_invoices.status=overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写审计;`POST /api/platform-admin/invoices/process-overdue` 支持平台后台 dry-run/执行,`GET /api/platform-admin/invoices/reminders` 支持查看催缴台账;`npm run test:worker:platform-dunning` 覆盖逾期标记、催缴幂等和审计。
- 平台 SaaS 催缴外部通知第一版已完成:`platform_dunning_notification_channels/events``GET/PUT /api/platform-admin/dunning-notification-channels``GET /api/platform-admin/dunning-notification-events``apps/worker --job platform-dunning-notifications` 已接入;支持 generic/钉钉/飞书/企微 webhook、按催缴类型/渠道/级别/租户筛选、发送重试、幂等、防重复、联系方式掩码、payload 脱敏和生产 readiness 阻断 localhost/不安全 webhook。 - 平台 SaaS 催缴外部通知第一版已完成:`platform_dunning_notification_channels/events``GET/PUT /api/platform-admin/dunning-notification-channels``GET /api/platform-admin/dunning-notification-events``apps/worker --job platform-dunning-notifications` 已接入;支持 generic/钉钉/飞书/企微 webhook、按催缴类型/渠道/级别/租户筛选、发送重试、幂等、防重复、联系方式掩码、payload 脱敏和生产 readiness 阻断 localhost/不安全 webhook。
- 平台审计告警 worker 已完成:`apps/worker --job platform-audit-alerts` 会扫描 `platform.%` 审计日志,根据 `platform_audit_alert_rules` 把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台操作生成内部告警API 已支持 `/api/platform-admin/audit-alert-rules``/api/platform-admin/audit-alerts``/api/platform-admin/audit-alerts/status`Taro 平台工作台可查看开放告警并确认/解决;`npm run test:worker:platform-audit-alerts` 覆盖规则匹配、幂等和敏感 details 脱敏。 - 平台审计告警 worker 已完成:`apps/worker --job platform-audit-alerts` 会扫描 `platform.%` 审计日志,根据 `platform_audit_alert_rules` 把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台操作生成内部告警API 已支持 `/api/platform-admin/audit-alert-rules``/api/platform-admin/audit-alerts``/api/platform-admin/audit-alerts/status`Taro 平台工作台可查看开放告警并确认/解决;`npm run test:worker:platform-audit-alerts` 覆盖规则匹配、幂等和敏感 details 脱敏。
@@ -124,7 +125,7 @@
- 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。 - 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。
- 已完成 `public-banks` worker 自动同步、失败记录、审计、同步通知、冲突查询 API 和单条/批量冲突处理 API。 - 已完成 `public-banks` worker 自动同步、失败记录、审计、同步通知、冲突查询 API 和单条/批量冲突处理 API。
- 已完成基础 SaaS 套餐访问边界:`starter_yearly` 默认 `limited_regions` + 地区 allowlist`pro_yearly` 默认 `national`;租户订阅 metadata 可进一步限制 allowedRegionIds、allowedSubjectIds、allowedQuestionBankIds。 - 已完成基础 SaaS 套餐访问边界:`starter_yearly` 默认 `limited_regions` + 地区 allowlist`pro_yearly` 默认 `national`;租户订阅 metadata 可进一步限制 allowedRegionIds、allowedSubjectIds、allowedQuestionBankIds。
- 继续补存储、学生数、题量、视频播放量等更多套餐用量限制和超额计费策略 - 存储、学生数、题量、视频播放量等月度用量已由 `platform-usage` worker 自动采集;继续补 SaaS 套餐额度判定、超额价格计算和 `usage_overage` 账单自动生成
- 继续补生产定时调度、失败告警和更完整运营后台消息。 - 继续补生产定时调度、失败告警和更完整运营后台消息。
6. 视频会员控制 6. 视频会员控制

View File

@@ -42,6 +42,7 @@
"test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js", "test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js",
"test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js", "test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js",
"test:worker:platform-billing": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-billing-worker-integration-test.js", "test:worker:platform-billing": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-billing-worker-integration-test.js",
"test:worker:platform-usage": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-usage-worker-integration-test.js",
"test:worker:platform-dunning": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-dunning-worker-integration-test.js", "test:worker:platform-dunning": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-dunning-worker-integration-test.js",
"test:worker:platform-dunning-notifications": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-dunning-notification-worker-integration-test.js", "test:worker:platform-dunning-notifications": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-dunning-notification-worker-integration-test.js",
"test:worker:platform-audit-alerts": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-audit-alert-worker-integration-test.js", "test:worker:platform-audit-alerts": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-audit-alert-worker-integration-test.js",

View File

@@ -0,0 +1,437 @@
import assert from 'node:assert/strict';
import pg from 'pg';
import { spawn } from 'node:child_process';
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const ids = {
tenant: '00000000-0000-0000-0000-00000000a899',
authPlatformAdminUser: '00000000-0000-0000-0000-00000000a900',
platformAdminUser: '00000000-0000-0000-0000-00000000a901',
studentOne: '00000000-0000-0000-0000-00000000a902',
studentTwo: '00000000-0000-0000-0000-00000000a903',
disabledStudent: '00000000-0000-0000-0000-00000000a904',
adminUser: '00000000-0000-0000-0000-00000000a905',
questionOne: '00000000-0000-0000-0000-00000000a906',
questionTwo: '00000000-0000-0000-0000-00000000a907',
questionThree: '00000000-0000-0000-0000-00000000a908',
questionDraft: '00000000-0000-0000-0000-00000000a909',
assetOne: '00000000-0000-0000-0000-00000000a910',
assetTwo: '00000000-0000-0000-0000-00000000a911',
assetArchived: '00000000-0000-0000-0000-00000000a912',
assetUnsafe: '00000000-0000-0000-0000-00000000a913',
videoOne: '00000000-0000-0000-0000-00000000a914',
videoTwo: '00000000-0000-0000-0000-00000000a915',
videoInactive: '00000000-0000-0000-0000-00000000a916',
orderOne: '00000000-0000-0000-0000-00000000a917',
orderTwo: '00000000-0000-0000-0000-00000000a918',
orderPending: '00000000-0000-0000-0000-00000000a919',
orderOutside: '00000000-0000-0000-0000-00000000a920',
entitlementOne: '00000000-0000-0000-0000-00000000a921',
entitlementTwo: '00000000-0000-0000-0000-00000000a922',
entitlementExpired: '00000000-0000-0000-0000-00000000a923',
entitlementOutside: '00000000-0000-0000-0000-00000000a924',
answerOne: '00000000-0000-0000-0000-00000000a925',
answerTwo: '00000000-0000-0000-0000-00000000a926',
answerOutside: '00000000-0000-0000-0000-00000000a927',
};
function runWorkerOnce() {
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-usage'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_PLATFORM_USAGE_BATCH_SIZE: '1',
WORKER_PLATFORM_USAGE_MONTH: '2026-06',
WORKER_PLATFORM_USAGE_ID: 'platform-usage-integration-test',
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
child.stdout.on('data', chunk => {
output += chunk.toString();
});
child.stderr.on('data', chunk => {
output += chunk.toString();
});
return new Promise((resolve, reject) => {
child.on('error', reject);
child.on('exit', code => {
try {
assert.equal(code, 0, `worker should exit 0\n${output}`);
assert.match(output, /platform-usage batch processed=\d+/, 'worker output should include platform usage summary');
resolve(output);
} catch (error) {
reject(error);
}
});
});
}
async function cleanup(pool) {
await pool.query('delete from public.tenants where id = $1', [ids.tenant]);
await pool.query(
`
delete from public.platform_users
where id = any($1::uuid[])
`,
[[ids.platformAdminUser, ids.studentOne, ids.studentTwo, ids.disabledStudent, ids.adminUser]],
);
await pool.query('delete from auth.users where id = $1', [ids.authPlatformAdminUser]).catch(() => {});
}
async function createAuthUser(pool) {
await pool.query(
`
insert into auth.users (
id, aud, role, phone, phone_confirmed_at, raw_app_meta_data, raw_user_meta_data, created_at, updated_at
)
values (
$1, 'authenticated', 'authenticated', '13900000900', now(),
'{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb,
'{}'::jsonb, now(), now()
)
on conflict (id)
do update set phone = excluded.phone, updated_at = now()
`,
[ids.authPlatformAdminUser],
).catch(() => {});
await pool.query(
`
insert into public.platform_users (
id, auth_user_id, username, phone, name, primary_role, status, platform_permissions, raw_profile
)
values (
$1, $2, 'usage_platform_admin', '13900000900', '用量平台管理员',
'platform_admin', 'active', '{"*":true}'::jsonb, '{"source":"platform-usage-worker-test"}'::jsonb
)
`,
[ids.platformAdminUser, ids.authPlatformAdminUser],
);
}
async function createFixture(pool) {
await createAuthUser(pool);
await pool.query(
`
insert into public.tenants (
id, slug, name, legal_name, status, mode, billing_status, metadata, created_at, updated_at
)
values (
$1, 'platform-usage-worker', '平台用量采集测试租户', '平台用量采集测试有限公司',
'active', 'saas', 'active', '{"source":"platform-usage-worker-test"}'::jsonb,
'2000-01-01 00:00:00+00', now()
)
`,
[ids.tenant],
);
await pool.query(
`
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
values
($1, 'usage_student_1', '13900000901', '用量学生一', 'student', '{"source":"platform-usage-worker-test"}'::jsonb),
($2, 'usage_student_2', '13900000902', '用量学生二', 'student', '{"source":"platform-usage-worker-test"}'::jsonb),
($3, 'usage_student_disabled', '13900000903', '禁用学生', 'student', '{"source":"platform-usage-worker-test"}'::jsonb),
($4, 'usage_admin', '13900000904', '用量管理员', 'tenant_admin', '{"source":"platform-usage-worker-test"}'::jsonb)
`,
[ids.studentOne, ids.studentTwo, ids.disabledStudent, ids.adminUser],
);
await pool.query(
`
insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions)
values
($1, $2, 'student', 'active', '{}'::jsonb),
($1, $3, 'student', 'active', '{}'::jsonb),
($1, $4, 'student', 'disabled', '{}'::jsonb),
($1, $5, 'tenant_admin', 'active', '{"platform:usage:read":false}'::jsonb)
`,
[ids.tenant, ids.studentOne, ids.studentTwo, ids.disabledStudent, ids.adminUser],
);
await pool.query(
`
insert into public.student_profiles (tenant_id, user_id, stats, progress)
values
($1, $2, '{}'::jsonb, '{}'::jsonb),
($1, $3, '{}'::jsonb, '{}'::jsonb)
`,
[ids.tenant, ids.studentOne, ids.studentTwo],
);
await pool.query(
`
insert into public.questions (id, tenant_id, legacy_id, type, status)
values
($1, $5, 'usage-q-1', 'choice', 'published'),
($2, $5, 'usage-q-2', 'choice', 'published'),
($3, $5, 'usage-q-3', 'choice', 'published'),
($4, $5, 'usage-q-draft', 'choice', 'draft')
`,
[ids.questionOne, ids.questionTwo, ids.questionThree, ids.questionDraft, ids.tenant],
);
await pool.query(
`
insert into public.answer_records (
id, tenant_id, user_id, question_id, legacy_id, selected_options, is_correct, answered_at
)
values
($1, $4, $5, $7, 'usage-answer-1', '[0]'::jsonb, true, '2026-06-10 08:00:00+00'),
($2, $4, $6, $8, 'usage-answer-2', '[1]'::jsonb, false, '2026-06-15 08:00:00+00'),
($3, $4, $5, $9, 'usage-answer-outside', '[0]'::jsonb, true, '2026-05-15 08:00:00+00')
`,
[
ids.answerOne,
ids.answerTwo,
ids.answerOutside,
ids.tenant,
ids.studentOne,
ids.studentTwo,
ids.questionOne,
ids.questionTwo,
ids.questionThree,
],
);
await pool.query(
`
insert into public.content_assets (
id, tenant_id, legacy_id, asset_key, title, asset_type, storage_provider,
bucket, object_key, file_name, mime_type, file_size_bytes,
visibility, status, upload_status, verified_size_bytes,
security_scan_status, security_scan_summary, source
)
values
($1, $5, 'usage-asset-1', 'usage/asset-1.pdf', '已验证资料', 'pdf', 'local_dev',
'tenant-assets', 'usage/asset-1.pdf', 'asset-1.pdf', 'application/pdf', 1073741824,
'tenant', 'active', 'verified', 1073741824, 'passed', '{"riskLevel":"none"}'::jsonb, 'test'),
($2, $5, 'usage-asset-2', 'usage/asset-2.pdf', '外链资料', 'pdf', 'external_url',
null, null, 'asset-2.pdf', 'application/pdf', 536870912,
'tenant', 'active', 'not_required', null, 'not_required', '{}'::jsonb, 'test'),
($3, $5, 'usage-asset-archived', 'usage/asset-archived.pdf', '归档资料', 'pdf', 'local_dev',
'tenant-assets', 'usage/asset-archived.pdf', 'asset-archived.pdf', 'application/pdf', 5368709120,
'tenant', 'archived', 'verified', 5368709120, 'passed', '{"riskLevel":"none"}'::jsonb, 'test'),
($4, $5, 'usage-asset-unsafe', 'usage/asset-unsafe.pdf', '未通过扫描资料', 'pdf', 'local_dev',
'tenant-assets', 'usage/asset-unsafe.pdf', 'asset-unsafe.pdf', 'application/pdf', 2147483648,
'tenant', 'active', 'verified', 2147483648, 'failed', '{"riskLevel":"high"}'::jsonb, 'test')
`,
[ids.assetOne, ids.assetTwo, ids.assetArchived, ids.assetUnsafe, ids.tenant],
);
await pool.query(
`
insert into public.video_explanations (
id, tenant_id, legacy_id, title, video_url, duration_seconds, is_active, access_mode, play_count
)
values
($1, $4, 'usage-video-1', '用量视频一', 'https://example.test/video-1.mp4', 120, true, 'svip', 0),
($2, $4, 'usage-video-2', '用量视频二', 'https://example.test/video-2.mp4', 180, true, 'video_quota', 0),
($3, $4, 'usage-video-inactive', '停用视频', 'https://example.test/video-3.mp4', 90, false, 'svip', 0)
`,
[ids.videoOne, ids.videoTwo, ids.videoInactive, ids.tenant],
);
await pool.query(
`
insert into public.video_play_events (
tenant_id, user_id, video_id, question_id, play_token_hash,
status, access_mode, consumed_quota, signed_url_expires_at, metadata, created_at, updated_at
)
values
($1, $2, $4, $6, 'usage-play-1', 'issued', 'svip', 0, '2026-06-10 09:00:00+00', '{}'::jsonb, '2026-06-10 08:00:00+00', '2026-06-10 08:00:00+00'),
($1, $3, $5, $7, 'usage-play-2', 'completed', 'video_quota', 1, '2026-06-12 09:00:00+00', '{}'::jsonb, '2026-06-12 08:00:00+00', '2026-06-12 08:00:00+00'),
($1, $2, $5, $7, 'usage-play-3', 'started', 'video_quota', 2, '2026-06-20 09:00:00+00', '{}'::jsonb, '2026-06-20 08:00:00+00', '2026-06-20 08:00:00+00'),
($1, $2, $4, $6, 'usage-play-outside', 'issued', 'svip', 1, '2026-05-10 09:00:00+00', '{}'::jsonb, '2026-05-10 08:00:00+00', '2026-05-10 08:00:00+00')
`,
[
ids.tenant,
ids.studentOne,
ids.studentTwo,
ids.videoOne,
ids.videoTwo,
ids.questionOne,
ids.questionTwo,
],
);
await pool.query(
`
insert into public.orders (
id, tenant_id, user_id, legacy_id, order_no, status, product_type,
product_name, amount_cents, pay_method, pay_provider, paid_at, created_at, updated_at
)
values
($1, $5, $6, 'usage-order-1', 'USAGE202606001', 'paid', 'svip', '用量套餐一', 1000, 'wechat', 'wechat_pay', '2026-06-05 08:00:00+00', '2026-06-05 07:00:00+00', '2026-06-05 08:00:00+00'),
($2, $5, $7, 'usage-order-2', 'USAGE202606002', 'paid', 'svip', '用量套餐二', 2500, 'alipay', 'alipay', '2026-06-18 08:00:00+00', '2026-06-18 07:00:00+00', '2026-06-18 08:00:00+00'),
($3, $5, $6, 'usage-order-pending', 'USAGE202606003', 'pending', 'svip', '待支付套餐', 9000, null, null, null, '2026-06-20 07:00:00+00', '2026-06-20 07:00:00+00'),
($4, $5, $6, 'usage-order-outside', 'USAGE202607001', 'paid', 'svip', '七月套餐', 7000, 'wechat', 'wechat_pay', '2026-07-01 08:00:00+00', '2026-07-01 07:00:00+00', '2026-07-01 08:00:00+00')
`,
[ids.orderOne, ids.orderTwo, ids.orderPending, ids.orderOutside, ids.tenant, ids.studentOne, ids.studentTwo],
);
await pool.query(
`
insert into public.entitlements (
id, tenant_id, user_id, entitlement_type, scope_type,
source_type, source_id, starts_at, expires_at, status, metadata
)
values
($1, $5, $6, 'svip', 'tenant', 'order', $8, '2026-06-01 00:00:00+00', null, 'active', '{}'::jsonb),
($2, $5, $7, 'svip', 'tenant', 'order', $9, '2026-05-01 00:00:00+00', '2026-06-10 00:00:00+00', 'active', '{}'::jsonb),
($3, $5, $6, 'svip', 'tenant', 'order', $8, '2026-05-01 00:00:00+00', '2026-06-10 00:00:00+00', 'expired', '{}'::jsonb),
($4, $5, $6, 'svip', 'tenant', 'order', $10, '2026-07-01 00:00:00+00', null, 'active', '{}'::jsonb)
`,
[
ids.entitlementOne,
ids.entitlementTwo,
ids.entitlementExpired,
ids.entitlementOutside,
ids.tenant,
ids.studentOne,
ids.studentTwo,
ids.orderOne,
ids.orderTwo,
ids.orderOutside,
],
);
await pool.query(
`
insert into public.tenant_usage_records (
tenant_id, metric_key, metric_value, period_start, period_end, metadata
)
values ($1, 'students', 999, '2026-06-01', '2026-06-30', '{"source":"manual_adjustment","note":"must not be overwritten"}'::jsonb)
`,
[ids.tenant],
);
}
async function usageMap(pool) {
const records = await pool.query(
`
select metric_key as "metricKey", metric_value as "metricValue", metadata
from public.tenant_usage_records
where tenant_id = $1
and period_start = '2026-06-01'
and period_end = '2026-06-30'
and metadata->>'source' = 'platform_usage_worker'
order by metric_key asc
`,
[ids.tenant],
);
return new Map(records.rows.map(row => [row.metricKey, row]));
}
function assertMetric(metrics, key, expected) {
assert.ok(metrics.has(key), `metric ${key} should exist`);
assert.equal(Number(metrics.get(key).metricValue), expected, `metric ${key} should match`);
}
async function main() {
const pool = new pg.Pool({ connectionString: databaseUrl });
try {
await cleanup(pool);
await createFixture(pool);
const firstOutput = await runWorkerOnce();
assert.match(firstOutput, /processed=\d+/, 'worker should process active tenants');
assert.match(firstOutput, /created=\d+/, 'first worker run should create usage metrics');
assert.match(firstOutput, /failed=0/, 'first worker run should not fail');
const firstMetrics = await usageMap(pool);
assert.equal(firstMetrics.size, 11, 'worker should create one record per supported metric');
assertMetric(firstMetrics, 'students', 2);
assertMetric(firstMetrics, 'active_students', 2);
assertMetric(firstMetrics, 'questions', 3);
assertMetric(firstMetrics, 'assets', 3);
assertMetric(firstMetrics, 'videos', 2);
assertMetric(firstMetrics, 'video_plays', 3);
assertMetric(firstMetrics, 'video_quota_consumed', 3);
assertMetric(firstMetrics, 'paid_orders', 2);
assertMetric(firstMetrics, 'paid_order_amount_cents', 3500);
assertMetric(firstMetrics, 'active_entitlements', 2);
assert.ok(Math.abs(Number(firstMetrics.get('storage_gb').metricValue) - 1.5) < 0.000001, 'storage_gb should use verified safe asset size');
assert.equal(firstMetrics.get('storage_gb').metadata?.bytes, 1610612736, 'storage metric should keep bytes in metadata');
assert.equal(firstMetrics.get('students').metadata?.workerId, 'platform-usage-integration-test', 'metadata should record worker id');
const manualRecords = await pool.query(
`
select metric_value as "metricValue", metadata
from public.tenant_usage_records
where tenant_id = $1
and metric_key = 'students'
and metadata->>'source' = 'manual_adjustment'
`,
[ids.tenant],
);
assert.equal(manualRecords.rowCount, 1, 'manual usage record should remain append-only');
assert.equal(Number(manualRecords.rows[0]?.metricValue), 999, 'manual usage value should not be overwritten by worker');
const secondOutput = await runWorkerOnce();
assert.match(secondOutput, /created=0/, 'second worker run should not duplicate usage metrics');
assert.match(secondOutput, /updated=\d+/, 'second worker run should update existing usage metrics');
const workerRecordCount = await pool.query(
`
select count(*)::integer as count
from public.tenant_usage_records
where tenant_id = $1
and period_start = '2026-06-01'
and period_end = '2026-06-30'
and metadata->>'source' = 'platform_usage_worker'
`,
[ids.tenant],
);
assert.equal(workerRecordCount.rows[0]?.count, 11, 'worker should keep exactly one record per metric after rerun');
const detailUsageResult = await pool.query(
`
select metric_key as "metricKey", metric_value as "metricValue", metadata
from public.tenant_usage_records
where tenant_id = $1
and metric_key = 'students'
and period_start = '2026-06-01'
and period_end = '2026-06-30'
order by created_at desc
`,
[ids.tenant],
);
const detailUsage = detailUsageResult.rows;
assert.ok(
detailUsage.some(item => item.metricKey === 'students' && item.metadata?.source === 'platform_usage_worker' && Number(item.metricValue) === 2),
'usage records should include generated worker usage',
);
assert.ok(
detailUsage.some(item => item.metricKey === 'students' && item.metadata?.source === 'manual_adjustment' && Number(item.metricValue) === 999),
'usage records should keep manual usage adjustment records for audit',
);
const audit = await pool.query(
`
select action, details
from public.audit_logs
where tenant_id = $1
and action = 'platform.usage.worker_collected'
order by created_at desc
limit 1
`,
[ids.tenant],
);
assert.equal(audit.rows[0]?.details?.metricKeys?.length, 11, 'worker should audit collected metric keys');
console.log('Platform usage worker integration test complete.');
} finally {
await cleanup(pool).catch(() => {});
await pool.end();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,6 @@
create unique index if not exists idx_tenant_usage_records_worker_unique
on public.tenant_usage_records(tenant_id, metric_key, period_start, period_end)
where metadata->>'source' = 'platform_usage_worker';
comment on index public.idx_tenant_usage_records_worker_unique is
'Ensures platform usage worker can upsert one authoritative record per tenant, metric and period while manual adjustments remain append-only.';