feat: add platform audit alerts

This commit is contained in:
Codex
2026-06-30 06:12:33 +08:00
parent b9957d18d5
commit 5bb0512ba7
20 changed files with 1061 additions and 23 deletions

View File

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

View File

@@ -7,6 +7,8 @@ import {
createTenantInvoicesBatchFromSubscriptionsRoute,
createTenantRoute,
invoiceRemindersRoute,
platformAuditAlertRulesRoute,
platformAuditAlertsRoute,
platformAuditLogsExportRoute,
platformAuditLogsRoute,
platformOverviewRoute,
@@ -20,6 +22,7 @@ import {
tenantInvoicesRoute,
tenantsRoute,
tenantUsageRoute,
updatePlatformAuditAlertStatusRoute,
updateTenantStatusRoute,
upsertQuestionBankGrantRoute,
upsertBillingProfileRoute,
@@ -38,6 +41,9 @@ export const platformAdminRoutes: RouteDefinition[] = [
['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute],
['GET', '/api/platform-admin/audit-logs', platformAuditLogsRoute],
['GET', '/api/platform-admin/audit-logs/export', platformAuditLogsExportRoute],
['GET', '/api/platform-admin/audit-alert-rules', platformAuditAlertRulesRoute],
['GET', '/api/platform-admin/audit-alerts', platformAuditAlertsRoute],
['POST', '/api/platform-admin/audit-alerts/status', updatePlatformAuditAlertStatusRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],

View File

@@ -43,6 +43,7 @@ function optionalUuidArray(body: Record<string, unknown>, key: string) {
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']);
function csvEscape(value: unknown) {
if (value === null || value === undefined) return '';
@@ -75,6 +76,8 @@ function redactAuditExportValue(value: unknown, parentKey = '', depth = 0): unkn
return value;
}
const redactAuditAlertValue = redactAuditExportValue;
function contentBase64AndHash(content: string) {
const buffer = Buffer.from(content, 'utf8');
return {
@@ -105,6 +108,24 @@ function auditExportFormat(value: string) {
return format;
}
function auditAlertStatusFrom(value: string) {
const status = value || 'acknowledged';
if (!PLATFORM_AUDIT_ALERT_STATUSES.has(status)) {
throw new HttpError(400, 'alert status is invalid', 'INVALID_ALERT_STATUS');
}
if (status === 'open') {
throw new HttpError(400, 'alert status cannot be reopened from this endpoint', 'INVALID_ALERT_STATUS');
}
return status;
}
interface PlatformAuditAlertRow {
id: string;
tenantId: string | null;
details: unknown;
[key: string]: unknown;
}
function optionalUuidList(value: unknown, key = 'ids', maxLength = 500) {
const values = Array.isArray(value)
? value.map(item => String(item).trim()).filter(Boolean)
@@ -829,6 +850,147 @@ export async function platformAuditLogsExportRoute(ctx: RequestContext) {
};
}
export async function platformAuditAlertRulesRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const enabled = listQuery(ctx, 'enabled');
if (enabled && !['true', 'false'].includes(enabled)) {
throw new HttpError(400, 'enabled must be true or false', 'INVALID_BOOLEAN');
}
const items = await query(
`
select id, code, name, description, enabled, severity,
action_patterns as "actionPatterns", target_types as "targetTypes",
tenant_id as "tenantId", conditions, metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.platform_audit_alert_rules
where ($1::text = '' or enabled = ($1 = 'true'))
order by enabled desc,
case severity when 'critical' then 1 when 'high' then 2 when 'medium' then 3 else 4 end,
code asc
`,
[enabled],
);
return { items };
}
export async function platformAuditAlertsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const tenantId = listQuery(ctx, 'tenantId');
const status = listQuery(ctx, 'status');
const severity = listQuery(ctx, 'severity');
const ruleCode = listQuery(ctx, 'ruleCode');
const q = listQuery(ctx, 'q');
const limit = intParam(ctx, 'limit', 100, 500);
if (tenantId && !UUID_RE.test(tenantId)) throw new HttpError(400, 'tenantId is invalid', 'INVALID_UUID');
if (status && !PLATFORM_AUDIT_ALERT_STATUSES.has(status)) throw new HttpError(400, 'status is invalid', 'INVALID_ALERT_STATUS');
if (severity && !['low', 'medium', 'high', 'critical'].includes(severity)) throw new HttpError(400, 'severity is invalid', 'INVALID_ALERT_SEVERITY');
const items = await query<PlatformAuditAlertRow>(
`
select a.id, a.rule_id as "ruleId", r.code as "ruleCode", r.name as "ruleName",
a.audit_log_id as "auditLogId", a.tenant_id as "tenantId",
t.slug::text as "tenantSlug", t.name as "tenantName",
a.severity, a.status, a.action, a.target_type as "targetType",
a.target_id as "targetId", a.title, a.summary, a.details,
a.first_seen_at as "firstSeenAt", a.last_seen_at as "lastSeenAt",
a.acknowledged_by as "acknowledgedBy", ack.username as "acknowledgedByUsername",
ack.name as "acknowledgedByName", a.acknowledged_at as "acknowledgedAt",
a.resolved_by as "resolvedBy", res.username as "resolvedByUsername",
res.name as "resolvedByName", a.resolved_at as "resolvedAt",
a.resolution_note as "resolutionNote",
al.actor_user_id as "actorUserId", actor.username as "actorUsername",
actor.name as "actorName", al.ip_address as "ipAddress",
al.user_agent as "userAgent", al.created_at as "auditCreatedAt",
a.created_at as "createdAt", a.updated_at as "updatedAt"
from public.platform_audit_alerts a
join public.platform_audit_alert_rules r on r.id = a.rule_id
left join public.audit_logs al on al.id = a.audit_log_id
left join public.tenants t on t.id = a.tenant_id
left join public.platform_users actor on actor.id = al.actor_user_id
left join public.platform_users ack on ack.id = a.acknowledged_by
left join public.platform_users res on res.id = a.resolved_by
where ($1::uuid is null or a.tenant_id = $1::uuid)
and ($2::text = '' or a.status = $2)
and ($3::text = '' or a.severity = $3)
and ($4::text = '' or r.code = $4)
and (
$5::text = ''
or a.action ilike '%' || $5 || '%'
or a.title ilike '%' || $5 || '%'
or coalesce(a.target_type, '') ilike '%' || $5 || '%'
or coalesce(a.target_id, '') ilike '%' || $5 || '%'
or coalesce(t.name, '') ilike '%' || $5 || '%'
or coalesce(t.slug::text, '') ilike '%' || $5 || '%'
)
order by
case a.status when 'open' then 1 when 'acknowledged' then 2 else 3 end,
case a.severity when 'critical' then 1 when 'high' then 2 when 'medium' then 3 else 4 end,
a.created_at desc
limit $6
`,
[tenantId || null, status, severity, ruleCode, q, limit],
);
return {
items: items.map(item => ({
...item,
details: redactAuditAlertValue(item.details),
})),
};
}
export async function updatePlatformAuditAlertStatusRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const alertId = requiredString(body, 'alertId');
if (!UUID_RE.test(alertId)) throw new HttpError(400, 'alertId is invalid', 'INVALID_UUID');
const status = auditAlertStatusFrom(optionalString(body, 'status'));
const resolutionNote = optionalString(body, 'resolutionNote') || null;
const session = currentSessionFromContext(ctx);
const item = await transaction(async client => {
const result = await client.query<PlatformAuditAlertRow>(
`
update public.platform_audit_alerts
set status = $2,
acknowledged_by = case when $2 in ('acknowledged', 'resolved', 'ignored') then $3::uuid else acknowledged_by end,
acknowledged_at = case when $2 in ('acknowledged', 'resolved', 'ignored') then coalesce(acknowledged_at, now()) else acknowledged_at end,
resolved_by = case when $2 in ('resolved', 'ignored') then $3::uuid else null end,
resolved_at = case when $2 in ('resolved', 'ignored') then now() else null end,
resolution_note = coalesce($4, resolution_note),
updated_at = now()
where id = $1
returning id, rule_id as "ruleId", audit_log_id as "auditLogId",
tenant_id as "tenantId", severity, status, action,
target_type as "targetType", target_id as "targetId",
title, summary, details, acknowledged_by as "acknowledgedBy",
acknowledged_at as "acknowledgedAt", resolved_by as "resolvedBy",
resolved_at as "resolvedAt", resolution_note as "resolutionNote",
created_at as "createdAt", updated_at as "updatedAt"
`,
[alertId, status, session?.id || null, resolutionNote],
);
const updated = result.rows[0];
if (!updated) throw new HttpError(404, 'Audit alert not found', 'AUDIT_ALERT_NOT_FOUND');
await recordPlatformAudit(client, ctx, 'platform.audit.alert_status_updated', 'platform_audit_alert', alertId, {
status,
resolutionNoteSet: Boolean(resolutionNote),
}, updated.tenantId || null);
return updated;
});
return {
item: {
...item,
details: redactAuditAlertValue(item.details),
},
};
}
export async function createTenantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
@@ -1360,6 +1522,17 @@ export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
);
}
await recordPlatformAudit(client, ctx, 'platform.invoice.payment_confirmed', 'tenant_invoice', invoiceId, {
tenantId,
invoiceId,
paymentNo,
provider: optionalString(body, 'provider') || 'manual',
method: optionalString(body, 'method') || 'manual',
amountCents,
providerTradeNoSet: Boolean(optionalString(body, 'providerTradeNo')),
invoiceStatus: updatedInvoice?.status || null,
}, tenantId);
return updatedInvoice;
});

View File

@@ -3,12 +3,15 @@ import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import {
exportPlatformAuditLogs,
loadPlatformAuditAlerts,
loadPlatformAuditLogs,
loadPlatformInvoices,
loadPlatformOverview,
loadPlatformQuestionBankGrants,
loadPlatformQuestionBanks,
loadPlatformTenants,
updatePlatformAuditAlertStatus,
type PlatformAuditAlertItem,
type PlatformAuditLogItem,
type PlatformInvoiceItem,
type PlatformOverview,
@@ -38,6 +41,7 @@ export default function PlatformWorkbenchPage() {
const [tenants, setTenants] = useState<PlatformTenantItem[]>([]);
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
const [auditLogs, setAuditLogs] = useState<PlatformAuditLogItem[]>([]);
const [auditAlerts, setAuditAlerts] = useState<PlatformAuditAlertItem[]>([]);
const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]);
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
const [error, setError] = useState('');
@@ -51,13 +55,15 @@ export default function PlatformWorkbenchPage() {
loadPlatformQuestionBanks({ limit: 6 }).catch(() => ({ items: [] })),
loadPlatformQuestionBankGrants({ limit: 6 }).catch(() => ({ items: [] })),
loadPlatformAuditLogs({ limit: 6 }).catch(() => ({ items: [] })),
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload]) => {
loadPlatformAuditAlerts({ status: 'open', limit: 6 }).catch(() => ({ items: [] })),
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload, alertPayload]) => {
setOverview(overviewPayload.item || null);
setTenants(tenantPayload.items || []);
setInvoices(invoicePayload.items || []);
setBanks(bankPayload.items || []);
setGrants(grantPayload.items || []);
setAuditLogs(auditPayload.items || []);
setAuditAlerts(alertPayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台后台加载失败'));
}, []);
@@ -84,6 +90,25 @@ export default function PlatformWorkbenchPage() {
}
}
async function updateAuditAlert(alertId: string, status: 'acknowledged' | 'resolved') {
setBusy(`alert-${alertId}-${status}`);
setError('');
try {
await updatePlatformAuditAlertStatus({
alertId,
status,
resolutionNote: status === 'resolved' ? '平台后台工作台处理' : undefined,
});
const payload = await loadPlatformAuditAlerts({ status: 'open', limit: 6 });
setAuditAlerts(payload.items || []);
Taro.showToast({ title: status === 'resolved' ? '已解决' : '已确认', icon: 'success' });
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '告警状态更新失败');
} finally {
setBusy('');
}
}
return (
<View className='platform-page'>
<View className='platform-shell'>
@@ -155,6 +180,22 @@ export default function PlatformWorkbenchPage() {
</View>
{!auditLogs.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{auditAlerts.map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.title || item.action || '-'}</Text>
<Text className='platform-row-meta'>{item.severity || '-'} · {item.tenantName || item.tenantSlug || '平台'} · {item.summary || item.targetType || '-'} · {String(item.auditCreatedAt || item.createdAt || '').slice(0, 19).replace('T', ' ')}</Text>
<View className='platform-actions'>
<Button className='platform-button' size='mini' loading={busy === `alert-${item.id}-acknowledged`} onClick={() => updateAuditAlert(item.id, 'acknowledged')}></Button>
<Button className='platform-button' size='mini' loading={busy === `alert-${item.id}-resolved`} onClick={() => updateAuditAlert(item.id, 'resolved')}></Button>
</View>
</View>
))}
</View>
{!auditAlerts.length ? <View className='platform-empty'></View> : null}
</View>
{error ? <Text className='platform-error'>{error}</Text> : null}
</View>
</View>

View File

@@ -235,6 +235,52 @@ export interface PlatformAuditExportItem {
filters?: Record<string, unknown> | null;
}
export interface PlatformAuditAlertRuleItem {
id: string;
code?: string | null;
name?: string | null;
description?: string | null;
enabled?: boolean | null;
severity?: string | null;
actionPatterns?: string[] | null;
targetTypes?: string[] | null;
tenantId?: string | null;
conditions?: Record<string, unknown> | null;
metadata?: Record<string, unknown> | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PlatformAuditAlertItem {
id: string;
ruleId?: string | null;
ruleCode?: string | null;
ruleName?: string | null;
auditLogId?: string | null;
tenantId?: string | null;
tenantSlug?: string | null;
tenantName?: string | null;
severity?: string | null;
status?: string | null;
action?: string | null;
targetType?: string | null;
targetId?: string | null;
title?: string | null;
summary?: string | null;
details?: Record<string, unknown> | null;
actorUserId?: string | null;
actorUsername?: string | null;
actorName?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
auditCreatedAt?: string | null;
acknowledgedAt?: string | null;
resolvedAt?: string | null;
resolutionNote?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface CreatePlatformTenantInput {
slug: string;
name: string;
@@ -371,6 +417,39 @@ export async function exportPlatformAuditLogs(query: {
});
}
export async function loadPlatformAuditAlertRules(query: { enabled?: boolean; limit?: number } = {}) {
return apiRequest<{ items?: PlatformAuditAlertRuleItem[] }>('/api/platform-admin/audit-alert-rules', {
query,
tenantId: null,
});
}
export async function loadPlatformAuditAlerts(query: {
tenantId?: string;
status?: string;
severity?: string;
ruleCode?: string;
q?: string;
limit?: number;
} = {}) {
return apiRequest<{ items?: PlatformAuditAlertItem[] }>('/api/platform-admin/audit-alerts', {
query: { ...query, limit: query.limit || 50 },
tenantId: null,
});
}
export async function updatePlatformAuditAlertStatus(input: {
alertId: string;
status: 'acknowledged' | 'resolved' | 'ignored';
resolutionNote?: string;
}) {
return apiRequest<{ item?: PlatformAuditAlertItem }>('/api/platform-admin/audit-alerts/status', {
method: 'POST',
body: input,
tenantId: null,
});
}
export async function loadPlatformInvoices(query: { tenantId?: string; status?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', {
query: { ...query, limit: query.limit || 80 },

View File

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

View File

@@ -24,6 +24,9 @@ export interface WorkerConfig {
platformBillingWorkerId: string;
platformDunningBatchSize: number;
platformDunningWorkerId: string;
platformAuditAlertBatchSize: number;
platformAuditAlertWorkerId: string;
platformAuditAlertLookbackDays: number;
assetBatchSize: number;
assetMinAgeSeconds: number;
assetRecheckIntervalSeconds: number;
@@ -178,6 +181,9 @@ const loadedConfig: WorkerConfig = {
platformBillingWorkerId: envString('WORKER_PLATFORM_BILLING_ID', `platform-billing-${process.pid}`),
platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100),
platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`),
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),
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),

View File

@@ -61,6 +61,15 @@ async function runOnce() {
);
return;
}
if (job === 'platform-audit-alerts') {
const { processPlatformAuditAlertBatch } = await import('./jobs/platform-audit-alerts.js');
const result = await processPlatformAuditAlertBatch();
console.log(
`[worker] platform-audit-alerts batch processed=${result.processed}`
+ ` created=${result.created} skipped=${result.skipped}`,
);
return;
}
if (job === 'assets') {
const result = await processAssetBatch();
console.log(

View File

@@ -0,0 +1,250 @@
import { pool } from '../db.js';
import { config } from '../config.js';
interface PlatformAuditAlertWorkerResult {
processed: number;
created: number;
skipped: number;
}
interface AlertCandidate {
auditLogId: string;
tenantId: string | null;
action: string;
targetType: string | null;
targetId: string | null;
auditDetails: Record<string, unknown> | null;
createdAt: string;
ruleId: string;
ruleCode: string;
ruleName: string;
severity: string;
conditions: Record<string, unknown> | null;
}
function positiveInteger(value: number, fallback: number, max: number) {
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.min(Math.trunc(value), max);
}
function nonNegativeInteger(value: number, fallback: number, max: number) {
if (!Number.isFinite(value) || value < 0) return fallback;
return Math.min(Math.trunc(value), max);
}
function truncate(value: unknown, max = 900) {
return String(value ?? '').slice(0, max);
}
function redactAuditAlertValue(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 => redactAuditAlertValue(item, parentKey, depth + 1));
if (typeof value === 'object') {
const output: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
output[key] = redactAuditAlertValue(item, key, depth + 1);
}
return output;
}
return value;
}
function alertTitle(candidate: AlertCandidate) {
return `${candidate.ruleName}: ${candidate.action}`;
}
function alertSummary(candidate: AlertCandidate) {
const target = [candidate.targetType, candidate.targetId].filter(Boolean).join(':');
return target ? `${candidate.action} -> ${target}` : candidate.action;
}
async function loadAlertCandidates(params: {
limit: number;
lookbackDays: number;
}) {
const result = await pool.query<AlertCandidate>(
`
with matched as (
select al.id as audit_log_id,
al.tenant_id,
al.action,
al.target_type,
al.target_id,
al.details as audit_details,
al.created_at,
r.id as rule_id,
r.code as rule_code,
r.name as rule_name,
r.severity,
r.conditions,
row_number() over (
partition by al.id
order by
case r.severity when 'critical' then 1 when 'high' then 2 when 'medium' then 3 else 4 end,
r.created_at asc
) as rn
from public.audit_logs al
join public.platform_audit_alert_rules r
on r.enabled = true
and (r.tenant_id is null or r.tenant_id = al.tenant_id)
and (
cardinality(r.action_patterns) = 0
or exists (
select 1
from unnest(r.action_patterns) as pattern
where al.action = pattern
or (right(pattern, 1) = '*' and al.action like left(pattern, length(pattern) - 1) || '%')
)
)
and (
cardinality(r.target_types) = 0
or al.target_type = any(r.target_types)
)
left join public.platform_audit_alerts existing
on existing.rule_id = r.id
and existing.audit_log_id = al.id
where existing.id is null
and al.action like 'platform.%'
and al.created_at >= now() - ($2::integer * interval '1 day')
)
select audit_log_id as "auditLogId",
tenant_id as "tenantId",
action,
target_type as "targetType",
target_id as "targetId",
audit_details as "auditDetails",
created_at as "createdAt",
rule_id as "ruleId",
rule_code as "ruleCode",
rule_name as "ruleName",
severity,
conditions
from matched
where rn = 1
order by created_at asc
limit $1
`,
[params.limit, params.lookbackDays],
);
return result.rows;
}
async function createAlert(candidate: AlertCandidate) {
const details = {
auditDetails: redactAuditAlertValue(candidate.auditDetails || {}),
ruleCode: candidate.ruleCode,
conditions: redactAuditAlertValue(candidate.conditions || {}),
workerId: config.platformAuditAlertWorkerId,
};
const client = await pool.connect();
try {
await client.query('begin');
const inserted = await client.query<{ id: string }>(
`
insert into public.platform_audit_alerts (
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::uuid, $4, 'open',
$5, $6, $7, $8, $9,
$10::jsonb, $11::timestamptz, $11::timestamptz
)
on conflict (rule_id, audit_log_id) do nothing
returning id
`,
[
candidate.ruleId,
candidate.auditLogId,
candidate.tenantId,
candidate.severity,
candidate.action,
candidate.targetType,
candidate.targetId,
alertTitle(candidate),
alertSummary(candidate),
JSON.stringify(details),
candidate.createdAt,
],
);
const alertId = inserted.rows[0]?.id;
if (!alertId) {
await client.query('rollback');
return 'skipped' as const;
}
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1::uuid, null, 'platform.audit.alert_created', 'platform_audit_alert', $2, $3::jsonb)
`,
[
candidate.tenantId,
alertId,
JSON.stringify({
ruleId: candidate.ruleId,
ruleCode: candidate.ruleCode,
auditLogId: candidate.auditLogId,
severity: candidate.severity,
sourceAction: candidate.action,
workerId: config.platformAuditAlertWorkerId,
}),
],
);
await client.query('commit');
return 'created' as const;
} catch (error) {
await client.query('rollback').catch(() => {});
await pool.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1::uuid, null, 'platform.audit.alert_failed', 'audit_logs', $2, $3::jsonb)
`,
[
candidate.tenantId,
candidate.auditLogId,
JSON.stringify({
ruleId: candidate.ruleId,
ruleCode: candidate.ruleCode,
code: typeof error === 'object' && error !== null && 'code' in error ? String((error as { code?: unknown }).code) : 'PLATFORM_AUDIT_ALERT_FAILED',
message: truncate(error instanceof Error ? error.message : String(error)),
workerId: config.platformAuditAlertWorkerId,
}),
],
).catch(() => {});
return 'skipped' as const;
} finally {
client.release();
}
}
export async function processPlatformAuditAlertBatch(options: {
limit?: number;
lookbackDays?: number;
} = {}): Promise<PlatformAuditAlertWorkerResult> {
const limit = positiveInteger(options.limit ?? config.platformAuditAlertBatchSize, 200, 1000);
const lookbackDays = nonNegativeInteger(options.lookbackDays ?? config.platformAuditAlertLookbackDays, 14, 365);
const candidates = await loadAlertCandidates({ limit, lookbackDays });
const result: PlatformAuditAlertWorkerResult = {
processed: candidates.length,
created: 0,
skipped: 0,
};
for (const candidate of candidates) {
const status = await createAlert(candidate);
result[status] += 1;
}
return result;
}

View File

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

View File

@@ -21,7 +21,7 @@
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
| --- | --- | --- | --- |
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 |
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台审计日志查询、平台审计 CSV/JSON 导出、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 外部催缴通知、平台在线收款、平台审计告警、更完整运营消息 |
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts 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商用收费和运营能力

View File

@@ -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/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 /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、批量分班、禁用/恢复也已接入权限校验。
@@ -34,6 +34,7 @@
- 已新增 commerce worker 和 `npm run test:worker:commerce`,用于补偿查询微信/支付宝支付、处理中退款和漏通知场景;支付成功会幂等更新订单/支付并开通权益,退款成功会幂等更新退款/订单/支付并在全额退款时撤销订单权益,测试覆盖密钥不泄露和重复执行不重复开通。
- 已新增 platform-billing worker 和 `npm run test:worker:platform-billing`,用于自动处理即将到期且未开票的 SaaS 订阅worker 使用订阅行锁和账单查重保证幂等,自动生成 `tenant_invoices/tenant_invoice_items` 并写 `platform.invoice.subscription_auto_created` 审计。
- 已新增 platform-dunning worker 和 `npm run test:worker:platform-dunning`,用于扫描已过 `due_date` 且未结清的 SaaS 服务费账单worker 使用账单行锁和每日唯一催缴约束保证幂等,自动标记 `overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写 `platform.invoice.overdue_processed` 审计。
- 已新增 platform-audit-alerts worker 和 `npm run test:worker:platform-audit-alerts`用于扫描高风险平台审计动作并生成内部告警worker 匹配启用规则、对告警 details 递归脱敏、通过 `(rule_id, audit_log_id)` 保证幂等,并写 `platform.audit.alert_created` 审计。
- 已新增 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。
@@ -60,6 +61,9 @@ PATCH /api/platform-admin/tenants/status
PUT /api/platform-admin/tenants/billing-profile
GET /api/platform-admin/audit-logs
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
POST /api/platform-admin/subscriptions
GET /api/platform-admin/invoices
POST /api/platform-admin/invoices
@@ -291,6 +295,7 @@ npm run test:worker:crm
npm run test:worker:commerce
npm run test:worker:platform-billing
npm run test:worker:platform-dunning
npm run test:worker:platform-audit-alerts
npm run test:worker:assets
npm run test:worker:imports
npm run test:worker:public-banks

View File

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

View File

@@ -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导出会递归脱敏敏感字段并写审计平台后台工作台/租户详情页可查看最近审计,工作台可导出 CSV缺更完整筛选 UI 和告警 |
| 租户安全审计 | 部分覆盖 | 租户侧 audit logs 和平台侧 `/api/platform-admin/audit-logs` 第一版已有,平台侧 `/api/platform-admin/audit-logs/export` 可按租户、动作、目标、操作者、日期和关键词导出 CSV/JSON导出会递归脱敏敏感字段并写审计平台审计告警规则、开放告警查询、确认/解决/忽略和 `platform-audit-alerts` worker 第一版已覆盖,告警 details 会递归脱敏;平台后台工作台/租户详情页可查看最近审计,工作台可导出 CSV 并处理开放告警;缺更完整筛选 UI、外部通知和升级策略 |
## 旧功能中应重新设计的点

View File

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

View File

@@ -2519,7 +2519,7 @@ src/services/ai.ts AI 择校推荐生成、报告列表、报告详情
src/services/pronunciation.ts H5/小程序单词发音适配
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录
src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证
src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计查询/CSV 导出、套餐账单、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览/内部催缴记录、用量、公共题库授权
src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计查询/CSV 导出、平台审计告警规则/列表/状态更新、套餐账单、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览/内部催缴记录、用量、公共题库授权
```
验证命令:
@@ -2596,6 +2596,55 @@ format csv | json
- 小程序端如果无法稳定写入文件系统,建议展示“已生成,请到 H5 平台后台下载”,不要把完整 base64 长文本复制到剪贴板。
- 平台后台不要把 `contentBase64` 持久存入本地缓存、日志或埋点。
## 平台审计告警
平台审计告警属于超级管理员后台能力,第一版只做内部告警台账和平台后台处理,不发送短信、企业微信、钉钉或飞书外部通知。告警由 `apps/worker --job platform-audit-alerts` 根据 `platform_audit_alert_rules` 从平台审计日志生成,前端不要直接读写 Supabase 表。
查询启用规则:
```text
GET /api/platform-admin/audit-alert-rules?enabled=true
```
查询开放告警:
```text
GET /api/platform-admin/audit-alerts?status=open&limit=50
```
可用筛选:
```text
tenantId=<tenant uuid>
status=open|acknowledged|resolved|ignored
severity=low|medium|high|critical
ruleCode=platform_tenant_status_changed
q=<keyword>
limit=1..500
```
更新告警状态:
```http
POST /api/platform-admin/audit-alerts/status
Content-Type: application/json
{
"alertId": "<alert uuid>",
"status": "acknowledged",
"resolutionNote": "已人工确认"
}
```
`status` 只允许 `acknowledged``resolved``ignored`。前端不能通过这个接口把告警改回 `open`;需要重新打开时后续另设计复核接口,避免随意撤销安全处理痕迹。
安全边界:
- 只允许平台管理员调用,普通学生、租户管理员和租户成员会返回 `PLATFORM_ADMIN_REQUIRED`
- 后端会对告警 `details` 递归脱敏 token、secret、password、key、authorization、cookie、session、cert、signature 等敏感字段;前端仍不要把 details 原样写入日志或埋点。
- 确认、解决和忽略都会写入 `platform.audit.alert_status_updated` 审计。
- 当前工作台只展示开放告警并提供“确认/解决”按钮;更完整筛选、批量处理、外部通知和升级策略属于下一阶段。
## 平台账务逾期催缴
平台后台账务页只调用命令式 API不直接改 `tenant_invoices.status``tenants.billing_status``tenant_invoice_reminders`。后端会做平台管理员鉴权、行锁、每日催缴去重和审计。
@@ -2653,7 +2702,7 @@ GET /api/platform-admin/invoices/reminders?tenantId=<tenantId>&invoiceId=<invoic
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。
- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计查询/CSV 导出、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑已接第一版后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计,`platform-billing` worker 会自动生成即将到期订阅账单并记录 `platform.invoice.subscription_auto_created` 审计,`platform-dunning` worker 会标记已过期未结清服务费账单、生成 `tenant_invoice_reminders` 并记录 `platform.invoice.overdue_processed` 审计;前端只展示候选、预览结果、跳过结果、逾期处理结果和生成后的账单/审计,不要直接更新账单状态租户 `billing_status`;继续补租户基础资料编辑增强、平台审计告警、外部催缴通知和平台在线收款。
- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑已接第一版后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计,`platform-billing` worker 会自动生成即将到期订阅账单并记录 `platform.invoice.subscription_auto_created` 审计,`platform-dunning` worker 会标记已过期未结清服务费账单、生成 `tenant_invoice_reminders` 并记录 `platform.invoice.overdue_processed` 审计`platform-audit-alerts` worker 会把高风险平台审计动作转换为内部告警;前端只展示候选、预览结果、跳过结果、逾期处理结果、开放告警和生成后的账单/审计,不要直接更新账单状态租户 `billing_status` 或告警表;继续补租户基础资料编辑增强、平台审计告警外部通知/升级策略、外部催缴通知和平台在线收款。
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
## AI 择校推荐接入

View File

@@ -41,6 +41,7 @@
"test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js",
"test:worker:platform-billing": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-billing-worker-integration-test.js",
"test:worker:platform-dunning": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-dunning-worker-integration-test.js",
"test:worker:platform-audit-alerts": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-audit-alert-worker-integration-test.js",
"test:worker:assets": "npm run db:smoke-seed && npm run build:worker && node scripts/asset-worker-integration-test.js",
"test:worker:exports": "npm run db:smoke-seed && npm run build:worker && node scripts/export-worker-integration-test.js",
"test:worker:imports": "npm run db:smoke-seed && npm run build:worker && node scripts/import-worker-integration-test.js",

View File

@@ -1232,6 +1232,8 @@ async function testPlatformTenantOperationsAndAudit() {
assert.ok(actions.has('platform.tenant.created'), 'platform audit should include tenant creation');
assert.ok(actions.has('platform.tenant.billing_profile_upserted'), 'platform audit should include billing profile update');
assert.ok(actions.has('platform.tenant.status_updated'), 'platform audit should include status update');
const statusAuditLog = audit.items?.find(item => item.action === 'platform.tenant.status_updated');
assert.ok(statusAuditLog?.id, 'platform tenant status audit should expose audit log id');
const auditExport = await request('/api/platform-admin/audit-logs/export', {
tenantId: false,
@@ -1253,6 +1255,92 @@ async function testPlatformTenantOperationsAndAudit() {
});
assert.equal(invalidAuditExportFormat.code, 'INVALID_EXPORT_FORMAT', 'platform audit export should reject invalid formats');
const alertRules = await request('/api/platform-admin/audit-alert-rules', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { enabled: true },
});
const statusAlertRule = alertRules.items?.find(item => item.code === 'platform_tenant_status_changed');
assert.ok(statusAlertRule?.id, 'platform audit alert rules should include default tenant status rule');
assert.ok(
alertRules.items?.some(item => item.code === 'platform_invoice_payment_confirmed'),
'platform audit alert rules should include manual payment confirmation rule',
);
const alertPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
let platformAlertId = '';
try {
const insertedAlert = await alertPool.query(
`
insert into public.platform_audit_alerts (
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::uuid, 'high', 'open',
'platform.tenant.status_updated', 'tenant', $4,
'租户状态变更告警', 'integration platform audit alert',
'{"source":"api-integration-test","accessToken":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
now(), now()
)
on conflict (rule_id, audit_log_id) do update
set status = 'open',
updated_at = now()
returning id
`,
[statusAlertRule.id, statusAuditLog.id, tenantId, tenantId],
);
platformAlertId = insertedAlert.rows[0].id;
} finally {
await alertPool.end();
}
const auditAlerts = await request('/api/platform-admin/audit-alerts', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId, status: 'open', limit: 20 },
});
assert.ok(
auditAlerts.items?.some(item => item.id === platformAlertId && item.ruleCode === 'platform_tenant_status_changed'),
'platform admin should list platform audit alerts',
);
const listedAlert = auditAlerts.items?.find(item => item.id === platformAlertId);
assert.equal(listedAlert?.details?.accessToken, '[REDACTED]', 'platform audit alert list should redact token-like details');
assert.equal(listedAlert?.details?.nested?.password, '[REDACTED]', 'platform audit alert list should redact nested password-like details');
assert.ok(!JSON.stringify(listedAlert).includes('must-not-leak'), 'platform audit alert list must not leak sensitive details');
const updatedAuditAlert = await request('/api/platform-admin/audit-alerts/status', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
alertId: platformAlertId,
status: 'resolved',
resolutionNote: 'integration resolved',
},
});
assert.equal(updatedAuditAlert.item?.status, 'resolved', 'platform admin should resolve audit alerts');
assert.equal(updatedAuditAlert.item?.resolutionNote, 'integration resolved', 'audit alert resolution note should persist');
assert.equal(updatedAuditAlert.item?.details?.accessToken, '[REDACTED]', 'audit alert status response should redact token-like details');
assert.ok(!JSON.stringify(updatedAuditAlert).includes('must-not-leak'), 'audit alert status response must not leak sensitive details');
const invalidAuditAlertStatus = await request('/api/platform-admin/audit-alerts/status', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
alertId: platformAlertId,
status: 'open',
},
expectStatus: 400,
});
assert.equal(invalidAuditAlertStatus.code, 'INVALID_ALERT_STATUS', 'audit alerts should not be reopened through status endpoint');
const candidates = await request('/api/platform-admin/invoices/subscription-candidates', {
tenantId: false,
userId: false,
@@ -1448,6 +1536,25 @@ async function testPlatformTenantOperationsAndAudit() {
});
assert.equal(studentAuditExportDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not export platform audit logs');
const studentAuditAlertDenied = await request('/api/platform-admin/audit-alerts', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentAuditAlertDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit alerts');
const studentAuditAlertStatusDenied = await request('/api/platform-admin/audit-alerts/status', {
tenantId: false,
userId: USER_ID,
method: 'POST',
body: {
alertId: platformAlertId,
status: 'acknowledged',
},
expectStatus: 403,
});
assert.equal(studentAuditAlertStatusDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not update platform audit alerts');
const studentReminderDenied = await request('/api/platform-admin/invoices/reminders', {
tenantId: false,
userId: USER_ID,

View File

@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import pg from 'pg';
import { spawn } from 'node:child_process';
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const ids = {
tenant: '00000000-0000-0000-0000-00000000aa01',
auditLog: '00000000-0000-0000-0000-00000000aa02',
};
function runWorkerOnce() {
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-audit-alerts'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE: '20',
WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS: '30',
WORKER_PLATFORM_AUDIT_ALERT_ID: 'platform-audit-alert-worker-test',
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
child.stdout.on('data', chunk => {
output += chunk.toString();
});
child.stderr.on('data', chunk => {
output += chunk.toString();
});
return new Promise((resolve, reject) => {
child.on('error', reject);
child.on('exit', code => {
try {
assert.equal(code, 0, `worker should exit 0\n${output}`);
assert.match(output, /platform-audit-alerts batch processed=\d+/, 'worker output should include audit alert summary');
resolve(output);
} catch (error) {
reject(error);
}
});
});
}
async function cleanup(pool) {
await pool.query('delete from public.platform_audit_alerts where tenant_id = $1 or audit_log_id = $2', [ids.tenant, ids.auditLog]);
await pool.query('delete from public.audit_logs where tenant_id = $1 or id = $2', [ids.tenant, ids.auditLog]);
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 createAuditLog(pool) {
await pool.query(
`
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
values ($1, 'platform-audit-alert-worker', '平台审计告警租户', '平台审计告警有限公司', 'active', 'saas', 'active', '{"source":"platform-audit-alert-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","reason":"integration alert","accessToken":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
'127.0.0.1', 'platform-audit-alert-worker-test', now()
)
`,
[ids.auditLog, ids.tenant, ids.tenant],
);
}
async function main() {
const pool = new pg.Pool({ connectionString: databaseUrl });
try {
await cleanup(pool);
await createAuditLog(pool);
const firstOutput = await runWorkerOnce();
assert.match(firstOutput, /created=\d+/, 'worker should create alert candidates');
const alerts = await pool.query(
`
select a.id, a.status, a.severity, a.action, a.target_type, a.target_id,
a.details, r.code as rule_code
from public.platform_audit_alerts a
join public.platform_audit_alert_rules r on r.id = a.rule_id
where a.tenant_id = $1 and a.audit_log_id = $2
`,
[ids.tenant, ids.auditLog],
);
assert.equal(alerts.rowCount, 1, 'worker should create one alert for the audit log');
assert.equal(alerts.rows[0].status, 'open', 'worker alert should start open');
assert.equal(alerts.rows[0].severity, 'high', 'tenant status changes should be high severity');
assert.equal(alerts.rows[0].rule_code, 'platform_tenant_status_changed', 'worker should match default tenant status rule');
assert.equal(alerts.rows[0].details?.workerId, 'platform-audit-alert-worker-test', 'alert should record worker id');
assert.equal(alerts.rows[0].details?.auditDetails?.accessToken, '[REDACTED]', 'alert should redact token-like audit details');
assert.equal(alerts.rows[0].details?.auditDetails?.nested?.password, '[REDACTED]', 'alert should redact nested password-like audit details');
assert.ok(!JSON.stringify(alerts.rows[0].details).includes('must-not-leak'), 'alert details must not leak sensitive audit values');
const audit = await pool.query(
`
select action, target_type, target_id, details
from public.audit_logs
where tenant_id = $1 and action = 'platform.audit.alert_created'
order by created_at desc
limit 1
`,
[ids.tenant],
);
assert.equal(audit.rows[0]?.target_type, 'platform_audit_alert', 'worker should audit alert creation');
assert.equal(audit.rows[0]?.details?.ruleCode, 'platform_tenant_status_changed', 'worker audit should include rule code');
const secondOutput = await runWorkerOnce();
assert.match(secondOutput, /created=0/, 'second worker run should not create duplicate alerts');
const count = await pool.query(
`
select count(*)::integer as count
from public.platform_audit_alerts
where tenant_id = $1 and audit_log_id = $2
`,
[ids.tenant, ids.auditLog],
);
assert.equal(Number(count.rows[0]?.count), 1, 'worker should be idempotent');
console.log('Platform audit alert worker integration test complete.');
} finally {
await cleanup(pool).catch(() => {});
await pool.end();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,151 @@
create table if not exists public.platform_audit_alert_rules (
id uuid primary key default gen_random_uuid(),
code text not null unique,
name text not null,
description text,
enabled boolean not null default true,
severity text not null default 'medium' check (severity in ('low', 'medium', 'high', 'critical')),
action_patterns text[] not null default '{}'::text[],
target_types text[] not null default '{}'::text[],
tenant_id uuid references public.tenants(id) on delete cascade,
conditions 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()
);
create table if not exists public.platform_audit_alerts (
id uuid primary key default gen_random_uuid(),
rule_id uuid not null references public.platform_audit_alert_rules(id) on delete cascade,
audit_log_id uuid not null references public.audit_logs(id) on delete cascade,
tenant_id uuid references public.tenants(id) on delete cascade,
severity text not null check (severity in ('low', 'medium', 'high', 'critical')),
status text not null default 'open' check (status in ('open', 'acknowledged', 'resolved', 'ignored')),
action text not null,
target_type text,
target_id text,
title text not null,
summary text,
details jsonb not null default '{}'::jsonb,
first_seen_at timestamptz not null default now(),
last_seen_at timestamptz not null default now(),
acknowledged_by uuid references public.platform_users(id) on delete set null,
acknowledged_at timestamptz,
resolved_by uuid references public.platform_users(id) on delete set null,
resolved_at timestamptz,
resolution_note text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (rule_id, audit_log_id)
);
create index if not exists idx_platform_audit_alert_rules_enabled
on public.platform_audit_alert_rules(enabled, severity, code);
create index if not exists idx_platform_audit_alerts_status_severity
on public.platform_audit_alerts(status, severity, created_at desc);
create index if not exists idx_platform_audit_alerts_tenant
on public.platform_audit_alerts(tenant_id, status, created_at desc);
create index if not exists idx_platform_audit_alerts_audit_log
on public.platform_audit_alerts(audit_log_id);
alter table public.platform_audit_alert_rules enable row level security;
alter table public.platform_audit_alerts enable row level security;
drop policy if exists platform_admin_audit_alert_rules on public.platform_audit_alert_rules;
create policy platform_admin_audit_alert_rules on public.platform_audit_alert_rules
for all
using (app.is_platform_admin())
with check (app.is_platform_admin());
drop policy if exists platform_admin_audit_alerts on public.platform_audit_alerts;
create policy platform_admin_audit_alerts on public.platform_audit_alerts
for all
using (app.is_platform_admin())
with check (app.is_platform_admin());
drop trigger if exists set_updated_at on public.platform_audit_alert_rules;
create trigger set_updated_at
before update on public.platform_audit_alert_rules
for each row execute function app.touch_updated_at();
drop trigger if exists set_updated_at on public.platform_audit_alerts;
create trigger set_updated_at
before update on public.platform_audit_alerts
for each row execute function app.touch_updated_at();
insert into public.platform_audit_alert_rules (
code, name, description, severity, action_patterns, target_types, conditions, metadata
)
values
(
'platform_tenant_status_changed',
'租户状态变更',
'平台租户启停、账务状态变化等高影响操作。',
'high',
array['platform.tenant.status_updated'],
array['tenant'],
'{"riskArea":"tenant_lifecycle"}'::jsonb,
'{"defaultRule":true}'::jsonb
),
(
'platform_billing_profile_changed',
'租户账务资料变更',
'开票抬头、税号、联系人或银行掩码资料发生变化。',
'medium',
array['platform.tenant.billing_profile_upserted'],
array['tenant_billing_profile'],
'{"riskArea":"billing_profile"}'::jsonb,
'{"defaultRule":true}'::jsonb
),
(
'platform_invoice_batch_created',
'平台服务费批量开票',
'平台批量为租户订阅生成服务费账单。',
'medium',
array['platform.invoice.subscription_batch_created', 'platform.invoice.subscription_auto_created'],
array['tenant_invoice_batch', 'tenant_invoice'],
'{"riskArea":"platform_billing"}'::jsonb,
'{"defaultRule":true}'::jsonb
),
(
'platform_invoice_overdue_processed',
'平台服务费逾期处理',
'平台标记逾期账单或生成内部催缴记录。',
'medium',
array['platform.invoice.overdue_processed', 'platform.invoice.overdue_batch_processed'],
array['tenant_invoice', 'tenant_invoice_batch'],
'{"riskArea":"platform_dunning"}'::jsonb,
'{"defaultRule":true}'::jsonb
),
(
'platform_invoice_payment_confirmed',
'平台服务费收款确认',
'平台管理员手工确认租户服务费收款。',
'high',
array['platform.invoice.payment_confirmed'],
array['tenant_invoice'],
'{"riskArea":"platform_billing_payment"}'::jsonb,
'{"defaultRule":true}'::jsonb
),
(
'platform_audit_exported',
'平台审计日志导出',
'平台管理员导出审计日志,应留痕并进入安全运营台。',
'high',
array['platform.audit.exported'],
array['audit_logs'],
'{"riskArea":"audit_export"}'::jsonb,
'{"defaultRule":true}'::jsonb
)
on conflict (code) do update
set name = excluded.name,
description = excluded.description,
severity = excluded.severity,
action_patterns = excluded.action_patterns,
target_types = excluded.target_types,
conditions = excluded.conditions,
metadata = public.platform_audit_alert_rules.metadata || excluded.metadata,
updated_at = now();