feat: add platform dunning notifications

This commit is contained in:
Codex
2026-06-30 07:28:32 +08:00
parent e66623158d
commit 51f8cbdec8
26 changed files with 1782 additions and 24 deletions

View File

@@ -74,6 +74,13 @@ WORKER_PLATFORM_BILLING_ID=platform-billing-1
# 逾期催缴只标记 overdue、写 tenant_invoice_reminders 和审计,不自动停用租户。
WORKER_PLATFORM_DUNNING_BATCH_SIZE=100
WORKER_PLATFORM_DUNNING_ID=platform-dunning-1
# 逾期催缴外部通知会消费 tenant_invoice_reminders并发送到 generic/钉钉/飞书/企微 webhook。
WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE=50
WORKER_PLATFORM_DUNNING_NOTIFICATION_MAX_ATTEMPTS=5
WORKER_PLATFORM_DUNNING_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800
WORKER_PLATFORM_DUNNING_NOTIFICATION_REQUEST_TIMEOUT_MS=10000
# 仅本地 fake webhook 测试允许 http://127.0.0.1;生产必须 false
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
# Worker 配置:平台审计告警生成和外部通知。
WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE=200

View File

@@ -15,11 +15,11 @@
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
- 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、用量记录、公共题库授权。
- 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 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 配置、跟进分配策略和队列。
- `apps/worker` 后台任务进程CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志commerce worker 可补偿查询微信/支付宝支付和退款状态provider-bills worker 可下载微信/支付宝官方账单并导入资金对账platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计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 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志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 图片素材包。
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
- 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。
@@ -35,7 +35,7 @@
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘H5 端已用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、用量和题库授权第一版写操作。
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘H5 端已用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
更完整的进度看这些文档:
@@ -216,6 +216,22 @@ WORKER_PLATFORM_DUNNING_BATCH_SIZE=100
WORKER_PLATFORM_DUNNING_ID=platform-dunning-prod-1
```
单次运行平台 SaaS 逾期催缴外部通知 worker
```bash
npm --workspace @tiku-saas/worker run platform-dunning-notifications:once
```
生产定时任务建议在 `platform-dunning` 之后每 5 到 15 分钟运行一次 `node dist/apps/worker/src/index.js --once --job platform-dunning-notifications`。它会把 `tenant_invoice_reminders` 中待发送或失败的内部催缴记录按 `platform_dunning_notification_channels` 配置入队到 `platform_dunning_notification_events`,支持 generic、钉钉、飞书和企业微信 webhook发送成功后会把对应催缴记录标记为 `sent`,发送失败会按退避策略重试并在终止失败时标记 `failed`。渠道密钥必须写入 `app_private.platform_secrets`API 只返回 `secretRef` 和 webhook host/path事件查询会递归脱敏 request payload。
```text
WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE=50
WORKER_PLATFORM_DUNNING_NOTIFICATION_MAX_ATTEMPTS=5
WORKER_PLATFORM_DUNNING_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800
WORKER_PLATFORM_DUNNING_NOTIFICATION_REQUEST_TIMEOUT_MS=10000
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
```
单次运行平台审计告警 worker
```bash
@@ -319,6 +335,7 @@ npm run test:worker:crm
npm run test:worker:commerce
npm run test:worker:platform-billing
npm run test:worker:platform-dunning
npm run test:worker:platform-dunning-notifications
npm run test:worker:platform-audit-alerts
npm run test:worker:platform-audit-notifications
npm run test:worker:assets
@@ -342,7 +359,7 @@ npm run readiness:production
npm run readiness:production:db
```
这个检查会阻断默认弱密钥、`CORS=*`、mock 短信、legacy 身份头、local_dev 存储、对象存储未配置、CRM insecure localhost 等生产风险;带 `:db` 的版本还会检查租户 provider 公开配置是否混入密钥、活跃短信/OAuth/支付 provider 是否缺少 `app_private.tenant_secrets`、域名是否未验证。
这个检查会阻断默认弱密钥、`CORS=*`、mock 短信、legacy 身份头、local_dev 存储、对象存储未配置、CRM insecure localhost、平台审计/催缴通知 localhost 等生产风险;带 `:db` 的版本还会检查租户 provider 公开配置是否混入密钥、活跃短信/OAuth/支付 provider 是否缺少 `app_private.tenant_secrets`平台通知 webhook 是否为生产 HTTPS、钉钉/飞书平台通知是否缺少 `app_private.platform_secrets`域名是否未验证。
Supabase Auth/JWKS 上云后需要用真实 access token 跑远程验收:
@@ -475,7 +492,9 @@ npm run check:worker
npm run test:worker:commerce
npm run test:worker:platform-billing
npm run test:worker:platform-dunning
npm run test:worker:platform-dunning-notifications
npm run test:worker:platform-audit-alerts
npm run test:worker:platform-audit-notifications
npm run test:worker:assets
npm run test:worker:exports
npm run test:auth:remote-smoke
@@ -486,7 +505,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-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:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed``npm run audit:runtime` 当前为 0 vulnerabilitiesExcel 解析已从 `exceljs` 切换为 `read-excel-file`,避免生产运行时携带 `exceljs -> uuid` 的已知中危依赖。
结果:通过。`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: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 官方修复。
@@ -495,7 +514,7 @@ git diff --check
优先继续补:
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台外部催缴通知、在线收款、审计报表增强、审计告警通知升级策略和更细平台权限点,小程序兼容验证。
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和更细平台权限点,小程序兼容验证。
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。

View File

@@ -13,6 +13,8 @@ import {
platformAuditNotificationEventsRoute,
platformAuditLogsExportRoute,
platformAuditLogsRoute,
platformDunningNotificationChannelsRoute,
platformDunningNotificationEventsRoute,
platformOverviewRoute,
platformPlansRoute,
platformQuestionBanksRoute,
@@ -27,6 +29,7 @@ import {
updatePlatformAuditAlertStatusRoute,
updateTenantStatusRoute,
upsertPlatformAuditNotificationChannelRoute,
upsertPlatformDunningNotificationChannelRoute,
upsertQuestionBankGrantRoute,
upsertBillingProfileRoute,
} from './routes.js';
@@ -50,6 +53,9 @@ export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/audit-notification-channels', platformAuditNotificationChannelsRoute],
['PUT', '/api/platform-admin/audit-notification-channels', upsertPlatformAuditNotificationChannelRoute],
['GET', '/api/platform-admin/audit-notification-events', platformAuditNotificationEventsRoute],
['GET', '/api/platform-admin/dunning-notification-channels', platformDunningNotificationChannelsRoute],
['PUT', '/api/platform-admin/dunning-notification-channels', upsertPlatformDunningNotificationChannelRoute],
['GET', '/api/platform-admin/dunning-notification-events', platformDunningNotificationEventsRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],

View File

@@ -55,6 +55,8 @@ const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved
const PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES = new Set(['pending', 'processing', 'sent', 'retrying', 'failed', 'discarded']);
const PLATFORM_AUDIT_NOTIFICATION_PROVIDERS = new Set(['generic', 'dingtalk', 'feishu', 'wecom']);
const PLATFORM_AUDIT_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
const PLATFORM_DUNNING_REMINDER_TYPES = new Set(['due_soon', 'overdue', 'final_notice', 'manual']);
const PLATFORM_DUNNING_REMINDER_CHANNELS = new Set(['manual', 'internal', 'sms', 'email', 'wechat', 'crm']);
function csvEscape(value: unknown) {
if (value === null || value === undefined) return '';
@@ -169,6 +171,36 @@ function platformAuditNotificationChannelCode(value: string) {
return code;
}
function platformDunningReminderTypes(value: unknown) {
const types = Array.isArray(value)
? value.map(item => String(item).trim()).filter(Boolean)
: ['overdue', 'final_notice'];
if (types.length === 0 || types.length > 4) {
throw new HttpError(400, 'reminderTypes is invalid', 'INVALID_REMINDER_TYPES');
}
for (const type of types) {
if (!PLATFORM_DUNNING_REMINDER_TYPES.has(type)) {
throw new HttpError(400, 'reminderTypes contains invalid type', 'INVALID_REMINDER_TYPES');
}
}
return [...new Set(types)];
}
function platformDunningReminderChannels(value: unknown) {
const channels = Array.isArray(value)
? value.map(item => String(item).trim()).filter(Boolean)
: ['internal'];
if (channels.length === 0 || channels.length > 6) {
throw new HttpError(400, 'reminderChannels is invalid', 'INVALID_REMINDER_CHANNELS');
}
for (const channel of channels) {
if (!PLATFORM_DUNNING_REMINDER_CHANNELS.has(channel)) {
throw new HttpError(400, 'reminderChannels contains invalid channel', 'INVALID_REMINDER_CHANNELS');
}
}
return [...new Set(channels)];
}
function platformSecretRef(scope: string, key: string) {
return `app_private.platform_secrets:${scope}:${key}`;
}
@@ -1296,6 +1328,217 @@ export async function platformAuditNotificationEventsRoute(ctx: RequestContext)
};
}
export async function platformDunningNotificationChannelsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const enabled = listQuery(ctx, 'enabled');
const provider = listQuery(ctx, 'provider');
const limit = intParam(ctx, 'limit', 100, 500);
if (enabled && !['true', 'false'].includes(enabled)) {
throw new HttpError(400, 'enabled must be true or false', 'INVALID_BOOLEAN');
}
if (provider && !PLATFORM_AUDIT_NOTIFICATION_PROVIDERS.has(provider)) {
throw new HttpError(400, 'provider is invalid', 'INVALID_NOTIFICATION_PROVIDER');
}
const items = await query<Record<string, unknown>>(
`
select id, channel_code as "channelCode", name, description, enabled,
provider, webhook_url as "webhookUrl", secret_ref as "secretRef",
reminder_types as "reminderTypes", reminder_channels as "reminderChannels",
min_reminder_level as "minReminderLevel", tenant_ids as "tenantIds",
timeout_sec as "timeoutSec", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.platform_dunning_notification_channels
where ($1::text = '' or enabled = ($1 = 'true'))
and ($2::text = '' or provider = $2)
order by enabled desc, min_reminder_level asc, channel_code asc
limit $3
`,
[enabled, provider, limit],
);
return { items: items.map(channelResponse) };
}
export async function upsertPlatformDunningNotificationChannelRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode'));
const name = requiredString(body, 'name');
const provider = platformAuditNotificationProviderFrom(optionalString(body, 'provider'));
const webhookUrl = validatePlatformWebhookUrl(requiredString(body, 'webhookUrl'));
const reminderTypes = platformDunningReminderTypes(body.reminderTypes);
const reminderChannels = platformDunningReminderChannels(body.reminderChannels);
const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds', 200);
const minReminderLevel = numberBetween(body.minReminderLevel, 1, 1, 20);
const timeoutSec = numberBetween(body.timeoutSec, 10, 1, 60);
const description = optionalString(body, 'description') || null;
const metadata = objectValue(body.metadata);
const secretKey = `platform_dunning_${channelCode}`;
let secretRef = optionalString(body, 'secretRef') || null;
const secret = typeof body.secret === 'string' && body.secret.trim() ? body.secret.trim() : '';
if (secret) secretRef = platformSecretRef('webhook', secretKey);
if (secretRef && !parsePlatformSecretRef(secretRef)) {
throw new HttpError(400, 'secretRef is invalid', 'INVALID_SECRET_REF');
}
const item = await transaction(async client => {
if (secret) {
await client.query(
`
insert into app_private.platform_secrets (
secret_scope, secret_key, secret_value, provider, last_rotated_at
)
values ('webhook', $1, $2, $3, now())
on conflict (secret_scope, secret_key)
do update set secret_value = excluded.secret_value,
provider = excluded.provider,
last_rotated_at = now(),
updated_at = now()
`,
[secretKey, secret, provider],
);
}
const result = await client.query<Record<string, unknown>>(
`
insert into public.platform_dunning_notification_channels (
channel_code, name, description, enabled, provider, webhook_url,
secret_ref, reminder_types, reminder_channels, min_reminder_level,
tenant_ids, timeout_sec, metadata
)
values (
$1, $2, $3, $4, $5, $6,
$7, $8::text[], $9::text[], $10,
$11::uuid[], $12, $13::jsonb
)
on conflict (channel_code)
do update set name = excluded.name,
description = excluded.description,
enabled = excluded.enabled,
provider = excluded.provider,
webhook_url = excluded.webhook_url,
secret_ref = excluded.secret_ref,
reminder_types = excluded.reminder_types,
reminder_channels = excluded.reminder_channels,
min_reminder_level = excluded.min_reminder_level,
tenant_ids = excluded.tenant_ids,
timeout_sec = excluded.timeout_sec,
metadata = excluded.metadata,
updated_at = now()
returning id, channel_code as "channelCode", name, description, enabled,
provider, webhook_url as "webhookUrl", secret_ref as "secretRef",
reminder_types as "reminderTypes", reminder_channels as "reminderChannels",
min_reminder_level as "minReminderLevel", tenant_ids as "tenantIds",
timeout_sec as "timeoutSec", metadata,
created_at as "createdAt", updated_at as "updatedAt"
`,
[
channelCode,
name,
description,
body.enabled !== false,
provider,
webhookUrl,
secretRef,
reminderTypes,
reminderChannels,
minReminderLevel,
tenantIds,
timeoutSec,
JSON.stringify(metadata),
],
);
const saved = result.rows[0];
await recordPlatformAudit(client, ctx, 'platform.invoice.dunning_notification_channel_upserted', 'platform_dunning_notification_channel', String(saved.id), {
channelCode,
provider,
enabled: body.enabled !== false,
reminderTypes,
reminderChannels,
minReminderLevel,
tenantIdCount: tenantIds.length,
secretRefSet: Boolean(secretRef),
secretRotated: Boolean(secret),
webhook: safeWebhookInfo(webhookUrl),
});
return saved;
});
return { item: channelResponse(item) };
}
export async function platformDunningNotificationEventsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const channelId = listQuery(ctx, 'channelId');
const reminderId = listQuery(ctx, 'reminderId');
const invoiceId = listQuery(ctx, 'invoiceId');
const tenantId = listQuery(ctx, 'tenantId');
const status = listQuery(ctx, 'status');
const provider = listQuery(ctx, 'provider');
const limit = intParam(ctx, 'limit', 100, 500);
if (channelId && !UUID_RE.test(channelId)) throw new HttpError(400, 'channelId is invalid', 'INVALID_UUID');
if (reminderId && !UUID_RE.test(reminderId)) throw new HttpError(400, 'reminderId is invalid', 'INVALID_UUID');
if (invoiceId && !UUID_RE.test(invoiceId)) throw new HttpError(400, 'invoiceId is invalid', 'INVALID_UUID');
if (tenantId && !UUID_RE.test(tenantId)) throw new HttpError(400, 'tenantId is invalid', 'INVALID_UUID');
if (status && !PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES.has(status)) {
throw new HttpError(400, 'status is invalid', 'INVALID_NOTIFICATION_EVENT_STATUS');
}
if (provider && !PLATFORM_AUDIT_NOTIFICATION_PROVIDERS.has(provider)) {
throw new HttpError(400, 'provider is invalid', 'INVALID_NOTIFICATION_PROVIDER');
}
const items = await query<Record<string, unknown>>(
`
select e.id, e.channel_id as "channelId", c.channel_code as "channelCode",
c.name as "channelName", e.reminder_id as "reminderId",
e.invoice_id as "invoiceId", i.invoice_no as "invoiceNo",
e.tenant_id as "tenantId", t.slug::text as "tenantSlug",
t.name as "tenantName", e.provider, e.status, e.attempts,
e.scheduled_at as "scheduledAt", e.next_attempt_at as "nextAttemptAt",
e.last_attempt_at as "lastAttemptAt", e.sent_at as "sentAt",
e.last_error as "lastError", e.last_http_code as "lastHttpCode",
e.last_response_summary as "lastResponseSummary",
e.request_payload as "requestPayload", e.metadata,
r.reminder_type as "reminderType", r.channel as "reminderChannel",
r.reminder_level as "reminderLevel", r.reminder_date as "reminderDate",
r.due_date as "dueDate", r.balance_cents_snapshot as "balanceCentsSnapshot",
r.message as "reminderMessage", i.status as "invoiceStatus",
i.balance_cents as "invoiceBalanceCents",
i.total_cents as "invoiceTotalCents",
e.created_at as "createdAt", e.updated_at as "updatedAt"
from public.platform_dunning_notification_events e
join public.platform_dunning_notification_channels c on c.id = e.channel_id
join public.tenant_invoice_reminders r on r.id = e.reminder_id
join public.tenant_invoices i on i.id = e.invoice_id
join public.tenants t on t.id = e.tenant_id
where ($1::uuid is null or e.channel_id = $1::uuid)
and ($2::uuid is null or e.reminder_id = $2::uuid)
and ($3::uuid is null or e.invoice_id = $3::uuid)
and ($4::uuid is null or e.tenant_id = $4::uuid)
and ($5::text = '' or e.status = $5)
and ($6::text = '' or e.provider = $6)
order by
case e.status when 'pending' then 1 when 'retrying' then 2 when 'processing' then 3 when 'failed' then 4 else 5 end,
e.created_at desc
limit $7
`,
[channelId || null, reminderId || null, invoiceId || null, tenantId || null, status, provider, limit],
);
return {
items: items.map(item => ({
...item,
requestPayload: redactAuditAlertValue(item.requestPayload),
lastError: truncate(item.lastError),
lastResponseSummary: truncate(item.lastResponseSummary),
})),
};
}
export async function createTenantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);

View File

@@ -7,6 +7,8 @@ import {
loadPlatformAuditLogs,
loadPlatformAuditNotificationChannels,
loadPlatformAuditNotificationEvents,
loadPlatformDunningNotificationChannels,
loadPlatformDunningNotificationEvents,
loadPlatformInvoices,
loadPlatformOverview,
loadPlatformQuestionBankGrants,
@@ -17,6 +19,8 @@ import {
type PlatformAuditLogItem,
type PlatformAuditNotificationChannelItem,
type PlatformAuditNotificationEventItem,
type PlatformDunningNotificationChannelItem,
type PlatformDunningNotificationEventItem,
type PlatformInvoiceItem,
type PlatformOverview,
type PlatformQuestionBankGrant,
@@ -48,6 +52,8 @@ export default function PlatformWorkbenchPage() {
const [auditAlerts, setAuditAlerts] = useState<PlatformAuditAlertItem[]>([]);
const [auditNotificationChannels, setAuditNotificationChannels] = useState<PlatformAuditNotificationChannelItem[]>([]);
const [auditNotificationEvents, setAuditNotificationEvents] = useState<PlatformAuditNotificationEventItem[]>([]);
const [dunningNotificationChannels, setDunningNotificationChannels] = useState<PlatformDunningNotificationChannelItem[]>([]);
const [dunningNotificationEvents, setDunningNotificationEvents] = useState<PlatformDunningNotificationEventItem[]>([]);
const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]);
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
const [error, setError] = useState('');
@@ -64,7 +70,9 @@ export default function PlatformWorkbenchPage() {
loadPlatformAuditAlerts({ status: 'open', limit: 6 }).catch(() => ({ items: [] })),
loadPlatformAuditNotificationChannels({ enabled: true, limit: 6 }).catch(() => ({ items: [] })),
loadPlatformAuditNotificationEvents({ limit: 6 }).catch(() => ({ items: [] })),
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload, alertPayload, channelPayload, eventPayload]) => {
loadPlatformDunningNotificationChannels({ enabled: true, limit: 6 }).catch(() => ({ items: [] })),
loadPlatformDunningNotificationEvents({ limit: 6 }).catch(() => ({ items: [] })),
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload, alertPayload, channelPayload, eventPayload, dunningChannelPayload, dunningEventPayload]) => {
setOverview(overviewPayload.item || null);
setTenants(tenantPayload.items || []);
setInvoices(invoicePayload.items || []);
@@ -74,6 +82,8 @@ export default function PlatformWorkbenchPage() {
setAuditAlerts(alertPayload.items || []);
setAuditNotificationChannels(channelPayload.items || []);
setAuditNotificationEvents(eventPayload.items || []);
setDunningNotificationChannels(dunningChannelPayload.items || []);
setDunningNotificationEvents(dunningEventPayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台后台加载失败'));
}, []);
@@ -230,6 +240,30 @@ export default function PlatformWorkbenchPage() {
</View>
{!auditNotificationChannels.length && !auditNotificationEvents.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-grid'>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(dunningNotificationChannels.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(dunningNotificationEvents.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(dunningNotificationEvents.filter(item => item.status === 'failed').length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(dunningNotificationEvents.filter(item => item.status === 'retrying').length)}</Text></View>
</View>
<View className='platform-list'>
{dunningNotificationChannels.map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.name || item.channelCode || '-'}</Text>
<Text className='platform-row-meta'>{item.provider || '-'} · level {String(item.minReminderLevel || '-')} · {item.webhook?.host || '-'} · {item.reminderTypes?.join('/') || '-'}</Text>
</View>
))}
{dunningNotificationEvents.map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.invoiceNo || item.invoiceId || '-'}</Text>
<Text className='platform-row-meta'>{item.channelName || item.channelCode || '-'} · {item.status || '-'} · HTTP {String(item.lastHttpCode || '-')} · {item.tenantName || item.tenantSlug || '-'} · {money(item.invoiceBalanceCents)}</Text>
</View>
))}
</View>
{!dunningNotificationChannels.length && !dunningNotificationEvents.length ? <View className='platform-empty'></View> : null}
</View>
{error ? <Text className='platform-error'>{error}</Text> : null}
</View>
</View>

View File

@@ -328,6 +328,55 @@ export interface PlatformAuditNotificationEventItem {
updatedAt?: string | null;
}
export interface PlatformDunningNotificationChannelItem {
id: string;
channelCode?: string | null;
name?: string | null;
description?: string | null;
enabled?: boolean | null;
provider?: string | null;
secretRef?: string | null;
reminderTypes?: string[] | null;
reminderChannels?: string[] | null;
minReminderLevel?: number | string | null;
tenantIds?: string[] | null;
timeoutSec?: number | string | null;
webhook?: {
protocol?: string | null;
host?: string | null;
pathname?: string | null;
} | null;
metadata?: Record<string, unknown> | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PlatformDunningNotificationEventItem {
id: string;
channelId?: string | null;
channelCode?: string | null;
channelName?: string | null;
reminderId?: string | null;
invoiceId?: string | null;
invoiceNo?: string | null;
tenantId?: string | null;
tenantName?: string | null;
tenantSlug?: string | null;
provider?: string | null;
status?: string | null;
attempts?: number | string | null;
lastHttpCode?: number | string | null;
lastError?: string | null;
lastResponseSummary?: string | null;
reminderType?: string | null;
reminderChannel?: string | null;
reminderLevel?: number | string | null;
invoiceBalanceCents?: number | string | null;
sentAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface CreatePlatformTenantInput {
slug: string;
name: string;
@@ -521,6 +570,32 @@ export async function loadPlatformAuditNotificationEvents(query: {
});
}
export async function loadPlatformDunningNotificationChannels(query: {
enabled?: boolean;
provider?: string;
limit?: number;
} = {}) {
return apiRequest<{ items?: PlatformDunningNotificationChannelItem[] }>('/api/platform-admin/dunning-notification-channels', {
query: { ...query, limit: query.limit || 50 },
tenantId: null,
});
}
export async function loadPlatformDunningNotificationEvents(query: {
channelId?: string;
reminderId?: string;
invoiceId?: string;
tenantId?: string;
status?: string;
provider?: string;
limit?: number;
} = {}) {
return apiRequest<{ items?: PlatformDunningNotificationEventItem[] }>('/api/platform-admin/dunning-notification-events', {
query: { ...query, limit: query.limit || 50 },
tenantId: null,
});
}
export async function loadPlatformInvoices(query: { tenantId?: string; status?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', {
query: { ...query, limit: query.limit || 80 },

View File

@@ -13,6 +13,7 @@
"provider-bills:once": "tsx src/index.ts --once --job provider-bills",
"platform-billing:once": "tsx src/index.ts --once --job platform-billing",
"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-audit-alerts:once": "tsx src/index.ts --once --job platform-audit-alerts",
"platform-audit-notifications:once": "tsx src/index.ts --once --job platform-audit-notifications",
"assets:once": "tsx src/index.ts --once --job assets",

View File

@@ -24,6 +24,11 @@ export interface WorkerConfig {
platformBillingWorkerId: string;
platformDunningBatchSize: number;
platformDunningWorkerId: string;
platformDunningNotificationBatchSize: number;
platformDunningNotificationMaxAttempts: number;
platformDunningNotificationBackoffSeconds: number[];
platformDunningNotificationRequestTimeoutMs: number;
platformDunningNotificationAllowInsecureLocalhost: boolean;
platformAuditAlertBatchSize: number;
platformAuditAlertWorkerId: string;
platformAuditAlertLookbackDays: number;
@@ -119,6 +124,9 @@ function validateProductionConfig(nextConfig: WorkerConfig) {
if (nextConfig.platformAuditNotificationAllowInsecureLocalhost) {
failures.push('WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true is not allowed in production workers');
}
if (nextConfig.platformDunningNotificationAllowInsecureLocalhost) {
failures.push('WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true is not allowed in production workers');
}
const scannerModes = nextConfig.assetSecurityScanner
.split(',')
.map(item => item.trim().toLowerCase())
@@ -189,6 +197,13 @@ const loadedConfig: WorkerConfig = {
platformBillingWorkerId: envString('WORKER_PLATFORM_BILLING_ID', `platform-billing-${process.pid}`),
platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100),
platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`),
platformDunningNotificationBatchSize: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE', 50),
platformDunningNotificationMaxAttempts: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_MAX_ATTEMPTS', 5),
platformDunningNotificationBackoffSeconds: envList('WORKER_PLATFORM_DUNNING_NOTIFICATION_BACKOFF_SECONDS', '10,60,300,900,1800')
.map((value: string) => Number(value))
.filter((value: number) => Number.isFinite(value) && value > 0),
platformDunningNotificationRequestTimeoutMs: envNumber('WORKER_PLATFORM_DUNNING_NOTIFICATION_REQUEST_TIMEOUT_MS', 10_000),
platformDunningNotificationAllowInsecureLocalhost: envBoolean('WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false),
platformAuditAlertBatchSize: envNumber('WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE', 200),
platformAuditAlertWorkerId: envString('WORKER_PLATFORM_AUDIT_ALERT_ID', `platform-audit-alerts-${process.pid}`),
platformAuditAlertLookbackDays: envNumber('WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS', 14),

View File

@@ -61,6 +61,16 @@ async function runOnce() {
);
return;
}
if (job === 'platform-dunning-notifications') {
const { processPlatformDunningNotificationBatch } = await import('./jobs/platform-dunning-notifications.js');
const result = await processPlatformDunningNotificationBatch();
console.log(
`[worker] platform-dunning-notifications batch enqueued=${result.enqueued}`
+ ` processed=${result.processed} sent=${result.sent} failed=${result.failed}`
+ ` retrying=${result.retrying} discarded=${result.discarded}`,
);
return;
}
if (job === 'platform-audit-alerts') {
const { processPlatformAuditAlertBatch } = await import('./jobs/platform-audit-alerts.js');
const result = await processPlatformAuditAlertBatch();

View File

@@ -0,0 +1,702 @@
import crypto from 'node:crypto';
import type pg from 'pg';
import { pool } from '../db.js';
import { config } from '../config.js';
const PROVIDERS = ['generic', 'dingtalk', 'feishu', 'wecom'] as const;
type NotificationProvider = typeof PROVIDERS[number];
interface DunningChannelRow {
id: string;
channelCode: string;
name: string;
provider: NotificationProvider;
webhookUrl: string;
secretRef: string | null;
reminderTypes: string[];
reminderChannels: string[];
minReminderLevel: number;
tenantIds: string[];
timeoutSec: number | null;
}
interface DunningReminderRow {
id: string;
tenantId: string;
invoiceId: string;
reminderType: string;
channel: string;
reminderLevel: number;
}
interface DunningEventRow {
id: string;
channelId: string;
channelCode: string;
channelName: string;
reminderId: string;
invoiceId: string;
tenantId: string;
provider: NotificationProvider;
attempts: number;
webhookUrl: string;
secretRef: string | null;
timeoutSec: number | null;
tenantSlug: string;
tenantName: string;
legalName: string | null;
billingStatus: string;
invoiceNo: string;
invoiceType: string;
invoiceStatus: string;
currency: string;
totalCents: number;
paidCents: number;
balanceCents: number;
dueDate: string | null;
issuedAt: string | null;
reminderType: string;
reminderChannel: string;
reminderStatus: string;
reminderDate: string;
reminderLevel: number;
balanceCentsSnapshot: number;
message: string | null;
reminderMetadata: Record<string, unknown> | null;
billingName: string | null;
contactName: string | null;
contactPhone: string | null;
contactEmail: string | null;
}
interface SecretRow {
secretValue: string | null;
secretJson: Record<string, unknown> | null;
}
interface PreparedRequest {
provider: NotificationProvider;
url: string;
body: Record<string, unknown>;
headers: Record<string, string>;
}
interface SendResult {
ok: boolean;
httpCode: number;
responseSummary: string;
errorMessage: string;
}
interface ProcessResult {
enqueued: number;
processed: number;
sent: number;
failed: number;
retrying: number;
discarded: number;
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function truncate(value: unknown, max = 1900) {
return String(value ?? '').slice(0, max);
}
function maskPhone(value: string | null) {
if (!value) return null;
const digits = value.replace(/\D/g, '');
if (digits.length < 7) return '***';
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
function maskEmail(value: string | null) {
if (!value) return null;
const [name, domain] = value.split('@');
if (!name || !domain) return '***';
return `${name.slice(0, 2)}***@${domain}`;
}
function redactDunningNotificationValue(value: unknown, parentKey = '', depth = 0): unknown {
if (value === null || value === undefined) return value;
if (depth > 8) return '[REDACTED_DEPTH_LIMIT]';
if (
/(?:password|passwd|secret|token|credential|private[_-]?key|api[_-]?key|app[_-]?secret|authorization|cookie|session|cert|signature|nonce)$/i
.test(parentKey)
) {
return '[REDACTED]';
}
if (Array.isArray(value)) return value.map(item => redactDunningNotificationValue(item, parentKey, depth + 1));
if (typeof value === 'object') {
const output: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
output[key] = redactDunningNotificationValue(item, key, depth + 1);
}
return output;
}
return value;
}
function parseSecretRef(ref: string | null) {
if (!ref) return null;
const parts = ref.split(':');
if (parts.length !== 3 || parts[0] !== 'app_private.platform_secrets') return null;
return { scope: parts[1], key: parts[2] };
}
function secretText(secret: SecretRow | null, keys: string[]) {
if (!secret) return '';
if (secret.secretValue?.trim()) return secret.secretValue.trim();
const json = objectValue(secret.secretJson);
for (const key of keys) {
const value = json[key];
if (typeof value === 'string' && value.trim()) return value.trim();
}
return '';
}
function validateWebhookUrl(rawUrl: string) {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error('Platform dunning notification webhook URL is invalid');
}
const isLocalhost = ['127.0.0.1', 'localhost', '::1'].includes(url.hostname);
if (url.protocol !== 'https:' && !(config.platformDunningNotificationAllowInsecureLocalhost && isLocalhost)) {
throw new Error('Platform dunning notification webhook URL must use HTTPS outside local development');
}
url.username = '';
url.password = '';
return url;
}
function dingtalkSign(secret: string): Record<string, string> {
if (!secret) return {};
const timestamp = String(Date.now());
const sign = crypto
.createHmac('sha256', secret)
.update(`${timestamp}\n${secret}`)
.digest('base64');
return { timestamp, sign };
}
function feishuSign(secret: string): Record<string, string> {
if (!secret) return {};
const timestamp = String(Math.floor(Date.now() / 1000));
const sign = crypto
.createHmac('sha256', `${timestamp}\n${secret}`)
.update('')
.digest('base64');
return { timestamp, sign };
}
function appendQuery(url: URL, params: Record<string, string>) {
for (const [key, value] of Object.entries(params)) {
if (value) url.searchParams.set(key, value);
}
return url.toString();
}
function dunningPayload(task: DunningEventRow) {
return {
event: 'platform.invoice.dunning_reminder',
tenant: {
id: task.tenantId,
slug: task.tenantSlug,
name: task.tenantName,
legalName: task.legalName,
billingStatus: task.billingStatus,
},
invoice: {
id: task.invoiceId,
invoiceNo: task.invoiceNo,
invoiceType: task.invoiceType,
status: task.invoiceStatus,
currency: task.currency,
totalCents: Number(task.totalCents || 0),
paidCents: Number(task.paidCents || 0),
balanceCents: Number(task.balanceCents || 0),
dueDate: task.dueDate,
issuedAt: task.issuedAt,
},
reminder: {
id: task.reminderId,
type: task.reminderType,
channel: task.reminderChannel,
status: task.reminderStatus,
date: task.reminderDate,
level: Number(task.reminderLevel || 0),
balanceCentsSnapshot: Number(task.balanceCentsSnapshot || 0),
message: task.message,
metadata: redactDunningNotificationValue(task.reminderMetadata || {}),
},
billingContact: {
billingName: task.billingName,
contactName: task.contactName,
phoneMasked: maskPhone(task.contactPhone),
emailMasked: maskEmail(task.contactEmail),
},
source: {
channelId: task.channelId,
channelCode: task.channelCode,
eventId: task.id,
},
};
}
function dunningMarkdown(task: DunningEventRow) {
return [
`### SaaS 服务费催缴:${task.tenantName}`,
`- 租户:${task.tenantName} (${task.tenantSlug})`,
`- 账单号:${task.invoiceNo}`,
`- 账单状态:${task.invoiceStatus}`,
`- 未结清金额:${Math.round(Number(task.balanceCents || 0)) / 100} ${task.currency}`,
`- 到期日:${task.dueDate || '未设置'}`,
`- 催缴类型:${task.reminderType}`,
`- 催缴级别:${task.reminderLevel}`,
`- 联系人:${task.contactName || '未设置'} ${maskPhone(task.contactPhone) || ''}`,
`- 催缴记录ID${task.reminderId}`,
`- 备注:${task.message || '无'}`,
].join('\n');
}
function prepareRequest(task: DunningEventRow, secret: SecretRow | null): PreparedRequest {
const target = validateWebhookUrl(task.webhookUrl);
const provider = task.provider;
const secretValue = secretText(secret, ['secret', 'signSecret', 'webhookSecret']);
const headers = { 'content-type': 'application/json' };
const markdown = dunningMarkdown(task);
if (provider === 'dingtalk') {
return {
provider,
url: appendQuery(target, dingtalkSign(secretValue)),
headers,
body: {
msgtype: 'markdown',
markdown: {
title: 'SaaS 服务费催缴',
text: markdown,
},
},
};
}
if (provider === 'feishu') {
return {
provider,
url: target.toString(),
headers,
body: {
msg_type: 'interactive',
...feishuSign(secretValue),
card: {
config: { wide_screen_mode: true },
header: { title: { tag: 'plain_text', content: 'SaaS 服务费催缴' }, template: 'orange' },
elements: [{ tag: 'markdown', content: markdown }],
},
},
};
}
if (provider === 'wecom') {
return {
provider,
url: target.toString(),
headers,
body: {
msgtype: 'markdown',
markdown: { content: markdown },
},
};
}
return {
provider,
url: target.toString(),
headers,
body: dunningPayload(task),
};
}
async function loadEnabledChannels(client: pg.PoolClient) {
const result = await client.query<DunningChannelRow>(
`
select id, channel_code as "channelCode", name, provider,
webhook_url as "webhookUrl", secret_ref as "secretRef",
reminder_types as "reminderTypes", reminder_channels as "reminderChannels",
min_reminder_level as "minReminderLevel", tenant_ids as "tenantIds",
timeout_sec as "timeoutSec"
from public.platform_dunning_notification_channels
where enabled = true
order by min_reminder_level asc, created_at asc
`,
);
return result.rows;
}
async function loadPendingReminders(client: pg.PoolClient, limit: number) {
const result = await client.query<DunningReminderRow>(
`
select id, tenant_id as "tenantId", invoice_id as "invoiceId",
reminder_type as "reminderType", channel, reminder_level as "reminderLevel"
from public.tenant_invoice_reminders
where status in ('pending', 'failed')
and reminder_type in ('due_soon', 'overdue', 'final_notice', 'manual')
order by reminder_level desc, created_at asc
limit $1
`,
[limit],
);
return result.rows;
}
async function enqueueNotificationEvents(client: pg.PoolClient, limit: number) {
const channels = await loadEnabledChannels(client);
if (!channels.length) return 0;
const reminders = await loadPendingReminders(client, Math.max(limit * 4, 50));
let enqueued = 0;
for (const reminder of reminders) {
for (const channel of channels) {
if (enqueued >= limit) return enqueued;
if (!channel.reminderTypes.includes(reminder.reminderType)) continue;
if (!channel.reminderChannels.includes(reminder.channel)) continue;
if (Number(reminder.reminderLevel || 0) < Number(channel.minReminderLevel || 1)) continue;
if (channel.tenantIds.length && !channel.tenantIds.includes(reminder.tenantId)) continue;
const result = await client.query(
`
insert into public.platform_dunning_notification_events (
channel_id, reminder_id, invoice_id, tenant_id, provider, status,
request_payload, metadata
)
values ($1, $2, $3, $4, $5, 'pending', $6::jsonb, $7::jsonb)
on conflict (channel_id, reminder_id) do nothing
returning id
`,
[
channel.id,
reminder.id,
reminder.invoiceId,
reminder.tenantId,
channel.provider,
JSON.stringify({
channelCode: channel.channelCode,
reminderId: reminder.id,
invoiceId: reminder.invoiceId,
tenantId: reminder.tenantId,
reminderType: reminder.reminderType,
}),
JSON.stringify({ enqueuedBy: 'platform-dunning-notifications-worker' }),
],
);
if (result.rowCount) enqueued += 1;
}
}
return enqueued;
}
async function claimDueEvents(client: pg.PoolClient, limit: number) {
const result = await client.query<DunningEventRow>(
`
with due as (
select e.id
from public.platform_dunning_notification_events e
where e.status in ('pending', 'retrying')
and coalesce(e.next_attempt_at, e.scheduled_at, e.created_at) <= now()
order by coalesce(e.next_attempt_at, e.scheduled_at, e.created_at) asc, e.created_at asc
limit $1
for update skip locked
)
update public.platform_dunning_notification_events e
set status = 'processing',
last_attempt_at = now(),
updated_at = now()
from due
join public.platform_dunning_notification_channels c on true
join public.tenant_invoice_reminders r on true
join public.tenant_invoices i on true
join public.tenants t on true
left join public.tenant_billing_profiles bp on bp.tenant_id = t.id
where e.id = due.id
and c.id = e.channel_id
and r.id = e.reminder_id
and i.id = e.invoice_id
and t.id = e.tenant_id
returning e.id, e.channel_id as "channelId", c.channel_code as "channelCode",
c.name as "channelName", e.reminder_id as "reminderId",
e.invoice_id as "invoiceId", e.tenant_id as "tenantId",
e.provider, e.attempts, c.webhook_url as "webhookUrl",
c.secret_ref as "secretRef", c.timeout_sec as "timeoutSec",
t.slug::text as "tenantSlug", t.name as "tenantName",
t.legal_name as "legalName", t.billing_status as "billingStatus",
i.invoice_no as "invoiceNo", i.invoice_type as "invoiceType",
i.status as "invoiceStatus", i.currency, i.total_cents as "totalCents",
i.paid_cents as "paidCents", i.balance_cents as "balanceCents",
i.due_date as "dueDate", i.issued_at as "issuedAt",
r.reminder_type as "reminderType", r.channel as "reminderChannel",
r.status as "reminderStatus", r.reminder_date as "reminderDate",
r.reminder_level as "reminderLevel",
r.balance_cents_snapshot as "balanceCentsSnapshot",
r.message, r.metadata as "reminderMetadata",
bp.billing_name as "billingName", bp.contact_name as "contactName",
bp.contact_phone as "contactPhone", bp.contact_email as "contactEmail"
`,
[limit],
);
return result.rows;
}
async function recoverStaleProcessingEvents(client: pg.PoolClient) {
const staleMs = Math.max(config.platformDunningNotificationRequestTimeoutMs * 3, 60_000);
await client.query(
`
update public.platform_dunning_notification_events
set status = 'retrying',
next_attempt_at = now(),
last_error = coalesce(last_error, 'Recovered stale processing platform dunning notification'),
updated_at = now()
where status = 'processing'
and coalesce(last_attempt_at, updated_at, created_at) < now() - ($1::int * interval '1 millisecond')
`,
[staleMs],
);
}
async function loadSecret(client: pg.PoolClient, secretRef: string | null) {
const parsed = parseSecretRef(secretRef);
if (!parsed) return null;
const result = await client.query<SecretRow>(
`
select secret_value as "secretValue", secret_json as "secretJson"
from app_private.platform_secrets
where secret_scope = $1
and secret_key = $2
limit 1
`,
[parsed.scope, parsed.key],
);
return result.rows[0] || null;
}
async function sendWebhook(request: PreparedRequest, timeoutMs: number): Promise<SendResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(request.url, {
method: 'POST',
headers: request.headers,
body: JSON.stringify(request.body),
signal: controller.signal,
});
const text = await response.text().catch(() => '');
return {
ok: response.ok,
httpCode: response.status,
responseSummary: truncate(text, 1900),
errorMessage: response.ok ? '' : `Platform dunning notification webhook returned HTTP ${response.status}`,
};
} catch (error) {
return {
ok: false,
httpCode: 0,
responseSummary: '',
errorMessage: error instanceof Error ? error.message : String(error),
};
} finally {
clearTimeout(timeout);
}
}
async function markEventResult(
client: pg.PoolClient,
task: DunningEventRow,
request: PreparedRequest | null,
result: SendResult,
attempt: number,
) {
const backoff = config.platformDunningNotificationBackoffSeconds[Math.min(attempt - 1, config.platformDunningNotificationBackoffSeconds.length - 1)] || 60;
const requestPayload = {
provider: request?.provider || task.provider,
urlHost: request ? new URL(request.url).hostname : null,
body: request?.body ? redactDunningNotificationValue(request.body) : null,
};
if (result.ok) {
await client.query(
`
update public.platform_dunning_notification_events
set status = 'sent',
attempts = $2,
next_attempt_at = null,
last_error = null,
last_http_code = $3,
last_response_summary = $4,
request_payload = $5::jsonb,
sent_at = now(),
updated_at = now()
where id = $1
`,
[task.id, attempt, result.httpCode, truncate(result.responseSummary), JSON.stringify(requestPayload)],
);
await client.query(
`
update public.tenant_invoice_reminders
set status = 'sent',
sent_at = coalesce(sent_at, now()),
metadata = metadata || $2::jsonb,
updated_at = now()
where id = $1
and status in ('pending', 'failed')
`,
[
task.reminderId,
JSON.stringify({
externalNotification: {
status: 'sent',
eventId: task.id,
channelCode: task.channelCode,
sentAt: new Date().toISOString(),
},
}),
],
);
return 'sent';
}
const terminal = attempt >= config.platformDunningNotificationMaxAttempts;
await client.query(
`
update public.platform_dunning_notification_events
set status = $2,
attempts = $3,
next_attempt_at = case when $2 = 'retrying' then now() + ($4::int * interval '1 second') else null end,
last_error = $5,
last_http_code = $6,
last_response_summary = $7,
request_payload = $8::jsonb,
updated_at = now()
where id = $1
`,
[
task.id,
terminal ? 'failed' : 'retrying',
attempt,
backoff,
truncate(result.errorMessage),
result.httpCode,
truncate(result.responseSummary),
JSON.stringify(requestPayload),
],
);
if (terminal) {
await client.query(
`
update public.tenant_invoice_reminders
set status = 'failed',
metadata = metadata || $2::jsonb,
updated_at = now()
where id = $1
and status in ('pending', 'failed')
`,
[
task.reminderId,
JSON.stringify({
externalNotification: {
status: 'failed',
eventId: task.id,
channelCode: task.channelCode,
failedAt: new Date().toISOString(),
lastError: truncate(result.errorMessage, 500),
},
}),
],
);
}
return terminal ? 'failed' : 'retrying';
}
async function discardEvent(client: pg.PoolClient, task: DunningEventRow, message: string) {
await client.query(
`
update public.platform_dunning_notification_events
set status = 'discarded',
attempts = attempts + 1,
last_attempt_at = now(),
last_error = $2,
request_payload = $3::jsonb,
updated_at = now()
where id = $1
`,
[
task.id,
truncate(message),
JSON.stringify({
provider: task.provider,
urlHost: '',
body: null,
}),
],
);
return 'discarded';
}
async function processEvent(task: DunningEventRow) {
const client = await pool.connect();
try {
if (!PROVIDERS.includes(task.provider)) {
return discardEvent(client, task, 'Unsupported platform dunning notification provider');
}
const secret = await loadSecret(client, task.secretRef);
const request = prepareRequest(task, secret);
const timeoutMs = Math.max(1000, (task.timeoutSec || 0) * 1000 || config.platformDunningNotificationRequestTimeoutMs);
const attempt = task.attempts + 1;
const result = await sendWebhook(request, timeoutMs);
return markEventResult(client, task, request, result, attempt);
} catch (error) {
const attempt = task.attempts + 1;
return markEventResult(client, task, null, {
ok: false,
httpCode: 0,
responseSummary: '',
errorMessage: error instanceof Error ? error.message : String(error),
}, attempt);
} finally {
client.release();
}
}
export async function processPlatformDunningNotificationBatch(limit = config.platformDunningNotificationBatchSize): Promise<ProcessResult> {
const client = await pool.connect();
let tasks: DunningEventRow[] = [];
let enqueued = 0;
try {
await client.query('begin');
await recoverStaleProcessingEvents(client);
enqueued = await enqueueNotificationEvents(client, limit);
tasks = await claimDueEvents(client, limit);
await client.query('commit');
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
const result: ProcessResult = { enqueued, processed: tasks.length, sent: 0, failed: 0, retrying: 0, discarded: 0 };
for (const task of tasks) {
const status = await processEvent(task);
if (status === 'sent') result.sent += 1;
if (status === 'failed') result.failed += 1;
if (status === 'retrying') result.retrying += 1;
if (status === 'discarded') result.discarded += 1;
}
return result;
}

View File

@@ -145,7 +145,7 @@
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes``classes/members``students``teachers`,支持班级范围权限和审计 |
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 |
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
| 平台租户/详情/账务资料/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账用量;审计导出、告警响应和通知事件都会对 `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 不回显原文;创建租户、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新通知渠道变更会写入审计 |
| 平台租户/详情/账务资料/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量;审计导出、告警响应和通知事件都会对 `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 会脱敏;创建租户、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 |
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks``question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
| 租户采纳/同步公共题库 | 可联调 | `/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`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计,冲突全部处理后相关通知自动 resolved |
@@ -202,6 +202,7 @@ npm run test:worker:crm
npm run test:worker:commerce
npm run test:worker:platform-billing
npm run test:worker:platform-dunning
npm run test:worker:platform-dunning-notifications
npm run test:worker:platform-audit-alerts
npm run test:worker:platform-audit-notifications
npm run test:worker:assets

View File

@@ -21,7 +21,7 @@
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
| --- | --- | --- | --- |
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、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、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 外部催缴通知、平台在线收款、平台审计告警升级策略更完整运营消息 |
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、平台审计告警升级策略更完整运营消息和更细平台权限点 |
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
@@ -35,7 +35,7 @@
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
| AI 择校推荐 | 可联调 | `ai_recommendation_reports`、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON、报告列表/详情和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF 渲染和人工复核流程 |
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台审计查询和 CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选预览/dry-run/批量生成、自动计费生成结果查看、逾期预览催缴记录第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台审计告警升级策略、小程序兼容验证和端到端测试 |
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台审计查询和 CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选预览/dry-run/批量生成、自动计费生成结果查看、逾期预览催缴记录和催缴外部通知摘要第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台审计告警升级策略、平台催缴通知配置操作台细节、小程序兼容验证和端到端测试 |
## 前端接入建议
@@ -79,7 +79,7 @@
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
- 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,确认 `migrationReadiness` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。
- 生产环境配置:`.env.example``npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
- Taro scaffold`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览催缴记录第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台审计告警升级策略和小程序兼容验证。
- Taro scaffold`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览催缴记录和催缴外部通知摘要第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台审计告警升级策略、平台催缴通知配置操作台细节和小程序兼容验证。
### P1商用收费和运营能力

View File

@@ -19,7 +19,7 @@
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
- `tenant`:域名/租户解析。
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者、日期和关键词查询平台审计;`GET /api/platform-admin/audit-logs/export` 支持平台管理员导出 CSV/JSON返回 base64 内容、sha256、行数和筛选条件并对 `details` 中的 token/secret/password/key 等敏感字段递归脱敏,同时写入 `platform.audit.exported` 审计;`GET /api/platform-admin/audit-alert-rules``GET /api/platform-admin/audit-alerts``POST /api/platform-admin/audit-alerts/status` 支持平台内部审计告警规则查询、开放告警查询、确认/解决/忽略API 返回告警 details 时递归脱敏敏感字段;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`GET/PUT /api/platform-admin/audit-notification-channels``GET /api/platform-admin/audit-notification-events` 已支持平台审计告警外部通知渠道配置和发送事件查询,`apps/worker --job platform-audit-notifications` 可按渠道把开放告警推送到 generic/钉钉/飞书/企微 webhook签名密钥进入 `app_private.platform_secrets`API 只回显 `secretRef` 和 webhook host/path`GET /api/platform-admin/invoices/subscription-candidates``POST /api/platform-admin/invoices/from-subscriptions-batch` 支持订阅账单候选预览、dry-run、批量生成、重复开票跳过和平台审计`apps/worker --job platform-billing` 可自动为即将到期且未开票订阅生成服务费账单;`POST /api/platform-admin/invoices/process-overdue``GET /api/platform-admin/invoices/reminders``apps/worker --job platform-dunning` 可处理已逾期未结清服务费账单,写入内部催缴台账和审计。创建租户、状态变更、账务资料维护、订阅批量开票、自动计费、逾期催缴、手工收款确认、审计导出、审计告警状态更新通知渠道变更会写入审计日志API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝、敏感字段脱敏和审计记录存在。
- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者、日期和关键词查询平台审计;`GET /api/platform-admin/audit-logs/export` 支持平台管理员导出 CSV/JSON返回 base64 内容、sha256、行数和筛选条件并对 `details` 中的 token/secret/password/key 等敏感字段递归脱敏,同时写入 `platform.audit.exported` 审计;`GET /api/platform-admin/audit-alert-rules``GET /api/platform-admin/audit-alerts``POST /api/platform-admin/audit-alerts/status` 支持平台内部审计告警规则查询、开放告警查询、确认/解决/忽略API 返回告警 details 时递归脱敏敏感字段;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`GET/PUT /api/platform-admin/audit-notification-channels``GET /api/platform-admin/audit-notification-events` 已支持平台审计告警外部通知渠道配置和发送事件查询,`apps/worker --job platform-audit-notifications` 可按渠道把开放告警推送到 generic/钉钉/飞书/企微 webhook签名密钥进入 `app_private.platform_secrets`API 只回显 `secretRef` 和 webhook host/path`GET /api/platform-admin/invoices/subscription-candidates``POST /api/platform-admin/invoices/from-subscriptions-batch` 支持订阅账单候选预览、dry-run、批量生成、重复开票跳过和平台审计`apps/worker --job platform-billing` 可自动为即将到期且未开票订阅生成服务费账单;`POST /api/platform-admin/invoices/process-overdue``GET /api/platform-admin/invoices/reminders``apps/worker --job platform-dunning` 可处理已逾期未结清服务费账单,写入内部催缴台账和审计`GET/PUT /api/platform-admin/dunning-notification-channels``GET /api/platform-admin/dunning-notification-events``apps/worker --job platform-dunning-notifications` 已支持平台催缴外部通知渠道配置、发送事件查询、重试和幂等发送。创建租户、状态变更、账务资料维护、订阅批量开票、自动计费、逾期催缴、手工收款确认、审计导出、审计告警状态更新、审计告警通知渠道变更和催缴通知渠道变更会写入审计日志API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝、敏感字段脱敏和审计记录存在。
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
- 班级与学生范围权限已落库:`tenant_classes``tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
@@ -34,6 +34,7 @@
- 已新增 commerce worker 和 `npm run test:worker:commerce`,用于补偿查询微信/支付宝支付、处理中退款和漏通知场景;支付成功会幂等更新订单/支付并开通权益,退款成功会幂等更新退款/订单/支付并在全额退款时撤销订单权益,测试覆盖密钥不泄露和重复执行不重复开通。
- 已新增 platform-billing worker 和 `npm run test:worker:platform-billing`,用于自动处理即将到期且未开票的 SaaS 订阅worker 使用订阅行锁和账单查重保证幂等,自动生成 `tenant_invoices/tenant_invoice_items` 并写 `platform.invoice.subscription_auto_created` 审计。
- 已新增 platform-dunning worker 和 `npm run test:worker:platform-dunning`,用于扫描已过 `due_date` 且未结清的 SaaS 服务费账单worker 使用账单行锁和每日唯一催缴约束保证幂等,自动标记 `overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写 `platform.invoice.overdue_processed` 审计。
- 已新增 platform-dunning-notifications worker 和 `npm run test:worker:platform-dunning-notifications`,用于把内部催缴记录按平台渠道推送到 generic/钉钉/飞书/企微 webhookworker 先入队 `platform_dunning_notification_events`再发送并记录重试、HTTP 状态、脱敏后的请求 payload避免泄露 webhook 签名密钥、租户联系方式原文和催缴 metadata 中的敏感字段。
- 已新增 platform-audit-alerts worker 和 `npm run test:worker:platform-audit-alerts`用于扫描高风险平台审计动作并生成内部告警worker 匹配启用规则、对告警 details 递归脱敏、通过 `(rule_id, audit_log_id)` 保证幂等,并写 `platform.audit.alert_created` 审计。
- 已新增 platform-audit-notifications worker 和 `npm run test:worker:platform-audit-notifications`,用于把开放平台审计告警按平台配置渠道推送到 generic/钉钉/飞书/企微 webhookworker 先入队 `platform_audit_notification_events`再发送并记录重试、HTTP 状态、脱敏后的请求 payload避免泄露 webhook 签名密钥和告警敏感 details。
- 已新增 assets worker 和 `npm run test:worker:assets`,用于复检 `content_assets` 托管对象元数据;正常资源会写入复检证据,异常资源会自动下架为 `draft`、标记 `upload_status=failed`,并记录审计与安全标记。
@@ -68,6 +69,9 @@ POST /api/platform-admin/audit-alerts/status
GET /api/platform-admin/audit-notification-channels
PUT /api/platform-admin/audit-notification-channels
GET /api/platform-admin/audit-notification-events
GET /api/platform-admin/dunning-notification-channels
PUT /api/platform-admin/dunning-notification-channels
GET /api/platform-admin/dunning-notification-events
POST /api/platform-admin/subscriptions
GET /api/platform-admin/invoices
POST /api/platform-admin/invoices
@@ -299,6 +303,7 @@ npm run test:worker:crm
npm run test:worker:commerce
npm run test:worker:platform-billing
npm run test:worker:platform-dunning
npm run test:worker:platform-dunning-notifications
npm run test:worker:platform-audit-alerts
npm run test:worker:platform-audit-notifications
npm run test:worker:assets

View File

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

View File

@@ -31,7 +31,7 @@
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV并可查看开放审计告警、确认或解决告警也能查看审计告警外部通知渠道和最近发送事件摘要账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成和催缴记录查看。
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV并可查看开放审计告警、确认或解决告警也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成和催缴记录查看。
- 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。
- 可以按新后端主模型接入内容导航:
- `content_entries`
@@ -106,9 +106,9 @@
| 页面 | 文件 | 已接接口 |
| --- | --- | --- |
| 工作台 | `apps/taro/src/pages/platform-admin/workbench/index.tsx` | `platform-admin/overview``tenants``invoices``question-banks``question-bank-grants``audit-logs``audit-logs/export``audit-alerts``audit-alerts/status` |
| 工作台 | `apps/taro/src/pages/platform-admin/workbench/index.tsx` | `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/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/question-banks/index.tsx` | `platform-admin/question-banks``question-bank-grants``PUT question-bank-grants` |
当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计查询、最近平台审计 CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑这些动作均经过前端基础校验和二次确认后端继续执行真实权限、重复开票保护和审计。平台审计导出只开放给平台管理员后端会对导出 `details` 中的 token/secret/password/key 等敏感字段脱敏,并返回 `contentBase64 + sha256`H5 可直接下载,小程序端建议先展示“已生成,需在 H5 管理台下载”。平台审计告警由 `platform-audit-alerts` worker 从高风险平台审计动作生成,外部通知由 `platform-audit-notifications` worker 根据平台渠道配置发送;前端只能调用告警查询、状态更新、通知渠道和发送事件 API不要直接写 `platform_audit_alerts``platform_audit_notification_channels``platform_audit_notification_events` 表。后端会对告警 `details` 和通知 payload 递归脱敏,渠道 API 只回显 `secretRef` 和 webhook host/path。下一批继续补租户基础资料编辑增强、平台审计告警升级策略、外部催缴通知、平台在线收款和更细平台权限点。
当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计查询、最近平台审计 CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件摘要、催缴外部通知渠道/事件摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑这些动作均经过前端基础校验和二次确认后端继续执行真实权限、重复开票保护和审计。平台审计导出只开放给平台管理员后端会对导出 `details` 中的 token/secret/password/key 等敏感字段脱敏,并返回 `contentBase64 + sha256`H5 可直接下载,小程序端建议先展示“已生成,需在 H5 管理台下载”。平台审计告警由 `platform-audit-alerts` worker 从高风险平台审计动作生成,外部通知由 `platform-audit-notifications` worker 根据平台渠道配置发送;平台催缴外部通知由 `platform-dunning-notifications` worker 根据 `tenant_invoice_reminders` 和平台渠道配置发送。前端只能调用告警查询、状态更新、通知渠道和发送事件 API不要直接写 `platform_audit_alerts``platform_audit_notification_channels``platform_audit_notification_events``platform_dunning_notification_channels``platform_dunning_notification_events` 表。后端会对告警 `details`、通知 payload 和催缴 payload 递归脱敏,渠道 API 只回显 `secretRef` 和 webhook host/path。下一批继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节、平台在线收款和更细平台权限点。

View File

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

View File

@@ -73,9 +73,9 @@
| 功能 | 新后端状态 | 待补齐 |
| --- | --- | --- |
| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护、最近平台审计查询/导出、开放审计告警查询/确认/解决、审计告警外部通知渠道/事件、订阅账单候选预览、dry-run、批量生成、自动计费 worker、逾期标记内部催缴台账已接真实 API/worker后续补外部催缴通知、租户基础资料编辑增强和审计告警升级策略 |
| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护、最近平台审计查询/导出、开放审计告警查询/确认/解决、审计告警外部通知渠道/事件、订阅账单候选预览、dry-run、批量生成、自动计费 worker、逾期标记内部催缴台账和催缴外部通知已接真实 API/worker后续补租户基础资料编辑增强和审计告警升级策略 |
| SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 |
| 年费/服务费账单 | 已覆盖 | 订阅账单候选、批量开票、自动计费、人工收款、逾期标记、租户 `past_due` 状态内部催缴记录已覆盖;真实平台在线收款、外部短信/微信订阅消息/企业微信催缴和停用策略待补 |
| 年费/服务费账单 | 已覆盖 | 订阅账单候选、批量开票、自动计费、人工收款、逾期标记、租户 `past_due` 状态内部催缴记录和平台催缴外部 webhook 通知已覆盖;真实平台在线收款、外部短信/微信订阅消息和停用策略待补 |
| 租户用量记录 | 已覆盖 | 自动采集 worker 待补 |
| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、同步通知、冲突查询 API、单条/批量冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺生产定时调度、失败告警和更完整运营消息 |
| 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 |

View File

@@ -11,7 +11,7 @@
- 学生端核心 API题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。
- 租户后台 API品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
- 租户主题系统平台默认经典蓝、专注绿、高对比三套模板租户可保存草稿、发布主题公开租户解析只返回已发布主题Taro 租户设置页已接第一版主题操作台。
- 平台后台 API/worker租户、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/列表/确认/解决、审计告警外部通知渠道/事件、platform-audit-alerts worker、platform-audit-notifications worker、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、用量。
- 平台后台 API/worker租户、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/列表/确认/解决、审计告警外部通知渠道/事件、platform-audit-alerts worker、platform-audit-notifications worker、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件、platform-dunning-notifications worker、用量。
- 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
@@ -34,6 +34,7 @@
- 微信/支付宝官方账单下载地基已完成:`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-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。
- 平台审计告警 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 脱敏。
- 平台审计告警外部通知第一版已完成:`platform_audit_notification_channels/events``GET/PUT /api/platform-admin/audit-notification-channels``GET /api/platform-admin/audit-notification-events``apps/worker --job platform-audit-notifications` 已接入;支持 generic/钉钉/飞书/企微 webhook、签名密钥 `app_private.platform_secrets`、发送重试、幂等、防重复、payload 脱敏和生产 readiness 阻断 localhost/不安全 webhook。
- 异常订单运营台和人工调整凭证已完成后端第一版:`/api/commerce/operations/anomalies` 聚合未关闭对账工单、失败官方账单任务、支付事件错误、长时间 pending 支付/退款;`/api/commerce/adjustment-vouchers*` 支持凭证提交、审批、驳回、作废、事件轨迹和复核报表,使用 `tenant:reconciliation:review` 做独立复核权限,且审批凭证不会直接修改订单、支付、退款或权益。
@@ -189,7 +190,7 @@
- H5 和小程序共用同一套业务 API client。
- 租户通过域名、小程序配置或启动参数解析。
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
### 第一批页面
@@ -242,7 +243,7 @@
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。
3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、外部催缴通知、平台在线收款和更细平台权限点。
3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节、平台在线收款和更细平台权限点。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。
6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。

View File

@@ -2519,7 +2519,7 @@ src/services/ai.ts AI 择校推荐生成、报告列表、报告详情
src/services/pronunciation.ts H5/小程序单词发音适配
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录
src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证
src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计查询/CSV 导出、平台审计告警规则/列表/状态更新、审计告警外部通知渠道/发送事件、套餐账单、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览/内部催缴记录、用量、公共题库授权
src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计查询/CSV 导出、平台审计告警规则/列表/状态更新、审计告警外部通知渠道/发送事件、套餐账单、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览/内部催缴记录、催缴外部通知渠道/发送事件、用量、公共题库授权
```
验证命令:
@@ -2763,11 +2763,83 @@ GET /api/platform-admin/invoices/reminders?tenantId=<tenantId>&invoiceId=<invoic
- 执行成功后重新加载 `/api/platform-admin/invoices``/api/platform-admin/invoices/reminders`
- `platform-dunning` worker 可每天在 `platform-billing` 之后运行一次;它不会自动停用租户,停用仍走平台管理员状态变更流程。
### 平台账务催缴外部通知
平台催缴外部通知用于把 `tenant_invoice_reminders` 中的内部催缴记录推送到平台自己的值班群、财务群或运营 webhook。它属于平台超级管理员能力前端只能调用后端 API 查看和配置渠道,不要直接写 Supabase 表,也不要在页面状态、日志、埋点或本地缓存保存 webhook secret。
查询催缴通知渠道:
```text
GET /api/platform-admin/dunning-notification-channels?enabled=true&limit=50
```
渠道响应不会返回原始 `webhookUrl`,只返回安全摘要:
```json
{
"items": [
{
"id": "...",
"channelCode": "finance_ops",
"name": "平台财务催缴群",
"enabled": true,
"provider": "wecom",
"secretRef": "app_private.platform_secrets:webhook:platform_dunning_finance_ops",
"reminderTypes": ["overdue", "final_notice"],
"reminderChannels": ["internal"],
"minReminderLevel": 1,
"tenantIds": [],
"webhook": {
"protocol": "https",
"host": "qyapi.weixin.qq.com",
"pathname": "/cgi-bin/webhook/send"
}
}
]
}
```
保存催缴通知渠道:
```http
PUT /api/platform-admin/dunning-notification-channels
Content-Type: application/json
{
"channelCode": "finance_ops",
"name": "平台财务催缴群",
"provider": "wecom",
"webhookUrl": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
"secret": "optional-signing-secret",
"reminderTypes": ["overdue", "final_notice"],
"reminderChannels": ["internal"],
"minReminderLevel": 1,
"tenantIds": [],
"timeoutSec": 10
}
```
查询发送事件:
```text
GET /api/platform-admin/dunning-notification-events?status=failed&limit=50
GET /api/platform-admin/dunning-notification-events?invoiceId=<invoiceId>&limit=50
```
前端展示建议:
- 工作台可展示启用渠道数、最近发送事件、失败数和待重试数;当前 `apps/taro/src/pages/platform-admin/workbench` 已接摘要第一版。
- 账务中心后续可在催缴记录旁展示外部通知状态,但不要把事件状态当作账单真实付款状态。
- `secret` 只在保存渠道时提交一次;后端写入 `app_private.platform_secrets`,响应只回显 `secretRef`
- API 会对事件 `requestPayload` 递归脱敏worker 会对联系人电话、邮箱做掩码。前端仍不要把 payload 原样写入日志。
- 生产 readiness 会阻断启用的非 HTTPS/localhost webhook钉钉/飞书签名渠道必须有平台私密 secret。
- `platform-dunning-notifications` worker 适合在 `platform-dunning` 后每 5 到 15 分钟运行一次;发送成功后会把该催缴记录标记为 `sent`,发送失败会按退避策略重试并在终止失败后标记 `failed`
下一批前端开发重点:
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。
- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑已接第一版后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计,`platform-billing` worker 会自动生成即将到期订阅账单并记录 `platform.invoice.subscription_auto_created` 审计,`platform-dunning` worker 会标记已过期未结清服务费账单、生成 `tenant_invoice_reminders` 并记录 `platform.invoice.overdue_processed` 审计,`platform-audit-alerts` worker 会把高风险平台审计动作转换为内部告警,`platform-audit-notifications` worker 会把开放告警按渠道发送外部通知;前端只展示候选、预览结果、跳过结果、逾期处理结果、开放告警、通知事件和生成后的账单/审计,不要直接更新账单状态、租户 `billing_status`、告警表或通知事件表;继续补租户基础资料编辑增强、平台审计告警升级策略、外部催缴通知和平台在线收款。
- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件状态摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑已接第一版后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计,`platform-billing` worker 会自动生成即将到期订阅账单并记录 `platform.invoice.subscription_auto_created` 审计,`platform-dunning` worker 会标记已过期未结清服务费账单、生成 `tenant_invoice_reminders` 并记录 `platform.invoice.overdue_processed` 审计,`platform-dunning-notifications` worker 会把内部催缴记录按渠道发送外部通知,`platform-audit-alerts` worker 会把高风险平台审计动作转换为内部告警,`platform-audit-notifications` worker 会把开放告警按渠道发送外部通知;前端只展示候选、预览结果、跳过结果、逾期处理结果、开放告警、通知事件和生成后的账单/审计,不要直接更新账单状态、租户 `billing_status`、告警表或通知事件表;继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
## AI 择校推荐接入

View File

@@ -42,6 +42,7 @@
"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-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-audit-alerts": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-audit-alert-worker-integration-test.js",
"test:worker:platform-audit-notifications": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-audit-notification-worker-integration-test.js",
"test:worker:assets": "npm run db:smoke-seed && npm run build:worker && node scripts/asset-worker-integration-test.js",

View File

@@ -1602,6 +1602,107 @@ async function testPlatformTenantOperationsAndAudit() {
query: { tenantId, invoiceId: ids.platformOverdueInvoice, limit: 10 },
});
assert.ok(reminders.items?.some(item => item.invoiceId === ids.platformOverdueInvoice && item.reminderType === 'overdue'), 'platform admin should list invoice reminders');
const platformReminder = reminders.items?.find(item => item.invoiceId === ids.platformOverdueInvoice && item.reminderType === 'overdue');
assert.ok(platformReminder?.id, 'platform overdue reminder should expose reminder id');
const dunningChannel = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PUT',
body: {
channelCode: 'integration_platform_dunning',
name: '集成测试平台催缴通知',
provider: 'generic',
webhookUrl: 'https://ops.example.test/platform-dunning',
secret: 'integration-platform-dunning-notification-secret',
reminderTypes: ['overdue'],
reminderChannels: ['internal'],
minReminderLevel: 1,
tenantIds: [tenantId],
timeoutSec: 5,
metadata: { owner: 'finance' },
},
});
assert.equal(dunningChannel.item?.channelCode, 'integration_platform_dunning', 'platform admin should upsert dunning notification channel');
assert.equal(dunningChannel.item?.secretRef, 'app_private.platform_secrets:webhook:platform_dunning_integration_platform_dunning', 'dunning channel should expose only platform secretRef');
assert.equal(dunningChannel.item?.webhook?.host, 'ops.example.test', 'dunning channel response should expose safe webhook host');
assert.equal(dunningChannel.item?.webhookUrl, undefined, 'dunning channel response must not expose raw webhook URL');
assert.ok(!JSON.stringify(dunningChannel).includes('integration-platform-dunning-notification-secret'), 'dunning channel response must not leak webhook secret');
const dunningChannels = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { enabled: true, provider: 'generic', limit: 20 },
});
assert.ok(
dunningChannels.items?.some(item => item.channelCode === 'integration_platform_dunning'),
'platform admin should list dunning notification channels',
);
assert.ok(!JSON.stringify(dunningChannels).includes('integration-platform-dunning-notification-secret'), 'dunning channel list must not leak webhook secret');
const invalidDunningChannel = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PUT',
body: {
channelCode: 'bad_dunning_channel',
name: 'bad dunning channel',
provider: 'generic',
webhookUrl: 'ftp://ops.example.test/hook',
},
expectStatus: 400,
});
assert.equal(invalidDunningChannel.code, 'INVALID_WEBHOOK_URL', 'dunning notification channel should reject unsafe webhook URL');
const dunningEventPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
let platformDunningNotificationEventId = '';
try {
const channelRow = await dunningEventPool.query(
"select id from public.platform_dunning_notification_channels where channel_code = 'integration_platform_dunning' limit 1",
);
const insertedEvent = await dunningEventPool.query(
`
insert into public.platform_dunning_notification_events (
channel_id, reminder_id, invoice_id, tenant_id, provider, status, attempts,
last_http_code, request_payload, last_response_summary
)
values (
$1, $2, $3, $4, 'generic', 'sent', 1,
200,
'{"body":{"token":"must-not-leak","nested":{"password":"must-not-leak"}}}'::jsonb,
'{"ok":true}'
)
on conflict (channel_id, reminder_id)
do update set status = excluded.status,
attempts = excluded.attempts,
request_payload = excluded.request_payload,
updated_at = now()
returning id
`,
[channelRow.rows[0].id, platformReminder.id, ids.platformOverdueInvoice, tenantId],
);
platformDunningNotificationEventId = insertedEvent.rows[0].id;
} finally {
await dunningEventPool.end();
}
const dunningEvents = await request('/api/platform-admin/dunning-notification-events', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { reminderId: platformReminder.id, status: 'sent', limit: 20 },
});
assert.ok(
dunningEvents.items?.some(item => item.id === platformDunningNotificationEventId),
'platform admin should list dunning notification events',
);
const listedDunningEvent = dunningEvents.items?.find(item => item.id === platformDunningNotificationEventId);
assert.equal(listedDunningEvent?.requestPayload?.body?.token, '[REDACTED]', 'dunning event list should redact token-like payload details');
assert.equal(listedDunningEvent?.requestPayload?.body?.nested?.password, '[REDACTED]', 'dunning event list should redact nested password-like payload details');
assert.ok(!JSON.stringify(dunningEvents).includes('must-not-leak'), 'dunning event list must not leak sensitive details');
const overdueAudit = await request('/api/platform-admin/audit-logs', {
tenantId: false,
@@ -1668,6 +1769,20 @@ async function testPlatformTenantOperationsAndAudit() {
});
assert.equal(studentAuditNotificationEventDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit notification events');
const studentDunningNotificationChannelDenied = await request('/api/platform-admin/dunning-notification-channels', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentDunningNotificationChannelDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform dunning notification channels');
const studentDunningNotificationEventDenied = await request('/api/platform-admin/dunning-notification-events', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentDunningNotificationEventDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform dunning notification events');
const studentReminderDenied = await request('/api/platform-admin/invoices/reminders', {
tenantId: false,
userId: USER_ID,

View File

@@ -0,0 +1,265 @@
import assert from 'node:assert/strict';
import http from 'node:http';
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-00000000db01',
invoice: '00000000-0000-0000-0000-00000000db02',
reminder: '00000000-0000-0000-0000-00000000db03',
};
function getFreePort() {
return new Promise((resolve, reject) => {
const server = http.createServer();
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close(() => resolve(address.port));
});
server.on('error', reject);
});
}
async function startWebhookServer() {
const port = await getFreePort();
const requests = [];
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', chunk => {
raw += chunk.toString();
});
req.on('end', () => {
requests.push({
url: req.url,
headers: req.headers,
body: raw ? JSON.parse(raw) : {},
});
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
return {
url: `http://127.0.0.1:${port}/platform-dunning`,
requests,
close: () => new Promise(resolve => server.close(resolve)),
};
}
function runWorkerOnce() {
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-dunning-notifications'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE: '20',
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true',
WORKER_PLATFORM_DUNNING_NOTIFICATION_REQUEST_TIMEOUT_MS: '5000',
},
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-dunning-notifications batch enqueued=\d+/, 'worker output should include dunning notification summary');
resolve(output);
} catch (error) {
reject(error);
}
});
});
}
async function cleanup(pool) {
await pool.query('delete from public.platform_dunning_notification_events where tenant_id = $1', [ids.tenant]);
await pool.query("delete from public.platform_dunning_notification_channels where channel_code = 'worker_platform_dunning_test'");
await pool.query("delete from app_private.platform_secrets where secret_scope = 'webhook' and secret_key = 'worker_platform_dunning_test'");
await pool.query('delete from public.audit_logs where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoice_reminders where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoice_items where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoice_payments where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoices where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_billing_profiles where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_domains where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenants where id = $1', [ids.tenant]);
}
async function seed(pool, webhookUrl) {
await pool.query(
`
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
values ($1, 'platform-dunning-notification-worker', '平台催缴通知租户', '平台催缴通知有限公司', 'active', 'saas', 'past_due', '{"source":"platform-dunning-notification-worker-test"}'::jsonb)
`,
[ids.tenant],
);
await pool.query(
`
insert into public.tenant_billing_profiles (
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
invoice_title, invoice_type, metadata
)
values (
$1, '平台催缴通知有限公司', 'TAX-DB-TEST', '财务负责人',
'13800006666', 'finance-dunning@example.test',
'平台催缴通知有限公司', 'normal_vat',
'{"apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb
)
on conflict (tenant_id)
do update set contact_phone = excluded.contact_phone,
contact_email = excluded.contact_email,
metadata = excluded.metadata,
updated_at = now()
`,
[ids.tenant],
);
await pool.query(
`
insert into public.tenant_invoices (
id, tenant_id, invoice_no, invoice_type, status, currency,
subtotal_cents, total_cents, paid_cents, balance_cents,
due_date, issued_at, note, metadata
)
values (
$1, $2, 'DNWORKER202606300001', 'service_fee', 'overdue', 'CNY',
880000, 880000, 0, 880000,
current_date - interval '9 days', now(), 'platform dunning notification worker invoice',
'{"source":"platform-dunning-notification-worker-test"}'::jsonb
)
`,
[ids.invoice, ids.tenant],
);
await pool.query(
`
insert into public.tenant_invoice_reminders (
id, tenant_id, invoice_id, reminder_type, channel, status,
reminder_date, reminder_level, due_date, balance_cents_snapshot,
message, metadata
)
values (
$1, $2, $3, 'overdue', 'internal', 'pending',
current_date, 2, current_date - interval '9 days', 880000,
'请尽快跟进服务费催缴',
'{"source":"worker-test","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb
)
`,
[ids.reminder, ids.tenant, ids.invoice],
);
await pool.query(
`
insert into app_private.platform_secrets (secret_scope, secret_key, secret_value, provider, last_rotated_at)
values ('webhook', 'worker_platform_dunning_test', 'platform-dunning-notification-secret', 'generic', now())
on conflict (secret_scope, secret_key)
do update set secret_value = excluded.secret_value,
provider = excluded.provider,
last_rotated_at = now(),
updated_at = now()
`,
);
await pool.query(
`
insert into public.platform_dunning_notification_channels (
channel_code, name, enabled, provider, webhook_url, secret_ref,
reminder_types, reminder_channels, min_reminder_level, tenant_ids, timeout_sec
)
values (
'worker_platform_dunning_test', 'Worker 平台催缴通知', true, 'generic',
$1, 'app_private.platform_secrets:webhook:worker_platform_dunning_test',
array['overdue']::text[], array['internal']::text[], 1, array[$2::uuid], 5
)
on conflict (channel_code)
do update set enabled = excluded.enabled,
provider = excluded.provider,
webhook_url = excluded.webhook_url,
secret_ref = excluded.secret_ref,
reminder_types = excluded.reminder_types,
reminder_channels = excluded.reminder_channels,
min_reminder_level = excluded.min_reminder_level,
tenant_ids = excluded.tenant_ids,
timeout_sec = excluded.timeout_sec,
updated_at = now()
`,
[webhookUrl, ids.tenant],
);
}
async function main() {
const webhook = await startWebhookServer();
const pool = new pg.Pool({ connectionString: databaseUrl });
try {
await cleanup(pool);
await seed(pool, webhook.url);
const firstOutput = await runWorkerOnce();
assert.match(firstOutput, /sent=1/, 'worker should send one platform dunning notification');
assert.equal(webhook.requests.length, 1, 'worker should call webhook exactly once');
assert.equal(webhook.requests[0].body.event, 'platform.invoice.dunning_reminder', 'generic notification should use dunning event name');
assert.equal(webhook.requests[0].body.reminder.id, ids.reminder, 'webhook body should include reminder id');
assert.equal(webhook.requests[0].body.invoice.id, ids.invoice, 'webhook body should include invoice id');
assert.equal(webhook.requests[0].body.tenant.id, ids.tenant, 'webhook body should include tenant id');
assert.equal(webhook.requests[0].body.billingContact.phoneMasked, '138****6666', 'webhook body should mask phone');
assert.equal(webhook.requests[0].body.billingContact.emailMasked, 'fi***@example.test', 'webhook body should mask email');
assert.equal(webhook.requests[0].body.reminder.metadata.apiKey, '[REDACTED]', 'webhook body should redact token-like keys');
assert.equal(webhook.requests[0].body.reminder.metadata.nested.password, '[REDACTED]', 'webhook body should redact nested password');
assert.ok(!JSON.stringify(webhook.requests[0]).includes('must-not-leak'), 'webhook request must not leak sensitive values');
assert.ok(!JSON.stringify(webhook.requests[0]).includes('platform-dunning-notification-secret'), 'webhook request must not leak signing secret');
assert.ok(!JSON.stringify(webhook.requests[0]).includes('13800006666'), 'webhook request must not leak raw phone');
assert.ok(!JSON.stringify(webhook.requests[0]).includes('finance-dunning@example.test'), 'webhook request must not leak raw email');
const events = await pool.query(
`
select status, attempts, last_http_code, sent_at, request_payload
from public.platform_dunning_notification_events
where reminder_id = $1
limit 1
`,
[ids.reminder],
);
assert.equal(events.rowCount, 1, 'worker should create one dunning notification event');
assert.equal(events.rows[0].status, 'sent', 'dunning notification event should be sent');
assert.equal(events.rows[0].attempts, 1, 'dunning notification event should record one attempt');
assert.equal(events.rows[0].last_http_code, 200, 'dunning notification event should record HTTP 200');
assert.ok(events.rows[0].sent_at, 'dunning notification event should record sent_at');
assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('must-not-leak'), 'stored request payload should be redacted');
assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('platform-dunning-notification-secret'), 'stored request payload should not leak secret');
assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('13800006666'), 'stored request payload should not leak raw phone');
const reminder = await pool.query(
`
select status, sent_at, metadata
from public.tenant_invoice_reminders
where id = $1
`,
[ids.reminder],
);
assert.equal(reminder.rows[0]?.status, 'sent', 'worker should mark reminder sent after successful external notification');
assert.ok(reminder.rows[0]?.sent_at, 'worker should record reminder sent_at');
assert.equal(reminder.rows[0]?.metadata?.externalNotification?.channelCode, 'worker_platform_dunning_test', 'worker should record external notification metadata');
const secondOutput = await runWorkerOnce();
assert.match(secondOutput, /sent=0/, 'second worker run should not resend sent dunning notification');
assert.equal(webhook.requests.length, 1, 'worker should not duplicate sent dunning notification');
console.log('Platform dunning notification worker integration test complete.');
} finally {
await cleanup(pool).catch(() => {});
await pool.end();
await webhook.close();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});

View File

@@ -21,6 +21,7 @@ const safeBaseEnv = {
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: 's3cure-asset-scanner-token-2026-06-30-abcdef',
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false',
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'false',
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'false',
};
const safeApiEnv = {
@@ -90,6 +91,17 @@ assert.match(
'worker config should name unsafe platform audit notification localhost mode',
);
const unsafeWorkerPlatformDunningNotification = runImport(workerConfigUrl, {
...safeBaseEnv,
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true',
});
assert.notEqual(unsafeWorkerPlatformDunningNotification.status, 0, 'production worker config should reject platform dunning notification localhost mode');
assert.match(
unsafeWorkerPlatformDunningNotification.output,
/WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true/,
'worker config should name unsafe platform dunning notification localhost mode',
);
const safeWorker = runImport(workerConfigUrl, safeBaseEnv);
assert.equal(safeWorker.status, 0, `safe production worker config should load: ${safeWorker.output}`);

View File

@@ -80,6 +80,7 @@ WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_CRM_BATCH_SIZE=20
WORKER_COMMERCE_BATCH_SIZE=20
WORKER_ASSET_BATCH_SIZE=50
@@ -117,6 +118,7 @@ WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
`);
assert.notEqual(missingJwksIssuer.status, 0, 'JWKS readiness without issuer should fail');
@@ -157,4 +159,37 @@ assert.ok(
'readiness should block platform audit notification localhost mode in production',
);
const unsafePlatformDunningNotificationLocalhost = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com
AUTH_SMS_PROVIDER=aliyun
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
PLATFORM_ADMIN_API_KEY=${strongSecretC}
STORAGE_DEFAULT_PROVIDER=aliyun_oss
STORAGE_DEFAULT_BUCKET=tiku-assets
STORAGE_REQUIRE_TENANT_PREFIX=true
ALIYUN_OSS_REGION=cn-hangzhou
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true
`);
assert.notEqual(unsafePlatformDunningNotificationLocalhost.status, 0, 'platform dunning notification localhost readiness should fail');
assert.ok(
unsafePlatformDunningNotificationLocalhost.payload.checks?.some(item => item.id === 'env.worker_platform_dunning_notification_insecure_localhost' && item.status === 'blocker'),
'readiness should block platform dunning notification localhost mode in production',
);
console.log('[PASS] production readiness check script');

View File

@@ -333,6 +333,12 @@ function validateEnv() {
pass('env.worker_platform_audit_notification_insecure_localhost', 'Platform audit notification worker insecure localhost webhook mode is disabled');
}
if (envBool('WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false)) {
block('env.worker_platform_dunning_notification_insecure_localhost', 'WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST must be false in production');
} else {
pass('env.worker_platform_dunning_notification_insecure_localhost', 'Platform dunning notification worker insecure localhost webhook mode is disabled');
}
const requiredPositiveNumbers = [
'WORKER_CRM_BATCH_SIZE',
'WORKER_COMMERCE_BATCH_SIZE',
@@ -470,6 +476,51 @@ async function validateDatabase() {
pass('db.platform_audit_notification_secrets', 'Signed platform audit notification channels have private secret rows');
}
const unsafePlatformDunningNotificationRows = await pool.query(`
select id, channel_code, provider, webhook_url
from public.platform_dunning_notification_channels
where enabled = true
and (
webhook_url !~* '^https://'
or webhook_url ~* '^https?://(localhost|127\\.0\\.0\\.1|\\[?::1\\]?)'
)
`);
if (unsafePlatformDunningNotificationRows.rowCount > 0) {
block('db.platform_dunning_notification_webhooks', 'Enabled platform dunning notification webhooks must use production HTTPS URLs', {
count: unsafePlatformDunningNotificationRows.rowCount,
samples: unsafePlatformDunningNotificationRows.rows.slice(0, 5).map(row => ({
id: row.id,
channelCode: row.channel_code,
provider: row.provider,
})),
});
} else {
pass('db.platform_dunning_notification_webhooks', 'Enabled platform dunning notification webhooks use production HTTPS URLs');
}
const missingPlatformDunningNotificationSecretRows = await pool.query(`
select c.id, c.channel_code, c.provider, c.secret_ref
from public.platform_dunning_notification_channels c
left join app_private.platform_secrets s
on s.secret_scope = split_part(c.secret_ref, ':', 2)
and s.secret_key = split_part(c.secret_ref, ':', 3)
where c.enabled = true
and c.provider in ('dingtalk', 'feishu')
and (c.secret_ref is null or c.secret_ref !~ '^app_private\\.platform_secrets:' or s.id is null)
`);
if (missingPlatformDunningNotificationSecretRows.rowCount > 0) {
block('db.platform_dunning_notification_secrets', 'Signed platform dunning notification channels require app_private.platform_secrets rows', {
count: missingPlatformDunningNotificationSecretRows.rowCount,
samples: missingPlatformDunningNotificationSecretRows.rows.slice(0, 5).map(row => ({
id: row.id,
channelCode: row.channel_code,
provider: row.provider,
})),
});
} else {
pass('db.platform_dunning_notification_secrets', 'Signed platform dunning notification channels have private secret rows');
}
const unverifiedDomainRows = await pool.query(`
select count(*)::int as count
from public.tenant_domains

View File

@@ -0,0 +1,88 @@
create table if not exists public.platform_dunning_notification_channels (
id uuid primary key default gen_random_uuid(),
channel_code text not null unique,
name text not null,
description text,
enabled boolean not null default true,
provider text not null default 'generic'
check (provider in ('generic', 'dingtalk', 'feishu', 'wecom')),
webhook_url text not null,
secret_ref text,
reminder_types text[] not null default array['overdue', 'final_notice']::text[],
reminder_channels text[] not null default array['internal']::text[],
min_reminder_level integer not null default 1 check (min_reminder_level between 1 and 20),
tenant_ids uuid[] not null default '{}'::uuid[],
timeout_sec integer not null default 10 check (timeout_sec between 1 and 60),
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint platform_dunning_notification_reminder_types_check
check (reminder_types <@ array['due_soon', 'overdue', 'final_notice', 'manual']::text[]),
constraint platform_dunning_notification_reminder_channels_check
check (reminder_channels <@ array['manual', 'internal', 'sms', 'email', 'wechat', 'crm']::text[])
);
create table if not exists public.platform_dunning_notification_events (
id uuid primary key default gen_random_uuid(),
channel_id uuid not null references public.platform_dunning_notification_channels(id) on delete cascade,
reminder_id uuid not null references public.tenant_invoice_reminders(id) on delete cascade,
invoice_id uuid not null references public.tenant_invoices(id) on delete cascade,
tenant_id uuid not null references public.tenants(id) on delete cascade,
provider text not null default 'generic'
check (provider in ('generic', 'dingtalk', 'feishu', 'wecom')),
status text not null default 'pending'
check (status in ('pending', 'processing', 'sent', 'retrying', 'failed', 'discarded')),
attempts integer not null default 0 check (attempts >= 0),
scheduled_at timestamptz not null default now(),
next_attempt_at timestamptz,
last_attempt_at timestamptz,
sent_at timestamptz,
last_error text,
last_http_code integer,
last_response_summary text,
request_payload jsonb not null default '{}'::jsonb,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (channel_id, reminder_id)
);
create index if not exists idx_platform_dunning_notification_channels_enabled
on public.platform_dunning_notification_channels(enabled, min_reminder_level, channel_code);
create index if not exists idx_platform_dunning_notification_events_due
on public.platform_dunning_notification_events(status, coalesce(next_attempt_at, scheduled_at), created_at);
create index if not exists idx_platform_dunning_notification_events_reminder
on public.platform_dunning_notification_events(reminder_id, status, created_at desc);
create index if not exists idx_platform_dunning_notification_events_invoice
on public.platform_dunning_notification_events(invoice_id, status, created_at desc);
create index if not exists idx_platform_dunning_notification_events_tenant
on public.platform_dunning_notification_events(tenant_id, status, created_at desc);
alter table public.platform_dunning_notification_channels enable row level security;
alter table public.platform_dunning_notification_events enable row level security;
drop policy if exists platform_admin_dunning_notification_channels on public.platform_dunning_notification_channels;
create policy platform_admin_dunning_notification_channels on public.platform_dunning_notification_channels
for all
using (app.is_platform_admin())
with check (app.is_platform_admin());
drop policy if exists platform_admin_dunning_notification_events on public.platform_dunning_notification_events;
create policy platform_admin_dunning_notification_events on public.platform_dunning_notification_events
for all
using (app.is_platform_admin())
with check (app.is_platform_admin());
drop trigger if exists set_updated_at on public.platform_dunning_notification_channels;
create trigger set_updated_at
before update on public.platform_dunning_notification_channels
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.platform_dunning_notification_events;
create trigger set_updated_at
before update on public.platform_dunning_notification_events
for each row execute function app.touch_updated_at();