forked from wangziqi/gongxue-base
feat: add platform subscription invoice batching
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
|
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
|
||||||
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
||||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
|
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
|
||||||
- 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
- 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、账单、服务费收款、用量记录、公共题库授权。
|
||||||
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
|
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
|
||||||
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。
|
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||||
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
|
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
|
||||||
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成,Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。
|
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成,Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。
|
||||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计、订阅、账单、收款、用量和题库授权第一版写操作。
|
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计、订阅、订阅账单候选/dry-run/批量生成、收款、用量和题库授权第一版写操作。
|
||||||
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
||||||
|
|
||||||
更完整的进度看这些文档:
|
更完整的进度看这些文档:
|
||||||
@@ -371,7 +371,7 @@ apps/api/src/features/
|
|||||||
commerce/ 订单、支付确认、退款、激活码、优惠券规则/核销、权益、资金对账和差错工单
|
commerce/ 订单、支付确认、退款、激活码、优惠券规则/核销、权益、资金对账和差错工单
|
||||||
health/ 健康检查
|
health/ 健康检查
|
||||||
learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜
|
learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜
|
||||||
platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量
|
platform-admin/ 平台方租户、SaaS 套餐、订阅、订阅账单候选/批量生成、账单、用量
|
||||||
profile/ 学生个人中心、勋章
|
profile/ 学生个人中心、勋章
|
||||||
referral/ 销售/代理客资追踪、CRM 队列
|
referral/ 销售/代理客资追踪、CRM 队列
|
||||||
referral/commission.ts
|
referral/commission.ts
|
||||||
@@ -430,7 +430,7 @@ git diff --check
|
|||||||
优先继续补:
|
优先继续补:
|
||||||
|
|
||||||
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
||||||
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台自动计费、批量账单、审计报表增强和更细平台权限点,小程序兼容验证。
|
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台自动计费 worker、催缴/收款流、审计报表增强和更细平台权限点,小程序兼容验证。
|
||||||
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
||||||
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||||
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。
|
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createInvoiceRoute,
|
createInvoiceRoute,
|
||||||
createSubscriptionRoute,
|
createSubscriptionRoute,
|
||||||
createTenantInvoiceFromSubscriptionRoute,
|
createTenantInvoiceFromSubscriptionRoute,
|
||||||
|
createTenantInvoicesBatchFromSubscriptionsRoute,
|
||||||
createTenantRoute,
|
createTenantRoute,
|
||||||
platformAuditLogsRoute,
|
platformAuditLogsRoute,
|
||||||
platformOverviewRoute,
|
platformOverviewRoute,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
platformQuestionBanksRoute,
|
platformQuestionBanksRoute,
|
||||||
questionBankGrantsRoute,
|
questionBankGrantsRoute,
|
||||||
recordUsageRoute,
|
recordUsageRoute,
|
||||||
|
subscriptionInvoiceCandidatesRoute,
|
||||||
tenantDetailRoute,
|
tenantDetailRoute,
|
||||||
tenantInvoicesRoute,
|
tenantInvoicesRoute,
|
||||||
tenantsRoute,
|
tenantsRoute,
|
||||||
@@ -35,7 +37,9 @@ export const platformAdminRoutes: RouteDefinition[] = [
|
|||||||
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
|
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
|
||||||
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
|
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
|
||||||
['POST', '/api/platform-admin/invoices', createInvoiceRoute],
|
['POST', '/api/platform-admin/invoices', createInvoiceRoute],
|
||||||
|
['GET', '/api/platform-admin/invoices/subscription-candidates', subscriptionInvoiceCandidatesRoute],
|
||||||
['POST', '/api/platform-admin/invoices/from-subscription', createTenantInvoiceFromSubscriptionRoute],
|
['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],
|
['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute],
|
||||||
['GET', '/api/platform-admin/usage', tenantUsageRoute],
|
['GET', '/api/platform-admin/usage', tenantUsageRoute],
|
||||||
['POST', '/api/platform-admin/usage', recordUsageRoute],
|
['POST', '/api/platform-admin/usage', recordUsageRoute],
|
||||||
|
|||||||
@@ -39,6 +39,38 @@ function optionalUuidArray(body: Record<string, unknown>, key: string) {
|
|||||||
return optionalStringArray(body, key).filter(Boolean);
|
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) {
|
function billingInvoiceTypeFrom(value: string) {
|
||||||
const invoiceType = value || 'none';
|
const invoiceType = value || 'none';
|
||||||
if (!['none', 'normal_vat', 'special_vat'].includes(invoiceType)) {
|
if (!['none', 'normal_vat', 'special_vat'].includes(invoiceType)) {
|
||||||
@@ -47,6 +79,14 @@ function billingInvoiceTypeFrom(value: string) {
|
|||||||
return invoiceType;
|
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) {
|
function platformAuditDetails(value: unknown) {
|
||||||
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
||||||
}
|
}
|
||||||
@@ -939,7 +979,7 @@ interface CreateInvoiceInput {
|
|||||||
items: ReturnType<typeof normalizeInvoiceItems>;
|
items: ReturnType<typeof normalizeInvoiceItems>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
if (!input.items.length) throw new HttpError(400, 'At least one invoice item is required', 'INVOICE_ITEMS_REQUIRED');
|
||||||
|
|
||||||
const discountCents = centsFrom(input.discountCents, 0);
|
const discountCents = centsFrom(input.discountCents, 0);
|
||||||
@@ -948,70 +988,72 @@ async function createInvoiceRecord(input: CreateInvoiceInput) {
|
|||||||
const totalCents = Math.max(0, subtotalCents - discountCents + taxCents);
|
const totalCents = Math.max(0, subtotalCents - discountCents + taxCents);
|
||||||
const invoiceNo = input.invoiceNo || createInvoiceNo();
|
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 (
|
insert into public.tenant_invoice_items (
|
||||||
tenant_id, invoice_no, invoice_type, status, currency,
|
tenant_id, invoice_id, item_type, description,
|
||||||
subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents,
|
quantity, unit_amount_cents, amount_cents, metadata
|
||||||
billing_period_start, billing_period_end, due_date, issued_at, note, metadata
|
|
||||||
)
|
)
|
||||||
values (
|
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||||
$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,
|
input.tenantId,
|
||||||
invoiceNo,
|
invoice.id,
|
||||||
input.invoiceType || 'subscription',
|
itemInput.itemType,
|
||||||
input.status || 'issued',
|
itemInput.description,
|
||||||
input.currency || 'CNY',
|
itemInput.quantity,
|
||||||
subtotalCents,
|
itemInput.unitAmountCents,
|
||||||
discountCents,
|
Math.round(itemInput.quantity * itemInput.unitAmountCents),
|
||||||
taxCents,
|
JSON.stringify(itemInput.metadata || {}),
|
||||||
totalCents,
|
|
||||||
input.billingPeriodStart || null,
|
|
||||||
input.billingPeriodEnd || null,
|
|
||||||
input.dueDate || null,
|
|
||||||
input.note || null,
|
|
||||||
JSON.stringify(input.metadata || {}),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const invoice = invoiceResult.rows[0];
|
await recalculateInvoiceTotals(client, invoice.id);
|
||||||
for (const itemInput of input.items) {
|
return invoice;
|
||||||
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);
|
async function createInvoiceRecord(input: CreateInvoiceInput) {
|
||||||
return invoice;
|
return transaction(async client => createInvoiceRecordWithClient(client, input));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createInvoiceRoute(ctx: RequestContext) {
|
export async function createInvoiceRoute(ctx: RequestContext) {
|
||||||
@@ -1176,6 +1218,87 @@ export async function tenantUsageRoute(ctx: RequestContext) {
|
|||||||
return { items };
|
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) {
|
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
|
||||||
await requirePlatformAdmin(ctx);
|
await requirePlatformAdmin(ctx);
|
||||||
|
|
||||||
@@ -1183,54 +1306,217 @@ export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestConte
|
|||||||
const tenantId = requiredString(body, 'tenantId');
|
const tenantId = requiredString(body, 'tenantId');
|
||||||
const subscriptionId = optionalString(body, 'subscriptionId');
|
const subscriptionId = optionalString(body, 'subscriptionId');
|
||||||
|
|
||||||
const subscription = await queryOne<{
|
const item = await transaction(async client => {
|
||||||
id: string;
|
const subscriptionResult = await client.query<{
|
||||||
planCode: string;
|
id: string;
|
||||||
amountCents: number;
|
planCode: string;
|
||||||
startsAt: string | null;
|
amountCents: number;
|
||||||
expiresAt: string | null;
|
startsAt: string | null;
|
||||||
}>(
|
expiresAt: string | null;
|
||||||
`
|
}>(
|
||||||
select id, plan_code as "planCode", amount_cents as "amountCents",
|
`
|
||||||
starts_at as "startsAt", expires_at as "expiresAt"
|
select id, plan_code as "planCode", amount_cents as "amountCents",
|
||||||
from public.tenant_subscriptions
|
starts_at as "startsAt", expires_at as "expiresAt"
|
||||||
where tenant_id = $1
|
from public.tenant_subscriptions
|
||||||
and ($2::uuid is null or id = $2::uuid)
|
where tenant_id = $1
|
||||||
order by created_at desc
|
and ($2::uuid is null or id = $2::uuid)
|
||||||
limit 1
|
order by created_at desc
|
||||||
`,
|
limit 1
|
||||||
[tenantId, subscriptionId || null],
|
for update
|
||||||
);
|
`,
|
||||||
if (!subscription) throw new HttpError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND');
|
[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 }>(
|
const planResult = await client.query<{ name: string }>(
|
||||||
`
|
`
|
||||||
select name from public.platform_saas_plans
|
select name from public.platform_saas_plans
|
||||||
where code = $1
|
where code = $1
|
||||||
limit 1
|
limit 1
|
||||||
`,
|
`,
|
||||||
[subscription.planCode],
|
[subscription.planCode],
|
||||||
);
|
);
|
||||||
|
const plan = planResult.rows[0];
|
||||||
|
|
||||||
const item = await createInvoiceRecord({
|
const existing = await client.query<{ id: string; invoiceNo: string; status: string }>(
|
||||||
tenantId,
|
`
|
||||||
invoiceType: 'subscription',
|
select id, invoice_no as "invoiceNo", status
|
||||||
status: optionalString(body, 'status') || 'issued',
|
from public.tenant_invoices
|
||||||
dueDate: optionalString(body, 'dueDate') || null,
|
where tenant_id = $1
|
||||||
billingPeriodStart: toDateText(subscription.startsAt),
|
and invoice_type = 'subscription'
|
||||||
billingPeriodEnd: toDateText(subscription.expiresAt),
|
and status <> 'void'
|
||||||
note: optionalString(body, 'note') || null,
|
and metadata->>'subscriptionId' = $2
|
||||||
metadata: { source: 'subscription', subscriptionId: subscription.id },
|
order by created_at desc
|
||||||
items: [
|
limit 1
|
||||||
{
|
`,
|
||||||
itemType: 'subscription',
|
[tenantId, subscription.id],
|
||||||
description: `${plan?.name || subscription.planCode} ${subscription.planCode}`,
|
);
|
||||||
quantity: 1,
|
if (existing.rows[0]) {
|
||||||
unitAmountCents: subscription.amountCents,
|
throw new HttpError(409, 'Invoice already exists for this subscription', 'SUBSCRIPTION_INVOICE_EXISTS');
|
||||||
metadata: { subscriptionId: subscription.id, planCode: subscription.planCode },
|
}
|
||||||
},
|
|
||||||
],
|
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 };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { Input } from '@tarojs/components';
|
|||||||
import {
|
import {
|
||||||
confirmPlatformInvoicePayment,
|
confirmPlatformInvoicePayment,
|
||||||
createPlatformInvoiceFromSubscription,
|
createPlatformInvoiceFromSubscription,
|
||||||
|
createPlatformInvoicesBatchFromSubscriptions,
|
||||||
createPlatformSubscription,
|
createPlatformSubscription,
|
||||||
loadPlatformInvoices,
|
loadPlatformInvoices,
|
||||||
loadPlatformPlans,
|
loadPlatformPlans,
|
||||||
|
loadPlatformSubscriptionInvoiceCandidates,
|
||||||
loadPlatformUsage,
|
loadPlatformUsage,
|
||||||
recordPlatformUsage,
|
recordPlatformUsage,
|
||||||
type PlatformInvoiceItem,
|
type PlatformInvoiceItem,
|
||||||
type PlatformSaasPlan,
|
type PlatformSaasPlan,
|
||||||
|
type PlatformSubscriptionInvoiceCandidate,
|
||||||
type PlatformUsageItem,
|
type PlatformUsageItem,
|
||||||
} from '@/services/platformAdmin';
|
} from '@/services/platformAdmin';
|
||||||
import '../platform.css';
|
import '../platform.css';
|
||||||
@@ -36,6 +39,8 @@ export default function PlatformBillingPage() {
|
|||||||
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
|
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
|
||||||
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
|
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
|
||||||
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
|
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
|
||||||
|
const [candidates, setCandidates] = useState<PlatformSubscriptionInvoiceCandidate[]>([]);
|
||||||
|
const [batchResult, setBatchResult] = useState('');
|
||||||
const [subscriptionForm, setSubscriptionForm] = useState({
|
const [subscriptionForm, setSubscriptionForm] = useState({
|
||||||
tenantId: '',
|
tenantId: '',
|
||||||
planCode: '',
|
planCode: '',
|
||||||
@@ -60,6 +65,11 @@ export default function PlatformBillingPage() {
|
|||||||
periodStart: todayText(),
|
periodStart: todayText(),
|
||||||
periodEnd: todayText(),
|
periodEnd: todayText(),
|
||||||
});
|
});
|
||||||
|
const [batchInvoiceForm, setBatchInvoiceForm] = useState({
|
||||||
|
daysAhead: '45',
|
||||||
|
dueDate: '',
|
||||||
|
note: '',
|
||||||
|
});
|
||||||
const [busy, setBusy] = useState('');
|
const [busy, setBusy] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -68,12 +78,14 @@ export default function PlatformBillingPage() {
|
|||||||
loadPlatformPlans(true).catch(() => ({ items: [] })),
|
loadPlatformPlans(true).catch(() => ({ items: [] })),
|
||||||
loadPlatformInvoices({ status: nextStatus || undefined, limit: 100 }),
|
loadPlatformInvoices({ status: nextStatus || undefined, limit: 100 }),
|
||||||
loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })),
|
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 || [];
|
const nextPlans = planPayload.items || [];
|
||||||
setPlans(nextPlans);
|
setPlans(nextPlans);
|
||||||
setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' }));
|
setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' }));
|
||||||
setInvoices(invoicePayload.items || []);
|
setInvoices(invoicePayload.items || []);
|
||||||
setUsage(usagePayload.items || []);
|
setUsage(usagePayload.items || []);
|
||||||
|
setCandidates(candidatePayload.items || []);
|
||||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败'));
|
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +117,10 @@ export default function PlatformBillingPage() {
|
|||||||
setUsageForm(current => ({ ...current, [key]: value }));
|
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) {
|
async function confirm(title: string, content: string) {
|
||||||
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
|
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
|
||||||
return result.confirm;
|
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 (
|
return (
|
||||||
<View className='platform-page'>
|
<View className='platform-page'>
|
||||||
<View className='platform-shell'>
|
<View className='platform-shell'>
|
||||||
@@ -288,6 +356,31 @@ export default function PlatformBillingPage() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='platform-section'>
|
||||||
|
<Text className='platform-section-title'>批量订阅账单</Text>
|
||||||
|
<View className='platform-form compact'>
|
||||||
|
<View className='platform-field'><Text className='platform-field-label'>候选天数</Text><Input className='platform-input' placeholder='45' type='number' value={batchInvoiceForm.daysAhead} onInput={event => updateBatchInvoiceForm('daysAhead', String(event.detail.value || ''))} /></View>
|
||||||
|
<View className='platform-field'><Text className='platform-field-label'>到期日</Text><Input className='platform-input' placeholder='YYYY-MM-DD,可选' value={batchInvoiceForm.dueDate} onInput={event => updateBatchInvoiceForm('dueDate', String(event.detail.value || ''))} /></View>
|
||||||
|
<View className='platform-field wide'><Text className='platform-field-label'>批量备注</Text><Input className='platform-input' placeholder='账单说明,可选' value={batchInvoiceForm.note} onInput={event => updateBatchInvoiceForm('note', String(event.detail.value || ''))} /></View>
|
||||||
|
</View>
|
||||||
|
<View className='platform-actions'>
|
||||||
|
<Button className='platform-button' loading={busy === 'candidates'} onClick={loadSubscriptionCandidates}>刷新候选</Button>
|
||||||
|
<Button className='platform-button' loading={busy === 'batch-dry-run'} onClick={() => submitBatchInvoices(true)}>预览批量</Button>
|
||||||
|
<Button className='platform-button primary' loading={busy === 'batch-create'} onClick={() => submitBatchInvoices(false)}>批量生成账单</Button>
|
||||||
|
</View>
|
||||||
|
{batchResult ? <Text className='platform-row-meta'>{batchResult}</Text> : null}
|
||||||
|
<View className='platform-list'>
|
||||||
|
{candidates.slice(0, 12).map(item => (
|
||||||
|
<View className='platform-row' key={item.subscriptionId}>
|
||||||
|
<Text className='platform-row-main'>{item.tenantName || item.tenantSlug || item.tenantId}</Text>
|
||||||
|
<Text className='platform-row-meta'>{item.planName || item.planCode || 'plan'} · {item.status || '-'} · {money(item.amountCents)} · 到期 {String(item.expiresAt || '').slice(0, 10) || '-'}</Text>
|
||||||
|
<Text className='platform-row-meta'>{item.hasExistingInvoice ? `已有账单 ${item.existingInvoiceNo || item.existingInvoiceId}` : item.wouldCreate ? '预览会生成' : '可生成订阅服务费账单'}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{!candidates.length ? <View className='platform-empty'>暂无即将到期且未开票的订阅。</View> : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='platform-section'>
|
<View className='platform-section'>
|
||||||
<Text className='platform-section-title'>SaaS 套餐</Text>
|
<Text className='platform-section-title'>SaaS 套餐</Text>
|
||||||
<View className='platform-list'>
|
<View className='platform-list'>
|
||||||
|
|||||||
@@ -138,6 +138,26 @@ export interface PlatformUsageItem {
|
|||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | 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 {
|
export interface PlatformQuestionBankItem {
|
||||||
id: string;
|
id: string;
|
||||||
tenantId?: string | null;
|
tenantId?: string | null;
|
||||||
@@ -226,6 +246,16 @@ export interface CreatePlatformInvoiceFromSubscriptionInput {
|
|||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreatePlatformInvoicesBatchFromSubscriptionsInput {
|
||||||
|
tenantIds?: string[];
|
||||||
|
subscriptionIds?: string[];
|
||||||
|
daysAhead?: number;
|
||||||
|
status?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
note?: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfirmPlatformInvoicePaymentInput {
|
export interface ConfirmPlatformInvoicePaymentInput {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
invoiceId: 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 } = {}) {
|
export async function loadPlatformQuestionBanks(query: { q?: string; status?: string; includeTenantBanks?: boolean; limit?: number } = {}) {
|
||||||
return apiRequest<{ items?: PlatformQuestionBankItem[] }>('/api/platform-admin/question-banks', {
|
return apiRequest<{ items?: PlatformQuestionBankItem[] }>('/api/platform-admin/question-banks', {
|
||||||
query: { status: 'active', ...query, limit: query.limit || 80 },
|
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<PlatformSubscriptionInvoiceCandidate & { invoice?: PlatformInvoiceItem }>;
|
||||||
|
skipped?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
}>('/api/platform-admin/invoices/from-subscriptions-batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: input,
|
||||||
|
tenantId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoicePaymentInput) {
|
export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoicePaymentInput) {
|
||||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/invoices/payments/manual-confirm', {
|
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/invoices/payments/manual-confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ apps/api/src/
|
|||||||
referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列
|
referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列
|
||||||
storage/ 对象存储签名 provider
|
storage/ 对象存储签名 provider
|
||||||
video/ 题目视频列表、搜索、SVIP/次数校验和签名播放
|
video/ 题目视频列表、搜索、SVIP/次数校验和签名播放
|
||||||
platform-admin/ 平台方 SaaS 租户、订阅、账单、使用量
|
platform-admin/ 平台方 SaaS 租户、订阅、订阅账单候选/批量生成、账单、使用量
|
||||||
tenant-admin/ 租户品牌、域名、公开设置、登录/商户配置、成员权限、活动/兑换码运营
|
tenant-admin/ 租户品牌、域名、公开设置、登录/商户配置、成员权限、活动/兑换码运营
|
||||||
tenant-content/ 租户后台内容维护:入口、分类树、集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、批量导入
|
tenant-content/ 租户后台内容维护:入口、分类树、集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、批量导入
|
||||||
```
|
```
|
||||||
@@ -37,7 +37,7 @@ features/
|
|||||||
learning/ 顺序/随机/模考组卷、答题记录、错题、收藏、学习进度、排行榜
|
learning/ 顺序/随机/模考组卷、答题记录、错题、收藏、学习进度、排行榜
|
||||||
commerce/ 商品、订单、优惠券、支付、退款、权益开通
|
commerce/ 商品、订单、优惠券、支付、退款、权益开通
|
||||||
referral/ 销售/代理增长链路、客资归属、分佣依据、CRM 入队
|
referral/ 销售/代理增长链路、客资归属、分佣依据、CRM 入队
|
||||||
platform-admin/ 平台租户管理、年费、服务费、账务审计
|
platform-admin/ 平台租户管理、年费、服务费、批量开票、账务审计
|
||||||
tenant-admin/ 合作商后台配置、品牌、域名、收款账户、登录 provider、密钥掩码、成员权限、审计、活动、兑换码、优惠券
|
tenant-admin/ 合作商后台配置、品牌、域名、收款账户、登录 provider、密钥掩码、成员权限、审计、活动、兑换码、优惠券
|
||||||
tenant-content/ 合作商内容导航、题库维护、批量导入、资源绑定、内容审计
|
tenant-content/ 合作商内容导航、题库维护、批量导入、资源绑定、内容审计
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -145,7 +145,7 @@
|
|||||||
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 |
|
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 |
|
||||||
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 |
|
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 |
|
||||||
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
|
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
|
||||||
| 平台租户/详情/账务资料/审计/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、收款和用量;创建租户、状态变更、账务资料维护会写入审计 |
|
| 平台租户/详情/账务资料/审计/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、重复开票保护、收款和用量;创建租户、状态变更、账务资料维护和订阅批量开票会写入审计 |
|
||||||
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
||||||
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
|
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
|
||||||
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计,冲突全部处理后相关通知自动 resolved |
|
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计,冲突全部处理后相关通知自动 resolved |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
新项目已经不是简单的 PocketBase 字段平移,而是按多租户 SaaS 重新建立了后端边界:
|
新项目已经不是简单的 PocketBase 字段平移,而是按多租户 SaaS 重新建立了后端边界:
|
||||||
|
|
||||||
- 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。
|
- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、服务费和用量。
|
||||||
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
||||||
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、站内通知、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
|
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、站内通知、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
|
||||||
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。
|
- 销售/代理/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 深测继续执行 |
|
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、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 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
||||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
|
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
|
||||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||||
| AI 择校推荐 | 可联调 | `ai_recommendation_reports`、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON、报告列表/详情和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF 渲染和人工复核流程 |
|
| AI 择校推荐 | 可联调 | `ai_recommendation_reports`、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON、报告列表/详情和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF 渲染和人工复核流程 |
|
||||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑和平台审计第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 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 级水印。
|
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
|
||||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,确认 `migrationReadiness` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。
|
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,确认 `migrationReadiness` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。
|
||||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑和最近平台审计第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台后台自动计费/批量账单/审计报表增强和小程序兼容验证。
|
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、最近平台审计和订阅账单候选/dry-run/批量生成第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台后台自动计费 worker/催缴/审计报表增强和小程序兼容验证。
|
||||||
|
|
||||||
### P1:商用收费和运营能力
|
### P1:商用收费和运营能力
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,12 @@
|
|||||||
- `video`:题目视频讲解、批量预加载、通用视频搜索。
|
- `video`:题目视频讲解、批量预加载、通用视频搜索。
|
||||||
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
|
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
|
||||||
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
|
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
|
||||||
- `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、使用量。
|
- `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、服务费收款、使用量。
|
||||||
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
|
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
|
||||||
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
|
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
|
||||||
- `tenant`:域名/租户解析。
|
- `tenant`:域名/租户解析。
|
||||||
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
|
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
|
||||||
- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者和关键词查询平台审计。创建租户、状态变更和账务资料维护会写入审计日志,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_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
|
||||||
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||||
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
|
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
|
||||||
@@ -60,7 +60,9 @@ GET /api/platform-admin/audit-logs
|
|||||||
POST /api/platform-admin/subscriptions
|
POST /api/platform-admin/subscriptions
|
||||||
GET /api/platform-admin/invoices
|
GET /api/platform-admin/invoices
|
||||||
POST /api/platform-admin/invoices
|
POST /api/platform-admin/invoices
|
||||||
|
GET /api/platform-admin/invoices/subscription-candidates
|
||||||
POST /api/platform-admin/invoices/from-subscription
|
POST /api/platform-admin/invoices/from-subscription
|
||||||
|
POST /api/platform-admin/invoices/from-subscriptions-batch
|
||||||
POST /api/platform-admin/invoices/payments/manual-confirm
|
POST /api/platform-admin/invoices/payments/manual-confirm
|
||||||
GET /api/platform-admin/usage
|
GET /api/platform-admin/usage
|
||||||
POST /api/platform-admin/usage
|
POST /api/platform-admin/usage
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
|
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、自动计费、批量账单和平台审计报表增强 |
|
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、重复开票保护、服务费收款、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、自动计费 worker、催缴/收款流和平台审计报表增强 |
|
||||||
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
|
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
|
||||||
| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 |
|
| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 |
|
||||||
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |
|
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要。
|
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要,账务中心已接订阅账单候选预览、dry-run 和批量生成。
|
||||||
- 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。
|
- 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||||
- 可以按新后端主模型接入内容导航:
|
- 可以按新后端主模型接入内容导航:
|
||||||
- `content_entries`
|
- `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/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/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` |
|
| 公共题库 | `apps/taro/src/pages/platform-admin/question-banks/index.tsx` | `platform-admin/question-banks`、`question-bank-grants`、`PUT question-bank-grants` |
|
||||||
|
|
||||||
当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限和审计。下一批继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费、账单批量操作和更细平台权限点。
|
当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限、重复开票保护和审计。下一批继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费 worker、催缴/收款流和更细平台权限点。
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 |
|
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 |
|
||||||
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
||||||
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
|
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 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 生产账号和回调域名联调待补 |
|
| 登录认证 | 已建短信验证码、会话、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、导入回归和抽样验收 |
|
| 数据导入 | 已建立 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 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
|
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
|
||||||
@@ -284,7 +284,9 @@ platform-admin:
|
|||||||
POST /api/platform-admin/subscriptions
|
POST /api/platform-admin/subscriptions
|
||||||
GET /api/platform-admin/invoices
|
GET /api/platform-admin/invoices
|
||||||
POST /api/platform-admin/invoices
|
POST /api/platform-admin/invoices
|
||||||
|
GET /api/platform-admin/invoices/subscription-candidates
|
||||||
POST /api/platform-admin/invoices/from-subscription
|
POST /api/platform-admin/invoices/from-subscription
|
||||||
|
POST /api/platform-admin/invoices/from-subscriptions-batch
|
||||||
POST /api/platform-admin/invoices/payments/manual-confirm
|
POST /api/platform-admin/invoices/payments/manual-confirm
|
||||||
GET /api/platform-admin/usage
|
GET /api/platform-admin/usage
|
||||||
POST /api/platform-admin/usage
|
POST /api/platform-admin/usage
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
|
|
||||||
| 功能 | 新后端状态 | 待补齐 |
|
| 功能 | 新后端状态 | 待补齐 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护和最近平台审计已接真实 API;后续补自动计费、批量账单、租户基础资料编辑增强和更完整平台审计报表 |
|
| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护、最近平台审计、订阅账单候选预览、dry-run 和批量生成已接真实 API;后续补自动计费 worker、催缴/收款流、租户基础资料编辑增强和更完整平台审计报表 |
|
||||||
| SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 |
|
| SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 |
|
||||||
| 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 |
|
| 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 |
|
||||||
| 租户用量记录 | 已覆盖 | 自动采集 worker 待补 |
|
| 租户用量记录 | 已覆盖 | 自动采集 worker 待补 |
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。
|
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。
|
||||||
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
||||||
- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。
|
- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。
|
||||||
- 平台后台 API:租户、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量。
|
- 平台后台 API:租户、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、服务费收款、用量。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。
|
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。
|
||||||
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
||||||
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
- H5 和小程序共用同一套业务 API client。
|
- H5 和小程序共用同一套业务 API client。
|
||||||
- 租户通过域名、小程序配置或启动参数解析。
|
- 租户通过域名、小程序配置或启动参数解析。
|
||||||
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
|
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
|
||||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台审计、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台审计、订阅、订阅账单候选/dry-run/批量生成、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||||
|
|
||||||
### 第一批页面
|
### 第一批页面
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
|
|
||||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。
|
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。
|
||||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。
|
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。
|
||||||
3. 补平台后台增强:租户基础资料编辑增强、平台审计报表导出/告警、自动计费、账单批量操作和更细平台权限点。
|
3. 补平台后台增强:租户基础资料编辑增强、平台审计报表导出/告警、自动计费 worker、催缴/收款流和更细平台权限点。
|
||||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。
|
5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。
|
||||||
6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||||
|
|||||||
@@ -2519,7 +2519,7 @@ src/services/ai.ts AI 择校推荐生成、报告列表、报告详情
|
|||||||
src/services/pronunciation.ts H5/小程序单词发音适配
|
src/services/pronunciation.ts H5/小程序单词发音适配
|
||||||
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录
|
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录
|
||||||
src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证
|
src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证
|
||||||
src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计、套餐账单、用量、公共题库授权
|
src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计、套餐账单、订阅账单候选/dry-run/批量生成、用量、公共题库授权
|
||||||
```
|
```
|
||||||
|
|
||||||
验证命令:
|
验证命令:
|
||||||
@@ -2537,7 +2537,7 @@ npm run build:taro:h5:platform
|
|||||||
|
|
||||||
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。
|
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。
|
||||||
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。
|
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。
|
||||||
- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费和批量账单操作。
|
- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计,前端只展示候选、预览结果和跳过结果;继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费 worker 和催缴/收款流。
|
||||||
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
||||||
|
|
||||||
## AI 择校推荐接入
|
## AI 择校推荐接入
|
||||||
|
|||||||
@@ -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.billing_profile_upserted'), 'platform audit should include billing profile update');
|
||||||
assert.ok(actions.has('platform.tenant.status_updated'), 'platform audit should include status 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', {
|
const studentAuditDenied = await request('/api/platform-admin/audit-logs', {
|
||||||
tenantId: false,
|
tenantId: false,
|
||||||
userId: USER_ID,
|
userId: USER_ID,
|
||||||
|
|||||||
Reference in New Issue
Block a user