feat: add platform audit alert notifications

This commit is contained in:
Codex
2026-06-30 06:46:35 +08:00
parent 5bb0512ba7
commit 7ab702f471
24 changed files with 1731 additions and 30 deletions

View File

@@ -75,6 +75,17 @@ WORKER_PLATFORM_BILLING_ID=platform-billing-1
WORKER_PLATFORM_DUNNING_BATCH_SIZE=100 WORKER_PLATFORM_DUNNING_BATCH_SIZE=100
WORKER_PLATFORM_DUNNING_ID=platform-dunning-1 WORKER_PLATFORM_DUNNING_ID=platform-dunning-1
# Worker 配置:平台审计告警生成和外部通知。
WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE=200
WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS=14
WORKER_PLATFORM_AUDIT_ALERT_ID=platform-audit-alerts-1
WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE=50
WORKER_PLATFORM_AUDIT_NOTIFICATION_MAX_ATTEMPTS=5
WORKER_PLATFORM_AUDIT_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800
WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS=10000
# 仅本地 fake webhook 测试允许 http://127.0.0.1;生产必须 false
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
# Worker 配置:内容资源复检。异常托管对象会被标记 failed 并从 active 退回 draft。 # Worker 配置:内容资源复检。异常托管对象会被标记 failed 并从 active 退回 draft。
WORKER_ASSET_BATCH_SIZE=50 WORKER_ASSET_BATCH_SIZE=50
WORKER_ASSET_MIN_AGE_SECONDS=300 WORKER_ASSET_MIN_AGE_SECONDS=300

View File

@@ -15,11 +15,11 @@
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
- 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、用量记录、公共题库授权。 - 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、用量记录、公共题库授权。
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 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 卡片和拼图包。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets``daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。
- `apps/worker` 后台任务进程CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志commerce worker 可补偿查询微信/支付宝支付和退款状态provider-bills worker 可下载微信/支付宝官方账单并导入资金对账platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计platform-audit-alerts worker 可把高风险平台审计动作转换为内部告警并递归脱敏告警 detailsassets 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-audit-alerts worker 可把高风险平台审计动作转换为内部告警并递归脱敏告警 detailsplatform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook并记录幂等发送事件assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源imports worker 可执行大批量导入public-banks worker 可自动同步公共题库采纳副本exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 - 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
- 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。 - 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。
@@ -35,7 +35,7 @@
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。 - 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成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 提交。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
更完整的进度看这些文档: 更完整的进度看这些文档:
@@ -63,7 +63,7 @@
```text ```text
apps/api/ Node.js 业务 API apps/api/ Node.js 业务 API
apps/taro/ Taro 4 React 跨端前端H5 三入口,后续扩展小程序 apps/taro/ Taro 4 React 跨端前端H5 三入口,后续扩展小程序
apps/worker/ 后台异步任务CRM webhook、支付/退款补偿、官方账单下载、平台审计告警、资源复检、导入执行、公共题库同步、题库导出渲染等 apps/worker/ 后台异步任务CRM webhook、支付/退款补偿、官方账单下载、平台审计告警/外部通知、资源复检、导入执行、公共题库同步、题库导出渲染等
packages/config/ 共享配置 packages/config/ 共享配置
packages/db/ PostgreSQL 连接池和查询封装 packages/db/ PostgreSQL 连接池和查询封装
packages/domain/ 领域常量和共享类型 packages/domain/ 领域常量和共享类型
@@ -222,7 +222,7 @@ WORKER_PLATFORM_DUNNING_ID=platform-dunning-prod-1
npm --workspace @tiku-saas/worker run platform-audit-alerts:once npm --workspace @tiku-saas/worker run platform-audit-alerts:once
``` ```
生产定时任务建议每 5 到 15 分钟运行一次 `node dist/apps/worker/src/index.js --once --job platform-audit-alerts`。它只扫描 `platform.%` 审计日志,把命中启用规则的高风险动作写入 `platform_audit_alerts`,不会发送外部通知;告警 details 会递归脱敏 token、secret、password、key、authorization、cookie、session、cert、signature 等敏感字段。 生产定时任务建议每 5 到 15 分钟运行一次 `node dist/apps/worker/src/index.js --once --job platform-audit-alerts`。它只扫描 `platform.%` 审计日志,把命中启用规则的高风险动作写入 `platform_audit_alerts`;告警 details 会递归脱敏 token、secret、password、key、authorization、cookie、session、cert、signature 等敏感字段。
```text ```text
WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE=200 WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE=200
@@ -230,6 +230,22 @@ WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS=14
WORKER_PLATFORM_AUDIT_ALERT_ID=platform-audit-alerts-prod-1 WORKER_PLATFORM_AUDIT_ALERT_ID=platform-audit-alerts-prod-1
``` ```
单次运行平台审计告警外部通知 worker
```bash
npm --workspace @tiku-saas/worker run platform-audit-notifications:once
```
生产定时任务建议在 `platform-audit-alerts` 后每 5 到 15 分钟运行一次 `node dist/apps/worker/src/index.js --once --job platform-audit-notifications`。它会把开放告警按 `platform_audit_notification_channels` 配置入队到 `platform_audit_notification_events`,支持 generic、钉钉、飞书和企业微信 webhook发送请求和事件台账都会递归脱敏敏感字段。钉钉/飞书签名密钥必须存入 `app_private.platform_secrets`API 只返回 `secretRef` 和 webhook host/path。
```text
WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE=50
WORKER_PLATFORM_AUDIT_NOTIFICATION_MAX_ATTEMPTS=5
WORKER_PLATFORM_AUDIT_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800
WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS=10000
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
```
单次运行内容资源复检 worker 单次运行内容资源复检 worker
```bash ```bash
@@ -304,6 +320,7 @@ npm run test:worker:commerce
npm run test:worker:platform-billing npm run test:worker:platform-billing
npm run test:worker:platform-dunning npm run test:worker:platform-dunning
npm run test:worker:platform-audit-alerts npm run test:worker:platform-audit-alerts
npm run test:worker:platform-audit-notifications
npm run test:worker:assets npm run test:worker:assets
npm run test:worker:exports npm run test:worker:exports
npm run test:worker:imports npm run test:worker:imports
@@ -469,7 +486,7 @@ npm run audit:runtime
git diff --check 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:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed``npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。 结果:通过。`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` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。 注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。
@@ -478,7 +495,7 @@ git diff --check
优先继续补: 优先继续补:
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台外部催缴通知、在线收款、审计报表增强、审计告警外部通知/升级策略和更细平台权限点,小程序兼容验证。 2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台外部催缴通知、在线收款、审计报表增强、审计告警通知升级策略和更细平台权限点,小程序兼容验证。
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。 5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。

View File

@@ -9,6 +9,8 @@ import {
invoiceRemindersRoute, invoiceRemindersRoute,
platformAuditAlertRulesRoute, platformAuditAlertRulesRoute,
platformAuditAlertsRoute, platformAuditAlertsRoute,
platformAuditNotificationChannelsRoute,
platformAuditNotificationEventsRoute,
platformAuditLogsExportRoute, platformAuditLogsExportRoute,
platformAuditLogsRoute, platformAuditLogsRoute,
platformOverviewRoute, platformOverviewRoute,
@@ -24,6 +26,7 @@ import {
tenantUsageRoute, tenantUsageRoute,
updatePlatformAuditAlertStatusRoute, updatePlatformAuditAlertStatusRoute,
updateTenantStatusRoute, updateTenantStatusRoute,
upsertPlatformAuditNotificationChannelRoute,
upsertQuestionBankGrantRoute, upsertQuestionBankGrantRoute,
upsertBillingProfileRoute, upsertBillingProfileRoute,
} from './routes.js'; } from './routes.js';
@@ -44,6 +47,9 @@ export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/audit-alert-rules', platformAuditAlertRulesRoute], ['GET', '/api/platform-admin/audit-alert-rules', platformAuditAlertRulesRoute],
['GET', '/api/platform-admin/audit-alerts', platformAuditAlertsRoute], ['GET', '/api/platform-admin/audit-alerts', platformAuditAlertsRoute],
['POST', '/api/platform-admin/audit-alerts/status', updatePlatformAuditAlertStatusRoute], ['POST', '/api/platform-admin/audit-alerts/status', updatePlatformAuditAlertStatusRoute],
['GET', '/api/platform-admin/audit-notification-channels', platformAuditNotificationChannelsRoute],
['PUT', '/api/platform-admin/audit-notification-channels', upsertPlatformAuditNotificationChannelRoute],
['GET', '/api/platform-admin/audit-notification-events', platformAuditNotificationEventsRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute], ['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute], ['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute], ['POST', '/api/platform-admin/invoices', createInvoiceRoute],

View File

@@ -41,9 +41,20 @@ function optionalUuidArray(body: Record<string, unknown>, key: string) {
return optionalStringArray(body, key).filter(Boolean); return optionalStringArray(body, key).filter(Boolean);
} }
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);
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']); const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']);
const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved', 'ignored']); const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved', 'ignored']);
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']);
function csvEscape(value: unknown) { function csvEscape(value: unknown) {
if (value === null || value === undefined) return ''; if (value === null || value === undefined) return '';
@@ -119,6 +130,98 @@ function auditAlertStatusFrom(value: string) {
return status; return status;
} }
function platformAuditNotificationProviderFrom(value: string) {
const provider = value || 'generic';
if (!PLATFORM_AUDIT_NOTIFICATION_PROVIDERS.has(provider)) {
throw new HttpError(400, 'provider is invalid', 'INVALID_NOTIFICATION_PROVIDER');
}
return provider;
}
function platformAuditSeverityFrom(value: string, fallback = 'medium') {
const severity = value || fallback;
if (!PLATFORM_AUDIT_SEVERITIES.has(severity)) {
throw new HttpError(400, 'severity is invalid', 'INVALID_ALERT_SEVERITY');
}
return severity;
}
function platformAuditNotificationStatusFilter(value: unknown) {
const statuses = Array.isArray(value)
? value.map(item => String(item).trim()).filter(Boolean)
: ['open'];
if (statuses.length === 0 || statuses.length > 4) {
throw new HttpError(400, 'statusFilter is invalid', 'INVALID_STATUS_FILTER');
}
for (const status of statuses) {
if (!PLATFORM_AUDIT_ALERT_STATUSES.has(status)) {
throw new HttpError(400, 'statusFilter contains invalid status', 'INVALID_STATUS_FILTER');
}
}
return [...new Set(statuses)];
}
function platformAuditNotificationChannelCode(value: string) {
const code = normalizeSlug(value).replace(/-/g, '_');
if (!/^[a-z0-9_]{3,64}$/.test(code)) {
throw new HttpError(400, 'channelCode is invalid', 'INVALID_CHANNEL_CODE');
}
return code;
}
function platformSecretRef(scope: string, key: string) {
return `app_private.platform_secrets:${scope}:${key}`;
}
function parsePlatformSecretRef(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 safeWebhookInfo(rawUrl: string) {
try {
const url = new URL(rawUrl);
return {
protocol: url.protocol.replace(':', ''),
host: url.host,
pathname: url.pathname,
};
} catch {
return { protocol: '', host: '', pathname: '' };
}
}
function validatePlatformWebhookUrl(rawUrl: string) {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new HttpError(400, 'webhookUrl is invalid', 'INVALID_WEBHOOK_URL');
}
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '::1'].includes(url.hostname)) {
throw new HttpError(400, 'webhookUrl must use HTTPS outside local development', 'INVALID_WEBHOOK_URL');
}
url.username = '';
url.password = '';
return url.toString();
}
function numberBetween(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(Math.trunc(parsed), min), max);
}
function channelResponse<T extends Record<string, unknown>>(item: T) {
return {
...item,
webhookUrl: undefined,
webhook: safeWebhookInfo(String(item.webhookUrl || '')),
};
}
interface PlatformAuditAlertRow { interface PlatformAuditAlertRow {
id: string; id: string;
tenantId: string | null; tenantId: string | null;
@@ -991,6 +1094,208 @@ export async function updatePlatformAuditAlertStatusRoute(ctx: RequestContext) {
}; };
} }
export async function platformAuditNotificationChannelsRoute(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",
min_severity as "minSeverity", status_filter as "statusFilter",
action_patterns as "actionPatterns", tenant_ids as "tenantIds",
timeout_sec as "timeoutSec", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.platform_audit_notification_channels
where ($1::text = '' or enabled = ($1 = 'true'))
and ($2::text = '' or provider = $2)
order by enabled desc,
case min_severity when 'critical' then 1 when 'high' then 2 when 'medium' then 3 else 4 end,
channel_code asc
limit $3
`,
[enabled, provider, limit],
);
return { items: items.map(channelResponse) };
}
export async function upsertPlatformAuditNotificationChannelRoute(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 statusFilter = platformAuditNotificationStatusFilter(body.statusFilter);
const actionPatterns = optionalStringArray(body, 'actionPatterns').slice(0, 50);
const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds', 200);
const minSeverity = platformAuditSeverityFrom(optionalString(body, 'minSeverity'), 'medium');
const timeoutSec = numberBetween(body.timeoutSec, 10, 1, 60);
const description = optionalString(body, 'description') || null;
const metadata = objectValue(body.metadata);
let secretRef = optionalString(body, 'secretRef') || null;
const secret = typeof body.secret === 'string' && body.secret.trim() ? body.secret.trim() : '';
if (secret) secretRef = platformSecretRef('webhook', channelCode);
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()
`,
[channelCode, secret, provider],
);
}
const result = await client.query<Record<string, unknown>>(
`
insert into public.platform_audit_notification_channels (
channel_code, name, description, enabled, provider, webhook_url,
secret_ref, min_severity, status_filter, action_patterns,
tenant_ids, timeout_sec, metadata
)
values (
$1, $2, $3, $4, $5, $6,
$7, $8, $9::text[], $10::text[],
$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,
min_severity = excluded.min_severity,
status_filter = excluded.status_filter,
action_patterns = excluded.action_patterns,
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",
min_severity as "minSeverity", status_filter as "statusFilter",
action_patterns as "actionPatterns", 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,
minSeverity,
statusFilter,
actionPatterns,
tenantIds,
timeoutSec,
JSON.stringify(metadata),
],
);
const saved = result.rows[0];
await recordPlatformAudit(client, ctx, 'platform.audit.notification_channel_upserted', 'platform_audit_notification_channel', String(saved.id), {
channelCode,
provider,
enabled: body.enabled !== false,
minSeverity,
statusFilter,
actionPatternCount: actionPatterns.length,
tenantIdCount: tenantIds.length,
secretRefSet: Boolean(secretRef),
secretRotated: Boolean(secret),
webhook: safeWebhookInfo(webhookUrl),
});
return saved;
});
return { item: channelResponse(item) };
}
export async function platformAuditNotificationEventsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const channelId = listQuery(ctx, 'channelId');
const alertId = listQuery(ctx, 'alertId');
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 (alertId && !UUID_RE.test(alertId)) throw new HttpError(400, 'alertId 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.alert_id as "alertId",
e.audit_log_id as "auditLogId", 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,
a.tenant_id as "tenantId", t.slug::text as "tenantSlug",
t.name as "tenantName", a.severity as "alertSeverity",
a.status as "alertStatus", a.title as "alertTitle",
a.action as "alertAction", a.target_type as "targetType",
a.target_id as "targetId",
e.created_at as "createdAt", e.updated_at as "updatedAt"
from public.platform_audit_notification_events e
join public.platform_audit_notification_channels c on c.id = e.channel_id
join public.platform_audit_alerts a on a.id = e.alert_id
left join public.tenants t on t.id = a.tenant_id
where ($1::uuid is null or e.channel_id = $1::uuid)
and ($2::uuid is null or e.alert_id = $2::uuid)
and ($3::text = '' or e.status = $3)
and ($4::text = '' or e.provider = $4)
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 $5
`,
[channelId || null, alertId || 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) { export async function createTenantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx); await requirePlatformAdmin(ctx);

View File

@@ -5,6 +5,8 @@ import {
exportPlatformAuditLogs, exportPlatformAuditLogs,
loadPlatformAuditAlerts, loadPlatformAuditAlerts,
loadPlatformAuditLogs, loadPlatformAuditLogs,
loadPlatformAuditNotificationChannels,
loadPlatformAuditNotificationEvents,
loadPlatformInvoices, loadPlatformInvoices,
loadPlatformOverview, loadPlatformOverview,
loadPlatformQuestionBankGrants, loadPlatformQuestionBankGrants,
@@ -13,6 +15,8 @@ import {
updatePlatformAuditAlertStatus, updatePlatformAuditAlertStatus,
type PlatformAuditAlertItem, type PlatformAuditAlertItem,
type PlatformAuditLogItem, type PlatformAuditLogItem,
type PlatformAuditNotificationChannelItem,
type PlatformAuditNotificationEventItem,
type PlatformInvoiceItem, type PlatformInvoiceItem,
type PlatformOverview, type PlatformOverview,
type PlatformQuestionBankGrant, type PlatformQuestionBankGrant,
@@ -42,6 +46,8 @@ export default function PlatformWorkbenchPage() {
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]); const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
const [auditLogs, setAuditLogs] = useState<PlatformAuditLogItem[]>([]); const [auditLogs, setAuditLogs] = useState<PlatformAuditLogItem[]>([]);
const [auditAlerts, setAuditAlerts] = useState<PlatformAuditAlertItem[]>([]); const [auditAlerts, setAuditAlerts] = useState<PlatformAuditAlertItem[]>([]);
const [auditNotificationChannels, setAuditNotificationChannels] = useState<PlatformAuditNotificationChannelItem[]>([]);
const [auditNotificationEvents, setAuditNotificationEvents] = useState<PlatformAuditNotificationEventItem[]>([]);
const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]); const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]);
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]); const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -56,7 +62,9 @@ export default function PlatformWorkbenchPage() {
loadPlatformQuestionBankGrants({ limit: 6 }).catch(() => ({ items: [] })), loadPlatformQuestionBankGrants({ limit: 6 }).catch(() => ({ items: [] })),
loadPlatformAuditLogs({ limit: 6 }).catch(() => ({ items: [] })), loadPlatformAuditLogs({ limit: 6 }).catch(() => ({ items: [] })),
loadPlatformAuditAlerts({ status: 'open', limit: 6 }).catch(() => ({ items: [] })), loadPlatformAuditAlerts({ status: 'open', limit: 6 }).catch(() => ({ items: [] })),
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload, alertPayload]) => { loadPlatformAuditNotificationChannels({ enabled: true, limit: 6 }).catch(() => ({ items: [] })),
loadPlatformAuditNotificationEvents({ limit: 6 }).catch(() => ({ items: [] })),
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload, alertPayload, channelPayload, eventPayload]) => {
setOverview(overviewPayload.item || null); setOverview(overviewPayload.item || null);
setTenants(tenantPayload.items || []); setTenants(tenantPayload.items || []);
setInvoices(invoicePayload.items || []); setInvoices(invoicePayload.items || []);
@@ -64,6 +72,8 @@ export default function PlatformWorkbenchPage() {
setGrants(grantPayload.items || []); setGrants(grantPayload.items || []);
setAuditLogs(auditPayload.items || []); setAuditLogs(auditPayload.items || []);
setAuditAlerts(alertPayload.items || []); setAuditAlerts(alertPayload.items || []);
setAuditNotificationChannels(channelPayload.items || []);
setAuditNotificationEvents(eventPayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台后台加载失败')); }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台后台加载失败'));
}, []); }, []);
@@ -196,6 +206,30 @@ export default function PlatformWorkbenchPage() {
</View> </View>
{!auditAlerts.length ? <View className='platform-empty'></View> : null} {!auditAlerts.length ? <View className='platform-empty'></View> : null}
</View> </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(auditNotificationChannels.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(auditNotificationEvents.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(auditNotificationEvents.filter(item => item.status === 'failed').length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(auditNotificationEvents.filter(item => item.status === 'retrying').length)}</Text></View>
</View>
<View className='platform-list'>
{auditNotificationChannels.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 || '-'} · {item.minSeverity || '-'} · {item.webhook?.host || '-'} · {item.statusFilter?.join('/') || '-'}</Text>
</View>
))}
{auditNotificationEvents.map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.alertTitle || item.alertAction || '-'}</Text>
<Text className='platform-row-meta'>{item.channelName || item.channelCode || '-'} · {item.status || '-'} · HTTP {String(item.lastHttpCode || '-')} · {item.tenantName || item.tenantSlug || '平台'}</Text>
</View>
))}
</View>
{!auditNotificationChannels.length && !auditNotificationEvents.length ? <View className='platform-empty'></View> : null}
</View>
{error ? <Text className='platform-error'>{error}</Text> : null} {error ? <Text className='platform-error'>{error}</Text> : null}
</View> </View>
</View> </View>

View File

@@ -281,6 +281,53 @@ export interface PlatformAuditAlertItem {
updatedAt?: string | null; updatedAt?: string | null;
} }
export interface PlatformAuditNotificationChannelItem {
id: string;
channelCode?: string | null;
name?: string | null;
description?: string | null;
enabled?: boolean | null;
provider?: string | null;
secretRef?: string | null;
minSeverity?: string | null;
statusFilter?: string[] | null;
actionPatterns?: 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 PlatformAuditNotificationEventItem {
id: string;
channelId?: string | null;
channelCode?: string | null;
channelName?: string | null;
alertId?: string | null;
auditLogId?: string | null;
provider?: string | null;
status?: string | null;
attempts?: number | string | null;
lastHttpCode?: number | string | null;
lastError?: string | null;
lastResponseSummary?: string | null;
alertSeverity?: string | null;
alertStatus?: string | null;
alertTitle?: string | null;
alertAction?: string | null;
tenantName?: string | null;
tenantSlug?: string | null;
sentAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface CreatePlatformTenantInput { export interface CreatePlatformTenantInput {
slug: string; slug: string;
name: string; name: string;
@@ -450,6 +497,30 @@ export async function updatePlatformAuditAlertStatus(input: {
}); });
} }
export async function loadPlatformAuditNotificationChannels(query: {
enabled?: boolean;
provider?: string;
limit?: number;
} = {}) {
return apiRequest<{ items?: PlatformAuditNotificationChannelItem[] }>('/api/platform-admin/audit-notification-channels', {
query: { ...query, limit: query.limit || 50 },
tenantId: null,
});
}
export async function loadPlatformAuditNotificationEvents(query: {
channelId?: string;
alertId?: string;
status?: string;
provider?: string;
limit?: number;
} = {}) {
return apiRequest<{ items?: PlatformAuditNotificationEventItem[] }>('/api/platform-admin/audit-notification-events', {
query: { ...query, limit: query.limit || 50 },
tenantId: null,
});
}
export async function loadPlatformInvoices(query: { tenantId?: string; status?: string; limit?: number } = {}) { export async function loadPlatformInvoices(query: { tenantId?: string; status?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', { return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', {
query: { ...query, limit: query.limit || 80 }, query: { ...query, limit: query.limit || 80 },

View File

@@ -14,6 +14,7 @@
"platform-billing:once": "tsx src/index.ts --once --job platform-billing", "platform-billing:once": "tsx src/index.ts --once --job platform-billing",
"platform-dunning:once": "tsx src/index.ts --once --job platform-dunning", "platform-dunning:once": "tsx src/index.ts --once --job platform-dunning",
"platform-audit-alerts:once": "tsx src/index.ts --once --job platform-audit-alerts", "platform-audit-alerts:once": "tsx src/index.ts --once --job platform-audit-alerts",
"platform-audit-notifications:once": "tsx src/index.ts --once --job platform-audit-notifications",
"assets:once": "tsx src/index.ts --once --job assets", "assets:once": "tsx src/index.ts --once --job assets",
"imports:once": "tsx src/index.ts --once --job imports", "imports:once": "tsx src/index.ts --once --job imports",
"public-banks:once": "tsx src/index.ts --once --job public-banks", "public-banks:once": "tsx src/index.ts --once --job public-banks",

View File

@@ -27,6 +27,11 @@ export interface WorkerConfig {
platformAuditAlertBatchSize: number; platformAuditAlertBatchSize: number;
platformAuditAlertWorkerId: string; platformAuditAlertWorkerId: string;
platformAuditAlertLookbackDays: number; platformAuditAlertLookbackDays: number;
platformAuditNotificationBatchSize: number;
platformAuditNotificationMaxAttempts: number;
platformAuditNotificationBackoffSeconds: number[];
platformAuditNotificationRequestTimeoutMs: number;
platformAuditNotificationAllowInsecureLocalhost: boolean;
assetBatchSize: number; assetBatchSize: number;
assetMinAgeSeconds: number; assetMinAgeSeconds: number;
assetRecheckIntervalSeconds: number; assetRecheckIntervalSeconds: number;
@@ -111,6 +116,9 @@ function validateProductionConfig(nextConfig: WorkerConfig) {
if (nextConfig.assetSecurityScanFailOpen) { if (nextConfig.assetSecurityScanFailOpen) {
failures.push('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=true is not allowed in production workers'); failures.push('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=true is not allowed in production workers');
} }
if (nextConfig.platformAuditNotificationAllowInsecureLocalhost) {
failures.push('WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true is not allowed in production workers');
}
const scannerModes = nextConfig.assetSecurityScanner const scannerModes = nextConfig.assetSecurityScanner
.split(',') .split(',')
.map(item => item.trim().toLowerCase()) .map(item => item.trim().toLowerCase())
@@ -184,6 +192,13 @@ const loadedConfig: WorkerConfig = {
platformAuditAlertBatchSize: envNumber('WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE', 200), platformAuditAlertBatchSize: envNumber('WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE', 200),
platformAuditAlertWorkerId: envString('WORKER_PLATFORM_AUDIT_ALERT_ID', `platform-audit-alerts-${process.pid}`), platformAuditAlertWorkerId: envString('WORKER_PLATFORM_AUDIT_ALERT_ID', `platform-audit-alerts-${process.pid}`),
platformAuditAlertLookbackDays: envNumber('WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS', 14), platformAuditAlertLookbackDays: envNumber('WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS', 14),
platformAuditNotificationBatchSize: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE', 50),
platformAuditNotificationMaxAttempts: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_MAX_ATTEMPTS', 5),
platformAuditNotificationBackoffSeconds: envList('WORKER_PLATFORM_AUDIT_NOTIFICATION_BACKOFF_SECONDS', '10,60,300,900,1800')
.map((value: string) => Number(value))
.filter((value: number) => Number.isFinite(value) && value > 0),
platformAuditNotificationRequestTimeoutMs: envNumber('WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS', 10_000),
platformAuditNotificationAllowInsecureLocalhost: envBoolean('WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false),
assetBatchSize: envNumber('WORKER_ASSET_BATCH_SIZE', 50), assetBatchSize: envNumber('WORKER_ASSET_BATCH_SIZE', 50),
assetMinAgeSeconds: envNumber('WORKER_ASSET_MIN_AGE_SECONDS', 300), assetMinAgeSeconds: envNumber('WORKER_ASSET_MIN_AGE_SECONDS', 300),
assetRecheckIntervalSeconds: envNumber('WORKER_ASSET_RECHECK_INTERVAL_SECONDS', 60 * 60 * 24), assetRecheckIntervalSeconds: envNumber('WORKER_ASSET_RECHECK_INTERVAL_SECONDS', 60 * 60 * 24),

View File

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

View File

@@ -0,0 +1,612 @@
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 NotificationChannelRow {
id: string;
channelCode: string;
name: string;
provider: NotificationProvider;
webhookUrl: string;
secretRef: string | null;
minSeverity: string;
statusFilter: string[];
actionPatterns: string[];
tenantIds: string[];
timeoutSec: number | null;
}
interface NotificationEventRow {
id: string;
channelId: string;
channelCode: string;
channelName: string;
alertId: string;
auditLogId: string | null;
provider: NotificationProvider;
attempts: number;
webhookUrl: string;
secretRef: string | null;
timeoutSec: number | null;
tenantId: string | null;
tenantSlug: string | null;
tenantName: string | null;
severity: string;
alertStatus: string;
action: string;
targetType: string | null;
targetId: string | null;
title: string;
summary: string | null;
details: Record<string, unknown> | null;
alertCreatedAt: string;
}
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 stringValue(value: unknown, fallback = '') {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function truncate(value: unknown, max = 1900) {
return String(value ?? '').slice(0, max);
}
function severityRank(value: string) {
if (value === 'critical') return 4;
if (value === 'high') return 3;
if (value === 'medium') return 2;
return 1;
}
function redactAuditNotificationValue(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 => redactAuditNotificationValue(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] = redactAuditNotificationValue(item, key, depth + 1);
}
return output;
}
return value;
}
function matchesPattern(value: string, patterns: string[]) {
if (!patterns.length) return true;
return patterns.some(pattern => (
value === pattern
|| (pattern.endsWith('*') && value.startsWith(pattern.slice(0, -1)))
));
}
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 audit notification webhook URL is invalid');
}
const isLocalhost = ['127.0.0.1', 'localhost', '::1'].includes(url.hostname);
if (url.protocol !== 'https:' && !(config.platformAuditNotificationAllowInsecureLocalhost && isLocalhost)) {
throw new Error('Platform audit 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 alertPayload(task: NotificationEventRow) {
return {
event: 'platform.audit.alert',
alert: {
id: task.alertId,
severity: task.severity,
status: task.alertStatus,
title: task.title,
summary: task.summary,
action: task.action,
targetType: task.targetType,
targetId: task.targetId,
createdAt: task.alertCreatedAt,
details: redactAuditNotificationValue(task.details || {}),
},
tenant: task.tenantId
? {
id: task.tenantId,
slug: task.tenantSlug,
name: task.tenantName,
}
: null,
source: {
channelId: task.channelId,
channelCode: task.channelCode,
auditLogId: task.auditLogId,
},
};
}
function alertMarkdown(task: NotificationEventRow) {
const payload = alertPayload(task);
const tenant = payload.tenant;
return [
`### 平台审计告警:${task.title}`,
`- 级别:${task.severity}`,
`- 状态:${task.alertStatus}`,
`- 租户:${tenant?.name || tenant?.slug || '平台'}`,
`- 动作:${task.action}`,
`- 目标:${[task.targetType, task.targetId].filter(Boolean).join(':') || '无'}`,
`- 摘要:${task.summary || '无'}`,
`- 告警ID${task.alertId}`,
`- 时间:${String(task.alertCreatedAt).slice(0, 19).replace('T', ' ')}`,
].join('\n');
}
function prepareRequest(task: NotificationEventRow, 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 = alertMarkdown(task);
if (provider === 'dingtalk') {
return {
provider,
url: appendQuery(target, dingtalkSign(secretValue)),
headers,
body: {
msgtype: 'markdown',
markdown: {
title: '平台审计告警',
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: '平台审计告警' }, template: 'red' },
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: alertPayload(task),
};
}
async function loadEnabledChannels(client: pg.PoolClient) {
const result = await client.query<NotificationChannelRow>(
`
select id, channel_code as "channelCode", name, provider,
webhook_url as "webhookUrl", secret_ref as "secretRef",
min_severity as "minSeverity", status_filter as "statusFilter",
action_patterns as "actionPatterns", tenant_ids as "tenantIds",
timeout_sec as "timeoutSec"
from public.platform_audit_notification_channels
where enabled = true
order by created_at asc
`,
);
return result.rows;
}
async function loadOpenAlerts(client: pg.PoolClient, limit: number) {
const result = await client.query<{
id: string;
auditLogId: string;
tenantId: string | null;
severity: string;
status: string;
action: string;
}>(
`
select id, audit_log_id as "auditLogId", tenant_id as "tenantId",
severity, status, action
from public.platform_audit_alerts
where status in ('open', 'acknowledged')
order by
case severity when 'critical' then 1 when 'high' then 2 when 'medium' then 3 else 4 end,
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 alerts = await loadOpenAlerts(client, Math.max(limit * 4, 50));
let enqueued = 0;
for (const alert of alerts) {
for (const channel of channels) {
if (enqueued >= limit) return enqueued;
if (!channel.statusFilter.includes(alert.status)) continue;
if (severityRank(alert.severity) < severityRank(channel.minSeverity)) continue;
if (!matchesPattern(alert.action, channel.actionPatterns)) continue;
if (channel.tenantIds.length && (!alert.tenantId || !channel.tenantIds.includes(alert.tenantId))) continue;
const result = await client.query(
`
insert into public.platform_audit_notification_events (
channel_id, alert_id, audit_log_id, provider, status,
request_payload, metadata
)
values ($1, $2, $3, $4, 'pending', $5::jsonb, $6::jsonb)
on conflict (channel_id, alert_id) do nothing
returning id
`,
[
channel.id,
alert.id,
alert.auditLogId,
channel.provider,
JSON.stringify({
channelCode: channel.channelCode,
alertId: alert.id,
severity: alert.severity,
action: alert.action,
}),
JSON.stringify({ enqueuedBy: 'platform-audit-notifications-worker' }),
],
);
if (result.rowCount) enqueued += 1;
}
}
return enqueued;
}
async function claimDueEvents(client: pg.PoolClient, limit: number) {
const result = await client.query<NotificationEventRow>(
`
with due as (
select e.id
from public.platform_audit_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_audit_notification_events e
set status = 'processing',
last_attempt_at = now(),
updated_at = now()
from due
join public.platform_audit_notification_channels c on true
join public.platform_audit_alerts a on true
left join public.tenants t on t.id = a.tenant_id
where e.id = due.id
and c.id = e.channel_id
and a.id = e.alert_id
returning e.id, e.channel_id as "channelId", c.channel_code as "channelCode",
c.name as "channelName", e.alert_id as "alertId",
e.audit_log_id as "auditLogId", e.provider, e.attempts,
c.webhook_url as "webhookUrl", c.secret_ref as "secretRef",
c.timeout_sec as "timeoutSec", a.tenant_id as "tenantId",
t.slug::text as "tenantSlug", t.name as "tenantName",
a.severity, a.status as "alertStatus", a.action,
a.target_type as "targetType", a.target_id as "targetId",
a.title, a.summary, a.details,
a.created_at as "alertCreatedAt"
`,
[limit],
);
return result.rows;
}
async function recoverStaleProcessingEvents(client: pg.PoolClient) {
const staleMs = Math.max(config.platformAuditNotificationRequestTimeoutMs * 3, 60_000);
await client.query(
`
update public.platform_audit_notification_events
set status = 'retrying',
next_attempt_at = now(),
last_error = coalesce(last_error, 'Recovered stale processing platform audit 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 audit 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: NotificationEventRow,
request: PreparedRequest | null,
result: SendResult,
attempt: number,
) {
const backoff = config.platformAuditNotificationBackoffSeconds[Math.min(attempt - 1, config.platformAuditNotificationBackoffSeconds.length - 1)] || 60;
const requestPayload = {
provider: request?.provider || task.provider,
urlHost: request ? new URL(request.url).hostname : null,
body: request?.body ? redactAuditNotificationValue(request.body) : null,
};
if (result.ok) {
await client.query(
`
update public.platform_audit_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)],
);
return 'sent';
}
const terminal = attempt >= config.platformAuditNotificationMaxAttempts;
await client.query(
`
update public.platform_audit_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),
],
);
return terminal ? 'failed' : 'retrying';
}
async function discardEvent(client: pg.PoolClient, task: NotificationEventRow, message: string) {
await client.query(
`
update public.platform_audit_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: NotificationEventRow) {
const client = await pool.connect();
try {
if (!PROVIDERS.includes(task.provider)) {
return discardEvent(client, task, 'Unsupported platform audit notification provider');
}
const secret = await loadSecret(client, task.secretRef);
const request = prepareRequest(task, secret);
const timeoutMs = Math.max(1000, (task.timeoutSec || 0) * 1000 || config.platformAuditNotificationRequestTimeoutMs);
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 processPlatformAuditNotificationBatch(limit = config.platformAuditNotificationBatchSize): Promise<ProcessResult> {
const client = await pool.connect();
let tasks: NotificationEventRow[] = [];
let enqueued = 0;
try {
await client.query('begin');
await recoverStaleProcessingEvents(client);
enqueued = await enqueueNotificationEvents(client, limit);
tasks = await claimDueEvents(client, limit);
await client.query('commit');
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
const result: ProcessResult = { enqueued, processed: tasks.length, sent: 0, failed: 0, retrying: 0, discarded: 0 };
for (const task of tasks) {
const status = await processEvent(task);
if (status === 'sent') result.sent += 1;
if (status === 'failed') result.failed += 1;
if (status === 'retrying') result.retrying += 1;
if (status === 'discarded') result.discarded += 1;
}
return result;
}

View File

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

View File

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

View File

@@ -14,12 +14,12 @@
- `video`:题目视频讲解、批量预加载、通用视频搜索。 - `video`:题目视频讲解、批量预加载、通用视频搜索。
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。 - `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。 - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
- `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/告警状态流、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、使用量。 - `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/告警状态流、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、使用量。
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
- `tenant`:域名/租户解析。 - `tenant`:域名/租户解析。
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。 - 鉴权上下文已支持 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 /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` 可处理已逾期未结清服务费账单,写入内部催缴台账和审计。创建租户、状态变更、账务资料维护、订阅批量开票、自动计费、逾期催缴、手工收款确认、审计导出审计告警状态更新和通知渠道变更会写入审计日志API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝、敏感字段脱敏和审计记录存在。
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。 - 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
- 班级与学生范围权限已落库:`tenant_classes``tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 班级与学生范围权限已落库:`tenant_classes``tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。 - 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
@@ -35,6 +35,7 @@
- 已新增 platform-billing worker 和 `npm run test:worker:platform-billing`,用于自动处理即将到期且未开票的 SaaS 订阅worker 使用订阅行锁和账单查重保证幂等,自动生成 `tenant_invoices/tenant_invoice_items` 并写 `platform.invoice.subscription_auto_created` 审计。 - 已新增 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 worker 和 `npm run test:worker:platform-dunning`,用于扫描已过 `due_date` 且未结清的 SaaS 服务费账单worker 使用账单行锁和每日唯一催缴约束保证幂等,自动标记 `overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写 `platform.invoice.overdue_processed` 审计。
- 已新增 platform-audit-alerts worker 和 `npm run test:worker:platform-audit-alerts`用于扫描高风险平台审计动作并生成内部告警worker 匹配启用规则、对告警 details 递归脱敏、通过 `(rule_id, audit_log_id)` 保证幂等,并写 `platform.audit.alert_created` 审计。 - 已新增 platform-audit-alerts worker 和 `npm run test:worker:platform-audit-alerts`用于扫描高风险平台审计动作并生成内部告警worker 匹配启用规则、对告警 details 递归脱敏、通过 `(rule_id, audit_log_id)` 保证幂等,并写 `platform.audit.alert_created` 审计。
- 已新增 platform-audit-notifications worker 和 `npm run test:worker:platform-audit-notifications`,用于把开放平台审计告警按平台配置渠道推送到 generic/钉钉/飞书/企微 webhookworker 先入队 `platform_audit_notification_events`再发送并记录重试、HTTP 状态、脱敏后的请求 payload避免泄露 webhook 签名密钥和告警敏感 details。
- 已新增 assets worker 和 `npm run test:worker:assets`,用于复检 `content_assets` 托管对象元数据;正常资源会写入复检证据,异常资源会自动下架为 `draft`、标记 `upload_status=failed`,并记录审计与安全标记。 - 已新增 assets worker 和 `npm run test:worker:assets`,用于复检 `content_assets` 托管对象元数据;正常资源会写入复检证据,异常资源会自动下架为 `draft`、标记 `upload_status=failed`,并记录审计与安全标记。
- 已新增分数线/视频 JSON 导入接口和集成测试:分数线支持字段、院校、专业、记录、动态字段值;视频支持视频元数据、资源引用、访问模式和题目绑定,全部复用 `content_import_jobs/items/issues` 管线。 - 已新增分数线/视频 JSON 导入接口和集成测试:分数线支持字段、院校、专业、记录、动态字段值;视频支持视频元数据、资源引用、访问模式和题目绑定,全部复用 `content_import_jobs/items/issues` 管线。
- 已新增 CSV/Excel 导入解析层:题目、单词、知识手册、分数线和视频都可通过 `sourceFormat=csv/excel` 先解析为规范 payload再进入同一套 preview/import、逐行 issue、幂等和审计管线分数线支持多 Sheet Excel。 - 已新增 CSV/Excel 导入解析层:题目、单词、知识手册、分数线和视频都可通过 `sourceFormat=csv/excel` 先解析为规范 payload再进入同一套 preview/import、逐行 issue、幂等和审计管线分数线支持多 Sheet Excel。
@@ -64,6 +65,9 @@ GET /api/platform-admin/audit-logs/export
GET /api/platform-admin/audit-alert-rules GET /api/platform-admin/audit-alert-rules
GET /api/platform-admin/audit-alerts GET /api/platform-admin/audit-alerts
POST /api/platform-admin/audit-alerts/status 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
POST /api/platform-admin/subscriptions POST /api/platform-admin/subscriptions
GET /api/platform-admin/invoices GET /api/platform-admin/invoices
POST /api/platform-admin/invoices POST /api/platform-admin/invoices
@@ -296,6 +300,7 @@ npm run test:worker:commerce
npm run test:worker:platform-billing npm run test:worker:platform-billing
npm run test:worker:platform-dunning npm run test:worker:platform-dunning
npm run test:worker:platform-audit-alerts npm run test:worker:platform-audit-alerts
npm run test:worker:platform-audit-notifications
npm run test:worker:assets npm run test:worker:assets
npm run test:worker:imports npm run test:worker:imports
npm run test:worker:public-banks npm run test:worker:public-banks

View File

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

View File

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

View File

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

View File

@@ -2519,7 +2519,7 @@ src/services/ai.ts AI 择校推荐生成、报告列表、报告详情
src/services/pronunciation.ts H5/小程序单词发音适配 src/services/pronunciation.ts H5/小程序单词发音适配
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录 src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录
src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证 src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证
src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计查询/CSV 导出、平台审计告警规则/列表/状态更新、套餐账单、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览/内部催缴记录、用量、公共题库授权 src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计查询/CSV 导出、平台审计告警规则/列表/状态更新、审计告警外部通知渠道/发送事件、套餐账单、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览/内部催缴记录、用量、公共题库授权
``` ```
验证命令: 验证命令:
@@ -2598,7 +2598,7 @@ format csv | json
## 平台审计告警 ## 平台审计告警
平台审计告警属于超级管理员后台能力,第一版只做内部告警台账和平台后台处理,不发送短信、企业微信、钉钉或飞书外部通知。告警由 `apps/worker --job platform-audit-alerts` 根据 `platform_audit_alert_rules` 从平台审计日志生成,前端不要直接读写 Supabase 表。 平台审计告警属于超级管理员后台能力。内部告警由 `apps/worker --job platform-audit-alerts` 根据 `platform_audit_alert_rules` 从平台审计日志生成,外部通知由 `apps/worker --job platform-audit-notifications` 根据 `platform_audit_notification_channels` 入队并发送到 generic、钉钉、飞书或企业微信 webhook。前端不要直接读写 Supabase 表。
查询启用规则: 查询启用规则:
@@ -2643,7 +2643,72 @@ Content-Type: application/json
- 只允许平台管理员调用,普通学生、租户管理员和租户成员会返回 `PLATFORM_ADMIN_REQUIRED` - 只允许平台管理员调用,普通学生、租户管理员和租户成员会返回 `PLATFORM_ADMIN_REQUIRED`
- 后端会对告警 `details` 递归脱敏 token、secret、password、key、authorization、cookie、session、cert、signature 等敏感字段;前端仍不要把 details 原样写入日志或埋点。 - 后端会对告警 `details` 递归脱敏 token、secret、password、key、authorization、cookie、session、cert、signature 等敏感字段;前端仍不要把 details 原样写入日志或埋点。
- 确认、解决和忽略都会写入 `platform.audit.alert_status_updated` 审计。 - 确认、解决和忽略都会写入 `platform.audit.alert_status_updated` 审计。
- 当前工作台展示开放告警并提供“确认/解决”按钮;更完整筛选、批量处理、外部通知和升级策略属于下一阶段。 - 当前工作台展示开放告警并提供“确认/解决”按钮,同时展示已启用外部通知渠道和最近发送事件摘要;更完整筛选、批量处理和升级策略属于下一阶段。
### 平台审计告警外部通知
查询通知渠道:
```text
GET /api/platform-admin/audit-notification-channels?enabled=true&limit=50
```
渠道响应不会返回原始 `webhookUrl`,只返回安全摘要:
```json
{
"items": [
{
"id": "...",
"channelCode": "security_ops",
"name": "安全值班群",
"enabled": true,
"provider": "wecom",
"secretRef": "app_private.platform_secrets:webhook:security_ops",
"minSeverity": "high",
"statusFilter": ["open"],
"actionPatterns": ["platform.*"],
"webhook": {
"protocol": "https",
"host": "qyapi.weixin.qq.com",
"pathname": "/cgi-bin/webhook/send"
}
}
]
}
```
保存通知渠道:
```http
PUT /api/platform-admin/audit-notification-channels
Content-Type: application/json
{
"channelCode": "security_ops",
"name": "安全值班群",
"provider": "wecom",
"webhookUrl": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
"secret": "optional-signing-secret",
"minSeverity": "high",
"statusFilter": ["open"],
"actionPatterns": ["platform.*"],
"timeoutSec": 10
}
```
查询发送事件:
```text
GET /api/platform-admin/audit-notification-events?status=failed&limit=50
```
前端安全边界:
- `secret` 只允许在保存渠道时一次性提交给后端,后端写入 `app_private.platform_secrets`;页面、日志、状态管理和本地缓存都不能保存原文。
- API 响应只展示 `secretRef`、webhook host/path、发送状态、HTTP code 和脱敏后的 request payload。前端不要自行拼 webhook 请求。
- 生产环境 readiness 会阻断启用的非 HTTPS webhook、localhost webhook以及缺少平台私密 secret 的钉钉/飞书签名渠道。
- 小程序端平台后台如果后续开放,只建议做查看和告警处理;通知渠道配置建议优先放在 H5 平台后台完成。
## 平台账务逾期催缴 ## 平台账务逾期催缴
@@ -2702,7 +2767,7 @@ GET /api/platform-admin/invoices/reminders?tenantId=<tenantId>&invoiceId=<invoic
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。 - 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。 - 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 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 会把高风险平台审计动作转换为内部告警;前端只展示候选、预览结果、跳过结果、逾期处理结果、开放告警和生成后的账单/审计,不要直接更新账单状态、租户 `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-audit-alerts` worker 会把高风险平台审计动作转换为内部告警`platform-audit-notifications` worker 会把开放告警按渠道发送外部通知;前端只展示候选、预览结果、跳过结果、逾期处理结果、开放告警、通知事件和生成后的账单/审计,不要直接更新账单状态、租户 `billing_status`、告警表或通知事件表;继续补租户基础资料编辑增强、平台审计告警升级策略、外部催缴通知和平台在线收款。
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。 - 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
## AI 择校推荐接入 ## AI 择校推荐接入

View File

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

View File

@@ -1341,6 +1341,105 @@ async function testPlatformTenantOperationsAndAudit() {
}); });
assert.equal(invalidAuditAlertStatus.code, 'INVALID_ALERT_STATUS', 'audit alerts should not be reopened through status endpoint'); assert.equal(invalidAuditAlertStatus.code, 'INVALID_ALERT_STATUS', 'audit alerts should not be reopened through status endpoint');
const notificationChannel = await request('/api/platform-admin/audit-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PUT',
body: {
channelCode: 'integration_platform_audit',
name: '集成测试平台审计通知',
provider: 'generic',
webhookUrl: 'https://ops.example.test/platform-audit',
secret: 'integration-platform-audit-notification-secret',
minSeverity: 'medium',
statusFilter: ['open'],
actionPatterns: ['platform.tenant.*'],
tenantIds: [tenantId],
timeoutSec: 5,
metadata: { owner: 'security' },
},
});
assert.equal(notificationChannel.item?.channelCode, 'integration_platform_audit', 'platform admin should upsert audit notification channel');
assert.equal(notificationChannel.item?.secretRef, 'app_private.platform_secrets:webhook:integration_platform_audit', 'channel should expose only platform secretRef');
assert.equal(notificationChannel.item?.webhook?.host, 'ops.example.test', 'channel response should expose safe webhook host');
assert.equal(notificationChannel.item?.webhookUrl, undefined, 'channel response must not expose raw webhook URL');
assert.ok(!JSON.stringify(notificationChannel).includes('integration-platform-audit-notification-secret'), 'channel response must not leak webhook secret');
const notificationChannels = await request('/api/platform-admin/audit-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { enabled: true, provider: 'generic', limit: 20 },
});
assert.ok(
notificationChannels.items?.some(item => item.channelCode === 'integration_platform_audit'),
'platform admin should list audit notification channels',
);
assert.ok(!JSON.stringify(notificationChannels).includes('integration-platform-audit-notification-secret'), 'channel list must not leak webhook secret');
const invalidNotificationChannel = await request('/api/platform-admin/audit-notification-channels', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PUT',
body: {
channelCode: 'bad_channel',
name: 'bad channel',
provider: 'generic',
webhookUrl: 'ftp://ops.example.test/hook',
},
expectStatus: 400,
});
assert.equal(invalidNotificationChannel.code, 'INVALID_WEBHOOK_URL', 'audit notification channel should reject unsafe webhook URL');
const eventPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
let platformNotificationEventId = '';
try {
const channelRow = await eventPool.query(
"select id from public.platform_audit_notification_channels where channel_code = 'integration_platform_audit' limit 1",
);
const insertedEvent = await eventPool.query(
`
insert into public.platform_audit_notification_events (
channel_id, alert_id, audit_log_id, provider, status, attempts,
last_http_code, request_payload, last_response_summary
)
values (
$1, $2, $3, 'generic', 'sent', 1,
200,
'{"body":{"token":"must-not-leak","nested":{"password":"must-not-leak"}}}'::jsonb,
'{"ok":true}'
)
on conflict (channel_id, alert_id)
do update set status = excluded.status,
attempts = excluded.attempts,
request_payload = excluded.request_payload,
updated_at = now()
returning id
`,
[channelRow.rows[0].id, platformAlertId, statusAuditLog.id],
);
platformNotificationEventId = insertedEvent.rows[0].id;
} finally {
await eventPool.end();
}
const notificationEvents = await request('/api/platform-admin/audit-notification-events', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { alertId: platformAlertId, status: 'sent', limit: 20 },
});
assert.ok(
notificationEvents.items?.some(item => item.id === platformNotificationEventId),
'platform admin should list audit notification events',
);
const listedNotificationEvent = notificationEvents.items?.find(item => item.id === platformNotificationEventId);
assert.equal(listedNotificationEvent?.requestPayload?.body?.token, '[REDACTED]', 'notification event list should redact token-like payload details');
assert.equal(listedNotificationEvent?.requestPayload?.body?.nested?.password, '[REDACTED]', 'notification event list should redact nested password-like payload details');
assert.ok(!JSON.stringify(notificationEvents).includes('must-not-leak'), 'notification event list must not leak sensitive details');
const candidates = await request('/api/platform-admin/invoices/subscription-candidates', { const candidates = await request('/api/platform-admin/invoices/subscription-candidates', {
tenantId: false, tenantId: false,
userId: false, userId: false,
@@ -1555,6 +1654,20 @@ async function testPlatformTenantOperationsAndAudit() {
}); });
assert.equal(studentAuditAlertStatusDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not update platform audit alerts'); assert.equal(studentAuditAlertStatusDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not update platform audit alerts');
const studentAuditNotificationChannelDenied = await request('/api/platform-admin/audit-notification-channels', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentAuditNotificationChannelDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit notification channels');
const studentAuditNotificationEventDenied = await request('/api/platform-admin/audit-notification-events', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentAuditNotificationEventDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit notification events');
const studentReminderDenied = await request('/api/platform-admin/invoices/reminders', { const studentReminderDenied = await request('/api/platform-admin/invoices/reminders', {
tenantId: false, tenantId: false,
userId: USER_ID, userId: USER_ID,

View File

@@ -0,0 +1,222 @@
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-00000000ab01',
auditLog: '00000000-0000-0000-0000-00000000ab02',
alert: '00000000-0000-0000-0000-00000000ab03',
};
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-audit`,
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-audit-notifications'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE: '20',
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true',
WORKER_PLATFORM_AUDIT_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-audit-notifications batch enqueued=\d+/, 'worker output should include notification summary');
resolve(output);
} catch (error) {
reject(error);
}
});
});
}
async function cleanup(pool) {
await pool.query("delete from public.platform_audit_notification_events where alert_id = $1", [ids.alert]);
await pool.query("delete from public.platform_audit_notification_channels where channel_code = 'worker_platform_audit_test'");
await pool.query("delete from app_private.platform_secrets where secret_scope = 'webhook' and secret_key = 'worker_platform_audit_test'");
await pool.query('delete from public.platform_audit_alerts where id = $1 or audit_log_id = $2', [ids.alert, ids.auditLog]);
await pool.query('delete from public.audit_logs where id = $1 or tenant_id = $2', [ids.auditLog, 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-audit-notification-worker', '平台审计通知租户', '平台审计通知有限公司', 'active', 'saas', 'active', '{"source":"platform-audit-notification-worker-test"}'::jsonb)
`,
[ids.tenant],
);
await pool.query(
`
insert into public.audit_logs (
id, tenant_id, actor_user_id, action, target_type, target_id,
details, ip_address, user_agent, created_at
)
values (
$1, $2::uuid, null, 'platform.tenant.status_updated', 'tenant', $3,
'{"status":"suspended","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
'127.0.0.1', 'platform-audit-notification-worker-test', now()
)
`,
[ids.auditLog, ids.tenant, ids.tenant],
);
const rule = await pool.query("select id from public.platform_audit_alert_rules where code = 'platform_tenant_status_changed' limit 1");
await pool.query(
`
insert into public.platform_audit_alerts (
id, rule_id, audit_log_id, tenant_id, severity, status,
action, target_type, target_id, title, summary, details,
first_seen_at, last_seen_at
)
values (
$1, $2, $3, $4::uuid, 'high', 'open',
'platform.tenant.status_updated', 'tenant', $4,
'租户状态变更告警', 'platform audit notification integration alert',
'{"source":"worker-test","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
now(), now()
)
`,
[ids.alert, rule.rows[0].id, ids.auditLog, ids.tenant],
);
await pool.query(
`
insert into app_private.platform_secrets (secret_scope, secret_key, secret_value, provider, last_rotated_at)
values ('webhook', 'worker_platform_audit_test', 'platform-audit-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_audit_notification_channels (
channel_code, name, enabled, provider, webhook_url, secret_ref,
min_severity, status_filter, action_patterns, timeout_sec
)
values (
'worker_platform_audit_test', 'Worker 平台审计通知', true, 'generic',
$1, 'app_private.platform_secrets:webhook:worker_platform_audit_test',
'medium', array['open']::text[], array['platform.tenant.*']::text[], 5
)
on conflict (channel_code)
do update set enabled = excluded.enabled,
provider = excluded.provider,
webhook_url = excluded.webhook_url,
secret_ref = excluded.secret_ref,
min_severity = excluded.min_severity,
status_filter = excluded.status_filter,
action_patterns = excluded.action_patterns,
timeout_sec = excluded.timeout_sec,
updated_at = now()
`,
[webhookUrl],
);
}
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 audit notification');
assert.equal(webhook.requests.length, 1, 'worker should call webhook exactly once');
assert.equal(webhook.requests[0].body.event, 'platform.audit.alert', 'generic notification should use platform audit event name');
assert.equal(webhook.requests[0].body.alert.id, ids.alert, 'webhook body should include alert id');
assert.equal(webhook.requests[0].body.alert.details.apiKey, '[REDACTED]', 'webhook body should redact token-like keys');
assert.equal(webhook.requests[0].body.alert.details.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-audit-notification-secret'), 'webhook request must not leak signing secret');
const events = await pool.query(
`
select status, attempts, last_http_code, sent_at, request_payload
from public.platform_audit_notification_events
where alert_id = $1
limit 1
`,
[ids.alert],
);
assert.equal(events.rowCount, 1, 'worker should create one notification event');
assert.equal(events.rows[0].status, 'sent', 'notification event should be sent');
assert.equal(events.rows[0].attempts, 1, 'notification event should record one attempt');
assert.equal(events.rows[0].last_http_code, 200, 'notification event should record HTTP 200');
assert.ok(events.rows[0].sent_at, '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-audit-notification-secret'), 'stored request payload should not leak secret');
const secondOutput = await runWorkerOnce();
assert.match(secondOutput, /sent=0/, 'second worker run should not resend sent notification');
assert.equal(webhook.requests.length, 1, 'worker should not duplicate sent notification');
console.log('Platform audit notification worker integration test complete.');
} finally {
await cleanup(pool).catch(() => {});
await pool.end();
await webhook.close();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});

View File

@@ -20,6 +20,7 @@ const safeBaseEnv = {
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT: 'https://scanner.gongxue100.com/api/scan', WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT: 'https://scanner.gongxue100.com/api/scan',
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: 's3cure-asset-scanner-token-2026-06-30-abcdef', WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: 's3cure-asset-scanner-token-2026-06-30-abcdef',
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false', WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false',
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'false',
}; };
const safeApiEnv = { const safeApiEnv = {
@@ -78,6 +79,17 @@ const unsafeWorkerScanner = runImport(workerConfigUrl, {
assert.notEqual(unsafeWorkerScanner.status, 0, 'production worker config should require external scanner'); assert.notEqual(unsafeWorkerScanner.status, 0, 'production worker config should require external scanner');
assert.match(unsafeWorkerScanner.output, /WORKER_ASSET_SECURITY_SCANNER must include http/, 'worker config should require http scanner'); assert.match(unsafeWorkerScanner.output, /WORKER_ASSET_SECURITY_SCANNER must include http/, 'worker config should require http scanner');
const unsafeWorkerPlatformAuditNotification = runImport(workerConfigUrl, {
...safeBaseEnv,
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true',
});
assert.notEqual(unsafeWorkerPlatformAuditNotification.status, 0, 'production worker config should reject platform audit notification localhost mode');
assert.match(
unsafeWorkerPlatformAuditNotification.output,
/WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true/,
'worker config should name unsafe platform audit notification localhost mode',
);
const safeWorker = runImport(workerConfigUrl, safeBaseEnv); const safeWorker = runImport(workerConfigUrl, safeBaseEnv);
assert.equal(safeWorker.status, 0, `safe production worker config should load: ${safeWorker.output}`); assert.equal(safeWorker.status, 0, `safe production worker config should load: ${safeWorker.output}`);

View File

@@ -79,6 +79,7 @@ WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuv
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000 WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_CRM_BATCH_SIZE=20 WORKER_CRM_BATCH_SIZE=20
WORKER_COMMERCE_BATCH_SIZE=20 WORKER_COMMERCE_BATCH_SIZE=20
WORKER_ASSET_BATCH_SIZE=50 WORKER_ASSET_BATCH_SIZE=50
@@ -115,6 +116,7 @@ WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan 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_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
`); `);
assert.notEqual(missingJwksIssuer.status, 0, 'JWKS readiness without issuer should fail'); assert.notEqual(missingJwksIssuer.status, 0, 'JWKS readiness without issuer should fail');
@@ -123,4 +125,36 @@ assert.ok(
'JWKS readiness should block missing AUTH_JWT_ISSUER', 'JWKS readiness should block missing AUTH_JWT_ISSUER',
); );
const unsafePlatformAuditNotificationLocalhost = 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=true
`);
assert.notEqual(unsafePlatformAuditNotificationLocalhost.status, 0, 'platform audit notification localhost readiness should fail');
assert.ok(
unsafePlatformAuditNotificationLocalhost.payload.checks?.some(item => item.id === 'env.worker_platform_audit_notification_insecure_localhost' && item.status === 'blocker'),
'readiness should block platform audit notification localhost mode in production',
);
console.log('[PASS] production readiness check script'); console.log('[PASS] production readiness check script');

View File

@@ -321,11 +321,17 @@ function validateEnv() {
pass('env.asset_security_scan_fail_open', 'asset security scanning fails closed'); pass('env.asset_security_scan_fail_open', 'asset security scanning fails closed');
} }
if (envBool('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false)) { if (envBool('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false)) {
block('env.worker_crm_insecure_localhost', 'WORKER_CRM_ALLOW_INSECURE_LOCALHOST must be false in production'); block('env.worker_crm_insecure_localhost', 'WORKER_CRM_ALLOW_INSECURE_LOCALHOST must be false in production');
} else { } else {
pass('env.worker_crm_insecure_localhost', 'CRM worker insecure localhost webhook mode is disabled'); pass('env.worker_crm_insecure_localhost', 'CRM worker insecure localhost webhook mode is disabled');
} }
if (envBool('WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false)) {
block('env.worker_platform_audit_notification_insecure_localhost', 'WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST must be false in production');
} else {
pass('env.worker_platform_audit_notification_insecure_localhost', 'Platform audit notification worker insecure localhost webhook mode is disabled');
}
const requiredPositiveNumbers = [ const requiredPositiveNumbers = [
'WORKER_CRM_BATCH_SIZE', 'WORKER_CRM_BATCH_SIZE',
@@ -419,6 +425,51 @@ async function validateDatabase() {
pass('db.payment_provider_secrets', 'Active payment accounts have private secret rows'); pass('db.payment_provider_secrets', 'Active payment accounts have private secret rows');
} }
const unsafePlatformAuditNotificationRows = await pool.query(`
select id, channel_code, provider, webhook_url
from public.platform_audit_notification_channels
where enabled = true
and (
webhook_url !~* '^https://'
or webhook_url ~* '^https?://(localhost|127\\.0\\.0\\.1|\\[?::1\\]?)'
)
`);
if (unsafePlatformAuditNotificationRows.rowCount > 0) {
block('db.platform_audit_notification_webhooks', 'Enabled platform audit notification webhooks must use production HTTPS URLs', {
count: unsafePlatformAuditNotificationRows.rowCount,
samples: unsafePlatformAuditNotificationRows.rows.slice(0, 5).map(row => ({
id: row.id,
channelCode: row.channel_code,
provider: row.provider,
})),
});
} else {
pass('db.platform_audit_notification_webhooks', 'Enabled platform audit notification webhooks use production HTTPS URLs');
}
const missingPlatformAuditNotificationSecretRows = await pool.query(`
select c.id, c.channel_code, c.provider, c.secret_ref
from public.platform_audit_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 (missingPlatformAuditNotificationSecretRows.rowCount > 0) {
block('db.platform_audit_notification_secrets', 'Signed platform audit notification channels require app_private.platform_secrets rows', {
count: missingPlatformAuditNotificationSecretRows.rowCount,
samples: missingPlatformAuditNotificationSecretRows.rows.slice(0, 5).map(row => ({
id: row.id,
channelCode: row.channel_code,
provider: row.provider,
})),
});
} else {
pass('db.platform_audit_notification_secrets', 'Signed platform audit notification channels have private secret rows');
}
const unverifiedDomainRows = await pool.query(` const unverifiedDomainRows = await pool.query(`
select count(*)::int as count select count(*)::int as count
from public.tenant_domains from public.tenant_domains

View File

@@ -0,0 +1,114 @@
create table if not exists app_private.platform_secrets (
id uuid primary key default gen_random_uuid(),
secret_scope text not null check (secret_scope in ('webhook', 'sms', 'email', 'payment', 'system', 'ai')),
secret_key text not null,
secret_value text,
secret_json jsonb not null default '{}'::jsonb,
provider text,
last_rotated_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (secret_scope, secret_key)
);
comment on table app_private.platform_secrets is
'Private platform-level secrets for SaaS operations. API responses must expose only secretRef/masked metadata.';
create table if not exists public.platform_audit_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,
min_severity text not null default 'medium'
check (min_severity in ('low', 'medium', 'high', 'critical')),
status_filter text[] not null default array['open']::text[],
action_patterns text[] not null default '{}'::text[],
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_audit_notification_status_filter_check
check (status_filter <@ array['open', 'acknowledged', 'resolved', 'ignored']::text[])
);
create table if not exists public.platform_audit_notification_events (
id uuid primary key default gen_random_uuid(),
channel_id uuid not null references public.platform_audit_notification_channels(id) on delete cascade,
alert_id uuid not null references public.platform_audit_alerts(id) on delete cascade,
audit_log_id uuid references public.audit_logs(id) on delete set null,
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, alert_id)
);
create index if not exists idx_platform_audit_notification_channels_enabled
on public.platform_audit_notification_channels(enabled, min_severity, channel_code);
create index if not exists idx_platform_audit_notification_events_due
on public.platform_audit_notification_events(status, coalesce(next_attempt_at, scheduled_at), created_at);
create index if not exists idx_platform_audit_notification_events_alert
on public.platform_audit_notification_events(alert_id, status, created_at desc);
create index if not exists idx_platform_audit_notification_events_channel
on public.platform_audit_notification_events(channel_id, status, created_at desc);
create index if not exists idx_platform_secrets_lookup
on app_private.platform_secrets(secret_scope, secret_key);
alter table app_private.platform_secrets enable row level security;
alter table public.platform_audit_notification_channels enable row level security;
alter table public.platform_audit_notification_events enable row level security;
drop policy if exists platform_admin_platform_secrets on app_private.platform_secrets;
create policy platform_admin_platform_secrets on app_private.platform_secrets
for all
using (app.is_platform_admin())
with check (app.is_platform_admin());
drop policy if exists platform_admin_audit_notification_channels on public.platform_audit_notification_channels;
create policy platform_admin_audit_notification_channels on public.platform_audit_notification_channels
for all
using (app.is_platform_admin())
with check (app.is_platform_admin());
drop policy if exists platform_admin_audit_notification_events on public.platform_audit_notification_events;
create policy platform_admin_audit_notification_events on public.platform_audit_notification_events
for all
using (app.is_platform_admin())
with check (app.is_platform_admin());
drop trigger if exists set_updated_at on app_private.platform_secrets;
create trigger set_updated_at
before update on app_private.platform_secrets
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.platform_audit_notification_channels;
create trigger set_updated_at
before update on public.platform_audit_notification_channels
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.platform_audit_notification_events;
create trigger set_updated_at
before update on public.platform_audit_notification_events
for each row execute function app.touch_updated_at();