diff --git a/README.md b/README.md index 0d24adaa..1776b44f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、跟进效果统计、学习督导自动化预览/生成、督导规则模板、学生批量 CRM 推送、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口(租户默认关闭)、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。 -- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、平台用量自动采集 worker、用量记录、公共题库授权。 +- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、平台用量自动采集 worker、用量记录、SaaS 套餐额度判定、用量超额账单候选预览/dry-run/生成、公共题库授权。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细;worker 失败会生成租户 `public_question_bank_sync_failed` 通知,恢复成功自动关闭失败通知,平台可用 `/api/platform-admin/question-bank-sync-status` 按 `platform:question_bank:ops` 查看跨租户同步运营摘要。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略、客资队列和学生批量 CRM 跟进推送。 @@ -38,6 +38,23 @@ - `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料、独立消息中心和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、学习概览、掌握率、收藏数、计划拆分、卡片翻转、发音、美/英音切换和本地位置恢复第一版,知识手册已接章节内搜索、安全文本摘要高亮和目录定位第一版,分数线已接目标地区默认筛选、院校/专业/年份 chip、租户动态字段筛选、结果字段 chip 和趋势摘要第一版,AI 择校已接报告生成、历史报告和 Markdown/HTML 导出第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、男女预设头像选择、积分任务/兑换/积分明细和消息中心摘要第一版,独立消息中心已接状态/类型筛选、批量已读、归档/忽略和站内安全跳转第一版;学生端不默认请求排行榜,仅在租户显式开启 `enableLeaderboard` 并完成压测后进入独立排行榜页或活动页;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,学生运营页已接跟进看板、学习督导自动化、督导规则保存和批量 CRM 推送第一版,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 +## 商用功能完成度总览 + +| 模块 | 当前状态 | 说明 | +| --- | --- | --- | +| 多租户 SaaS 底座 | √ 可联调 | PostgreSQL schema、RLS、租户、品牌、域名、主题、成员权限、平台/租户/学生三类身份边界已建立 | +| 学生刷题主链路 | √ 可联调 | 入口、分类、集合、顺序/随机/模考、答题、错题、收藏、报告、视频、资料、个人中心、勋章、站内通知已接 API | +| 背单词/知识手册/分数线 | √ 可联调 | 列表、学习/阅读、动态筛选、JSON/CSV/Excel 导入和 Taro 第一版页面已具备 | +| 会员/订单/优惠券/激活码 | √ 可联调 | 下单、订单详情/状态轮询、优惠券规则/核销、激活码、权益、退款状态机和对账地基已完成 | +| 国内登录/支付 provider | √ 本地可跑,待真实密钥 | 阿里云/腾讯云短信、微信小程序/网页、QQ、微信支付、支付宝均有 adapter/fake 测试;生产账号和回调域名上云后联调 | +| 租户后台运营 | √ 可联调 | 学生/班级、内容导入导出、营销、优惠券、积分、勋章、CRM、分佣、财务运营、主题和角色模板已具备第一版 | +| 平台 SaaS 账务 | √ 可联调 | 套餐、订阅、订阅账单、自动计费、用量采集、超额账单、收款、逾期催缴、外部通知和审计已具备 | +| 公共题库商业化 | √ 可联调 | 平台题库授权、单地区/全国 SaaS 范围、租户采纳、手动/自动同步、冲突处理和通知已完成基础闭环 | +| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成 | +| PocketBase 真实数据迁移 | √ 本地跑通,待人工复核 blocker | SQLite 导出、标准化导入、校验和抽样脚本已跑通;正式切换前处理缺用户订单和缺归属手册章节 | +| Taro H5 三端前端 | √ 第一版可构建 | 学生端、租户后台、平台后台均有真实 API 页面;后续继续补小程序兼容、视觉精修、状态管理和端到端测试 | +| 生产安全/压测交付 | △ 待专项阶段 | 需执行 `@codex-security`、生产 readiness、远程 Auth/RLS、4c16g 压测、PostgreSQL 调优和上线证据门禁 | + 更完整的进度看这些文档: - `docs/refactor/implementation-status.md` @@ -607,6 +624,6 @@ git diff --check 2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 4. 题库导出模板精排、导出操作台、导入字段映射 UI 和复检结果操作台;继续对真实迁移数据做题目、订单、权益、错题、资料和视频抽样验收。 -5. 上云后接真实 OAuth/短信/支付生产账号、回调域名和真实生产账单抽样验收;本地阶段继续用 mock/fake provider 验证回调后业务链路、幂等、审计、密钥不泄露和权益开通/撤销。后续还要补真实打款 provider、发票、公共题库版本通知/冲突处理操作台、平台套餐额度/超额账单、积分活动风控和连续签到奖励深化。排行榜不是默认主线功能,仅在租户显式购买/开启活动并完成压测后,才进入防刷、日/周榜预聚合和运营看板开发。 +5. 上云后接真实 OAuth/短信/支付生产账号、回调域名和真实生产账单抽样验收;本地阶段继续用 mock/fake provider 验证回调后业务链路、幂等、审计、密钥不泄露和权益开通/撤销。后续还要补真实打款 provider、发票、公共题库版本通知/冲突处理操作台、平台超额账单定时生成 worker/失败告警、积分活动风控和连续签到奖励深化。排行榜不是默认主线功能,仅在租户显式购买/开启活动并完成压测后,才进入防刷、日/周榜预聚合和运营看板开发。 旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro H5/小程序补体验时只作为页面状态、微信平台能力和交互参考,不继承旧 PocketBase 直连和旧鉴权逻辑。 diff --git a/apps/api/src/features/platform-admin/index.ts b/apps/api/src/features/platform-admin/index.ts index 6ce950e1..d082d390 100644 --- a/apps/api/src/features/platform-admin/index.ts +++ b/apps/api/src/features/platform-admin/index.ts @@ -5,6 +5,7 @@ import { createSubscriptionRoute, createTenantInvoiceFromSubscriptionRoute, createTenantInvoicesBatchFromSubscriptionsRoute, + createTenantInvoicesFromUsageOverageRoute, createTenantRoute, invoiceRemindersRoute, platformAuditAlertRulesRoute, @@ -25,6 +26,7 @@ import { questionBankGrantsRoute, recordUsageRoute, subscriptionInvoiceCandidatesRoute, + usageOverageInvoiceCandidatesRoute, tenantDetailRoute, tenantInvoicesRoute, tenantsRoute, @@ -70,8 +72,10 @@ export const platformAdminRoutes: RouteDefinition[] = [ ['GET', '/api/platform-admin/invoices', tenantInvoicesRoute], ['POST', '/api/platform-admin/invoices', createInvoiceRoute], ['GET', '/api/platform-admin/invoices/subscription-candidates', subscriptionInvoiceCandidatesRoute], + ['GET', '/api/platform-admin/invoices/usage-overage-candidates', usageOverageInvoiceCandidatesRoute], ['POST', '/api/platform-admin/invoices/from-subscription', createTenantInvoiceFromSubscriptionRoute], ['POST', '/api/platform-admin/invoices/from-subscriptions-batch', createTenantInvoicesBatchFromSubscriptionsRoute], + ['POST', '/api/platform-admin/invoices/from-usage-overage', createTenantInvoicesFromUsageOverageRoute], ['POST', '/api/platform-admin/invoices/process-overdue', processOverdueInvoicesRoute], ['GET', '/api/platform-admin/invoices/reminders', invoiceRemindersRoute], ['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute], diff --git a/apps/api/src/features/platform-admin/routes.ts b/apps/api/src/features/platform-admin/routes.ts index bf86f3f4..b7b50b70 100644 --- a/apps/api/src/features/platform-admin/routes.ts +++ b/apps/api/src/features/platform-admin/routes.ts @@ -349,6 +349,36 @@ function tenantInvoiceStatusFrom(value: string) { return status; } +function usageOverageInvoiceStatusFrom(value: string) { + const status = tenantInvoiceStatusFrom(value); + if (!['draft', 'issued'].includes(status)) { + throw new HttpError(400, 'usage overage invoices can only be draft or issued', 'INVALID_INVOICE_STATUS'); + } + return status; +} + +function dateTextFrom(value: unknown, key: string, required = true) { + const text = typeof value === 'string' ? value.trim() : ''; + if (!text) { + if (required) throw new HttpError(400, `${key} is required`, 'REQUIRED_FIELD'); + return ''; + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) { + throw new HttpError(400, `${key} must use YYYY-MM-DD format`, 'INVALID_DATE'); + } + const date = new Date(`${text}T00:00:00.000Z`); + if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== text) { + throw new HttpError(400, `${key} must be a valid date`, 'INVALID_DATE'); + } + return text; +} + +function assertDateRange(periodStart: string, periodEnd: string) { + if (periodStart > periodEnd) { + throw new HttpError(400, 'periodStart must be before or equal to periodEnd', 'INVALID_DATE_RANGE'); + } +} + function platformAuditDetails(value: unknown) { return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {}); } @@ -2688,9 +2718,9 @@ export async function recordUsageRoute(ctx: RequestContext) { const tenantId = requiredString(body, 'tenantId'); const metricKey = requiredString(body, 'metricKey'); const metricValue = quantityFrom(body.metricValue, 0); - const periodStart = optionalString(body, 'periodStart'); - const periodEnd = optionalString(body, 'periodEnd'); - if (!periodStart || !periodEnd) throw new HttpError(400, 'periodStart and periodEnd are required', 'REQUIRED_FIELD'); + const periodStart = dateTextFrom(body.periodStart, 'periodStart'); + const periodEnd = dateTextFrom(body.periodEnd, 'periodEnd'); + assertDateRange(periodStart, periodEnd); const item = await queryOne( ` @@ -2732,6 +2762,387 @@ export async function tenantUsageRoute(ctx: RequestContext) { return { items }; } +interface UsageMetricSnapshot { + value: number; + recordId: string; + source: string | null; + createdAt: string | null; +} + +interface UsageOverageItem { + itemType: string; + description: string; + quantity: number; + unitAmountCents: number; + metadata: Record; +} + +interface UsageOverageCandidate { + tenantId: string; + tenantSlug: string; + tenantName: string; + billingStatus: string; + subscriptionId: string; + planCode: string; + planName: string | null; + subscriptionStatus: string; + billingCycle: string | null; + periodStart: string; + periodEnd: string; + existingInvoiceId: string | null; + existingInvoiceNo: string | null; + existingInvoiceStatus: string | null; + hasExistingInvoice: boolean; + wouldCreate: boolean; + totalCents: number; + items: UsageOverageItem[]; +} + +const USAGE_METRIC_ALIASES: Record = { + students: ['students', 'studentCount'], + active_students: ['active_students', 'activeStudents', 'activeStudentCount'], + questions: ['questions', 'questionCount'], + assets: ['assets', 'assetCount'], + storage_gb: ['storage_gb', 'storageGb', 'storageGB', 'storage'], + videos: ['videos', 'videoCount'], + video_plays: ['video_plays', 'videoPlays', 'videoPlayCount'], + video_quota_consumed: ['video_quota_consumed', 'videoQuotaConsumed', 'videoQuota'], + paid_orders: ['paid_orders', 'paidOrders', 'paidOrderCount'], + paid_order_amount_cents: ['paid_order_amount_cents', 'paidOrderAmountCents', 'paidOrderGmvCents'], + active_entitlements: ['active_entitlements', 'activeEntitlements', 'activeEntitlementCount'], +}; + +const USAGE_METRIC_LABELS: Record = { + students: '学生数', + active_students: '活跃学生数', + questions: '题目数量', + assets: '资源数量', + storage_gb: '存储容量 GB', + videos: '视频数量', + video_plays: '视频播放次数', + video_quota_consumed: '视频次数消耗', + paid_orders: '已支付订单数', + paid_order_amount_cents: '已支付订单金额', + active_entitlements: '有效权益数', +}; + +function numberOrNull(value: unknown) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function positiveIntegerOrNull(value: unknown) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return null; + return Math.max(1, Math.trunc(parsed)); +} + +function metricAliases(metricKey: string) { + return [...new Set([metricKey, ...(USAGE_METRIC_ALIASES[metricKey] || [])])]; +} + +function camelMetricKey(metricKey: string) { + return metricKey.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase()); +} + +function snakeMetricKey(metricKey: string) { + return metricKey.replace(/[A-Z]/g, char => `_${char.toLowerCase()}`); +} + +function objectOrNull(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; +} + +function nestedMetricSpec(source: Record, metricKey: string) { + for (const key of metricAliases(metricKey)) { + if (Object.prototype.hasOwnProperty.call(source, key)) return source[key]; + } + return undefined; +} + +function firstMetricValue(sources: Array | null>, metricKey: string, suffixes: string[]) { + const camel = camelMetricKey(metricKey); + const snake = snakeMetricKey(metricKey); + const directKeys = metricAliases(metricKey); + const generatedKeys = suffixes.flatMap(suffix => [ + `${camel}${suffix}`, + `${snake}_${suffix.replace(/[A-Z]/g, char => `_${char.toLowerCase()}`).replace(/^_/, '')}`, + ]); + + for (const source of sources) { + if (!source) continue; + for (const key of [...directKeys, ...generatedKeys]) { + if (Object.prototype.hasOwnProperty.call(source, key)) return source[key]; + } + } + return undefined; +} + +function quotaForMetric(metricKey: string, planQuotas: Record, subscriptionMetadata: Record) { + const metadataQuotaSources = [ + objectOrNull(subscriptionMetadata.includedQuotas), + objectOrNull(subscriptionMetadata.quotas), + objectOrNull(subscriptionMetadata.quotaOverrides), + ]; + const sources = [...metadataQuotaSources, planQuotas]; + const direct = firstMetricValue(sources, metricKey, ['Included', 'Quota', 'Limit']); + if (direct && typeof direct === 'object' && !Array.isArray(direct)) { + const spec = direct as Record; + return numberOrNull(spec.included ?? spec.includedQuota ?? spec.quota ?? spec.limit ?? spec.value); + } + const numeric = numberOrNull(direct); + if (numeric !== null) return numeric; + + for (const source of sources) { + if (!source) continue; + const nested = objectOrNull(nestedMetricSpec(source, metricKey)); + if (nested) { + const value = numberOrNull(nested.included ?? nested.includedQuota ?? nested.quota ?? nested.limit ?? nested.value); + if (value !== null) return value; + } + } + return null; +} + +function priceForMetric(metricKey: string, planPrices: Record, subscriptionMetadata: Record) { + const metadataPriceSources = [ + objectOrNull(subscriptionMetadata.overagePrices), + objectOrNull(subscriptionMetadata.overagePriceOverrides), + objectOrNull(subscriptionMetadata.prices), + ]; + const sources = [...metadataPriceSources, planPrices]; + const suffixes = [ + 'UnitAmountCents', + 'AmountCents', + 'PriceCents', + 'OverageCents', + 'PerUnitCents', + 'ExtraCents', + 'ExtraPerMonthCents', + 'ExtraPerYearCents', + 'PerMonthCents', + 'PerYearCents', + ]; + const direct = firstMetricValue(sources, metricKey, suffixes); + const directObject = objectOrNull(direct); + let unitAmountCents = directObject + ? positiveIntegerOrNull(directObject.unitAmountCents ?? directObject.amountCents ?? directObject.priceCents ?? directObject.overageCents ?? directObject.cents ?? directObject.perUnitCents) + : positiveIntegerOrNull(direct); + let unitSize = directObject ? numberOrNull(directObject.unitSize ?? directObject.step ?? directObject.per ?? directObject.quantityUnit) : null; + + for (const source of sources) { + if (!source) continue; + const nested = objectOrNull(nestedMetricSpec(source, metricKey)); + if (!nested) continue; + unitAmountCents = unitAmountCents ?? positiveIntegerOrNull(nested.unitAmountCents ?? nested.amountCents ?? nested.priceCents ?? nested.overageCents ?? nested.cents ?? nested.perUnitCents); + unitSize = unitSize ?? numberOrNull(nested.unitSize ?? nested.step ?? nested.per ?? nested.quantityUnit); + } + + if (!unitAmountCents) return null; + return { + unitAmountCents, + unitSize: unitSize && unitSize > 0 ? unitSize : 1, + }; +} + +function usageSnapshotMap(value: unknown) { + const usage = objectOrNull(value) || {}; + const output: Record = {}; + for (const [metricKey, rawSnapshot] of Object.entries(usage)) { + const snapshot = objectOrNull(rawSnapshot); + if (!snapshot) continue; + const metricValue = numberOrNull(snapshot.value); + if (metricValue === null) continue; + output[metricKey] = { + value: metricValue, + recordId: String(snapshot.recordId || ''), + source: typeof snapshot.source === 'string' ? snapshot.source : null, + createdAt: typeof snapshot.createdAt === 'string' ? snapshot.createdAt : null, + }; + } + return output; +} + +function buildUsageOverageItems(row: { + planCode: string; + planName: string | null; + includedQuotas: Record | null; + overagePrices: Record | null; + subscriptionMetadata: Record | null; + usageSnapshots: unknown; +}) { + const planQuotas = row.includedQuotas || {}; + const planPrices = row.overagePrices || {}; + const subscriptionMetadata = row.subscriptionMetadata || {}; + const usage = usageSnapshotMap(row.usageSnapshots); + const items: UsageOverageItem[] = []; + + for (const [metricKey, snapshot] of Object.entries(usage)) { + const includedQuota = quotaForMetric(metricKey, planQuotas, subscriptionMetadata); + const price = priceForMetric(metricKey, planPrices, subscriptionMetadata); + if (includedQuota === null || !price) continue; + const overageValue = snapshot.value - includedQuota; + if (overageValue <= 0) continue; + + const billableUnits = Math.ceil(overageValue / price.unitSize); + if (billableUnits <= 0) continue; + const label = USAGE_METRIC_LABELS[metricKey] || metricKey; + items.push({ + itemType: 'usage_overage', + description: `${row.planName || row.planCode} ${label}超额 ${Number(overageValue.toFixed(4))}`, + quantity: billableUnits, + unitAmountCents: price.unitAmountCents, + metadata: { + metricKey, + metricLabel: label, + metricValue: snapshot.value, + includedQuota, + overageValue: Number(overageValue.toFixed(4)), + billableUnits, + unitSize: price.unitSize, + unitAmountCents: price.unitAmountCents, + usageRecordId: snapshot.recordId || null, + usageSource: snapshot.source, + }, + }); + } + + return items; +} + +async function usageOverageCandidateQuery(params: { + tenantIds: string[]; + periodStart: string; + periodEnd: string; + includeExisting: boolean; + includeZero: boolean; + limit: number; +}) { + const rows = await query<{ + tenantId: string; + tenantSlug: string; + tenantName: string; + billingStatus: string; + subscriptionId: string; + planCode: string; + planName: string | null; + subscriptionStatus: string; + billingCycle: string | null; + includedQuotas: Record | null; + overagePrices: Record | null; + subscriptionMetadata: Record | null; + usageSnapshots: unknown; + existingInvoiceId: string | null; + existingInvoiceNo: string | null; + existingInvoiceStatus: string | null; + }>( + ` + select t.id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName", + t.billing_status as "billingStatus", + s.id as "subscriptionId", s.plan_code as "planCode", + p.name as "planName", s.status as "subscriptionStatus", + s.billing_cycle as "billingCycle", + p.included_quotas as "includedQuotas", + p.overage_prices as "overagePrices", + s.metadata as "subscriptionMetadata", + coalesce(usage_snapshots.metrics, '{}'::jsonb) as "usageSnapshots", + existing.id as "existingInvoiceId", + existing.invoice_no as "existingInvoiceNo", + existing.status as "existingInvoiceStatus" + from public.tenants t + join lateral ( + select id, tenant_id, plan_code, status, billing_cycle, metadata, created_at, expires_at + from public.tenant_subscriptions + where tenant_id = t.id + and status in ('trial', 'active', 'past_due') + order by case status when 'active' then 0 when 'trial' then 1 else 2 end, + expires_at desc nulls last, + created_at desc + limit 1 + ) s on true + join public.platform_saas_plans p on p.code = s.plan_code + left join lateral ( + select jsonb_object_agg(metric_key, jsonb_build_object( + 'value', metric_value, + 'recordId', id, + 'source', metadata->>'source', + 'createdAt', created_at + )) as metrics + from ( + select distinct on (u.metric_key) + u.id, u.metric_key, u.metric_value, u.metadata, u.created_at + from public.tenant_usage_records u + where u.tenant_id = t.id + and u.period_start = $2::date + and u.period_end = $3::date + order by u.metric_key, + case when u.metadata->>'source' = 'platform_usage_worker' then 0 else 1 end, + u.created_at desc + ) latest + ) usage_snapshots on true + left join lateral ( + select id, invoice_no, status + from public.tenant_invoices i + where i.tenant_id = t.id + and i.invoice_type = 'usage_overage' + and i.status <> 'void' + and i.billing_period_start = $2::date + and i.billing_period_end = $3::date + and i.metadata->>'source' = 'usage_overage_auto' + order by i.created_at desc + limit 1 + ) existing on true + where t.status = 'active' + and ($1::uuid[] = '{}'::uuid[] or t.id = any($1::uuid[])) + and ($4::boolean = true or existing.id is null) + order by t.created_at asc + limit $5 + `, + [params.tenantIds, params.periodStart, params.periodEnd, params.includeExisting, params.limit], + ); + + const candidates: UsageOverageCandidate[] = rows.map(row => { + const items = buildUsageOverageItems(row); + return { + tenantId: row.tenantId, + tenantSlug: row.tenantSlug, + tenantName: row.tenantName, + billingStatus: row.billingStatus, + subscriptionId: row.subscriptionId, + planCode: row.planCode, + planName: row.planName, + subscriptionStatus: row.subscriptionStatus, + billingCycle: row.billingCycle, + periodStart: params.periodStart, + periodEnd: params.periodEnd, + existingInvoiceId: row.existingInvoiceId, + existingInvoiceNo: row.existingInvoiceNo, + existingInvoiceStatus: row.existingInvoiceStatus, + hasExistingInvoice: Boolean(row.existingInvoiceId), + wouldCreate: items.length > 0 && !row.existingInvoiceId, + totalCents: invoiceSubtotal(items), + items, + }; + }); + + return candidates.filter(item => params.includeZero || item.items.length > 0); +} + +export async function usageOverageInvoiceCandidatesRoute(ctx: RequestContext) { + await requirePlatformAdmin(ctx, 'platform:billing:read'); + + const tenantIds = optionalUuidList(ctx.url.searchParams.get('tenantIds'), 'tenantIds'); + const periodStart = dateTextFrom(ctx.url.searchParams.get('periodStart'), 'periodStart'); + const periodEnd = dateTextFrom(ctx.url.searchParams.get('periodEnd'), 'periodEnd'); + assertDateRange(periodStart, periodEnd); + const includeExisting = listQuery(ctx, 'includeExisting') === 'true'; + const includeZero = listQuery(ctx, 'includeZero') === 'true'; + const limit = intParam(ctx, 'limit', 100, 500); + const items = await usageOverageCandidateQuery({ tenantIds, periodStart, periodEnd, includeExisting, includeZero, limit }); + + return { items }; +} + async function subscriptionInvoiceCandidateQuery(params: { tenantIds: string[]; subscriptionIds: string[]; @@ -3034,3 +3445,170 @@ export async function createTenantInvoicesBatchFromSubscriptionsRoute(ctx: Reque return { item: result }; } + +export async function createTenantInvoicesFromUsageOverageRoute(ctx: RequestContext) { + await requirePlatformAdmin(ctx, 'platform:billing:write'); + + const body = await readJsonBody(ctx); + const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds'); + const periodStart = dateTextFrom(body.periodStart, 'periodStart'); + const periodEnd = dateTextFrom(body.periodEnd, 'periodEnd'); + assertDateRange(periodStart, periodEnd); + const dueDate = dateTextFrom(body.dueDate, 'dueDate', false) || null; + const status = usageOverageInvoiceStatusFrom(optionalString(body, 'status')); + const note = optionalString(body, 'note') || null; + const dryRun = booleanFrom(body.dryRun, false); + const limit = Math.min(Math.max(tenantIds.length || 0, 100), 500); + + const candidates = await usageOverageCandidateQuery({ + tenantIds, + periodStart, + periodEnd, + includeExisting: true, + includeZero: false, + limit, + }); + + if (!candidates.length) { + return { item: { dryRun, createdCount: 0, skippedCount: 0, totalCents: 0, items: [], skipped: [] } }; + } + + if (dryRun) { + const wouldCreate = candidates.filter(item => !item.hasExistingInvoice); + const existing = candidates.filter(item => item.hasExistingInvoice); + return { + item: { + dryRun: true, + createdCount: 0, + skippedCount: existing.length, + totalCents: wouldCreate.reduce((sum, item) => sum + item.totalCents, 0), + items: candidates.map(item => ({ ...item, wouldCreate: !item.hasExistingInvoice })), + skipped: existing.map(item => ({ + tenantId: item.tenantId, + subscriptionId: item.subscriptionId, + reason: 'USAGE_OVERAGE_INVOICE_EXISTS', + invoiceId: item.existingInvoiceId, + invoiceNo: item.existingInvoiceNo, + })), + }, + }; + } + + const result = await transaction(async client => { + const created: Array }> = []; + const skipped: unknown[] = []; + + for (const candidate of candidates) { + const lock = await client.query( + ` + select id + from public.tenant_subscriptions + where tenant_id = $1 + and id = $2 + for update + `, + [candidate.tenantId, candidate.subscriptionId], + ); + if (!lock.rows[0]) { + skipped.push({ tenantId: candidate.tenantId, subscriptionId: candidate.subscriptionId, reason: 'SUBSCRIPTION_NOT_FOUND' }); + continue; + } + + const existing = await client.query<{ id: string; invoiceNo: string; status: string }>( + ` + select id, invoice_no as "invoiceNo", status + from public.tenant_invoices + where tenant_id = $1 + and invoice_type = 'usage_overage' + and status <> 'void' + and billing_period_start = $2::date + and billing_period_end = $3::date + and metadata->>'source' = 'usage_overage_auto' + order by created_at desc + limit 1 + `, + [candidate.tenantId, periodStart, periodEnd], + ); + if (existing.rows[0]) { + skipped.push({ + tenantId: candidate.tenantId, + subscriptionId: candidate.subscriptionId, + reason: 'USAGE_OVERAGE_INVOICE_EXISTS', + invoiceId: existing.rows[0].id, + invoiceNo: existing.rows[0].invoiceNo, + }); + continue; + } + + if (!candidate.items.length) { + skipped.push({ tenantId: candidate.tenantId, subscriptionId: candidate.subscriptionId, reason: 'NO_USAGE_OVERAGE' }); + continue; + } + + const invoice = await createInvoiceRecordWithClient(client, { + tenantId: candidate.tenantId, + invoiceType: 'usage_overage', + status, + dueDate, + billingPeriodStart: periodStart, + billingPeriodEnd: periodEnd, + note, + metadata: { + source: 'usage_overage_auto', + periodStart, + periodEnd, + subscriptionId: candidate.subscriptionId, + planCode: candidate.planCode, + tenantSlug: candidate.tenantSlug, + }, + items: candidate.items.map(item => ({ + ...item, + metadata: { + ...item.metadata, + periodStart, + periodEnd, + subscriptionId: candidate.subscriptionId, + planCode: candidate.planCode, + }, + })), + }); + + await recordPlatformAudit(client, ctx, 'platform.invoice.usage_overage_created', 'tenant_invoice', invoice.id, { + tenantId: candidate.tenantId, + subscriptionId: candidate.subscriptionId, + planCode: candidate.planCode, + periodStart, + periodEnd, + totalCents: candidate.totalCents, + metrics: candidate.items.map(item => item.metadata.metricKey), + invoiceNo: invoice.invoiceNo, + }, candidate.tenantId); + + created.push({ ...candidate, invoice }); + } + + const totalCents = created.reduce((sum, item) => sum + Number(item.totalCents || 0), 0); + + await recordPlatformAudit(client, ctx, 'platform.invoice.usage_overage_batch_created', 'tenant_invoice_batch', null, { + createdCount: created.length, + skippedCount: skipped.length, + totalCents, + tenantIds, + periodStart, + periodEnd, + dueDate, + status, + }); + + return { + dryRun: false, + createdCount: created.length, + skippedCount: skipped.length, + totalCents, + items: created, + skipped, + }; + }); + + return { item: result }; +} diff --git a/apps/api/src/features/platform-admin/service.ts b/apps/api/src/features/platform-admin/service.ts index 76f3b07b..8805f34d 100644 --- a/apps/api/src/features/platform-admin/service.ts +++ b/apps/api/src/features/platform-admin/service.ts @@ -97,7 +97,6 @@ export async function recalculateInvoiceTotals(client: pg.PoolClient, invoiceId: status = case when status = 'void' then status when $3 >= greatest(0, $2 - discount_cents + tax_cents) and greatest(0, $2 - discount_cents + tax_cents) > 0 then 'paid' - when status = 'draft' then 'issued' else status end, paid_at = case diff --git a/apps/taro/src/pages/platform-admin/billing/index.tsx b/apps/taro/src/pages/platform-admin/billing/index.tsx index e022c566..6bb944b5 100644 --- a/apps/taro/src/pages/platform-admin/billing/index.tsx +++ b/apps/taro/src/pages/platform-admin/billing/index.tsx @@ -6,11 +6,13 @@ import { confirmPlatformInvoicePayment, createPlatformInvoiceFromSubscription, createPlatformInvoicesBatchFromSubscriptions, + createPlatformInvoicesFromUsageOverage, createPlatformSubscription, loadPlatformInvoiceReminders, loadPlatformInvoices, loadPlatformPlans, loadPlatformSubscriptionInvoiceCandidates, + loadPlatformUsageOverageInvoiceCandidates, loadPlatformUsage, processPlatformOverdueInvoices, recordPlatformUsage, @@ -18,6 +20,7 @@ import { type PlatformInvoiceItem, type PlatformSaasPlan, type PlatformSubscriptionInvoiceCandidate, + type PlatformUsageOverageInvoiceCandidate, type PlatformUsageItem, } from '@/services/platformAdmin'; import '../platform.css'; @@ -31,6 +34,11 @@ function todayText() { return new Date().toISOString().slice(0, 10); } +function monthStartText() { + const now = new Date(); + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10); +} + function centsFromYuan(value: string) { const amount = Number(value || 0); if (!Number.isFinite(amount) || amount <= 0) return 0; @@ -44,6 +52,7 @@ export default function PlatformBillingPage() { const [reminders, setReminders] = useState([]); const [usage, setUsage] = useState([]); const [candidates, setCandidates] = useState([]); + const [overageCandidates, setOverageCandidates] = useState([]); const [batchResult, setBatchResult] = useState(''); const [subscriptionForm, setSubscriptionForm] = useState({ tenantId: '', @@ -74,6 +83,12 @@ export default function PlatformBillingPage() { dueDate: '', note: '', }); + const [overageForm, setOverageForm] = useState({ + periodStart: monthStartText(), + periodEnd: todayText(), + dueDate: '', + note: '', + }); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); @@ -84,7 +99,12 @@ export default function PlatformBillingPage() { loadPlatformInvoiceReminders({ limit: 80 }).catch(() => ({ items: [] })), loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })), loadPlatformSubscriptionInvoiceCandidates({ daysAhead: Number(batchInvoiceForm.daysAhead || 45), limit: 100 }).catch(() => ({ items: [] })), - ]).then(([planPayload, invoicePayload, reminderPayload, usagePayload, candidatePayload]) => { + loadPlatformUsageOverageInvoiceCandidates({ + periodStart: overageForm.periodStart, + periodEnd: overageForm.periodEnd, + limit: 100, + }).catch(() => ({ items: [] })), + ]).then(([planPayload, invoicePayload, reminderPayload, usagePayload, candidatePayload, overagePayload]) => { const nextPlans = planPayload.items || []; setPlans(nextPlans); setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' })); @@ -92,6 +112,7 @@ export default function PlatformBillingPage() { setReminders(reminderPayload.items || []); setUsage(usagePayload.items || []); setCandidates(candidatePayload.items || []); + setOverageCandidates(overagePayload.items || []); }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败')); } @@ -127,6 +148,10 @@ export default function PlatformBillingPage() { setBatchInvoiceForm(current => ({ ...current, [key]: value })); } + function updateOverageForm(key: keyof typeof overageForm, value: string) { + setOverageForm(current => ({ ...current, [key]: value })); + } + async function confirm(title: string, content: string) { const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' }); return result.confirm; @@ -289,6 +314,62 @@ export default function PlatformBillingPage() { } } + async function loadUsageOverageCandidates() { + setError(''); + if (!overageForm.periodStart || !overageForm.periodEnd) { + setError('请填写超额计费账期。'); + return; + } + setBusy('overage-candidates'); + try { + const payload = await loadPlatformUsageOverageInvoiceCandidates({ + periodStart: overageForm.periodStart, + periodEnd: overageForm.periodEnd, + limit: 100, + }); + setOverageCandidates(payload.items || []); + setBatchResult(''); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '超额账单候选加载失败'); + } finally { + setBusy(''); + } + } + + async function submitUsageOverageInvoices(dryRun: boolean) { + setError(''); + if (!overageForm.periodStart || !overageForm.periodEnd) { + setError('请填写超额计费账期。'); + return; + } + const ok = dryRun + ? true + : await confirm('生成超额账单', `确认按 ${overageForm.periodStart} 至 ${overageForm.periodEnd} 的后端用量快照生成超额服务费账单?`); + if (!ok) return; + setBusy(dryRun ? 'overage-dry-run' : 'overage-create'); + try { + const payload = await createPlatformInvoicesFromUsageOverage({ + periodStart: overageForm.periodStart, + periodEnd: overageForm.periodEnd, + dueDate: overageForm.dueDate || undefined, + note: overageForm.note.trim() || undefined, + status: 'issued', + dryRun, + }); + const item = payload.item || {}; + setBatchResult(`${dryRun ? '超额预览' : '超额账单生成'}完成:创建 ${item.createdCount || 0},跳过 ${item.skippedCount || 0},金额 ${money(item.totalCents || 0)}`); + setOverageCandidates((item.items || []).map(candidate => ({ ...candidate, wouldCreate: dryRun ? true : candidate.wouldCreate }))); + if (!dryRun) { + Taro.showToast({ title: '已生成', icon: 'success' }); + reload(status); + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '超额账单处理失败'); + } finally { + setBusy(''); + } + } + async function submitOverdueProcess(dryRun: boolean) { setError(''); const ok = dryRun @@ -413,6 +494,34 @@ export default function PlatformBillingPage() { {!candidates.length ? 暂无即将到期且未开票的订阅。 : null} + + 用量超额账单 + + 账期开始 updateOverageForm('periodStart', String(event.detail.value || ''))} /> + 账期结束 updateOverageForm('periodEnd', String(event.detail.value || ''))} /> + 到期日 updateOverageForm('dueDate', String(event.detail.value || ''))} /> + 备注 updateOverageForm('note', String(event.detail.value || ''))} /> + + + + + + + + {overageCandidates.slice(0, 12).map(item => ( + + {item.tenantName || item.tenantSlug || item.tenantId} + {item.planName || item.planCode || 'plan'} · {item.periodStart || '-'} 至 {item.periodEnd || '-'} · {money(item.totalCents)} + {(item.items || []).map(overage => ( + {overage.description || '超额项'} · {String(overage.quantity || 0)} × {money(overage.unitAmountCents)} + ))} + {item.hasExistingInvoice ? `已有超额账单 ${item.existingInvoiceNo || item.existingInvoiceId}` : item.wouldCreate ? '预览会生成超额账单' : '可生成超额账单'} + + ))} + + {!overageCandidates.length ? 当前账期暂无超过套餐额度的租户。 : null} + + 逾期与催缴 diff --git a/apps/taro/src/services/platformAdmin.ts b/apps/taro/src/services/platformAdmin.ts index 3ec4149b..20f0c20d 100644 --- a/apps/taro/src/services/platformAdmin.ts +++ b/apps/taro/src/services/platformAdmin.ts @@ -207,6 +207,35 @@ export interface PlatformSubscriptionInvoiceCandidate { wouldCreate?: boolean | null; } +export interface PlatformUsageOverageItem { + itemType?: string | null; + description?: string | null; + quantity?: number | string | null; + unitAmountCents?: number | string | null; + metadata?: Record | null; +} + +export interface PlatformUsageOverageInvoiceCandidate { + tenantId: string; + tenantSlug?: string | null; + tenantName?: string | null; + billingStatus?: string | null; + subscriptionId?: string | null; + planCode?: string | null; + planName?: string | null; + subscriptionStatus?: string | null; + billingCycle?: string | null; + periodStart?: string | null; + periodEnd?: string | null; + existingInvoiceId?: string | null; + existingInvoiceNo?: string | null; + existingInvoiceStatus?: string | null; + hasExistingInvoice?: boolean | null; + wouldCreate?: boolean | null; + totalCents?: number | string | null; + items?: PlatformUsageOverageItem[]; +} + export interface PlatformQuestionBankItem { id: string; tenantId?: string | null; @@ -459,6 +488,16 @@ export interface CreatePlatformInvoicesBatchFromSubscriptionsInput { dryRun?: boolean; } +export interface CreatePlatformInvoicesFromUsageOverageInput { + tenantIds?: string[]; + periodStart: string; + periodEnd: string; + dueDate?: string; + note?: string; + status?: 'draft' | 'issued'; + dryRun?: boolean; +} + export interface ConfirmPlatformInvoicePaymentInput { tenantId: string; invoiceId: string; @@ -695,6 +734,20 @@ export async function loadPlatformSubscriptionInvoiceCandidates(query: { tenantI }); } +export async function loadPlatformUsageOverageInvoiceCandidates(query: { + tenantIds?: string; + periodStart: string; + periodEnd: string; + includeExisting?: boolean; + includeZero?: boolean; + limit?: number; +}) { + return apiRequest<{ items?: PlatformUsageOverageInvoiceCandidate[] }>('/api/platform-admin/invoices/usage-overage-candidates', { + query: { ...query, limit: query.limit || 100 }, + tenantId: null, + }); +} + export async function loadPlatformQuestionBanks(query: { q?: string; status?: string; includeTenantBanks?: boolean; limit?: number } = {}) { return apiRequest<{ items?: PlatformQuestionBankItem[] }>('/api/platform-admin/question-banks', { query: { status: 'active', ...query, limit: query.limit || 80 }, @@ -765,6 +818,23 @@ export async function createPlatformInvoicesBatchFromSubscriptions(input: Create }); } +export async function createPlatformInvoicesFromUsageOverage(input: CreatePlatformInvoicesFromUsageOverageInput) { + return apiRequest<{ + item?: { + dryRun?: boolean; + createdCount?: number; + skippedCount?: number; + totalCents?: number; + items?: Array; + skipped?: Array>; + }; + }>('/api/platform-admin/invoices/from-usage-overage', { + method: 'POST', + body: input, + tenantId: null, + }); +} + export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoicePaymentInput) { return apiRequest<{ item?: Record }>('/api/platform-admin/invoices/payments/manual-confirm', { method: 'POST', diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 9fc4ed26..93fd3646 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -42,7 +42,7 @@ | 手机号绑定/换绑 | 可联调 | `/api/auth/phone/bind` 使用 `bind_phone` 短信验证码,后端校验当前登录态、手机号唯一性、移除旧手机号 identity,并撤销其它迁移期 session | | 微信网页/QQ OAuth | 可联调 | `/api/auth/oauth/wechat` 已完成微信网页登录 code 换 token、userinfo、unionid 合并、session 签发和审计;`/api/auth/oauth/qq` 已完成 code/token/openid/userinfo 主链路;生产前需真实开放平台账号和回调域名联调 | | 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT;平台账号以后端 `platform_users.primary_role='platform_admin'`、`status='active'` 和 `platform_permissions` 为准;`GET /api/platform-admin/permissions` 返回权限目录和当前账号 `effective` 能力,平台路由按 `platform:staff:*`、`platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验;`GET/PUT/PATCH /api/platform-admin/staff` 可管理平台员工,禁用员工会拒绝后续 JWT 映射并撤销迁移期 session;`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 | -| 平台 SaaS 用量采集 | 可联调 | `GET/POST /api/platform-admin/usage` 保留平台用量台账和手工调整能力;`apps/worker --job platform-usage` 会按月自动从权威业务表采集学生数、活跃学生、题量、资源数、存储 GB、视频数、视频播放、视频次数消耗、已支付订单、GMV 和有效权益,写入 `tenant_usage_records.metadata.source=platform_usage_worker` 和审计日志;手工调整记录不被覆盖,平台概览优先取每租户每指标最新 worker 快照 | +| 平台 SaaS 用量采集和超额账单 | 可联调 | `GET/POST /api/platform-admin/usage` 保留平台用量台账和手工调整能力;`apps/worker --job platform-usage` 会按月自动从权威业务表采集学生数、活跃学生、题量、资源数、存储 GB、视频数、视频播放、视频次数消耗、已支付订单、GMV 和有效权益,写入 `tenant_usage_records.metadata.source=platform_usage_worker` 和审计日志;手工调整记录不被覆盖,平台概览优先取每租户每指标最新 worker 快照;`GET /api/platform-admin/invoices/usage-overage-candidates` 和 `POST /api/platform-admin/invoices/from-usage-overage` 已支持按账期读取最新用量、套用 SaaS 套餐 `included_quotas/overage_prices` 或订阅 metadata 覆盖、dry-run/正式生成 `usage_overage` 账单、重复开票保护和平台审计 | | 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 | | 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`,支持权限、菜单、模块、字段、数据范围配置;Taro 租户设置页已接创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置第一版 | | 班级/教师/学生范围权限 | 可联调 | `tenant_classes`、`tenant_class_members` + `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`;教师默认只看自己负责班级,字段权限可脱敏学生手机号 | @@ -147,7 +147,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`、`students/followups/report`、`students/supervision/preview`、`students/supervision/generate`、`students/supervision/rules`、`students/crm-push`;支持逐行结果、限量、防跨租户和教师范围校验;跟进报表支持 7/30/90 天或自定义日期范围、状态/类型/优先级/负责人/班级聚合、逾期待办、CRM 推送队列摘要和每日趋势;学习督导由后端读取答题、错题、单词待复习和未完成练习数据,按阈值预览候选并幂等生成 `learning` 跟进任务,也可保存手动/每日/每周规则交给 worker 定时生成,要求 `students:supervision:read/write`;批量 CRM 推送会为学生生成跟进任务并写入异步 CRM 队列,要求 `crm:write`、`students:read`、`students:followups:write`;学生导入/upsert/分班禁止 `avatarUrl/avatar_url/avatar/headimgurl/figureurl` 和 `primaryRole/primary_role`,避免绕过预设头像与租户角色体系 | | 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 | -| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量台账和平台用量自动采集 worker;平台 API 已拆分 `platform:staff:read/write/status`、`platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:usage:read/write`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant/ops` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-usage` 会生成月度 SaaS 用量快照并保留手工调整记录;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook,发送成功会推进提醒状态,失败会退避重试,联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、用量采集、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 | +| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、用量超额账单候选预览/dry-run/生成、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量台账和平台用量自动采集 worker;平台 API 已拆分 `platform:staff:read/write/status`、`platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:usage:read/write`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant/ops` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-usage` 会生成月度 SaaS 用量快照并保留手工调整记录;平台超额账单只使用后端权威用量快照和套餐/订阅 metadata,前端不得自行计算服务费;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook,发送成功会推进提醒状态,失败会退避重试,联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、用量采集、用量超额开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 | | 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 | | 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库,并可限制授权地区和科目。平台保存 grant 时会校验 `allowedRegionIds`、`allowedSubjectIds` 属于源平台题库租户,且已发布题目的科目必须被授权科目覆盖 | | 租户采纳/同步公共题库 | 可联调 | `/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`、`/api/platform-admin/question-bank-sync-status`;租户只能看到自己 `question_bank_grants`、有效 `tenant_subscriptions`、`platform_saas_plans.feature_flags.publicQuestionBanks` 和订阅 `metadata.publicQuestionBankAccess` 同时允许的题库。基础版默认 `limited_regions` 且需要地区 allowlist,专业版默认 `national`;采纳、同步和冲突处理都会重新校验当前授权,越权 grant 返回 `QUESTION_BANK_GRANT_NOT_AVAILABLE`。采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新、冲突和 worker 失败会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会写入逐条审计,冲突全部处理后相关通知自动 resolved,失败通知会在后续同步恢复成功后自动 resolved;平台运营接口需 `platform:question_bank:ops`,只返回跨租户同步摘要和通知数量,不返回题目正文/答案/解析 | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 128d322b..fd9ad659 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -8,7 +8,7 @@ 新项目已经不是简单的 PocketBase 字段平移,而是按多租户 SaaS 重新建立了后端边界: -- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴、用量台账和月度用量自动采集。 +- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴、用量台账、月度用量自动采集和用量超额账单。 - 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。 - 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;学生头像不支持上传或第三方头像落库,学生写入口会拒绝头像 URL;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知;租户后台已具备反馈运营聚合报表和积分风控只读报表。 - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。 @@ -21,7 +21,7 @@ | 模块 | 当前状态 | 已经具备 | 上线前还要补 | | --- | --- | --- | --- | | 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 | -| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台员工列表/创建/编辑/启停、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量台账、platform-usage 月度自动采集 worker、公共题库授权、公共题库地区/科目授权校验、SaaS 套餐/订阅 metadata 公共题库访问边界、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、SaaS 套餐额度/超额账单、平台审计告警升级策略和更完整运营消息 | +| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台员工列表/创建/编辑/启停、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量台账、platform-usage 月度自动采集 worker、套餐额度判定、用量超额账单候选预览/dry-run/生成、公共题库授权、公共题库地区/科目授权校验、SaaS 套餐/订阅 metadata 公共题库访问边界、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、平台审计告警升级策略和更完整运营消息 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、积分风控只读报表、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限、学生运营跟进、学习督导规则模板和 `student-supervision` worker;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注、跟进任务、学习督导预览/生成和保存每日规则第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台、积分风控摘要和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、学习督导触达联动/效果归因、真实打款/导出/凭证和完整权限菜单 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜接口(租户默认关闭)、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、starter 单地区/专业版全国公共题库访问边界、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台;排行榜仅在租户显式开启活动后再补压测、防刷和预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜接口(租户默认关闭) | 更细复习参数 | @@ -86,7 +86,7 @@ - 资金对账已支持手工/API 账单导入比对、微信/支付宝官方账单下载任务、异常查询和差错工单处理;继续补真实生产账单格式验收、财务复核报表和异常订单运营台。 - XPay 或其它实际支付网关 adapter。 - 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。 -- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;公共题库可见、采纳、同步、冲突处理会统一校验 SaaS 套餐、有效订阅、订阅 metadata、grant 地区和科目范围。平台用量采集已覆盖存储、学生数、题量和视频播放等基础指标;继续补生产定时调度、失败告警、SaaS 套餐额度判定和超额计费账单生成。 +- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;公共题库可见、采纳、同步、冲突处理会统一校验 SaaS 套餐、有效订阅、订阅 metadata、grant 地区和科目范围。平台用量采集已覆盖存储、学生数、题量和视频播放等基础指标,超额服务费由后端按套餐额度生成 `usage_overage` 账单;继续补生产定时调度、失败告警和更完整运营消息。 - 导入模板、字段映射、导入任务详情和复检 API 已可用;Taro 租户内容页已接模板下载、字段别名覆盖、导入执行、异步 job 轮询和复检结果面板第一版。前端继续补真实导入目标选择体验和大数据量导入验收。 - 视频深度防盗链、转码级水印和播放统计。 - 数据看板 API:收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 793a5ce4..a3603ed1 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/PUT /api/platform-admin/audit-notification-channels` 和 `GET /api/platform-admin/audit-notification-events` 已支持平台审计告警外部通知渠道配置和发送事件查询,`apps/worker --job platform-audit-notifications` 可按渠道把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥进入 `app_private.platform_secrets`,API 只回显 `secretRef` 和 webhook host/path;`GET /api/platform-admin/invoices/subscription-candidates` 和 `POST /api/platform-admin/invoices/from-subscriptions-batch` 支持订阅账单候选预览、dry-run、批量生成、重复开票跳过和平台审计;`apps/worker --job platform-billing` 可自动为即将到期且未开票订阅生成服务费账单;`POST /api/platform-admin/invoices/process-overdue`、`GET /api/platform-admin/invoices/reminders` 和 `apps/worker --job platform-dunning` 可处理已逾期未结清服务费账单,写入内部催缴台账和审计;`GET/PUT /api/platform-admin/dunning-notification-channels`、`GET /api/platform-admin/dunning-notification-events` 和 `apps/worker --job platform-dunning-notifications` 已支持平台催缴外部通知渠道配置、发送事件查询、重试和幂等发送。创建租户、状态变更、账务资料维护、订阅批量开票、自动计费、逾期催缴、手工收款确认、审计导出、审计告警状态更新、审计告警通知渠道变更和催缴通知渠道变更会写入审计日志,API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝、敏感字段脱敏和审计记录存在。 +- 平台后台租户运营第一版已补齐:`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、批量生成、重复开票跳过和平台审计;`GET /api/platform-admin/invoices/usage-overage-candidates` 和 `POST /api/platform-admin/invoices/from-usage-overage` 支持用量超额候选预览、dry-run、正式生成 `usage_overage` 账单、重复开票保护和平台审计;`apps/worker --job platform-billing` 可自动为即将到期且未开票订阅生成服务费账单;`POST /api/platform-admin/invoices/process-overdue`、`GET /api/platform-admin/invoices/reminders` 和 `apps/worker --job platform-dunning` 可处理已逾期未结清服务费账单,写入内部催缴台账和审计;`GET/PUT /api/platform-admin/dunning-notification-channels`、`GET /api/platform-admin/dunning-notification-events` 和 `apps/worker --job platform-dunning-notifications` 已支持平台催缴外部通知渠道配置、发送事件查询、重试和幂等发送。创建租户、状态变更、账务资料维护、订阅批量开票、自动计费、用量超额开票、逾期催缴、手工收款确认、审计导出、审计告警状态更新、审计告警通知渠道变更和催缴通知渠道变更会写入审计日志,API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝、敏感字段脱敏和审计记录存在。 - 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。 - 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups`、`tenant_student_supervision_rules` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态、督导规则模板和审计;批量学生 upsert、批量分班、禁用/恢复、学生批量 CRM 推送、跟进效果统计和学习督导自动化已接入权限校验、范围校验和集成测试。`GET /api/tenant-admin/students/followups/report` 支持 7/30/90 天或自定义日期范围、负责人/类型/班级筛选、状态/类型/优先级/负责人/班级聚合、每日趋势、逾期待办和 CRM 队列摘要,并会沿用教师班级范围与字段脱敏规则。`GET /api/tenant-admin/students/supervision/preview` 和 `POST /api/tenant-admin/students/supervision/generate` 支持按未学习、错题积压、低正确率、单词待复习和超期未完成练习生成风险候选,并用 `metadata.autoSupervision.idempotencyKey` 幂等生成 `learning` 跟进任务;`GET/PUT /api/tenant-admin/students/supervision/rules` 支持租户保存手动/每日/每周督导策略,`apps/worker --job student-supervision` 会按启用规则定时生成跟进任务并写回 `last_result/next_run_at`。 diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index dcada0c6..0450a01f 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -15,7 +15,7 @@ | 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 | | --- | --- | --- | --- | -| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、月度用量自动采集 worker、公共题库披露策略、公共题库地区/科目授权和基础版单地区/专业版全国访问边界 | 平台侧主题模板库、平台在线收款、平台审计报表增强、套餐存储/学生数/题量/视频播放额度判定和超额账单 | +| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、月度用量自动采集 worker、套餐存储/学生数/题量/视频播放额度判定、用量超额账单候选预览/dry-run/生成、公共题库披露策略、公共题库地区/科目授权和基础版单地区/专业版全国访问边界 | 平台侧主题模板库、平台在线收款、平台审计报表增强、超额账单定时生成 worker 和失败告警 | | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | | 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 | | 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询 API、按 SaaS 套餐和订阅 metadata 控制公共题库地区/科目/题库范围 | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 | diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index a1616950..46bed5f6 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` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、跟进看板、学习督导候选预览/生成、督导规则保存和 CRM 批量推送第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表、积分任务/兑换、积分风控只读摘要和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。 -- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权、平台员工;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,平台员工页已接员工列表、搜索、创建/编辑、权限点勾选、禁用和恢复,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成、催缴记录查看和用量台账查看。平台用量由 `platform-usage` worker 从后端权威表采集,前端不要自行统计答题记录、资源大小、订单或视频播放后计算服务费。 +- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权、平台员工;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,平台员工页已接员工列表、搜索、创建/编辑、权限点勾选、禁用和恢复,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、用量超额账单候选预览/dry-run/生成、逾期预览、内部催缴生成、催缴记录查看和用量台账查看。平台用量由 `platform-usage` worker 从后端权威表采集,超额费用由后端按套餐额度统一计算,前端不要自行统计答题记录、资源大小、订单或视频播放后计算服务费。 - 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习/学习概览、商城收银台、订单详情和售后入口已经有第一版页面。 - 可以按新后端主模型接入内容导航: - `content_entries` @@ -111,8 +111,8 @@ | --- | --- | --- | | 工作台 | `apps/taro/src/pages/platform-admin/workbench/index.tsx` | `platform-admin/permissions`、`platform-admin/overview`、`tenants`、`invoices`、`question-banks`、`question-bank-grants`、`audit-logs`、`audit-logs/export`、`audit-alerts`、`audit-alerts/status`、`audit-notification-channels/events`、`dunning-notification-channels/events` | | 租户管理 | `apps/taro/src/pages/platform-admin/tenants/index.tsx` | `platform-admin/tenants`、`POST tenants`、`tenants/detail`、`PATCH tenants/status`、`PUT tenants/billing-profile`、`audit-logs` | -| 账务中心 | `apps/taro/src/pages/platform-admin/billing/index.tsx` | `platform-admin/plans`、`invoices`、`invoices/subscription-candidates`、`invoices/from-subscription`、`invoices/from-subscriptions-batch`、`invoices/payments/manual-confirm`、`usage`、`subscriptions`、`POST usage`;`GET usage` 会展示 `platform-usage` worker 生成的月度快照和手工调整记录,`POST usage` 仅用于人工补录/调整 | +| 账务中心 | `apps/taro/src/pages/platform-admin/billing/index.tsx` | `platform-admin/plans`、`invoices`、`invoices/subscription-candidates`、`invoices/from-subscription`、`invoices/from-subscriptions-batch`、`invoices/usage-overage-candidates`、`invoices/from-usage-overage`、`invoices/payments/manual-confirm`、`usage`、`subscriptions`、`POST usage`;`GET usage` 会展示 `platform-usage` worker 生成的月度快照和手工调整记录,`POST usage` 仅用于人工补录/调整;超额账单只展示后端返回的候选、明细和金额,不在前端计算 | | 公共题库 | `apps/taro/src/pages/platform-admin/question-banks/index.tsx` | `platform-admin/question-banks`、`question-bank-grants`、`PUT question-bank-grants` | | 平台员工 | `apps/taro/src/pages/platform-admin/staff/index.tsx` | `platform-admin/permissions`、`platform-admin/staff`、`PUT platform-admin/staff`、`PATCH platform-admin/staff/status` | -当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、平台员工权限点勾选、最近平台审计查询、最近平台审计 CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件摘要、催缴外部通知渠道/事件摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限、重复开票保护和审计。平台后台的菜单和按钮必须用 `platform-admin/permissions` 返回的 `effective` 做可见性控制,但安全边界仍以后端 `PLATFORM_PERMISSION_REQUIRED` 为准。平台员工创建必须绑定 Supabase Auth 用户 ID,前端只提交公开资料和权限点,不创建密码账号、不接触 service role;禁用员工时应调用 `PATCH /api/platform-admin/staff/status` 并默认 `revokeSessions=true`。平台审计导出只开放给具备 `platform:audit:export` 的平台账号,后端会对导出 `details` 中的 token/secret/password/key 等敏感字段脱敏,并返回 `contentBase64 + sha256`,H5 可直接下载,小程序端建议先展示“已生成,需在 H5 管理台下载”。平台审计告警由 `platform-audit-alerts` worker 从高风险平台审计动作生成,外部通知由 `platform-audit-notifications` worker 根据平台渠道配置发送;平台催缴外部通知由 `platform-dunning-notifications` worker 根据 `tenant_invoice_reminders` 和平台渠道配置发送。前端只能调用告警查询、状态更新、通知渠道和发送事件 API,不要直接写 `platform_audit_alerts`、`platform_audit_notification_channels`、`platform_audit_notification_events`、`platform_dunning_notification_channels` 或 `platform_dunning_notification_events` 表。后端会对告警 `details`、通知 payload 和催缴 payload 递归脱敏,渠道 API 只回显 `secretRef` 和 webhook host/path。下一批继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 +当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、平台员工权限点勾选、最近平台审计查询、最近平台审计 CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件摘要、催缴外部通知渠道/事件摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、用量超额账单候选预览、超额 dry-run、超额账单生成、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限、重复开票保护和审计。平台后台的菜单和按钮必须用 `platform-admin/permissions` 返回的 `effective` 做可见性控制,但安全边界仍以后端 `PLATFORM_PERMISSION_REQUIRED` 为准。平台员工创建必须绑定 Supabase Auth 用户 ID,前端只提交公开资料和权限点,不创建密码账号、不接触 service role;禁用员工时应调用 `PATCH /api/platform-admin/staff/status` 并默认 `revokeSessions=true`。平台审计导出只开放给具备 `platform:audit:export` 的平台账号,后端会对导出 `details` 中的 token/secret/password/key 等敏感字段脱敏,并返回 `contentBase64 + sha256`,H5 可直接下载,小程序端建议先展示“已生成,需在 H5 管理台下载”。平台审计告警由 `platform-audit-alerts` worker 从高风险平台审计动作生成,外部通知由 `platform-audit-notifications` worker 根据平台渠道配置发送;平台催缴外部通知由 `platform-dunning-notifications` worker 根据 `tenant_invoice_reminders` 和平台渠道配置发送。前端只能调用告警查询、状态更新、通知渠道和发送事件 API,不要直接写 `platform_audit_alerts`、`platform_audit_notification_channels`、`platform_audit_notification_events`、`platform_dunning_notification_channels` 或 `platform_dunning_notification_events` 表。后端会对告警 `details`、通知 payload 和催缴 payload 递归脱敏,渠道 API 只回显 `secretRef` 和 webhook host/path。下一批继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 53924a38..a895fbf0 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -37,7 +37,7 @@ | 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分任务领取、积分兑换、优惠券兑换履约、积分风控只读报表和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换、积分风控摘要和用户通知查看第一版;营销自动化、外部订阅消息/短信和更完整活动效果看板继续补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI | -| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件、平台权限字段和平台员工状态字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台员工列表/创建/编辑/启停、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量台账、平台用量自动采集 worker、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台员工创建/权限目录/JWT 访问/越权拒绝/自降级拒绝/禁用后 JWT 拒绝/审计脱敏、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-usage` 覆盖月度用量自动采集、手工调整不覆盖和幂等,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露;Taro 类型检查覆盖平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款、套餐额度/超额账单和更完整平台审计报表待补 | +| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件、平台权限字段和平台员工状态字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台员工列表/创建/编辑/启停、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、用量超额账单候选预览/dry-run/生成、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量台账、平台用量自动采集 worker、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台员工创建/权限目录/JWT 访问/越权拒绝/自降级拒绝/禁用后 JWT 拒绝/审计脱敏、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、用量超额账单候选/dry-run/生成/重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-usage` 覆盖月度用量自动采集、手工调整不覆盖和幂等,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露;Taro 类型检查覆盖平台账务和平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款和更完整平台审计报表待补 | | 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 | | 数据导入 | 已建立 importer、risk report、dry-run report、validate | 已覆盖多类旧集合 | 命令行 dry-run/导入/校验 | `pb:import:dry-run`、`pb:import:validate`、`test:pb:dry-run` 覆盖 strict warning 和关系断裂门禁 | 基础工具和真实迁移 runbook 可用,需拿真实完整数据执行多轮 dry-run、导入回归和抽样验收 | | 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 | diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 48012cb6..61336067 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -74,9 +74,9 @@ | 功能 | 新后端状态 | 待补齐 | | --- | --- | --- | | 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护、最近平台审计查询/导出、开放审计告警查询/确认/解决、审计告警外部通知渠道/事件、订阅账单候选预览、dry-run、批量生成、自动计费 worker、逾期标记、内部催缴台账和催缴外部通知已接真实 API/worker;后续补租户基础资料编辑增强和审计告警升级策略 | -| SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 | +| SaaS 套餐 | 已覆盖 | 已和公共题库授权、订阅账单、用量采集和超额账单打通;支持 `included_quotas/overage_prices` 和订阅 metadata 覆盖,后续主要补更友好的套餐配置 UI 和更多组合套餐模板 | | 年费/服务费账单 | 已覆盖 | 订阅账单候选、批量开票、自动计费、人工收款、逾期标记、租户 `past_due` 状态、内部催缴记录和平台催缴外部 webhook 通知已覆盖;真实平台在线收款、外部短信/微信订阅消息和停用策略待补 | -| 租户用量记录 | 已覆盖 | 已有平台用量手工记录和 `platform-usage` worker 月度自动采集;worker 会从权威业务表汇总学生数、活跃学生、题量、资源数、存储 GB、视频数、视频播放、视频次数消耗、已支付订单、GMV 和有效权益,手工调整记录保留为审计台账不被覆盖。后续补套餐额度判定和超额账单自动生成 | +| 租户用量记录/超额账单 | 已覆盖 | 已有平台用量手工记录和 `platform-usage` worker 月度自动采集;worker 会从权威业务表汇总学生数、活跃学生、题量、资源数、存储 GB、视频数、视频播放、视频次数消耗、已支付订单、GMV 和有效权益,手工调整记录保留为审计台账不被覆盖。平台可按账期预览超额候选、dry-run 或正式生成 `usage_overage` 账单,后端负责套餐额度、价格、重复开票保护和审计,前端不得自行计算服务费 | | 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 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 会递归脱敏;`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 5ccd1048..20b878c9 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、platform-audit-notifications worker、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件、platform-dunning-notifications worker、用量。 +- 平台后台 API/worker:租户、租户详情、账务资料维护、平台员工列表/创建/编辑/启停、平台审计日志查询/导出、平台审计告警规则/列表/确认/解决、审计告警外部通知渠道/事件、platform-audit-alerts worker、platform-audit-notifications worker、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件、platform-dunning-notifications worker、用量、套餐额度判定和用量超额账单。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 @@ -34,6 +34,7 @@ - 微信/支付宝官方账单下载地基已完成:`commerce_bill_download_jobs`、`POST /api/commerce/reconciliation/provider-bills/request`、`GET /api/commerce/reconciliation/provider-bills/jobs` 和 `apps/worker --job provider-bills` 已接入,worker 负责后端签名申请下载 URL、hash 校验、JSON/CSV/ZIP 账单解析、复用 `provider_download` 对账导入、任务状态回写和密钥脱敏。 - 平台 SaaS 自动计费 worker 已完成:`apps/worker --job platform-billing` 会按 `WORKER_PLATFORM_BILLING_DAYS_AHEAD` 查找即将到期且未开票的订阅,生成 `tenant_invoices/tenant_invoice_items`,使用订阅行锁和账单查重防重复,写入 `platform.invoice.subscription_auto_created` 审计;`npm run test:worker:platform-billing` 覆盖自动开票、明细、审计和二次运行幂等。 - 平台 SaaS 用量自动采集 worker 已完成:`apps/worker --job platform-usage` 默认按上海时区当前月采集,也可用 `WORKER_PLATFORM_USAGE_MONTH=YYYY-MM` 补跑指定月份;当前会生成 `students`、`active_students`、`questions`、`assets`、`storage_gb`、`videos`、`video_plays`、`video_quota_consumed`、`paid_orders`、`paid_order_amount_cents`、`active_entitlements` 11 类指标,写入 `tenant_usage_records` 和 `platform.usage.worker_collected` 审计;手工调整用量不被 worker 覆盖,`npm run test:worker:platform-usage` 覆盖幂等和指标口径。 +- 平台 SaaS 超额账单已完成第一版:`GET /api/platform-admin/invoices/usage-overage-candidates` 可按账期预览超出套餐额度的租户,`POST /api/platform-admin/invoices/from-usage-overage` 支持 dry-run 或正式生成 `usage_overage` 账单;后端支持套餐 `included_quotas/overage_prices`、订阅 metadata 覆盖、重复开票保护、审计和 Taro 平台账务中心第一版操作台。 - 平台 SaaS 逾期催缴 worker 已完成:`apps/worker --job platform-dunning` 会扫描已过 `due_date` 且未结清的服务费账单,标记 `tenant_invoices.status=overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写审计;`POST /api/platform-admin/invoices/process-overdue` 支持平台后台 dry-run/执行,`GET /api/platform-admin/invoices/reminders` 支持查看催缴台账;`npm run test:worker:platform-dunning` 覆盖逾期标记、催缴幂等和审计。 - 平台 SaaS 催缴外部通知第一版已完成:`platform_dunning_notification_channels/events`、`GET/PUT /api/platform-admin/dunning-notification-channels`、`GET /api/platform-admin/dunning-notification-events` 和 `apps/worker --job platform-dunning-notifications` 已接入;支持 generic/钉钉/飞书/企微 webhook、按催缴类型/渠道/级别/租户筛选、发送重试、幂等、防重复、联系方式掩码、payload 脱敏和生产 readiness 阻断 localhost/不安全 webhook。 - 平台审计告警 worker 已完成:`apps/worker --job platform-audit-alerts` 会扫描 `platform.%` 审计日志,根据 `platform_audit_alert_rules` 把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台操作生成内部告警;API 已支持 `/api/platform-admin/audit-alert-rules`、`/api/platform-admin/audit-alerts`、`/api/platform-admin/audit-alerts/status`,Taro 平台工作台可查看开放告警并确认/解决;`npm run test:worker:platform-audit-alerts` 覆盖规则匹配、幂等和敏感 details 脱敏。 @@ -125,7 +126,7 @@ - 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。 - 已完成 `public-banks` worker 自动同步、失败记录、审计、同步通知、冲突查询 API 和单条/批量冲突处理 API。 - 已完成基础 SaaS 套餐访问边界:`starter_yearly` 默认 `limited_regions` + 地区 allowlist,`pro_yearly` 默认 `national`;租户订阅 metadata 可进一步限制 allowedRegionIds、allowedSubjectIds、allowedQuestionBankIds。 - - 存储、学生数、题量、视频播放量等月度用量已由 `platform-usage` worker 自动采集;继续补 SaaS 套餐额度判定、超额价格计算和 `usage_overage` 账单自动生成。 + - 存储、学生数、题量、视频播放量等月度用量已由 `platform-usage` worker 自动采集;SaaS 套餐额度判定、超额价格计算和 `usage_overage` 账单手动触发生成已完成第一版。后续继续补定时生成 worker、失败告警、套餐配置 UI 和平台在线收款。 - 继续补生产定时调度、失败告警和更完整运营后台消息。 6. 视频会员控制 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 11ada44b..932d1189 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -2696,7 +2696,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/批量生成、自动计费生成结果查看、用量超额账单候选/dry-run/生成、逾期预览/内部催缴记录、催缴外部通知渠道/发送事件、用量、公共题库授权 ``` 验证命令: @@ -3057,7 +3057,7 @@ GET /api/platform-admin/dunning-notification-events?invoiceId=&limit= - 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、逐题复盘、背单词学习概览/卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息摘要、独立消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补真正 KaTeX/小程序公式方案、私有题图签名资源映射、小程序支付容器和分享场景。 - 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务、完成跟进、跟进看板和 CRM 批量推送第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。 -- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件状态摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑已接第一版;后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计,`platform-billing` worker 会自动生成即将到期订阅账单并记录 `platform.invoice.subscription_auto_created` 审计,`platform-dunning` worker 会标记已过期未结清服务费账单、生成 `tenant_invoice_reminders` 并记录 `platform.invoice.overdue_processed` 审计,`platform-dunning-notifications` worker 会把内部催缴记录按渠道发送外部通知,`platform-audit-alerts` worker 会把高风险平台审计动作转换为内部告警,`platform-audit-notifications` worker 会把开放告警按渠道发送外部通知;前端只展示候选、预览结果、跳过结果、逾期处理结果、开放告警、通知事件和生成后的账单/审计,不要直接更新账单状态、租户 `billing_status`、告警表或通知事件表;继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 +- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件状态摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、用量超额账单候选预览、超额 dry-run、超额账单生成、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑已接第一版;后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计,`platform-billing` worker 会自动生成即将到期订阅账单并记录 `platform.invoice.subscription_auto_created` 审计,平台超额账单由后端按 `platform-usage` 用量快照、套餐额度和订阅 metadata 计算并记录 `platform.invoice.usage_overage_created` / `platform.invoice.usage_overage_batch_created` 审计,`platform-dunning` worker 会标记已过期未结清服务费账单、生成 `tenant_invoice_reminders` 并记录 `platform.invoice.overdue_processed` 审计,`platform-dunning-notifications` worker 会把内部催缴记录按渠道发送外部通知,`platform-audit-alerts` worker 会把高风险平台审计动作转换为内部告警,`platform-audit-notifications` worker 会把开放告警按渠道发送外部通知;前端只展示候选、预览结果、跳过结果、逾期处理结果、开放告警、通知事件和生成后的账单/审计,不要直接更新账单状态、租户 `billing_status`、告警表或通知事件表,也不要自行计算超额服务费;继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 - 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。 ## AI 择校推荐接入 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 4d5e1719..9247faff 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -1944,6 +1944,181 @@ async function testPlatformTenantOperationsAndAudit() { }); assert.ok(auditAfterBatch.items?.some(item => item.action === 'platform.invoice.subscription_batch_created'), 'batch invoice creation should write platform audit'); + const usageOveragePeriodStart = '2026-08-01'; + const usageOveragePeriodEnd = '2026-08-31'; + const usageOveragePool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL }); + try { + await usageOveragePool.query( + ` + update public.tenant_subscriptions + set metadata = metadata || $2::jsonb, + updated_at = now() + where id = $1 + `, + [ + ids.partnerSubscription, + JSON.stringify({ + includedQuotas: { students: 100, questions: 500, storage_gb: 2 }, + overagePrices: { + students: { unitAmountCents: 200, unitSize: 1 }, + questions: { unitAmountCents: 50, unitSize: 10 }, + storage_gb: { unitAmountCents: 12000, unitSize: 1 }, + }, + }), + ], + ); + await usageOveragePool.query( + ` + delete from public.tenant_usage_records + where tenant_id = $1 + and period_start = $2::date + and period_end = $3::date + `, + [PARTNER_TENANT_ID, usageOveragePeriodStart, usageOveragePeriodEnd], + ); + await usageOveragePool.query( + ` + insert into public.tenant_usage_records (tenant_id, metric_key, metric_value, period_start, period_end, metadata) + values + ($1, 'students', 120, $2::date, $3::date, '{"source":"platform_usage_worker","workerId":"api-integration-usage-overage"}'::jsonb), + ($1, 'questions', 860, $2::date, $3::date, '{"source":"platform_usage_worker","workerId":"api-integration-usage-overage"}'::jsonb), + ($1, 'storage_gb', 3.5, $2::date, $3::date, '{"source":"platform_usage_worker","workerId":"api-integration-usage-overage"}'::jsonb) + `, + [PARTNER_TENANT_ID, usageOveragePeriodStart, usageOveragePeriodEnd], + ); + await usageOveragePool.query( + ` + delete from public.tenant_invoices + where tenant_id = $1 + and invoice_type = 'usage_overage' + and billing_period_start = $2::date + and billing_period_end = $3::date + `, + [PARTNER_TENANT_ID, usageOveragePeriodStart, usageOveragePeriodEnd], + ); + } finally { + await usageOveragePool.end(); + } + + const usageOverageCandidates = await request('/api/platform-admin/invoices/usage-overage-candidates', { + tenantId: false, + userId: false, + headers: adminHeaders, + query: { + tenantIds: PARTNER_TENANT_ID, + periodStart: usageOveragePeriodStart, + periodEnd: usageOveragePeriodEnd, + limit: 20, + }, + }); + const usageOverageCandidate = usageOverageCandidates.items?.find(item => item.tenantId === PARTNER_TENANT_ID); + assert.ok(usageOverageCandidate, 'platform admin should preview usage-overage invoice candidates'); + assert.equal(usageOverageCandidate.hasExistingInvoice, false, 'usage-overage candidate should not have an existing invoice before generation'); + assert.ok(usageOverageCandidate.totalCents > 0, 'usage-overage candidate should expose billable amount'); + assert.ok( + usageOverageCandidate.items?.some(item => item.metadata?.metricKey === 'students'), + 'usage-overage candidate should include student overage item', + ); + assert.ok( + usageOverageCandidate.items?.some(item => item.metadata?.metricKey === 'storage_gb'), + 'usage-overage candidate should include storage overage item', + ); + + const invalidUsageOverageDate = await request('/api/platform-admin/invoices/usage-overage-candidates', { + tenantId: false, + userId: false, + headers: adminHeaders, + query: { + tenantIds: PARTNER_TENANT_ID, + periodStart: '2026-06-31', + periodEnd: '2026-06-30', + }, + expectStatus: 400, + }); + assert.equal(invalidUsageOverageDate.code, 'INVALID_DATE', 'usage-overage candidate query should reject invalid dates'); + + const usageOverageDryRun = await request('/api/platform-admin/invoices/from-usage-overage', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'POST', + body: { + tenantIds: [PARTNER_TENANT_ID], + periodStart: usageOveragePeriodStart, + periodEnd: usageOveragePeriodEnd, + dueDate: '2026-09-10', + note: 'integration usage overage dry run', + dryRun: true, + }, + }); + assert.equal(usageOverageDryRun.item?.dryRun, true, 'usage-overage dry-run should not create invoices'); + assert.equal(usageOverageDryRun.item?.createdCount, 0, 'usage-overage dry-run should not create records'); + assert.ok(usageOverageDryRun.item?.totalCents > 0, 'usage-overage dry-run should expose total cents'); + + const usageOverageCreated = await request('/api/platform-admin/invoices/from-usage-overage', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'POST', + body: { + tenantIds: [PARTNER_TENANT_ID], + periodStart: usageOveragePeriodStart, + periodEnd: usageOveragePeriodEnd, + dueDate: '2026-09-10', + note: 'integration usage overage invoice', + status: 'issued', + }, + }); + assert.equal(usageOverageCreated.item?.createdCount, 1, 'usage-overage invoice generation should create one invoice'); + assert.equal(usageOverageCreated.item?.skippedCount, 0, 'first usage-overage invoice generation should not skip'); + const usageOverageInvoice = usageOverageCreated.item?.items?.[0]?.invoice; + assert.equal(usageOverageInvoice?.invoiceType, 'usage_overage', 'usage-overage invoice should use usage_overage type'); + assert.ok(usageOverageInvoice?.invoiceNo, 'usage-overage invoice should expose invoice number'); + + const usageOverageAfterCreate = await request('/api/platform-admin/invoices/usage-overage-candidates', { + tenantId: false, + userId: false, + headers: adminHeaders, + query: { + tenantIds: PARTNER_TENANT_ID, + periodStart: usageOveragePeriodStart, + periodEnd: usageOveragePeriodEnd, + includeExisting: 'true', + limit: 20, + }, + }); + const existingOverageCandidate = usageOverageAfterCreate.items?.find(item => item.tenantId === PARTNER_TENANT_ID); + assert.equal(existingOverageCandidate?.hasExistingInvoice, true, 'usage-overage candidate should expose existing invoice after generation'); + assert.equal(existingOverageCandidate?.existingInvoiceNo, usageOverageInvoice?.invoiceNo, 'usage-overage candidate should identify the generated invoice'); + + const duplicateUsageOverage = await request('/api/platform-admin/invoices/from-usage-overage', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'POST', + body: { + tenantIds: [PARTNER_TENANT_ID], + periodStart: usageOveragePeriodStart, + periodEnd: usageOveragePeriodEnd, + }, + }); + assert.equal(duplicateUsageOverage.item?.createdCount, 0, 'duplicate usage-overage run should not create another invoice'); + + const usageOverageAudit = await request('/api/platform-admin/audit-logs', { + tenantId: false, + userId: false, + headers: adminHeaders, + query: { q: 'platform.invoice.usage_overage', limit: 20 }, + }); + assert.ok( + usageOverageAudit.items?.some(item => item.action === 'platform.invoice.usage_overage_created'), + 'usage-overage invoice generation should write invoice audit', + ); + assert.ok( + usageOverageAudit.items?.some(item => item.action === 'platform.invoice.usage_overage_batch_created'), + 'usage-overage invoice generation should write batch audit', + ); + const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL }); try { await pool.query('delete from public.tenant_invoice_reminders where tenant_id = $1 and invoice_id = $2', [tenantId, ids.platformOverdueInvoice]); diff --git a/supabase/migrations/202606300016_usage_overage_invoices.sql b/supabase/migrations/202606300016_usage_overage_invoices.sql new file mode 100644 index 00000000..c8b44ed5 --- /dev/null +++ b/supabase/migrations/202606300016_usage_overage_invoices.sql @@ -0,0 +1,8 @@ +create unique index if not exists idx_tenant_invoices_usage_overage_auto_unique + on public.tenant_invoices(tenant_id, billing_period_start, billing_period_end) + where invoice_type = 'usage_overage' + and status <> 'void' + and metadata->>'source' = 'usage_overage_auto'; + +comment on index public.idx_tenant_invoices_usage_overage_auto_unique is + 'Prevents duplicate automatic SaaS usage-overage invoices for the same tenant and billing period while allowing voided invoices to be regenerated.';