feat: add platform invoice dunning workflow

This commit is contained in:
Codex
2026-06-30 05:27:58 +08:00
parent f9f96bee63
commit c74432ce98
25 changed files with 858 additions and 23 deletions

View File

@@ -65,6 +65,16 @@ WORKER_COMMERCE_BATCH_SIZE=20
WORKER_COMMERCE_MIN_AGE_SECONDS=300
WORKER_COMMERCE_REQUEST_TIMEOUT_MS=10000
# Worker 配置:平台 SaaS 订阅自动开票和逾期催缴。
# 自动开票只处理有明确 expires_at 且未开过非 void 订阅账单的订阅。
WORKER_PLATFORM_BILLING_BATCH_SIZE=50
WORKER_PLATFORM_BILLING_DAYS_AHEAD=45
WORKER_PLATFORM_BILLING_DUE_DAYS=15
WORKER_PLATFORM_BILLING_ID=platform-billing-1
# 逾期催缴只标记 overdue、写 tenant_invoice_reminders 和审计,不自动停用租户。
WORKER_PLATFORM_DUNNING_BATCH_SIZE=100
WORKER_PLATFORM_DUNNING_ID=platform-dunning-1
# Worker 配置:内容资源复检。异常托管对象会被标记 failed 并从 active 退回 draft。
WORKER_ASSET_BATCH_SIZE=50
WORKER_ASSET_MIN_AGE_SECONDS=300

View File

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

View File

@@ -6,10 +6,12 @@ import {
createTenantInvoiceFromSubscriptionRoute,
createTenantInvoicesBatchFromSubscriptionsRoute,
createTenantRoute,
invoiceRemindersRoute,
platformAuditLogsRoute,
platformOverviewRoute,
platformPlansRoute,
platformQuestionBanksRoute,
processOverdueInvoicesRoute,
questionBankGrantsRoute,
recordUsageRoute,
subscriptionInvoiceCandidatesRoute,
@@ -40,6 +42,8 @@ export const platformAdminRoutes: RouteDefinition[] = [
['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/process-overdue', processOverdueInvoicesRoute],
['GET', '/api/platform-admin/invoices/reminders', invoiceRemindersRoute],
['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute],
['GET', '/api/platform-admin/usage', tenantUsageRoute],
['POST', '/api/platform-admin/usage', recordUsageRoute],

View File

@@ -17,6 +17,7 @@ import {
normalizeHost,
normalizeInvoiceItems,
normalizeSlug,
processOverduePlatformInvoices,
quantityFrom,
recalculateInvoiceTotals,
} from './service.js';
@@ -1167,6 +1168,78 @@ export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
return { item };
}
export async function processOverdueInvoicesRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const dryRun = booleanFrom(body.dryRun, false);
const limit = Math.min(Math.max(Number(body.limit || 100), 1), 500);
const channel = optionalString(body, 'channel') || 'internal';
if (!['manual', 'internal', 'sms', 'email', 'wechat', 'crm'].includes(channel)) {
throw new HttpError(400, 'channel is invalid', 'INVALID_REMINDER_CHANNEL');
}
const session = currentSessionFromContext(ctx);
const item = await transaction(async client => {
const result = await processOverduePlatformInvoices(client, {
actorUserId: session?.id || null,
channel,
dryRun,
limit,
});
if (!dryRun) {
await recordPlatformAudit(client, ctx, 'platform.invoice.overdue_batch_processed', 'tenant_invoice_batch', null, {
processed: result.processed,
markedOverdue: result.markedOverdue,
reminderCreated: result.reminderCreated,
skippedReminder: result.skippedReminder,
channel,
});
}
return result;
});
return { item };
}
export async function invoiceRemindersRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const tenantId = ctx.url.searchParams.get('tenantId') || '';
const invoiceId = ctx.url.searchParams.get('invoiceId') || '';
const status = listQuery(ctx, 'status');
const reminderType = listQuery(ctx, 'reminderType');
const limit = intParam(ctx, 'limit', 100, 500);
if (tenantId && !UUID_RE.test(tenantId)) throw new HttpError(400, 'tenantId is invalid', 'INVALID_UUID');
if (invoiceId && !UUID_RE.test(invoiceId)) throw new HttpError(400, 'invoiceId is invalid', 'INVALID_UUID');
const items = await query(
`
select r.id, r.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
r.invoice_id as "invoiceId", i.invoice_no as "invoiceNo",
r.reminder_type as "reminderType", r.channel, r.status,
r.reminder_date as "reminderDate", r.reminder_level as "reminderLevel",
r.due_date as "dueDate", r.balance_cents_snapshot as "balanceCentsSnapshot",
r.message, r.metadata, r.sent_at as "sentAt", r.acknowledged_at as "acknowledgedAt",
r.created_at as "createdAt", r.updated_at as "updatedAt"
from public.tenant_invoice_reminders r
join public.tenants t on t.id = r.tenant_id
join public.tenant_invoices i on i.id = r.invoice_id
where ($1::uuid is null or r.tenant_id = $1::uuid)
and ($2::uuid is null or r.invoice_id = $2::uuid)
and ($3::text = '' or r.status = $3)
and ($4::text = '' or r.reminder_type = $4)
order by r.reminder_date desc, r.created_at desc
limit $5
`,
[tenantId || null, invoiceId || null, status, reminderType, limit],
);
return { items };
}
export async function recordUsageRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);

View File

@@ -116,3 +116,232 @@ export async function recalculateInvoiceTotals(client: pg.PoolClient, invoiceId:
return updateResult.rows[0];
}
interface ProcessOverduePlatformInvoicesOptions {
actorUserId?: string | null;
channel?: string;
reminderType?: string;
dryRun?: boolean;
limit?: number;
today?: string | null;
workerId?: string | null;
}
function clampPositiveInteger(value: unknown, fallback: number, max: number) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(Math.trunc(parsed), max);
}
function yyyyMmDd(value: Date) {
return value.toISOString().slice(0, 10);
}
function currentDateText(value?: string | null) {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
return yyyyMmDd(new Date());
}
export async function processOverduePlatformInvoices(
client: pg.PoolClient,
options: ProcessOverduePlatformInvoicesOptions = {},
) {
const limit = clampPositiveInteger(options.limit, 100, 1000);
const today = currentDateText(options.today);
const channel = options.channel || 'internal';
const reminderType = options.reminderType || 'overdue';
const invoiceResult = await client.query<{
id: string;
tenantId: string;
tenantSlug: string;
tenantName: string;
invoiceNo: string;
status: string;
balanceCents: number;
dueDate: string | null;
existingReminderId: string | null;
reminderCount: number;
}>(
`
select i.id,
i.tenant_id as "tenantId",
t.slug::text as "tenantSlug",
t.name as "tenantName",
i.invoice_no as "invoiceNo",
i.status,
i.balance_cents as "balanceCents",
i.due_date as "dueDate",
existing.id as "existingReminderId",
coalesce(history.reminder_count, 0)::integer as "reminderCount"
from public.tenant_invoices i
join public.tenants t on t.id = i.tenant_id
left join lateral (
select id
from public.tenant_invoice_reminders r
where r.tenant_id = i.tenant_id
and r.invoice_id = i.id
and r.reminder_type = $2
and r.channel = $3
and r.reminder_date = $1::date
limit 1
) existing on true
left join lateral (
select count(*)::integer as reminder_count
from public.tenant_invoice_reminders r
where r.tenant_id = i.tenant_id
and r.invoice_id = i.id
and r.reminder_type = $2
) history on true
where i.status in ('issued', 'overdue')
and i.balance_cents > 0
and i.due_date is not null
and i.due_date < $1::date
and t.status = 'active'
order by i.due_date asc, i.created_at asc
limit $4
for update of i skip locked
`,
[today, reminderType, channel, limit],
);
if (options.dryRun) {
return {
dryRun: true,
processed: invoiceResult.rowCount,
markedOverdue: 0,
reminderCreated: 0,
skippedReminder: invoiceResult.rows.filter(row => row.existingReminderId).length,
items: invoiceResult.rows.map(row => ({
...row,
wouldMarkOverdue: row.status !== 'overdue',
wouldCreateReminder: !row.existingReminderId,
})),
};
}
const items: unknown[] = [];
let markedOverdue = 0;
let reminderCreated = 0;
let skippedReminder = 0;
for (const invoice of invoiceResult.rows) {
if (invoice.status !== 'overdue') {
await client.query(
`
update public.tenant_invoices
set status = 'overdue',
metadata = metadata || $3::jsonb,
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
invoice.tenantId,
invoice.id,
JSON.stringify({
overdueMarkedAt: new Date().toISOString(),
overdueMarkedBy: options.workerId || options.actorUserId || 'platform-admin',
}),
],
);
markedOverdue += 1;
}
await client.query(
`
update public.tenants
set billing_status = case when billing_status = 'active' then 'past_due' else billing_status end,
updated_at = now()
where id = $1
`,
[invoice.tenantId],
);
let reminder = null;
if (invoice.existingReminderId) {
skippedReminder += 1;
} else {
const reminderResult = await client.query(
`
insert into public.tenant_invoice_reminders (
tenant_id, invoice_id, reminder_type, channel, status,
reminder_date, reminder_level, due_date,
balance_cents_snapshot, message, metadata, created_by
)
values (
$1, $2, $3, $4, 'pending',
$5::date, $6, $7::date,
$8, $9, $10::jsonb, $11::uuid
)
on conflict (tenant_id, invoice_id, reminder_type, channel, reminder_date)
do nothing
returning id, tenant_id as "tenantId", invoice_id as "invoiceId",
reminder_type as "reminderType", channel, status,
reminder_date as "reminderDate", reminder_level as "reminderLevel",
due_date as "dueDate", balance_cents_snapshot as "balanceCentsSnapshot",
message, metadata, created_at as "createdAt"
`,
[
invoice.tenantId,
invoice.id,
reminderType,
channel,
today,
Number(invoice.reminderCount || 0) + 1,
invoice.dueDate,
Number(invoice.balanceCents || 0),
`租户 ${invoice.tenantName} 的平台服务费账单 ${invoice.invoiceNo} 已逾期,请跟进收款。`,
JSON.stringify({
source: options.workerId ? 'worker' : 'platform_admin',
workerId: options.workerId || null,
invoiceNo: invoice.invoiceNo,
tenantSlug: invoice.tenantSlug,
}),
options.actorUserId || null,
],
);
reminder = reminderResult.rows[0] || null;
if (reminder) reminderCreated += 1;
else skippedReminder += 1;
}
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2::uuid, 'platform.invoice.overdue_processed', 'tenant_invoice', $3, $4::jsonb)
`,
[
invoice.tenantId,
options.actorUserId || null,
invoice.id,
JSON.stringify({
invoiceNo: invoice.invoiceNo,
dueDate: invoice.dueDate,
balanceCents: invoice.balanceCents,
markedOverdue: invoice.status !== 'overdue',
reminderCreated: Boolean(reminder),
channel,
reminderType,
workerId: options.workerId || null,
}),
],
);
items.push({
...invoice,
status: 'overdue',
markedOverdue: invoice.status !== 'overdue',
reminderCreated: Boolean(reminder),
reminder,
});
}
return {
dryRun: false,
processed: invoiceResult.rowCount,
markedOverdue,
reminderCreated,
skippedReminder,
items,
};
}

View File

@@ -7,11 +7,14 @@ import {
createPlatformInvoiceFromSubscription,
createPlatformInvoicesBatchFromSubscriptions,
createPlatformSubscription,
loadPlatformInvoiceReminders,
loadPlatformInvoices,
loadPlatformPlans,
loadPlatformSubscriptionInvoiceCandidates,
loadPlatformUsage,
processPlatformOverdueInvoices,
recordPlatformUsage,
type PlatformInvoiceReminderItem,
type PlatformInvoiceItem,
type PlatformSaasPlan,
type PlatformSubscriptionInvoiceCandidate,
@@ -38,6 +41,7 @@ export default function PlatformBillingPage() {
const [status, setStatus] = useState('');
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
const [reminders, setReminders] = useState<PlatformInvoiceReminderItem[]>([]);
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
const [candidates, setCandidates] = useState<PlatformSubscriptionInvoiceCandidate[]>([]);
const [batchResult, setBatchResult] = useState('');
@@ -77,13 +81,15 @@ export default function PlatformBillingPage() {
Promise.all([
loadPlatformPlans(true).catch(() => ({ items: [] })),
loadPlatformInvoices({ status: nextStatus || undefined, limit: 100 }),
loadPlatformInvoiceReminders({ limit: 80 }).catch(() => ({ items: [] })),
loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })),
loadPlatformSubscriptionInvoiceCandidates({ daysAhead: Number(batchInvoiceForm.daysAhead || 45), limit: 100 }).catch(() => ({ items: [] })),
]).then(([planPayload, invoicePayload, usagePayload, candidatePayload]) => {
]).then(([planPayload, invoicePayload, reminderPayload, usagePayload, candidatePayload]) => {
const nextPlans = planPayload.items || [];
setPlans(nextPlans);
setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' }));
setInvoices(invoicePayload.items || []);
setReminders(reminderPayload.items || []);
setUsage(usagePayload.items || []);
setCandidates(candidatePayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败'));
@@ -283,6 +289,32 @@ export default function PlatformBillingPage() {
}
}
async function submitOverdueProcess(dryRun: boolean) {
setError('');
const ok = dryRun
? true
: await confirm('处理逾期账单', '确认扫描已过期未结清的平台服务费账单,并生成内部催缴记录?该动作不会自动停用租户。');
if (!ok) return;
setBusy(dryRun ? 'overdue-dry-run' : 'overdue-process');
try {
const payload = await processPlatformOverdueInvoices({
dryRun,
channel: 'internal',
limit: 100,
});
const item = payload.item || {};
setBatchResult(`${dryRun ? '逾期预览' : '逾期处理'}完成:处理 ${item.processed || 0},标记 ${item.markedOverdue || 0},催缴 ${item.reminderCreated || 0},跳过 ${item.skippedReminder || 0}`);
if (!dryRun) {
Taro.showToast({ title: '已处理', icon: 'success' });
reload(status);
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '逾期处理失败');
} finally {
setBusy('');
}
}
return (
<View className='platform-page'>
<View className='platform-shell'>
@@ -381,6 +413,25 @@ export default function PlatformBillingPage() {
{!candidates.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-actions'>
<Button className='platform-button' loading={busy === 'overdue-dry-run'} onClick={() => submitOverdueProcess(true)}></Button>
<Button className='platform-button primary' loading={busy === 'overdue-process'} onClick={() => submitOverdueProcess(false)}></Button>
</View>
<View className='platform-list'>
{reminders.slice(0, 12).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.invoiceNo || item.invoiceId}</Text>
<Text className='platform-row-meta'>{item.tenantName || item.tenantSlug || item.tenantId} · {item.reminderType || 'reminder'} · {item.channel || 'internal'} · {item.status || '-'}</Text>
<Text className='platform-row-meta'> {String(item.reminderLevel || 1)} · {item.dueDate ? String(item.dueDate).slice(0, 10) : '-'} · {money(item.balanceCentsSnapshot)}</Text>
<Text className='platform-row-meta'>{item.message || '暂无催缴备注'}</Text>
</View>
))}
</View>
{!reminders.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-section'>
<Text className='platform-section-title'>SaaS </Text>
<View className='platform-list'>

View File

@@ -126,6 +126,24 @@ export interface PlatformInvoiceItem {
note?: string | null;
}
export interface PlatformInvoiceReminderItem {
id: string;
tenantId: string;
tenantSlug?: string | null;
tenantName?: string | null;
invoiceId: string;
invoiceNo?: string | null;
reminderType?: string | null;
channel?: string | null;
status?: string | null;
reminderDate?: string | null;
reminderLevel?: number | string | null;
dueDate?: string | null;
balanceCentsSnapshot?: number | string | null;
message?: string | null;
createdAt?: string | null;
}
export interface PlatformUsageItem {
id: string;
tenantId: string;
@@ -265,6 +283,12 @@ export interface ConfirmPlatformInvoicePaymentInput {
providerTradeNo?: string;
}
export interface ProcessPlatformOverdueInvoicesInput {
dryRun?: boolean;
channel?: string;
limit?: number;
}
export interface RecordPlatformUsageInput {
tenantId: string;
metricKey: string;
@@ -417,6 +441,34 @@ export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoic
});
}
export async function processPlatformOverdueInvoices(input: ProcessPlatformOverdueInvoicesInput = {}) {
return apiRequest<{
item?: {
dryRun?: boolean;
processed?: number;
markedOverdue?: number;
reminderCreated?: number;
skippedReminder?: number;
items?: Array<PlatformInvoiceItem & {
wouldMarkOverdue?: boolean;
wouldCreateReminder?: boolean;
reminderCreated?: boolean;
}>;
};
}>('/api/platform-admin/invoices/process-overdue', {
method: 'POST',
body: input,
tenantId: null,
});
}
export async function loadPlatformInvoiceReminders(query: { tenantId?: string; invoiceId?: string; status?: string; reminderType?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformInvoiceReminderItem[] }>('/api/platform-admin/invoices/reminders', {
query: { ...query, limit: query.limit || 100 },
tenantId: null,
});
}
export async function recordPlatformUsage(input: RecordPlatformUsageInput) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/usage', {
method: 'POST',

View File

@@ -12,6 +12,7 @@
"commerce:once": "tsx src/index.ts --once --job commerce",
"provider-bills:once": "tsx src/index.ts --once --job provider-bills",
"platform-billing:once": "tsx src/index.ts --once --job platform-billing",
"platform-dunning:once": "tsx src/index.ts --once --job platform-dunning",
"assets:once": "tsx src/index.ts --once --job assets",
"imports:once": "tsx src/index.ts --once --job imports",
"public-banks:once": "tsx src/index.ts --once --job public-banks",

View File

@@ -22,6 +22,8 @@ export interface WorkerConfig {
platformBillingDaysAhead: number;
platformBillingDueDays: number;
platformBillingWorkerId: string;
platformDunningBatchSize: number;
platformDunningWorkerId: string;
assetBatchSize: number;
assetMinAgeSeconds: number;
assetRecheckIntervalSeconds: number;
@@ -174,6 +176,8 @@ const loadedConfig: WorkerConfig = {
platformBillingDaysAhead: envNumber('WORKER_PLATFORM_BILLING_DAYS_AHEAD', 45),
platformBillingDueDays: envNumber('WORKER_PLATFORM_BILLING_DUE_DAYS', 15),
platformBillingWorkerId: envString('WORKER_PLATFORM_BILLING_ID', `platform-billing-${process.pid}`),
platformDunningBatchSize: envNumber('WORKER_PLATFORM_DUNNING_BATCH_SIZE', 100),
platformDunningWorkerId: envString('WORKER_PLATFORM_DUNNING_ID', `platform-dunning-${process.pid}`),
assetBatchSize: envNumber('WORKER_ASSET_BATCH_SIZE', 50),
assetMinAgeSeconds: envNumber('WORKER_ASSET_MIN_AGE_SECONDS', 300),
assetRecheckIntervalSeconds: envNumber('WORKER_ASSET_RECHECK_INTERVAL_SECONDS', 60 * 60 * 24),

View File

@@ -51,6 +51,16 @@ async function runOnce() {
);
return;
}
if (job === 'platform-dunning') {
const { processPlatformDunningBatch } = await import('./jobs/platform-dunning.js');
const result = await processPlatformDunningBatch();
console.log(
`[worker] platform-dunning batch processed=${result.processed}`
+ ` markedOverdue=${result.markedOverdue} reminderCreated=${result.reminderCreated}`
+ ` skippedReminder=${result.skippedReminder}`,
);
return;
}
if (job === 'assets') {
const result = await processAssetBatch();
console.log(

View File

@@ -0,0 +1,34 @@
import { pool } from '../db.js';
import { config } from '../config.js';
import { processOverduePlatformInvoices } from '../../../api/src/features/platform-admin/service.js';
interface PlatformDunningWorkerResult {
processed: number;
markedOverdue: number;
reminderCreated: number;
skippedReminder: number;
}
export async function processPlatformDunningBatch(limit = config.platformDunningBatchSize): Promise<PlatformDunningWorkerResult> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await processOverduePlatformInvoices(client, {
channel: 'internal',
limit,
workerId: config.platformDunningWorkerId,
});
await client.query('commit');
return {
processed: Number(result.processed || 0),
markedOverdue: Number(result.markedOverdue || 0),
reminderCreated: Number(result.reminderCreated || 0),
skippedReminder: Number(result.skippedReminder || 0),
};
} catch (error) {
await client.query('rollback').catch(() => {});
throw error;
} finally {
client.release();
}
}

View File

@@ -145,7 +145,7 @@
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes``classes/members``students``teachers`,支持班级范围权限和审计 |
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 |
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
| 平台租户/详情/账务资料/审计/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款和用量创建租户、状态变更、账务资料维护、订阅批量开票自动开票会写入审计 |
| 平台租户/详情/账务资料/审计/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账和用量;创建租户、状态变更、账务资料维护、订阅批量开票自动开票和逾期催缴会写入审计 |
| 数据看板聚合接口 | 可联调 | `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 |
@@ -201,6 +201,7 @@ npm run test:worker:imports
npm run test:worker:crm
npm run test:worker:commerce
npm run test:worker:platform-billing
npm run test:worker:platform-dunning
npm run test:worker:assets
npm run test:worker:exports
npm run test:worker:public-banks

View File

@@ -8,7 +8,7 @@
新项目已经不是简单的 PocketBase 字段平移,而是按多租户 SaaS 重新建立了后端边界:
- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费和用量。
- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴和用量。
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、站内通知、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、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 深测继续执行 |
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台审计日志、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 催缴/收款、平台审计报表增强、更完整运营消息 |
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台审计日志、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 外部催缴通知、平台在线收款、平台审计报表增强、更完整运营消息 |
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
@@ -35,7 +35,7 @@
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
| AI 择校推荐 | 可联调 | `ai_recommendation_reports`、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON、报告列表/详情和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF 渲染和人工复核流程 |
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台审计、订阅账单候选预览/dry-run/批量生成自动计费生成结果查看第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台后台催缴/审计报表增强、小程序兼容验证和端到端测试 |
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台审计、订阅账单候选预览/dry-run/批量生成自动计费生成结果查看、逾期预览和催缴记录第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台审计报表增强、小程序兼容验证和端到端测试 |
## 前端接入建议
@@ -79,7 +79,7 @@
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
- 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,确认 `migrationReadiness` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。
- 生产环境配置:`.env.example``npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
- Taro scaffold`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、最近平台审计、订阅账单候选/dry-run/批量生成自动计费生成结果查看第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台后台催缴/审计报表增强和小程序兼容验证。
- Taro scaffold`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、最近平台审计、订阅账单候选/dry-run/批量生成自动计费生成结果查看、逾期预览和催缴记录第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台审计报表增强和小程序兼容验证。
### P1商用收费和运营能力

View File

@@ -14,12 +14,12 @@
- `video`:题目视频讲解、批量预加载、通用视频搜索。
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
- `platform-admin`平台方租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、使用量。
- `platform-admin`平台方租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、使用量。
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
- `tenant`:域名/租户解析。
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者和关键词查询平台审计;`GET /api/platform-admin/invoices/subscription-candidates``POST /api/platform-admin/invoices/from-subscriptions-batch` 支持订阅账单候选预览、dry-run、批量生成、重复开票跳过和平台审计`apps/worker --job platform-billing` 可自动为即将到期且未开票订阅生成服务费账单。创建租户、状态变更、账务资料维护、订阅批量开票自动计费会写入审计日志API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝和审计记录存在。
- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者和关键词查询平台审计;`GET /api/platform-admin/invoices/subscription-candidates``POST /api/platform-admin/invoices/from-subscriptions-batch` 支持订阅账单候选预览、dry-run、批量生成、重复开票跳过和平台审计`apps/worker --job platform-billing` 可自动为即将到期且未开票订阅生成服务费账单`POST /api/platform-admin/invoices/process-overdue``GET /api/platform-admin/invoices/reminders``apps/worker --job platform-dunning` 可处理已逾期未结清服务费账单,写入内部催缴台账和审计。创建租户、状态变更、账务资料维护、订阅批量开票自动计费和逾期催缴会写入审计日志API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝和审计记录存在。
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
- 班级与学生范围权限已落库:`tenant_classes``tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
@@ -33,6 +33,7 @@
- 已新增 `apps/worker``npm run test:worker:crm`,用于消费 CRM webhook 队列,验证本地 fake webhook、队列状态、日志和密钥不泄露。
- 已新增 commerce worker 和 `npm run test:worker:commerce`,用于补偿查询微信/支付宝支付、处理中退款和漏通知场景;支付成功会幂等更新订单/支付并开通权益,退款成功会幂等更新退款/订单/支付并在全额退款时撤销订单权益,测试覆盖密钥不泄露和重复执行不重复开通。
- 已新增 platform-billing worker 和 `npm run test:worker:platform-billing`,用于自动处理即将到期且未开票的 SaaS 订阅worker 使用订阅行锁和账单查重保证幂等,自动生成 `tenant_invoices/tenant_invoice_items` 并写 `platform.invoice.subscription_auto_created` 审计。
- 已新增 platform-dunning worker 和 `npm run test:worker:platform-dunning`,用于扫描已过 `due_date` 且未结清的 SaaS 服务费账单worker 使用账单行锁和每日唯一催缴约束保证幂等,自动标记 `overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写 `platform.invoice.overdue_processed` 审计。
- 已新增 assets worker 和 `npm run test:worker:assets`,用于复检 `content_assets` 托管对象元数据;正常资源会写入复检证据,异常资源会自动下架为 `draft`、标记 `upload_status=failed`,并记录审计与安全标记。
- 已新增分数线/视频 JSON 导入接口和集成测试:分数线支持字段、院校、专业、记录、动态字段值;视频支持视频元数据、资源引用、访问模式和题目绑定,全部复用 `content_import_jobs/items/issues` 管线。
- 已新增 CSV/Excel 导入解析层:题目、单词、知识手册、分数线和视频都可通过 `sourceFormat=csv/excel` 先解析为规范 payload再进入同一套 preview/import、逐行 issue、幂等和审计管线分数线支持多 Sheet Excel。
@@ -288,6 +289,7 @@ npm run test:api
npm run test:worker:crm
npm run test:worker:commerce
npm run test:worker:platform-billing
npm run test:worker:platform-dunning
npm run test:worker:assets
npm run test:worker:imports
npm run test:worker:public-banks

View File

@@ -15,7 +15,7 @@
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
| --- | --- | --- | --- |
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、催缴/收款和平台审计报表增强 |
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台审计日志、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、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |

View File

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

View File

@@ -37,7 +37,7 @@
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 |
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志 | 不适用 | 租户管理、租户详情、账务资料维护、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计 | 平台收费和租户运营链路骨架可用,催缴/收款流和更完整平台审计报表待补 |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志和催缴台账 | 不适用 | 租户管理、租户详情、账务资料维护、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计 | 平台收费和租户运营链路骨架可用,平台在线收款、外部催缴通知和更完整平台审计报表待补 |
| 登录认证 | 已建短信验证码、会话、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 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |

View File

@@ -73,9 +73,9 @@
| 功能 | 新后端状态 | 待补齐 |
| --- | --- | --- |
| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护、最近平台审计、订阅账单候选预览、dry-run、批量生成自动计费 worker 已接真实 API/worker后续补催缴/收款流、租户基础资料编辑增强和更完整平台审计报表 |
| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护、最近平台审计、订阅账单候选预览、dry-run、批量生成自动计费 worker、逾期标记和内部催缴台账已接真实 API/worker后续补外部催缴通知、租户基础资料编辑增强和更完整平台审计报表 |
| SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 |
| 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 |
| 年费/服务费账单 | 已覆盖 | 订阅账单候选、批量开票、自动计费、人工收款、逾期标记、租户 `past_due` 状态和内部催缴记录已覆盖;真实平台在线收款、外部短信/微信订阅消息/企业微信催缴和停用策略待补 |
| 租户用量记录 | 已覆盖 | 自动采集 worker 待补 |
| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、同步通知、冲突查询 API、单条/批量冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺生产定时调度、失败告警和更完整运营消息 |
| 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 |

View File

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

View File

@@ -114,6 +114,16 @@
"failed": 0
}
},
{
"id": "worker.platform-dunning",
"status": "pass",
"command": "npm run test:worker:platform-dunning > docs/refactor/launch-artifacts/worker-platform-dunning.log",
"completedAt": "2026-06-30T11:18:00+08:00",
"artifact": "launch-artifacts/worker-platform-dunning.log",
"summary": {
"failed": 0
}
},
{
"id": "worker.imports",
"status": "pass",

View File

@@ -2519,7 +2519,7 @@ src/services/ai.ts AI 择校推荐生成、报告列表、报告详情
src/services/pronunciation.ts H5/小程序单词发音适配
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录
src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证
src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计、套餐账单、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、用量、公共题库授权
src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计、套餐账单、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览/内部催缴记录、用量、公共题库授权
```
验证命令:
@@ -2533,11 +2533,64 @@ npm run build:taro:h5:platform
已通过。构建仍有 Taro H5 入口体积 warning属于当前 Taro 工程既有警告,不阻断联调。
## 平台账务逾期催缴
平台后台账务页只调用命令式 API不直接改 `tenant_invoices.status``tenants.billing_status``tenant_invoice_reminders`。后端会做平台管理员鉴权、行锁、每日催缴去重和审计。
预览逾期账单:
```http
POST /api/platform-admin/invoices/process-overdue
```
```json
{
"dryRun": true,
"limit": 100
}
```
执行内部催缴:
```json
{
"dryRun": false,
"channel": "internal",
"limit": 100
}
```
响应核心字段:
```json
{
"item": {
"dryRun": false,
"processed": 3,
"markedOverdue": 2,
"reminderCreated": 3,
"skippedReminder": 0
}
}
```
查询催缴记录:
```http
GET /api/platform-admin/invoices/reminders?tenantId=<tenantId>&invoiceId=<invoiceId>&limit=100
```
前端展示建议:
- dry-run 只展示“将标记逾期/将生成提醒”,不要写本地状态。
- 执行成功后重新加载 `/api/platform-admin/invoices``/api/platform-admin/invoices/reminders`
- `platform-dunning` worker 可每天在 `platform-billing` 之后运行一次;它不会自动停用租户,停用仍走平台管理员状态变更流程。
下一批前端开发重点:
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。
- 平台后台租户创建、租户详情、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、用量录入、公共题库授权编辑已接第一版后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计,`platform-billing` worker 会自动生成即将到期订阅账单并记录 `platform.invoice.subscription_auto_created` 审计,前端只展示候选、预览结果、跳过结果和生成后的账单/审计;继续补租户基础资料编辑增强、平台审计报表导出/告警和催缴/收款
- 平台后台租户创建、租户详情、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑已接第一版;后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计,`platform-billing` worker 会自动生成即将到期订阅账单并记录 `platform.invoice.subscription_auto_created` 审计,`platform-dunning` worker 会标记已过期未结清服务费账单、生成 `tenant_invoice_reminders` 并记录 `platform.invoice.overdue_processed` 审计;前端只展示候选、预览结果、跳过结果、逾期处理结果和生成后的账单/审计,不要直接更新账单状态或租户 `billing_status`;继续补租户基础资料编辑增强、平台审计报表导出/告警、外部催缴通知和平台在线收款。
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
## AI 择校推荐接入

View File

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

View File

@@ -62,6 +62,7 @@ const ids = {
pointExpensiveExchangeItem: '00000000-0000-0000-0000-000000000880',
questionBank: '00000000-0000-0000-0000-000000000400',
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
platformOverdueInvoice: crypto.randomUUID(),
};
const paymentFixture = (() => {
@@ -1336,12 +1337,96 @@ async function testPlatformTenantOperationsAndAudit() {
});
assert.ok(auditAfterBatch.items?.some(item => item.action === 'platform.invoice.subscription_batch_created'), 'batch invoice creation should write platform audit');
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await pool.query('delete from public.tenant_invoice_reminders where tenant_id = $1 and invoice_id = $2', [tenantId, ids.platformOverdueInvoice]);
await pool.query('delete from public.tenant_invoice_items where tenant_id = $1 and invoice_id = $2', [tenantId, ids.platformOverdueInvoice]);
await pool.query('delete from public.tenant_invoices where tenant_id = $1 and id = $2', [tenantId, ids.platformOverdueInvoice]);
await pool.query(
`
insert into public.tenant_invoices (
id, tenant_id, invoice_no, invoice_type, status, currency,
subtotal_cents, total_cents, paid_cents, balance_cents,
due_date, issued_at, note, metadata
)
values (
$1, $2, $3, 'service_fee', 'issued', 'CNY',
120000, 120000, 0, 120000,
current_date - interval '2 days', now(), 'integration overdue invoice',
'{"source":"api-integration-overdue"}'::jsonb
)
`,
[ids.platformOverdueInvoice, tenantId, `OD${Date.now()}`],
);
} finally {
await pool.end();
}
const overdueDryRun = await request('/api/platform-admin/invoices/process-overdue', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
dryRun: true,
limit: 20,
},
});
assert.equal(overdueDryRun.item?.dryRun, true, 'overdue processing dry-run should be supported');
assert.ok(overdueDryRun.item?.items?.some(item => item.id === ids.platformOverdueInvoice && item.wouldCreateReminder), 'dry-run should preview overdue reminder creation');
const overdueProcessed = await request('/api/platform-admin/invoices/process-overdue', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
channel: 'internal',
limit: 20,
},
});
assert.equal(overdueProcessed.item?.markedOverdue >= 1, true, 'overdue processing should mark issued overdue invoices');
assert.equal(overdueProcessed.item?.reminderCreated >= 1, true, 'overdue processing should create reminder records');
const reminders = await request('/api/platform-admin/invoices/reminders', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId, invoiceId: ids.platformOverdueInvoice, limit: 10 },
});
assert.ok(reminders.items?.some(item => item.invoiceId === ids.platformOverdueInvoice && item.reminderType === 'overdue'), 'platform admin should list invoice reminders');
const overdueAudit = await request('/api/platform-admin/audit-logs', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId, q: 'platform.invoice.overdue', limit: 20 },
});
assert.ok(overdueAudit.items?.some(item => item.action === 'platform.invoice.overdue_processed'), 'overdue processing should write invoice audit');
const invalidOverdueChannel = await request('/api/platform-admin/invoices/process-overdue', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: { channel: 'unsafe-channel' },
expectStatus: 400,
});
assert.equal(invalidOverdueChannel.code, 'INVALID_REMINDER_CHANNEL', 'overdue processing should reject invalid reminder channels');
const studentAuditDenied = await request('/api/platform-admin/audit-logs', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentAuditDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit logs');
const studentReminderDenied = await request('/api/platform-admin/invoices/reminders', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentReminderDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform invoice reminders');
}
function stopServer() {

View File

@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import pg from 'pg';
import { spawn } from 'node:child_process';
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const ids = {
tenant: '00000000-0000-0000-0000-00000000d901',
invoice: '00000000-0000-0000-0000-00000000d902',
};
function runWorkerOnce() {
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-dunning'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_PLATFORM_DUNNING_BATCH_SIZE: '10',
WORKER_PLATFORM_DUNNING_ID: 'platform-dunning-integration-test',
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
child.stdout.on('data', chunk => {
output += chunk.toString();
});
child.stderr.on('data', chunk => {
output += chunk.toString();
});
return new Promise((resolve, reject) => {
child.on('error', reject);
child.on('exit', code => {
try {
assert.equal(code, 0, `worker should exit 0\n${output}`);
assert.match(output, /platform-dunning batch processed=\d+/, 'worker output should include platform dunning summary');
resolve(output);
} catch (error) {
reject(error);
}
});
});
}
async function cleanup(pool) {
await pool.query('delete from public.audit_logs where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoice_reminders where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoice_items where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoice_payments where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_invoices where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_billing_profiles where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenant_domains where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenants where id = $1', [ids.tenant]);
}
async function createTenantAndInvoice(pool) {
await pool.query(
`
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
values ($1, 'platform-dunning-worker', '平台催缴测试租户', '平台催缴测试有限公司', 'active', 'saas', 'active', '{"source":"platform-dunning-worker-test"}'::jsonb)
`,
[ids.tenant],
);
await pool.query(
`
insert into public.tenant_invoices (
id, tenant_id, invoice_no, invoice_type, status, currency,
subtotal_cents, total_cents, paid_cents, balance_cents,
due_date, issued_at, note, metadata
)
values (
$1, $2, 'ODWORKER202606300001', 'service_fee', 'issued', 'CNY',
660000, 660000, 0, 660000,
current_date - interval '5 days', now(), 'platform dunning worker invoice',
'{"source":"platform-dunning-worker-test"}'::jsonb
)
`,
[ids.invoice, ids.tenant],
);
}
async function main() {
const pool = new pg.Pool({ connectionString: databaseUrl });
try {
await cleanup(pool);
await createTenantAndInvoice(pool);
const firstOutput = await runWorkerOnce();
assert.match(firstOutput, /processed=\d+/, 'worker should process overdue invoice candidates');
assert.match(firstOutput, /markedOverdue=\d+/, 'worker should mark overdue invoice candidates');
assert.match(firstOutput, /reminderCreated=\d+/, 'worker should create reminder candidates');
const invoice = await pool.query(
`
select status, metadata
from public.tenant_invoices
where tenant_id = $1 and id = $2
`,
[ids.tenant, ids.invoice],
);
assert.equal(invoice.rows[0]?.status, 'overdue', 'worker should update invoice status to overdue');
assert.equal(invoice.rows[0]?.metadata?.overdueMarkedBy, 'platform-dunning-integration-test', 'worker should record overdue marker id');
const tenant = await pool.query('select billing_status from public.tenants where id = $1', [ids.tenant]);
assert.equal(tenant.rows[0]?.billing_status, 'past_due', 'worker should mark tenant billing status past_due');
const reminders = await pool.query(
`
select reminder_type, channel, status, reminder_level, balance_cents_snapshot, metadata
from public.tenant_invoice_reminders
where tenant_id = $1 and invoice_id = $2
`,
[ids.tenant, ids.invoice],
);
assert.equal(reminders.rowCount, 1, 'worker should create exactly one reminder for the day');
assert.equal(reminders.rows[0].reminder_type, 'overdue', 'worker reminder type should be overdue');
assert.equal(reminders.rows[0].channel, 'internal', 'worker reminder channel should be internal');
assert.equal(Number(reminders.rows[0].balance_cents_snapshot), 660000, 'worker reminder should snapshot balance');
assert.equal(reminders.rows[0].metadata?.workerId, 'platform-dunning-integration-test', 'worker reminder should record worker id');
const audit = await pool.query(
`
select action, target_type, target_id, details
from public.audit_logs
where tenant_id = $1 and action = 'platform.invoice.overdue_processed'
order by created_at desc
limit 1
`,
[ids.tenant],
);
assert.equal(audit.rows[0]?.target_type, 'tenant_invoice', 'worker should audit overdue invoice processing');
assert.equal(audit.rows[0]?.target_id, ids.invoice, 'worker audit target should be the invoice id');
assert.equal(audit.rows[0]?.details?.reminderCreated, true, 'worker audit should record reminder creation');
const secondOutput = await runWorkerOnce();
assert.match(secondOutput, /markedOverdue=0/, 'second worker run should not re-mark overdue status for processed candidates');
const reminderCount = await pool.query(
`
select count(*)::integer as count
from public.tenant_invoice_reminders
where tenant_id = $1 and invoice_id = $2
`,
[ids.tenant, ids.invoice],
);
assert.equal(Number(reminderCount.rows[0]?.count), 1, 'worker should be idempotent for daily reminders');
console.log('Platform dunning worker integration test complete.');
} finally {
await cleanup(pool).catch(() => {});
await pool.end();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,41 @@
create table if not exists public.tenant_invoice_reminders (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
invoice_id uuid not null references public.tenant_invoices(id) on delete cascade,
reminder_type text not null default 'overdue' check (reminder_type in ('due_soon', 'overdue', 'final_notice', 'manual')),
channel text not null default 'manual' check (channel in ('manual', 'internal', 'sms', 'email', 'wechat', 'crm')),
status text not null default 'pending' check (status in ('pending', 'sent', 'acknowledged', 'dismissed', 'failed')),
reminder_date date not null default current_date,
reminder_level integer not null default 1,
due_date date,
balance_cents_snapshot integer not null default 0,
message text,
metadata jsonb not null default '{}'::jsonb,
created_by uuid references public.platform_users(id) on delete set null,
sent_at timestamptz,
acknowledged_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index if not exists uniq_tenant_invoice_reminders_daily
on public.tenant_invoice_reminders(tenant_id, invoice_id, reminder_type, channel, reminder_date);
create index if not exists idx_tenant_invoice_reminders_tenant_status
on public.tenant_invoice_reminders(tenant_id, status, reminder_date desc);
create index if not exists idx_tenant_invoice_reminders_invoice
on public.tenant_invoice_reminders(invoice_id, reminder_date desc);
alter table public.tenant_invoice_reminders enable row level security;
drop policy if exists tenant_isolation on public.tenant_invoice_reminders;
create policy tenant_isolation on public.tenant_invoice_reminders
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop trigger if exists set_updated_at on public.tenant_invoice_reminders;
create trigger set_updated_at
before update on public.tenant_invoice_reminders
for each row execute function app.touch_updated_at();