diff --git a/README.md b/README.md index 4fe837a4..02f07d76 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 -- 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 +- 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、账单、服务费收款、用量记录、公共题库授权。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 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 配置、跟进分配策略和队列。 @@ -35,7 +35,7 @@ - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 - 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成,Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。 -- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计、订阅、账单、收款、用量和题库授权第一版写操作。 +- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计、订阅、订阅账单候选/dry-run/批量生成、收款、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 更完整的进度看这些文档: @@ -371,7 +371,7 @@ apps/api/src/features/ commerce/ 订单、支付确认、退款、激活码、优惠券规则/核销、权益、资金对账和差错工单 health/ 健康检查 learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜 - platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量 + platform-admin/ 平台方租户、SaaS 套餐、订阅、订阅账单候选/批量生成、账单、用量 profile/ 学生个人中心、勋章 referral/ 销售/代理客资追踪、CRM 队列 referral/commission.ts @@ -430,7 +430,7 @@ git diff --check 优先继续补: 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 -2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台自动计费、批量账单、审计报表增强和更细平台权限点,小程序兼容验证。 +2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台自动计费 worker、催缴/收款流、审计报表增强和更细平台权限点,小程序兼容验证。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/platform-admin/index.ts b/apps/api/src/features/platform-admin/index.ts index e622cce6..aac0336e 100644 --- a/apps/api/src/features/platform-admin/index.ts +++ b/apps/api/src/features/platform-admin/index.ts @@ -4,6 +4,7 @@ import { createInvoiceRoute, createSubscriptionRoute, createTenantInvoiceFromSubscriptionRoute, + createTenantInvoicesBatchFromSubscriptionsRoute, createTenantRoute, platformAuditLogsRoute, platformOverviewRoute, @@ -11,6 +12,7 @@ import { platformQuestionBanksRoute, questionBankGrantsRoute, recordUsageRoute, + subscriptionInvoiceCandidatesRoute, tenantDetailRoute, tenantInvoicesRoute, tenantsRoute, @@ -35,7 +37,9 @@ export const platformAdminRoutes: RouteDefinition[] = [ ['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute], ['GET', '/api/platform-admin/invoices', tenantInvoicesRoute], ['POST', '/api/platform-admin/invoices', createInvoiceRoute], + ['GET', '/api/platform-admin/invoices/subscription-candidates', subscriptionInvoiceCandidatesRoute], ['POST', '/api/platform-admin/invoices/from-subscription', createTenantInvoiceFromSubscriptionRoute], + ['POST', '/api/platform-admin/invoices/from-subscriptions-batch', createTenantInvoicesBatchFromSubscriptionsRoute], ['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute], ['GET', '/api/platform-admin/usage', tenantUsageRoute], ['POST', '/api/platform-admin/usage', recordUsageRoute], diff --git a/apps/api/src/features/platform-admin/routes.ts b/apps/api/src/features/platform-admin/routes.ts index cc3d37da..de460a68 100644 --- a/apps/api/src/features/platform-admin/routes.ts +++ b/apps/api/src/features/platform-admin/routes.ts @@ -39,6 +39,38 @@ function optionalUuidArray(body: Record, key: string) { return optionalStringArray(body, key).filter(Boolean); } +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']); + +function optionalUuidList(value: unknown, key = 'ids', maxLength = 500) { + const values = Array.isArray(value) + ? value.map(item => String(item).trim()).filter(Boolean) + : typeof value === 'string' && value.trim() + ? value.split(',').map(item => item.trim()).filter(Boolean) + : []; + + if (values.length > maxLength) throw new HttpError(400, `${key} contains too many values`, 'TOO_MANY_UUIDS'); + for (const item of values) { + if (!UUID_RE.test(item)) throw new HttpError(400, `${key} contains an invalid UUID`, 'INVALID_UUID'); + } + return values; +} + +function daysAheadFrom(value: unknown, fallback = 45) { + const parsed = Number(value ?? fallback); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return Math.min(Math.trunc(parsed), 1095); +} + +function booleanFrom(value: unknown, fallback = false) { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + if (value === 'true') return true; + if (value === 'false') return false; + } + return fallback; +} + function billingInvoiceTypeFrom(value: string) { const invoiceType = value || 'none'; if (!['none', 'normal_vat', 'special_vat'].includes(invoiceType)) { @@ -47,6 +79,14 @@ function billingInvoiceTypeFrom(value: string) { return invoiceType; } +function tenantInvoiceStatusFrom(value: string) { + const status = value || 'issued'; + if (!TENANT_INVOICE_STATUSES.has(status)) { + throw new HttpError(400, 'invoice status is invalid', 'INVALID_INVOICE_STATUS'); + } + return status; +} + function platformAuditDetails(value: unknown) { return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {}); } @@ -939,7 +979,7 @@ interface CreateInvoiceInput { items: ReturnType; } -async function createInvoiceRecord(input: CreateInvoiceInput) { +async function createInvoiceRecordWithClient(client: pg.PoolClient, input: CreateInvoiceInput) { if (!input.items.length) throw new HttpError(400, 'At least one invoice item is required', 'INVOICE_ITEMS_REQUIRED'); const discountCents = centsFrom(input.discountCents, 0); @@ -948,70 +988,72 @@ async function createInvoiceRecord(input: CreateInvoiceInput) { const totalCents = Math.max(0, subtotalCents - discountCents + taxCents); const invoiceNo = input.invoiceNo || createInvoiceNo(); - return transaction(async client => { - const invoiceResult = await client.query( + const invoiceResult = await client.query( + ` + insert into public.tenant_invoices ( + tenant_id, invoice_no, invoice_type, status, currency, + subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents, + billing_period_start, billing_period_end, due_date, issued_at, note, metadata + ) + values ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, 0, $9, + $10::date, $11::date, $12::date, + case when $4 = 'draft' then null else now() end, + $13, $14::jsonb + ) + returning id, tenant_id as "tenantId", invoice_no as "invoiceNo", + invoice_type as "invoiceType", status, total_cents as "totalCents", + balance_cents as "balanceCents", due_date as "dueDate", + created_at as "createdAt" + `, + [ + input.tenantId, + invoiceNo, + input.invoiceType || 'subscription', + input.status || 'issued', + input.currency || 'CNY', + subtotalCents, + discountCents, + taxCents, + totalCents, + input.billingPeriodStart || null, + input.billingPeriodEnd || null, + input.dueDate || null, + input.note || null, + JSON.stringify(input.metadata || {}), + ], + ); + + const invoice = invoiceResult.rows[0]; + for (const itemInput of input.items) { + await client.query( ` - insert into public.tenant_invoices ( - tenant_id, invoice_no, invoice_type, status, currency, - subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents, - billing_period_start, billing_period_end, due_date, issued_at, note, metadata + insert into public.tenant_invoice_items ( + tenant_id, invoice_id, item_type, description, + quantity, unit_amount_cents, amount_cents, metadata ) - values ( - $1, $2, $3, $4, $5, - $6, $7, $8, $9, 0, $9, - $10::date, $11::date, $12::date, - case when $4 = 'draft' then null else now() end, - $13, $14::jsonb - ) - returning id, tenant_id as "tenantId", invoice_no as "invoiceNo", - invoice_type as "invoiceType", status, total_cents as "totalCents", - balance_cents as "balanceCents", due_date as "dueDate", - created_at as "createdAt" + values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) `, [ input.tenantId, - invoiceNo, - input.invoiceType || 'subscription', - input.status || 'issued', - input.currency || 'CNY', - subtotalCents, - discountCents, - taxCents, - totalCents, - input.billingPeriodStart || null, - input.billingPeriodEnd || null, - input.dueDate || null, - input.note || null, - JSON.stringify(input.metadata || {}), + invoice.id, + itemInput.itemType, + itemInput.description, + itemInput.quantity, + itemInput.unitAmountCents, + Math.round(itemInput.quantity * itemInput.unitAmountCents), + JSON.stringify(itemInput.metadata || {}), ], ); + } - const invoice = invoiceResult.rows[0]; - for (const itemInput of input.items) { - await client.query( - ` - insert into public.tenant_invoice_items ( - tenant_id, invoice_id, item_type, description, - quantity, unit_amount_cents, amount_cents, metadata - ) - values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) - `, - [ - input.tenantId, - invoice.id, - itemInput.itemType, - itemInput.description, - itemInput.quantity, - itemInput.unitAmountCents, - Math.round(itemInput.quantity * itemInput.unitAmountCents), - JSON.stringify(itemInput.metadata || {}), - ], - ); - } + await recalculateInvoiceTotals(client, invoice.id); + return invoice; +} - await recalculateInvoiceTotals(client, invoice.id); - return invoice; - }); +async function createInvoiceRecord(input: CreateInvoiceInput) { + return transaction(async client => createInvoiceRecordWithClient(client, input)); } export async function createInvoiceRoute(ctx: RequestContext) { @@ -1176,6 +1218,87 @@ export async function tenantUsageRoute(ctx: RequestContext) { return { items }; } +async function subscriptionInvoiceCandidateQuery(params: { + tenantIds: string[]; + subscriptionIds: string[]; + daysAhead: number; + includeExisting: boolean; + limit: number; +}) { + return query<{ + tenantId: string; + tenantSlug: string; + tenantName: string; + billingStatus: string; + subscriptionId: string; + planCode: string; + planName: string | null; + status: string; + startsAt: string | null; + expiresAt: string | null; + billingCycle: string | null; + amountCents: number; + 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, s.starts_at as "startsAt", + s.expires_at as "expiresAt", s.billing_cycle as "billingCycle", + s.amount_cents as "amountCents", + existing.id as "existingInvoiceId", + existing.invoice_no as "existingInvoiceNo", + existing.status as "existingInvoiceStatus" + from public.tenant_subscriptions s + join public.tenants t on t.id = s.tenant_id + left join public.platform_saas_plans p on p.code = s.plan_code + left join lateral ( + select id, invoice_no, status + from public.tenant_invoices i + where i.tenant_id = s.tenant_id + and i.invoice_type = 'subscription' + and i.status <> 'void' + and i.metadata->>'subscriptionId' = s.id::text + order by i.created_at desc + limit 1 + ) existing on true + where t.status = 'active' + and s.status in ('trial', 'active', 'past_due') + and ($1::uuid[] = '{}'::uuid[] or s.tenant_id = any($1::uuid[])) + and ($2::uuid[] = '{}'::uuid[] or s.id = any($2::uuid[])) + and ( + s.expires_at is null + or s.expires_at <= now() + ($3::integer * interval '1 day') + ) + and ($4::boolean = true or existing.id is null) + order by s.expires_at asc nulls last, t.created_at asc + limit $5 + `, + [params.tenantIds, params.subscriptionIds, params.daysAhead, params.includeExisting, params.limit], + ); +} + +export async function subscriptionInvoiceCandidatesRoute(ctx: RequestContext) { + await requirePlatformAdmin(ctx); + + const tenantIds = optionalUuidList(ctx.url.searchParams.get('tenantIds'), 'tenantIds'); + const subscriptionIds = optionalUuidList(ctx.url.searchParams.get('subscriptionIds'), 'subscriptionIds'); + const daysAhead = daysAheadFrom(ctx.url.searchParams.get('daysAhead'), 45); + const includeExisting = listQuery(ctx, 'includeExisting') === 'true'; + const limit = intParam(ctx, 'limit', 100, 500); + const items = await subscriptionInvoiceCandidateQuery({ tenantIds, subscriptionIds, daysAhead, includeExisting, limit }); + + return { + items: items.map(item => ({ + ...item, + hasExistingInvoice: Boolean(item.existingInvoiceId), + })), + }; +} + export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) { await requirePlatformAdmin(ctx); @@ -1183,54 +1306,217 @@ export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestConte const tenantId = requiredString(body, 'tenantId'); const subscriptionId = optionalString(body, 'subscriptionId'); - const subscription = await queryOne<{ - id: string; - planCode: string; - amountCents: number; - startsAt: string | null; - expiresAt: string | null; - }>( - ` - select id, plan_code as "planCode", amount_cents as "amountCents", - starts_at as "startsAt", expires_at as "expiresAt" - from public.tenant_subscriptions - where tenant_id = $1 - and ($2::uuid is null or id = $2::uuid) - order by created_at desc - limit 1 - `, - [tenantId, subscriptionId || null], - ); - if (!subscription) throw new HttpError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND'); + const item = await transaction(async client => { + const subscriptionResult = await client.query<{ + id: string; + planCode: string; + amountCents: number; + startsAt: string | null; + expiresAt: string | null; + }>( + ` + select id, plan_code as "planCode", amount_cents as "amountCents", + starts_at as "startsAt", expires_at as "expiresAt" + from public.tenant_subscriptions + where tenant_id = $1 + and ($2::uuid is null or id = $2::uuid) + order by created_at desc + limit 1 + for update + `, + [tenantId, subscriptionId || null], + ); + const subscription = subscriptionResult.rows[0]; + if (!subscription) throw new HttpError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND'); - const plan = await queryOne<{ name: string }>( - ` - select name from public.platform_saas_plans - where code = $1 - limit 1 - `, - [subscription.planCode], - ); + const planResult = await client.query<{ name: string }>( + ` + select name from public.platform_saas_plans + where code = $1 + limit 1 + `, + [subscription.planCode], + ); + const plan = planResult.rows[0]; - const item = await createInvoiceRecord({ - tenantId, - invoiceType: 'subscription', - status: optionalString(body, 'status') || 'issued', - dueDate: optionalString(body, 'dueDate') || null, - billingPeriodStart: toDateText(subscription.startsAt), - billingPeriodEnd: toDateText(subscription.expiresAt), - note: optionalString(body, 'note') || null, - metadata: { source: 'subscription', subscriptionId: subscription.id }, - items: [ - { - itemType: 'subscription', - description: `${plan?.name || subscription.planCode} ${subscription.planCode}`, - quantity: 1, - unitAmountCents: subscription.amountCents, - metadata: { subscriptionId: subscription.id, planCode: subscription.planCode }, - }, - ], + 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 = 'subscription' + and status <> 'void' + and metadata->>'subscriptionId' = $2 + order by created_at desc + limit 1 + `, + [tenantId, subscription.id], + ); + if (existing.rows[0]) { + throw new HttpError(409, 'Invoice already exists for this subscription', 'SUBSCRIPTION_INVOICE_EXISTS'); + } + + const invoice = await createInvoiceRecordWithClient(client, { + tenantId, + invoiceType: 'subscription', + status: tenantInvoiceStatusFrom(optionalString(body, 'status')), + dueDate: optionalString(body, 'dueDate') || null, + billingPeriodStart: toDateText(subscription.startsAt), + billingPeriodEnd: toDateText(subscription.expiresAt), + note: optionalString(body, 'note') || null, + metadata: { source: 'subscription', subscriptionId: subscription.id }, + items: [ + { + itemType: 'subscription', + description: `${plan?.name || subscription.planCode} ${subscription.planCode}`, + quantity: 1, + unitAmountCents: subscription.amountCents, + metadata: { subscriptionId: subscription.id, planCode: subscription.planCode }, + }, + ], + }); + + await recordPlatformAudit(client, ctx, 'platform.invoice.subscription_created', 'tenant_invoice', invoice.id, { + tenantId, + subscriptionId: subscription.id, + planCode: subscription.planCode, + invoiceNo: invoice.invoiceNo, + source: 'single', + }, tenantId); + return invoice; }); return { item }; } + +export async function createTenantInvoicesBatchFromSubscriptionsRoute(ctx: RequestContext) { + await requirePlatformAdmin(ctx); + + const body = await readJsonBody(ctx); + const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds'); + const requestedSubscriptionIds = optionalUuidList(body.subscriptionIds, 'subscriptionIds'); + const daysAhead = daysAheadFrom(body.daysAhead, 45); + const status = tenantInvoiceStatusFrom(optionalString(body, 'status')); + const dueDate = optionalString(body, 'dueDate') || null; + const note = optionalString(body, 'note') || null; + const dryRun = booleanFrom(body.dryRun, false); + const limit = Math.min(Math.max(requestedSubscriptionIds.length || 0, 100), 500); + + const candidates = await subscriptionInvoiceCandidateQuery({ + tenantIds, + subscriptionIds: requestedSubscriptionIds, + daysAhead, + includeExisting: false, + limit, + }); + const scopedCandidates = candidates; + + if (!scopedCandidates.length) { + return { item: { dryRun, createdCount: 0, skippedCount: 0, items: [] } }; + } + + if (dryRun) { + return { + item: { + dryRun: true, + createdCount: 0, + skippedCount: 0, + items: scopedCandidates.map(item => ({ + ...item, + hasExistingInvoice: false, + wouldCreate: true, + })), + }, + }; + } + + const result = await transaction(async client => { + const created: unknown[] = []; + const skipped: unknown[] = []; + + for (const candidate of scopedCandidates) { + const lock = await client.query( + ` + select id + from public.tenant_subscriptions + where id = $1 + for update + `, + [candidate.subscriptionId], + ); + if (!lock.rows[0]) { + skipped.push({ 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 = 'subscription' + and status <> 'void' + and metadata->>'subscriptionId' = $2 + order by created_at desc + limit 1 + `, + [candidate.tenantId, candidate.subscriptionId], + ); + if (existing.rows[0]) { + skipped.push({ + tenantId: candidate.tenantId, + subscriptionId: candidate.subscriptionId, + reason: 'SUBSCRIPTION_INVOICE_EXISTS', + invoiceId: existing.rows[0].id, + invoiceNo: existing.rows[0].invoiceNo, + }); + continue; + } + + const invoice = await createInvoiceRecordWithClient(client, { + tenantId: candidate.tenantId, + invoiceType: 'subscription', + status, + dueDate, + billingPeriodStart: toDateText(candidate.startsAt), + billingPeriodEnd: toDateText(candidate.expiresAt), + note, + metadata: { + source: 'subscription_batch', + subscriptionId: candidate.subscriptionId, + tenantSlug: candidate.tenantSlug, + }, + items: [ + { + itemType: 'subscription', + description: `${candidate.planName || candidate.planCode} ${candidate.planCode}`, + quantity: 1, + unitAmountCents: Number(candidate.amountCents || 0), + metadata: { subscriptionId: candidate.subscriptionId, planCode: candidate.planCode }, + }, + ], + }); + created.push({ ...candidate, invoice }); + } + + await recordPlatformAudit(client, ctx, 'platform.invoice.subscription_batch_created', 'tenant_invoice_batch', null, { + createdCount: created.length, + skippedCount: skipped.length, + tenantIds, + subscriptionIds: requestedSubscriptionIds, + daysAhead, + dueDate, + status, + }); + + return { + dryRun: false, + createdCount: created.length, + skippedCount: skipped.length, + items: created, + skipped, + }; + }); + + return { item: result }; +} diff --git a/apps/taro/src/pages/platform-admin/billing/index.tsx b/apps/taro/src/pages/platform-admin/billing/index.tsx index 889e1970..91322e8e 100644 --- a/apps/taro/src/pages/platform-admin/billing/index.tsx +++ b/apps/taro/src/pages/platform-admin/billing/index.tsx @@ -5,13 +5,16 @@ import { Input } from '@tarojs/components'; import { confirmPlatformInvoicePayment, createPlatformInvoiceFromSubscription, + createPlatformInvoicesBatchFromSubscriptions, createPlatformSubscription, loadPlatformInvoices, loadPlatformPlans, + loadPlatformSubscriptionInvoiceCandidates, loadPlatformUsage, recordPlatformUsage, type PlatformInvoiceItem, type PlatformSaasPlan, + type PlatformSubscriptionInvoiceCandidate, type PlatformUsageItem, } from '@/services/platformAdmin'; import '../platform.css'; @@ -36,6 +39,8 @@ export default function PlatformBillingPage() { const [plans, setPlans] = useState([]); const [invoices, setInvoices] = useState([]); const [usage, setUsage] = useState([]); + const [candidates, setCandidates] = useState([]); + const [batchResult, setBatchResult] = useState(''); const [subscriptionForm, setSubscriptionForm] = useState({ tenantId: '', planCode: '', @@ -60,6 +65,11 @@ export default function PlatformBillingPage() { periodStart: todayText(), periodEnd: todayText(), }); + const [batchInvoiceForm, setBatchInvoiceForm] = useState({ + daysAhead: '45', + dueDate: '', + note: '', + }); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); @@ -68,12 +78,14 @@ export default function PlatformBillingPage() { loadPlatformPlans(true).catch(() => ({ items: [] })), loadPlatformInvoices({ status: nextStatus || undefined, limit: 100 }), loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })), - ]).then(([planPayload, invoicePayload, usagePayload]) => { + loadPlatformSubscriptionInvoiceCandidates({ daysAhead: Number(batchInvoiceForm.daysAhead || 45), limit: 100 }).catch(() => ({ items: [] })), + ]).then(([planPayload, invoicePayload, usagePayload, candidatePayload]) => { const nextPlans = planPayload.items || []; setPlans(nextPlans); setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' })); setInvoices(invoicePayload.items || []); setUsage(usagePayload.items || []); + setCandidates(candidatePayload.items || []); }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败')); } @@ -105,6 +117,10 @@ export default function PlatformBillingPage() { setUsageForm(current => ({ ...current, [key]: value })); } + function updateBatchInvoiceForm(key: keyof typeof batchInvoiceForm, value: string) { + setBatchInvoiceForm(current => ({ ...current, [key]: value })); + } + async function confirm(title: string, content: string) { const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' }); return result.confirm; @@ -215,6 +231,58 @@ export default function PlatformBillingPage() { } } + async function loadSubscriptionCandidates() { + setError(''); + setBusy('candidates'); + try { + const payload = await loadPlatformSubscriptionInvoiceCandidates({ + daysAhead: Number(batchInvoiceForm.daysAhead || 45), + limit: 100, + }); + setCandidates(payload.items || []); + setBatchResult(''); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '订阅账单候选加载失败'); + } finally { + setBusy(''); + } + } + + async function submitBatchInvoices(dryRun: boolean) { + setError(''); + const daysAhead = Number(batchInvoiceForm.daysAhead || 45); + if (!Number.isFinite(daysAhead) || daysAhead < 0) { + setError('请填写有效的候选天数。'); + return; + } + const ok = dryRun + ? true + : await confirm('批量生成账单', `确认给 ${candidates.length} 个候选订阅批量生成服务费账单?后端会跳过已存在账单的订阅。`); + if (!ok) return; + setBusy(dryRun ? 'batch-dry-run' : 'batch-create'); + try { + const payload = await createPlatformInvoicesBatchFromSubscriptions({ + daysAhead, + dueDate: batchInvoiceForm.dueDate || undefined, + note: batchInvoiceForm.note.trim() || undefined, + status: 'issued', + dryRun, + }); + const item = payload.item || {}; + setBatchResult(`${dryRun ? '预览' : '生成'}完成:创建 ${item.createdCount || 0},跳过 ${item.skippedCount || 0}`); + if (dryRun) { + setCandidates((item.items || []).map(candidate => ({ ...candidate, wouldCreate: true }))); + } else { + Taro.showToast({ title: '已批量生成', icon: 'success' }); + reload(status); + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '批量账单处理失败'); + } finally { + setBusy(''); + } + } + return ( @@ -288,6 +356,31 @@ export default function PlatformBillingPage() { + + 批量订阅账单 + + 候选天数 updateBatchInvoiceForm('daysAhead', String(event.detail.value || ''))} /> + 到期日 updateBatchInvoiceForm('dueDate', String(event.detail.value || ''))} /> + 批量备注 updateBatchInvoiceForm('note', String(event.detail.value || ''))} /> + + + + + + + {batchResult ? {batchResult} : null} + + {candidates.slice(0, 12).map(item => ( + + {item.tenantName || item.tenantSlug || item.tenantId} + {item.planName || item.planCode || 'plan'} · {item.status || '-'} · {money(item.amountCents)} · 到期 {String(item.expiresAt || '').slice(0, 10) || '-'} + {item.hasExistingInvoice ? `已有账单 ${item.existingInvoiceNo || item.existingInvoiceId}` : item.wouldCreate ? '预览会生成' : '可生成订阅服务费账单'} + + ))} + + {!candidates.length ? 暂无即将到期且未开票的订阅。 : null} + + SaaS 套餐 diff --git a/apps/taro/src/services/platformAdmin.ts b/apps/taro/src/services/platformAdmin.ts index e787b6b3..ce7954fa 100644 --- a/apps/taro/src/services/platformAdmin.ts +++ b/apps/taro/src/services/platformAdmin.ts @@ -138,6 +138,26 @@ export interface PlatformUsageItem { metadata?: Record | null; } +export interface PlatformSubscriptionInvoiceCandidate { + tenantId: string; + tenantSlug?: string | null; + tenantName?: string | null; + billingStatus?: string | null; + subscriptionId: string; + planCode?: string | null; + planName?: string | null; + status?: string | null; + startsAt?: string | null; + expiresAt?: string | null; + billingCycle?: string | null; + amountCents?: number | string | null; + existingInvoiceId?: string | null; + existingInvoiceNo?: string | null; + existingInvoiceStatus?: string | null; + hasExistingInvoice?: boolean | null; + wouldCreate?: boolean | null; +} + export interface PlatformQuestionBankItem { id: string; tenantId?: string | null; @@ -226,6 +246,16 @@ export interface CreatePlatformInvoiceFromSubscriptionInput { note?: string; } +export interface CreatePlatformInvoicesBatchFromSubscriptionsInput { + tenantIds?: string[]; + subscriptionIds?: string[]; + daysAhead?: number; + status?: string; + dueDate?: string; + note?: string; + dryRun?: boolean; +} + export interface ConfirmPlatformInvoicePaymentInput { tenantId: string; invoiceId: string; @@ -302,6 +332,13 @@ export async function loadPlatformUsage(query: { tenantId?: string; limit?: numb }); } +export async function loadPlatformSubscriptionInvoiceCandidates(query: { tenantIds?: string; subscriptionIds?: string; daysAhead?: number; includeExisting?: boolean; limit?: number } = {}) { + return apiRequest<{ items?: PlatformSubscriptionInvoiceCandidate[] }>('/api/platform-admin/invoices/subscription-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 }, @@ -356,6 +393,22 @@ export async function createPlatformInvoiceFromSubscription(input: CreatePlatfor }); } +export async function createPlatformInvoicesBatchFromSubscriptions(input: CreatePlatformInvoicesBatchFromSubscriptionsInput) { + return apiRequest<{ + item?: { + dryRun?: boolean; + createdCount?: number; + skippedCount?: number; + items?: Array; + skipped?: Array>; + }; + }>('/api/platform-admin/invoices/from-subscriptions-batch', { + 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/api-structure.md b/docs/refactor/api-structure.md index 72d353ef..f520a4d0 100644 --- a/docs/refactor/api-structure.md +++ b/docs/refactor/api-structure.md @@ -22,7 +22,7 @@ apps/api/src/ referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列 storage/ 对象存储签名 provider video/ 题目视频列表、搜索、SVIP/次数校验和签名播放 - platform-admin/ 平台方 SaaS 租户、订阅、账单、使用量 + platform-admin/ 平台方 SaaS 租户、订阅、订阅账单候选/批量生成、账单、使用量 tenant-admin/ 租户品牌、域名、公开设置、登录/商户配置、成员权限、活动/兑换码运营 tenant-content/ 租户后台内容维护:入口、分类树、集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、批量导入 ``` @@ -37,7 +37,7 @@ features/ learning/ 顺序/随机/模考组卷、答题记录、错题、收藏、学习进度、排行榜 commerce/ 商品、订单、优惠券、支付、退款、权益开通 referral/ 销售/代理增长链路、客资归属、分佣依据、CRM 入队 - platform-admin/ 平台租户管理、年费、服务费、账务审计 + platform-admin/ 平台租户管理、年费、服务费、批量开票、账务审计 tenant-admin/ 合作商后台配置、品牌、域名、收款账户、登录 provider、密钥掩码、成员权限、审计、活动、兑换码、优惠券 tenant-content/ 合作商内容导航、题库维护、批量导入、资源绑定、内容审计 ``` diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index b5f90eb0..7fd574eb 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -145,7 +145,7 @@ | 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 | | 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 | | 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 | -| 平台租户/详情/账务资料/审计/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、收款和用量;创建租户、状态变更、账务资料维护会写入审计 | +| 平台租户/详情/账务资料/审计/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、重复开票保护、收款和用量;创建租户、状态变更、账务资料维护和订阅批量开票会写入审计 | | 数据看板聚合接口 | 可联调 | `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 | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index c50f4ffc..77c7edf3 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -8,7 +8,7 @@ 新项目已经不是简单的 PocketBase 字段平移,而是按多租户 SaaS 重新建立了后端边界: -- 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。 +- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、服务费和用量。 - 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。 - 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、站内通知、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 - 销售/代理/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 深测继续执行 | -| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台审计日志、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、批量账单、平台审计报表增强、更完整运营消息 | +| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台审计日志、套餐、订阅、订阅账单候选预览、dry-run、批量生成、重复开票保护、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费 worker、催缴/收款流、平台审计报表增强、更完整运营消息 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 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 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑和平台审计第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台后台自动计费/批量账单/审计报表增强、小程序兼容验证和端到端测试 | +| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台审计、订阅账单候选预览/dry-run/批量生成第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台后台自动计费 worker/催缴/审计报表增强、小程序兼容验证和端到端测试 | ## 前端接入建议 @@ -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,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑和最近平台审计第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台后台自动计费/批量账单/审计报表增强和小程序兼容验证。 +- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、最近平台审计和订阅账单候选/dry-run/批量生成第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台后台自动计费 worker/催缴/审计报表增强和小程序兼容验证。 ### P1:商用收费和运营能力 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 84d29513..2922e16e 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 套餐、订阅、账单、服务费收款、使用量。 + - `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、服务费收款、使用量。 - `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` 支持按租户、动作、目标、操作者和关键词查询平台审计。创建租户、状态变更和账务资料维护会写入审计日志,API 集成测试已覆盖平台管理员可操作、学生越权拒绝和审计记录存在。 +- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者和关键词查询平台审计;`GET /api/platform-admin/invoices/subscription-candidates` 和 `POST /api/platform-admin/invoices/from-subscriptions-batch` 支持订阅账单候选预览、dry-run、批量生成、重复开票跳过和平台审计。创建租户、状态变更、账务资料维护和订阅批量开票会写入审计日志,API 集成测试已覆盖平台管理员可操作、学生越权拒绝和审计记录存在。 - 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。 - 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。 @@ -60,7 +60,9 @@ GET /api/platform-admin/audit-logs POST /api/platform-admin/subscriptions GET /api/platform-admin/invoices POST /api/platform-admin/invoices +GET /api/platform-admin/invoices/subscription-candidates POST /api/platform-admin/invoices/from-subscription +POST /api/platform-admin/invoices/from-subscriptions-batch POST /api/platform-admin/invoices/payments/manual-confirm GET /api/platform-admin/usage POST /api/platform-admin/usage diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index 31110123..351bb0fa 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -15,7 +15,7 @@ | 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 | | --- | --- | --- | --- | -| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、自动计费、批量账单和平台审计报表增强 | +| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、重复开票保护、服务费收款、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、自动计费 worker、催缴/收款流和平台审计报表增强 | | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | | 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 | | 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 | diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index a7b59f40..bcfc09f1 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -31,7 +31,7 @@ - `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。 - 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。 -- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要。 +- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要,账务中心已接订阅账单候选预览、dry-run 和批量生成。 - 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。 - 可以按新后端主模型接入内容导航: - `content_entries` @@ -108,7 +108,7 @@ | --- | --- | --- | | 工作台 | `apps/taro/src/pages/platform-admin/workbench/index.tsx` | `platform-admin/overview`、`tenants`、`invoices`、`question-banks`、`question-bank-grants`、`audit-logs` | | 租户管理 | `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`、`usage`、`subscriptions`、`invoices/from-subscription`、`invoices/payments/manual-confirm`、`POST usage` | +| 账务中心 | `apps/taro/src/pages/platform-admin/billing/index.tsx` | `platform-admin/plans`、`invoices`、`invoices/subscription-candidates`、`invoices/from-subscription`、`invoices/from-subscriptions-batch`、`invoices/payments/manual-confirm`、`usage`、`subscriptions`、`POST usage` | | 公共题库 | `apps/taro/src/pages/platform-admin/question-banks/index.tsx` | `platform-admin/question-banks`、`question-bank-grants`、`PUT question-bank-grants` | -当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限和审计。下一批继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费、账单批量操作和更细平台权限点。 +当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限、重复开票保护和审计。下一批继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费 worker、催缴/收款流和更细平台权限点。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 71fe1a24..00f303bf 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 套餐、订阅、账单、服务费、用量、审计日志 | 不适用 | 租户管理、租户详情、账务资料维护、平台审计日志、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台租户创建、详情、账务资料更新、状态变更、审计查询和学生越权拒绝 | 平台收费和租户运营链路骨架可用,自动计费、批量账单和更完整平台审计报表待补 | +| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志 | 不适用 | 租户管理、租户详情、账务资料维护、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、重复开票保护、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、非法输入拒绝和学生越权拒绝 | 平台收费和租户运营链路骨架可用,自动计费 worker、催缴/收款流和更完整平台审计报表待补 | | 登录认证 | 已建短信验证码、会话、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 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 | @@ -284,7 +284,9 @@ platform-admin: POST /api/platform-admin/subscriptions GET /api/platform-admin/invoices POST /api/platform-admin/invoices + GET /api/platform-admin/invoices/subscription-candidates POST /api/platform-admin/invoices/from-subscription + POST /api/platform-admin/invoices/from-subscriptions-batch POST /api/platform-admin/invoices/payments/manual-confirm GET /api/platform-admin/usage POST /api/platform-admin/usage diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 6876c0e0..408e8010 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -73,7 +73,7 @@ | 功能 | 新后端状态 | 待补齐 | | --- | --- | --- | -| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护和最近平台审计已接真实 API;后续补自动计费、批量账单、租户基础资料编辑增强和更完整平台审计报表 | +| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护、最近平台审计、订阅账单候选预览、dry-run 和批量生成已接真实 API;后续补自动计费 worker、催缴/收款流、租户基础资料编辑增强和更完整平台审计报表 | | SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 | | 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 | | 租户用量记录 | 已覆盖 | 自动采集 worker 待补 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index b0ddd9c7..bed00a1c 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -11,7 +11,7 @@ - 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。 - 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。 -- 平台后台 API:租户、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量。 +- 平台后台 API:租户、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、服务费收款、用量。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 @@ -185,7 +185,7 @@ - H5 和小程序共用同一套业务 API client。 - 租户通过域名、小程序配置或启动参数解析。 - 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。 -- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台审计、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。 +- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台审计、订阅、订阅账单候选/dry-run/批量生成、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。 ### 第一批页面 @@ -238,7 +238,7 @@ 1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。 -3. 补平台后台增强:租户基础资料编辑增强、平台审计报表导出/告警、自动计费、账单批量操作和更细平台权限点。 +3. 补平台后台增强:租户基础资料编辑增强、平台审计报表导出/告警、自动计费 worker、催缴/收款流和更细平台权限点。 4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。 6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 194f945a..47149fd0 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -2519,7 +2519,7 @@ src/services/ai.ts AI 择校推荐生成、报告列表、报告详情 src/services/pronunciation.ts H5/小程序单词发音适配 src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录 src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证 -src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计、套餐账单、用量、公共题库授权 +src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计、套餐账单、订阅账单候选/dry-run/批量生成、用量、公共题库授权 ``` 验证命令: @@ -2537,7 +2537,7 @@ npm run build:taro:h5:platform - 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。 - 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。 -- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费和批量账单操作。 +- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计,前端只展示候选、预览结果和跳过结果;继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费 worker 和催缴/收款流。 - 小程序:验证 `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 81822eba..bcb29de0 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -1232,6 +1232,110 @@ async function testPlatformTenantOperationsAndAudit() { 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 candidates = await request('/api/platform-admin/invoices/subscription-candidates', { + tenantId: false, + userId: false, + headers: adminHeaders, + query: { tenantIds: tenantId, daysAhead: 400, limit: 20 }, + }); + const candidate = candidates.items?.find(item => item.tenantId === tenantId); + assert.ok(candidate?.subscriptionId, 'platform admin should preview subscription invoice candidates'); + assert.equal(candidate.hasExistingInvoice, false, 'new tenant subscription should not have an existing invoice'); + + const invalidCandidateQuery = await request('/api/platform-admin/invoices/subscription-candidates', { + tenantId: false, + userId: false, + headers: adminHeaders, + query: { tenantIds: 'not-a-uuid' }, + expectStatus: 400, + }); + assert.equal(invalidCandidateQuery.code, 'INVALID_UUID', 'subscription candidate query should reject malformed UUID filters'); + + const candidateBySubscription = await request('/api/platform-admin/invoices/subscription-candidates', { + tenantId: false, + userId: false, + headers: adminHeaders, + query: { subscriptionIds: candidate.subscriptionId, daysAhead: 400, limit: 20 }, + }); + assert.equal(candidateBySubscription.items?.length, 1, 'subscription candidate query should support precise subscription filters'); + assert.equal(candidateBySubscription.items?.[0]?.subscriptionId, candidate.subscriptionId, 'subscription candidate filter should return the requested subscription'); + + const dryRunBatch = await request('/api/platform-admin/invoices/from-subscriptions-batch', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'POST', + body: { + tenantIds: [tenantId], + daysAhead: 400, + dryRun: true, + dueDate: '2026-07-31', + note: 'integration dry-run', + }, + }); + assert.equal(dryRunBatch.item?.dryRun, true, 'batch subscription invoice dry-run should not create invoices'); + assert.ok(dryRunBatch.item?.items?.some(item => item.subscriptionId === candidate.subscriptionId), 'dry-run should include the candidate subscription'); + + const invalidBatchStatus = await request('/api/platform-admin/invoices/from-subscriptions-batch', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'POST', + body: { + tenantIds: [tenantId], + daysAhead: 400, + status: 'unexpected', + }, + expectStatus: 400, + }); + assert.equal(invalidBatchStatus.code, 'INVALID_INVOICE_STATUS', 'batch subscription invoice should reject invalid invoice statuses'); + + const batchCreated = await request('/api/platform-admin/invoices/from-subscriptions-batch', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'POST', + body: { + tenantIds: [tenantId], + daysAhead: 400, + dueDate: '2026-07-31', + note: 'integration batch invoice', + }, + }); + assert.equal(batchCreated.item?.createdCount, 1, 'batch subscription invoice should create one invoice'); + assert.equal(batchCreated.item?.skippedCount, 0, 'first batch subscription invoice should not skip'); + + const duplicateBatch = await request('/api/platform-admin/invoices/from-subscriptions-batch', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'POST', + body: { + tenantIds: [tenantId], + daysAhead: 400, + dueDate: '2026-07-31', + }, + }); + assert.equal(duplicateBatch.item?.createdCount, 0, 'duplicate batch invoice run should not create another invoice'); + + const duplicateSingle = await request('/api/platform-admin/invoices/from-subscription', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'POST', + body: { tenantId }, + expectStatus: 409, + }); + assert.equal(duplicateSingle.code, 'SUBSCRIPTION_INVOICE_EXISTS', 'single subscription invoice should reject duplicates'); + + const auditAfterBatch = await request('/api/platform-admin/audit-logs', { + tenantId: false, + userId: false, + headers: adminHeaders, + query: { q: 'platform.invoice.subscription_batch_created', limit: 20 }, + }); + assert.ok(auditAfterBatch.items?.some(item => item.action === 'platform.invoice.subscription_batch_created'), 'batch invoice creation should write platform audit'); + const studentAuditDenied = await request('/api/platform-admin/audit-logs', { tenantId: false, userId: USER_ID,