forked from wangziqi/gongxue-base
feat: add platform dunning notifications
This commit is contained in:
@@ -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
|
||||
|
||||
31
README.md
31
README.md
@@ -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 可把高风险平台审计动作转换为内部告警并递归脱敏告警 details;platform-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 可把高风险平台审计动作转换为内部告警并递归脱敏告警 details;platform-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 vulnerabilities;Excel 解析已从 `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 vulnerabilities;Excel 解析已从 `exceljs` 切换为 `read-excel-file`,避免生产运行时携带 `exceljs -> uuid` 的已知中危依赖。
|
||||
|
||||
注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。
|
||||
|
||||
@@ -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、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
702
apps/worker/src/jobs/platform-dunning-notifications.ts
Normal file
702
apps/worker/src/jobs/platform-dunning-notifications.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:商用收费和运营能力
|
||||
|
||||
|
||||
@@ -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/钉钉/飞书/企微 webhook;worker 先入队 `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/钉钉/飞书/企微 webhook;worker 先入队 `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
|
||||
|
||||
@@ -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、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |
|
||||
|
||||
@@ -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。下一批继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节、平台在线收款和更细平台权限点。
|
||||
|
||||
@@ -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 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
|
||||
|
||||
@@ -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 聚合 |
|
||||
|
||||
@@ -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 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||
|
||||
@@ -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 择校推荐接入
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
265
scripts/platform-dunning-notification-worker-integration-test.js
Normal file
265
scripts/platform-dunning-notification-worker-integration-test.js
Normal 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);
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user