diff --git a/.env.example b/.env.example index 5be0fc10..b24f769a 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,17 @@ WORKER_PLATFORM_BILLING_ID=platform-billing-1 WORKER_PLATFORM_DUNNING_BATCH_SIZE=100 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_ASSET_BATCH_SIZE=50 WORKER_ASSET_MIN_AGE_SECONDS=300 diff --git a/README.md b/README.md index 24ac7de0..ff936833 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ - 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 -- 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、用量记录、公共题库授权。 +- 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、用量记录、公共题库授权。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。 -- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;provider-bills worker 可下载微信/支付宝官方账单并导入资金对账;platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计;platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计;platform-audit-alerts worker 可把高风险平台审计动作转换为内部告警并递归脱敏告警 details;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 +- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;provider-bills worker 可下载微信/支付宝官方账单并导入资金对账;platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计;platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计;platform-audit-alerts worker 可把高风险平台审计动作转换为内部告警并递归脱敏告警 details;platform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。 - 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 - 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。 @@ -35,7 +35,7 @@ - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 - 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成,Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。 -- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警确认/解决、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、用量和题库授权第一版写操作。 +- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 更完整的进度看这些文档: @@ -63,7 +63,7 @@ ```text apps/api/ Node.js 业务 API apps/taro/ Taro 4 React 跨端前端,H5 三入口,后续扩展小程序 -apps/worker/ 后台异步任务:CRM webhook、支付/退款补偿、官方账单下载、平台审计告警、资源复检、导入执行、公共题库同步、题库导出渲染等 +apps/worker/ 后台异步任务:CRM webhook、支付/退款补偿、官方账单下载、平台审计告警/外部通知、资源复检、导入执行、公共题库同步、题库导出渲染等 packages/config/ 共享配置 packages/db/ PostgreSQL 连接池和查询封装 packages/domain/ 领域常量和共享类型 @@ -222,7 +222,7 @@ WORKER_PLATFORM_DUNNING_ID=platform-dunning-prod-1 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 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: + +```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: ```bash @@ -304,6 +320,7 @@ npm run test:worker:commerce npm run test:worker:platform-billing npm run test:worker:platform-dunning npm run test:worker:platform-audit-alerts +npm run test:worker:platform-audit-notifications npm run test:worker:assets npm run test:worker:exports npm run test:worker:imports @@ -469,7 +486,7 @@ npm run audit:runtime git diff --check ``` -结果:通过。`npm run test:auth:remote-smoke` 覆盖远程 Auth/JWKS 验收脚本自身。`npm run test:rls` 覆盖 75 条运行时 RLS 断言,包含主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝。`npm run test:api` 覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、平台账单逾期 dry-run/催缴记录、平台审计告警查询/状态更新/越权拒绝/敏感 details 脱敏、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:platform-billing` 覆盖平台 SaaS 订阅自动计费、重复开票保护、账单明细和审计。`npm run test:worker:platform-dunning` 覆盖平台 SaaS 逾期账单标记、内部催缴记录、租户 `past_due` 状态和每日催缴幂等。`npm run test:worker:platform-audit-alerts` 覆盖平台审计告警生成、规则匹配、幂等、防重复和告警 details 脱敏。`npm run test:worker: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 官方修复。 @@ -478,7 +495,7 @@ git diff --check 优先继续补: 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 -2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台外部催缴通知、在线收款、审计报表增强、审计告警外部通知/升级策略和更细平台权限点,小程序兼容验证。 +2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台外部催缴通知、在线收款、审计报表增强、审计告警通知升级策略和更细平台权限点,小程序兼容验证。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/platform-admin/index.ts b/apps/api/src/features/platform-admin/index.ts index e34851ef..d700a257 100644 --- a/apps/api/src/features/platform-admin/index.ts +++ b/apps/api/src/features/platform-admin/index.ts @@ -9,6 +9,8 @@ import { invoiceRemindersRoute, platformAuditAlertRulesRoute, platformAuditAlertsRoute, + platformAuditNotificationChannelsRoute, + platformAuditNotificationEventsRoute, platformAuditLogsExportRoute, platformAuditLogsRoute, platformOverviewRoute, @@ -24,6 +26,7 @@ import { tenantUsageRoute, updatePlatformAuditAlertStatusRoute, updateTenantStatusRoute, + upsertPlatformAuditNotificationChannelRoute, upsertQuestionBankGrantRoute, upsertBillingProfileRoute, } from './routes.js'; @@ -44,6 +47,9 @@ export const platformAdminRoutes: RouteDefinition[] = [ ['GET', '/api/platform-admin/audit-alert-rules', platformAuditAlertRulesRoute], ['GET', '/api/platform-admin/audit-alerts', platformAuditAlertsRoute], ['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], ['GET', '/api/platform-admin/invoices', tenantInvoicesRoute], ['POST', '/api/platform-admin/invoices', createInvoiceRoute], diff --git a/apps/api/src/features/platform-admin/routes.ts b/apps/api/src/features/platform-admin/routes.ts index 0c6c9e7a..c72cf176 100644 --- a/apps/api/src/features/platform-admin/routes.ts +++ b/apps/api/src/features/platform-admin/routes.ts @@ -41,9 +41,20 @@ function optionalUuidArray(body: Record, key: string) { return optionalStringArray(body, key).filter(Boolean); } +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +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 TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']); 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) { if (value === null || value === undefined) return ''; @@ -119,6 +130,98 @@ function auditAlertStatusFrom(value: string) { 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>(item: T) { + return { + ...item, + webhookUrl: undefined, + webhook: safeWebhookInfo(String(item.webhookUrl || '')), + }; +} + interface PlatformAuditAlertRow { id: string; 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>( + ` + 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>( + ` + 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>( + ` + 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) { await requirePlatformAdmin(ctx); diff --git a/apps/taro/src/pages/platform-admin/workbench/index.tsx b/apps/taro/src/pages/platform-admin/workbench/index.tsx index a0f25946..34140644 100644 --- a/apps/taro/src/pages/platform-admin/workbench/index.tsx +++ b/apps/taro/src/pages/platform-admin/workbench/index.tsx @@ -5,6 +5,8 @@ import { exportPlatformAuditLogs, loadPlatformAuditAlerts, loadPlatformAuditLogs, + loadPlatformAuditNotificationChannels, + loadPlatformAuditNotificationEvents, loadPlatformInvoices, loadPlatformOverview, loadPlatformQuestionBankGrants, @@ -13,6 +15,8 @@ import { updatePlatformAuditAlertStatus, type PlatformAuditAlertItem, type PlatformAuditLogItem, + type PlatformAuditNotificationChannelItem, + type PlatformAuditNotificationEventItem, type PlatformInvoiceItem, type PlatformOverview, type PlatformQuestionBankGrant, @@ -42,6 +46,8 @@ export default function PlatformWorkbenchPage() { const [invoices, setInvoices] = useState([]); const [auditLogs, setAuditLogs] = useState([]); const [auditAlerts, setAuditAlerts] = useState([]); + const [auditNotificationChannels, setAuditNotificationChannels] = useState([]); + const [auditNotificationEvents, setAuditNotificationEvents] = useState([]); const [banks, setBanks] = useState([]); const [grants, setGrants] = useState([]); const [error, setError] = useState(''); @@ -56,7 +62,9 @@ export default function PlatformWorkbenchPage() { loadPlatformQuestionBankGrants({ limit: 6 }).catch(() => ({ items: [] })), loadPlatformAuditLogs({ 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); setTenants(tenantPayload.items || []); setInvoices(invoicePayload.items || []); @@ -64,6 +72,8 @@ export default function PlatformWorkbenchPage() { setGrants(grantPayload.items || []); setAuditLogs(auditPayload.items || []); setAuditAlerts(alertPayload.items || []); + setAuditNotificationChannels(channelPayload.items || []); + setAuditNotificationEvents(eventPayload.items || []); }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台后台加载失败')); }, []); @@ -196,6 +206,30 @@ export default function PlatformWorkbenchPage() { {!auditAlerts.length ? 暂无开放审计告警。 : null} + + 告警外部通知 + + 启用渠道{String(auditNotificationChannels.length)} + 最近事件{String(auditNotificationEvents.length)} + 失败事件{String(auditNotificationEvents.filter(item => item.status === 'failed').length)} + 待重试{String(auditNotificationEvents.filter(item => item.status === 'retrying').length)} + + + {auditNotificationChannels.map(item => ( + + {item.name || item.channelCode || '-'} + {item.provider || '-'} · {item.minSeverity || '-'} · {item.webhook?.host || '-'} · {item.statusFilter?.join('/') || '-'} + + ))} + {auditNotificationEvents.map(item => ( + + {item.alertTitle || item.alertAction || '-'} + {item.channelName || item.channelCode || '-'} · {item.status || '-'} · HTTP {String(item.lastHttpCode || '-')} · {item.tenantName || item.tenantSlug || '平台'} + + ))} + + {!auditNotificationChannels.length && !auditNotificationEvents.length ? 暂无外部通知渠道或发送事件。 : null} + {error ? {error} : null} diff --git a/apps/taro/src/services/platformAdmin.ts b/apps/taro/src/services/platformAdmin.ts index 995fda17..6282f82b 100644 --- a/apps/taro/src/services/platformAdmin.ts +++ b/apps/taro/src/services/platformAdmin.ts @@ -281,6 +281,53 @@ export interface PlatformAuditAlertItem { 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 | 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 { slug: 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 } = {}) { return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', { query: { ...query, limit: query.limit || 80 }, diff --git a/apps/worker/package.json b/apps/worker/package.json index 89c2a4b3..7df38888 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -14,6 +14,7 @@ "platform-billing:once": "tsx src/index.ts --once --job platform-billing", "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-notifications:once": "tsx src/index.ts --once --job platform-audit-notifications", "assets:once": "tsx src/index.ts --once --job assets", "imports:once": "tsx src/index.ts --once --job imports", "public-banks:once": "tsx src/index.ts --once --job public-banks", diff --git a/apps/worker/src/config.ts b/apps/worker/src/config.ts index 50e03a5d..7329702d 100644 --- a/apps/worker/src/config.ts +++ b/apps/worker/src/config.ts @@ -27,6 +27,11 @@ export interface WorkerConfig { platformAuditAlertBatchSize: number; platformAuditAlertWorkerId: string; platformAuditAlertLookbackDays: number; + platformAuditNotificationBatchSize: number; + platformAuditNotificationMaxAttempts: number; + platformAuditNotificationBackoffSeconds: number[]; + platformAuditNotificationRequestTimeoutMs: number; + platformAuditNotificationAllowInsecureLocalhost: boolean; assetBatchSize: number; assetMinAgeSeconds: number; assetRecheckIntervalSeconds: number; @@ -111,6 +116,9 @@ function validateProductionConfig(nextConfig: WorkerConfig) { if (nextConfig.assetSecurityScanFailOpen) { 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 .split(',') .map(item => item.trim().toLowerCase()) @@ -184,6 +192,13 @@ const loadedConfig: WorkerConfig = { platformAuditAlertBatchSize: envNumber('WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE', 200), platformAuditAlertWorkerId: envString('WORKER_PLATFORM_AUDIT_ALERT_ID', `platform-audit-alerts-${process.pid}`), platformAuditAlertLookbackDays: envNumber('WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS', 14), + 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), assetMinAgeSeconds: envNumber('WORKER_ASSET_MIN_AGE_SECONDS', 300), assetRecheckIntervalSeconds: envNumber('WORKER_ASSET_RECHECK_INTERVAL_SECONDS', 60 * 60 * 24), diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 167b513f..1c3659e7 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -70,6 +70,16 @@ async function runOnce() { ); 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') { const result = await processAssetBatch(); console.log( diff --git a/apps/worker/src/jobs/platform-audit-notifications.ts b/apps/worker/src/jobs/platform-audit-notifications.ts new file mode 100644 index 00000000..d8f8c39b --- /dev/null +++ b/apps/worker/src/jobs/platform-audit-notifications.ts @@ -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 | null; + alertCreatedAt: string; +} + +interface SecretRow { + secretValue: string | null; + secretJson: Record | null; +} + +interface PreparedRequest { + provider: NotificationProvider; + url: string; + body: Record; + headers: Record; +} + +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 { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +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 = {}; + for (const [key, item] of Object.entries(value as Record)) { + 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 { + 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 { + 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) { + 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( + ` + 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( + ` + 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( + ` + 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 { + 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 { + 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; +} diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index bf46d2b0..1ccf8cca 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -145,7 +145,7 @@ | 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 | | 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 | | 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 | -| 平台租户/详情/账务资料/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账和用量;审计导出和告警响应都会对 `details` 中的 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 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 | | 平台公共题库授权 | 可联调 | `/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 | @@ -203,6 +203,7 @@ npm run test:worker:commerce npm run test:worker:platform-billing npm run test:worker:platform-dunning npm run test:worker:platform-audit-alerts +npm run test:worker:platform-audit-notifications npm run test:worker:assets npm run test:worker:exports npm run test:worker:public-banks diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index d806541e..77cd5b39 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -21,7 +21,7 @@ | 模块 | 当前状态 | 已经具备 | 上线前还要补 | | --- | --- | --- | --- | | 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 | -| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 外部催缴通知、平台在线收款、平台审计告警外部通知/升级策略、更完整运营消息 | +| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 外部催缴通知、平台在线收款、平台审计告警升级策略和更完整运营消息 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 | @@ -35,7 +35,7 @@ | 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | | AI 择校推荐 | 可联调 | `ai_recommendation_reports`、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON、报告列表/详情和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF 渲染和人工复核流程 | -| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台审计查询和 CSV 导出、开放审计告警展示/确认/解决、订阅账单候选预览/dry-run/批量生成、自动计费生成结果查看、逾期预览和催缴记录第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台审计告警外部通知/升级策略、小程序兼容验证和端到端测试 | +| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台审计查询和 CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选预览/dry-run/批量生成、自动计费生成结果查看、逾期预览和催缴记录第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台审计告警升级策略、小程序兼容验证和端到端测试 | ## 前端接入建议 @@ -79,7 +79,7 @@ - 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。 - 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,确认 `migrationReadiness` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。 - 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。 -- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览和催缴记录第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台审计告警外部通知/升级策略和小程序兼容验证。 +- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览和催缴记录第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台审计告警升级策略和小程序兼容验证。 ### P1:商用收费和运营能力 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 2d382e86..dea7ece5 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -14,12 +14,12 @@ - `video`:题目视频讲解、批量预加载、通用视频搜索。 - `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。 - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。 - - `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/告警状态流、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、使用量。 + - `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/告警状态流、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、使用量。 - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。 - `tenant`:域名/租户解析。 - 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。 -- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者、日期和关键词查询平台审计;`GET /api/platform-admin/audit-logs/export` 支持平台管理员导出 CSV/JSON,返回 base64 内容、sha256、行数和筛选条件,并对 `details` 中的 token/secret/password/key 等敏感字段递归脱敏,同时写入 `platform.audit.exported` 审计;`GET /api/platform-admin/audit-alert-rules`、`GET /api/platform-admin/audit-alerts` 和 `POST /api/platform-admin/audit-alerts/status` 支持平台内部审计告警规则查询、开放告警查询、确认/解决/忽略,API 返回告警 details 时递归脱敏敏感字段;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`GET /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_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理已落库:`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-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-notifications worker 和 `npm run test:worker:platform-audit-notifications`,用于把开放平台审计告警按平台配置渠道推送到 generic/钉钉/飞书/企微 webhook;worker 先入队 `platform_audit_notification_events`,再发送并记录重试、HTTP 状态、脱敏后的请求 payload,避免泄露 webhook 签名密钥和告警敏感 details。 - 已新增 assets worker 和 `npm run test:worker:assets`,用于复检 `content_assets` 托管对象元数据;正常资源会写入复检证据,异常资源会自动下架为 `draft`、标记 `upload_status=failed`,并记录审计与安全标记。 - 已新增分数线/视频 JSON 导入接口和集成测试:分数线支持字段、院校、专业、记录、动态字段值;视频支持视频元数据、资源引用、访问模式和题目绑定,全部复用 `content_import_jobs/items/issues` 管线。 - 已新增 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-alerts 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 GET /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-dunning npm run test:worker:platform-audit-alerts +npm run test:worker:platform-audit-notifications npm run test:worker:assets npm run test:worker:imports npm run test:worker:public-banks diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 1150240a..0f27059f 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -31,7 +31,7 @@ - `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。 - 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。 -- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成和催缴记录查看。 +- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成和催缴记录查看。 - 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。 - 可以按新后端主模型接入内容导航: - `content_entries` @@ -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/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。下一批继续补租户基础资料编辑增强、平台审计告警升级策略、外部催缴通知、平台在线收款和更细平台权限点。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index fc0fc52c..4e82ef9b 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -73,13 +73,13 @@ | 功能 | 新后端状态 | 待补齐 | | --- | --- | --- | -| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护、最近平台审计查询/导出、开放审计告警查询/确认/解决、订阅账单候选预览、dry-run、批量生成、自动计费 worker、逾期标记和内部催缴台账已接真实 API/worker;后续补外部催缴通知、租户基础资料编辑增强和审计告警外部通知/升级策略 | +| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护、最近平台审计查询/导出、开放审计告警查询/确认/解决、审计告警外部通知渠道/事件、订阅账单候选预览、dry-run、批量生成、自动计费 worker、逾期标记和内部催缴台账已接真实 API/worker;后续补外部催缴通知、租户基础资料编辑增强和审计告警升级策略 | | SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 | | 年费/服务费账单 | 已覆盖 | 订阅账单候选、批量开票、自动计费、人工收款、逾期标记、租户 `past_due` 状态和内部催缴记录已覆盖;真实平台在线收款、外部短信/微信订阅消息/企业微信催缴和停用策略待补 | | 租户用量记录 | 已覆盖 | 自动采集 worker 待补 | | 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、同步通知、冲突查询 API、单条/批量冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺生产定时调度、失败告警和更完整运营消息 | | 跨租户运营看板 | 部分覆盖 | 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、升级策略和运营报表 | ## 旧功能中应重新设计的点 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 4129c3d3..d40fa0be 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -11,7 +11,7 @@ - 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。 - 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,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、分佣和优惠券规则/核销报表第一版操作台。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 练习组卷:`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-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 脱敏。 +- 平台审计告警外部通知第一版已完成:`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` 做独立复核权限,且审批凭证不会直接修改订单、支付、退款或权益。 - 内容资源复检与安全扫描 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。 @@ -188,7 +189,7 @@ - H5 和小程序共用同一套业务 API client。 - 租户通过域名、小程序配置或启动参数解析。 - 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。 -- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。 +- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。 ### 第一批页面 @@ -241,7 +242,7 @@ 1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。 -3. 补平台后台增强:租户基础资料编辑增强、平台审计告警外部通知/升级策略、外部催缴通知、平台在线收款和更细平台权限点。 +3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、外部催缴通知、平台在线收款和更细平台权限点。 4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。 6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index d0232e5c..10d48087 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -2519,7 +2519,7 @@ src/services/ai.ts AI 择校推荐生成、报告列表、报告详情 src/services/pronunciation.ts H5/小程序单词发音适配 src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录 src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证 -src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计查询/CSV 导出、平台审计告警规则/列表/状态更新、套餐账单、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览/内部催缴记录、用量、公共题库授权 +src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计查询/CSV 导出、平台审计告警规则/列表/状态更新、审计告警外部通知渠道/发送事件、套餐账单、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览/内部催缴记录、用量、公共题库授权 ``` 验证命令: @@ -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`。 - 后端会对告警 `details` 递归脱敏 token、secret、password、key、authorization、cookie、session、cert、signature 等敏感字段;前端仍不要把 details 原样写入日志或埋点。 - 确认、解决和忽略都会写入 `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=&invoiceId= 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', { tenantId: false, userId: false, @@ -1555,6 +1654,20 @@ async function testPlatformTenantOperationsAndAudit() { }); 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', { tenantId: false, userId: USER_ID, diff --git a/scripts/platform-audit-notification-worker-integration-test.js b/scripts/platform-audit-notification-worker-integration-test.js new file mode 100644 index 00000000..97772276 --- /dev/null +++ b/scripts/platform-audit-notification-worker-integration-test.js @@ -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); +}); diff --git a/scripts/production-config-failfast-test.js b/scripts/production-config-failfast-test.js index 11a2307e..a297f748 100644 --- a/scripts/production-config-failfast-test.js +++ b/scripts/production-config-failfast-test.js @@ -20,6 +20,7 @@ const safeBaseEnv = { 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_FAIL_OPEN: 'false', + WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'false', }; const safeApiEnv = { @@ -78,6 +79,17 @@ const unsafeWorkerScanner = runImport(workerConfigUrl, { 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'); +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); assert.equal(safeWorker.status, 0, `safe production worker config should load: ${safeWorker.output}`); diff --git a/scripts/production-readiness-check-test.js b/scripts/production-readiness-check-test.js index 78eaad8f..547ca18c 100644 --- a/scripts/production-readiness-check-test.js +++ b/scripts/production-readiness-check-test.js @@ -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_FAIL_OPEN=false WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false +WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false WORKER_CRM_BATCH_SIZE=20 WORKER_COMMERCE_BATCH_SIZE=20 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_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx 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'); @@ -123,4 +125,36 @@ assert.ok( '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'); diff --git a/scripts/production-readiness-check.js b/scripts/production-readiness-check.js index 78611e26..e3ec96b1 100644 --- a/scripts/production-readiness-check.js +++ b/scripts/production-readiness-check.js @@ -321,11 +321,17 @@ function validateEnv() { pass('env.asset_security_scan_fail_open', 'asset security scanning fails closed'); } - if (envBool('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false)) { - block('env.worker_crm_insecure_localhost', 'WORKER_CRM_ALLOW_INSECURE_LOCALHOST must be false in production'); - } else { - pass('env.worker_crm_insecure_localhost', 'CRM worker insecure localhost webhook mode is disabled'); - } + if (envBool('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false)) { + block('env.worker_crm_insecure_localhost', 'WORKER_CRM_ALLOW_INSECURE_LOCALHOST must be false in production'); + } else { + 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 = [ 'WORKER_CRM_BATCH_SIZE', @@ -419,6 +425,51 @@ async function validateDatabase() { 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(` select count(*)::int as count from public.tenant_domains diff --git a/supabase/migrations/202606300003_platform_audit_alert_notifications.sql b/supabase/migrations/202606300003_platform_audit_alert_notifications.sql new file mode 100644 index 00000000..eef3b379 --- /dev/null +++ b/supabase/migrations/202606300003_platform_audit_alert_notifications.sql @@ -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();