feat: add commerce reconciliation ledger

This commit is contained in:
Codex
2026-06-29 18:46:38 +08:00
parent 79d0d786a0
commit 7258e4a7d5
14 changed files with 1555 additions and 15 deletions

View File

@@ -22,13 +22,14 @@
- `apps/worker` 后台任务进程CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志commerce worker 可补偿查询微信/支付宝支付和退款状态assets worker 可复检托管资源元数据并自动下架异常资源imports worker 可执行大批量导入public-banks worker 可自动同步公共题库采纳副本exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
- 资金对账基础闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志。
- PocketBase schema/数据导入器雏形和导入后校验脚本。
- 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。
还没有达到生产交付的部分:
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;完整资金流水对账和真实生产账号联调还没接完。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入比对,微信/支付宝官方账单自动下载、差错处理工单和真实生产账号联调还没接完。
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界和资源复检 worker 已完成杀毒扫描、视频动态水印、CDN 刷新和对象生命周期策略还没完成。
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
@@ -277,7 +278,7 @@ dry-run 会检查导出目录、JSON 形态、核心集合缺失、重复/缺失
apps/api/src/features/
auth/ 短信登录、迁移期 session、微信小程序登录、微信网页登录、QQ 登录
catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口
commerce/ 订单、支付确认、激活码、优惠券、权益
commerce/ 订单、支付确认、退款、激活码、优惠券、权益、资金对账
health/ 健康检查
learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜
platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量
@@ -308,7 +309,7 @@ API 身份上下文:
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时顶层题目和复合题子题都必须脱敏PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款。
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款,并定期通过资金对账接口导入供应商账单核对本地订单
## 最近一次验证
@@ -337,4 +338,4 @@ git diff --check
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI平台后台租户详情/审计/自动计费增强,小程序兼容验证。
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
5. 真实 OAuth/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
5. 真实 OAuth/短信/支付生产账号联调、微信/支付宝官方账单自动下载与差错处理台、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。

View File

@@ -17,6 +17,13 @@ import {
refundNotifyRoute,
updateRefundStatusRoute,
} from './routes.js';
import {
importReconciliationRoute,
previewReconciliationRoute,
reconciliationAnomaliesRoute,
reconciliationBatchesRoute,
reconciliationItemsRoute,
} from './reconciliation.js';
export const commerceRoutes: RouteDefinition[] = [
['POST', '/api/commerce/orders', createOrderRoute],
@@ -36,6 +43,11 @@ export const commerceRoutes: RouteDefinition[] = [
['POST', '/api/commerce/refunds/notify/wechat_pay', refundNotifyRoute],
['POST', '/api/commerce/refunds/notify/wechat-pay', refundNotifyRoute],
['POST', '/api/commerce/refunds/notify/alipay', refundNotifyRoute],
['POST', '/api/commerce/reconciliation/preview', previewReconciliationRoute],
['POST', '/api/commerce/reconciliation/import', importReconciliationRoute],
['GET', '/api/commerce/reconciliation/batches', reconciliationBatchesRoute],
['GET', '/api/commerce/reconciliation/items', reconciliationItemsRoute],
['GET', '/api/commerce/reconciliation/anomalies', reconciliationAnomaliesRoute],
['POST', '/api/commerce/activation-codes/check', checkActivationCodeRoute],
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
['POST', '/api/commerce/coupons/claim', claimCouponRoute],

File diff suppressed because it is too large Load Diff

View File

@@ -90,6 +90,8 @@ export function tenantPermissionCatalog() {
{ key: 'tenant:domains:write', label: '域名管理' },
{ key: 'tenant:payment:read', label: '商户配置查看' },
{ key: 'tenant:payment:write', label: '商户配置管理' },
{ key: 'tenant:reconciliation:read', label: '资金对账查看' },
{ key: 'tenant:reconciliation:write', label: '资金对账导入' },
{ key: 'tenant:refund:read', label: '退款查看' },
{ key: 'tenant:refund:write', label: '退款申请/处理' },
{ key: 'tenant:refund:review', label: '退款审核' },

View File

@@ -272,6 +272,23 @@ body: { "code": "<qq oauth code>", "redirectUri": "https://h5.example.com/auth/q
- 支付成功事务内更新 `orders``payments``payment_events``entitlements`
- 支付密钥只允许放在 `app_private.tenant_secrets` 或生产 KMS/Vault。
### 资金对账
当前后端已提供租户级资金对账基础能力:
- `POST /api/commerce/reconciliation/preview`:预览供应商账单行和本地订单/支付/退款的匹配结果。
- `POST /api/commerce/reconciliation/import`:确认导入对账批次和明细,写入审计。
- `GET /api/commerce/reconciliation/batches`:查询对账批次。
- `GET /api/commerce/reconciliation/items`:查询逐行结果。
- `GET /api/commerce/reconciliation/anomalies`:查询金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行等异常。
权限点:
- `tenant:reconciliation:read`:查看/预览对账。
- `tenant:reconciliation:write`:导入对账批次。
对账只生成差异台账,不自动修改订单、支付、退款和权益。真实生产中,微信/支付宝官方账单下载 adapter 应复用同一套 `commerce_reconciliation_batches/items`,下载后的 CSV/JSON 先规范化为 `rows`,再调用同一套匹配逻辑。差异处理、人工调整凭证、财务复核和异常订单运营台仍需要继续补。
B 端合作商年费、服务费、服务器资源费不走学生端 `orders`,而是走平台账务:
- `platform_saas_plans`:平台售卖给合作商的 SaaS 套餐。

View File

@@ -117,7 +117,8 @@
| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
| 退款状态机和供应商确认 | 可联调 | `/api/commerce/refunds``/api/commerce/refunds/status``/api/commerce/refunds/notify/{provider}`;支持退款申请、审核、调用微信/支付宝发起退款、`query_provider_refund` 查询确认、微信/支付宝退款通知、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 |
| 支付/退款补偿 worker | 可联调 | `apps/worker --job commerce` 查询微信/支付宝订单和处理中退款,补偿漏通知支付、补发权益、确认退款、全额退款撤销权益;`npm run test:worker:commerce` 覆盖幂等和密钥不泄露 |
| 完整资金流水对账 | 待补齐 | 后续补微信/支付宝账单下载、平台账单比对、差错处理、异常订单运营台 |
| 资金流水对账 | 可联调 | `commerce_reconciliation_batches/items` + `/api/commerce/reconciliation/preview/import/batches/items/anomalies`;租户后台需 `tenant:reconciliation:read/write`,支持支付/退款账单行手工或 API 导入、来源 hash、批次统计、逐行匹配、金额/状态差异、本地缺失、供应商缺失、重复行、无效行和审计;本阶段只生成差异,不自动改订单/权益 |
| 官方账单下载和差错处理台 | 待补齐 | 后续补微信/支付宝账单下载、差错单处理流、人工调整凭证、财务复核和异常订单运营台 |
## 租户后台与平台后台

View File

@@ -29,7 +29,7 @@
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 |
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架、题库导出 PDF/Word/每日一练 ZIP 可生成 `content_assets` 并走签名下载/预览 | CDN 防盗链、杀毒扫描、视频水印、资料前端操作体验 |
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 |
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API | 微信/支付宝官方账单自动下载、差错处理工单、财务复核和异常订单运营台 |
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
| 销售/代理/CRM | 可联调 | 邀请码、首绑保护、团队关系、销售统计、CRM 配置/队列、钉钉/飞书/企微 worker、分佣规则、成员分佣比例、订单/激活码归因、结算生成、审核和打款状态Taro 营销中心已接第一版操作台 | 小程序码真实生成、CRM 分配策略、结算导出、真实打款、凭证、财务复核和销售转化看板 |
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
@@ -83,7 +83,7 @@
### P1商用收费和运营能力
- 完整资金流水对账、账单下载比对和异常订单运营台。
- 资金对账已支持手工/API 账单导入比对和异常查询;继续补微信/支付宝官方账单下载、差错处理工单、财务复核和异常订单运营台。
- XPay 或其它实际支付网关 adapter。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;继续补生产定时调度、失败告警,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。

View File

@@ -242,7 +242,7 @@ GET /api/tenant-admin/audit-logs
- 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。
- `/api/commerce/payments/manual-confirm` 是线下收款/迁移期能力,只允许租户后台具备 `tenant:payment:write` 的成员调用,普通学生不能伪造手工支付成功。
- `/api/commerce/refunds``/api/commerce/refunds/status``/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook全额退款成功会撤销订单来源权益`apps/worker --job commerce` 会定时补偿查询支付和退款状态,完整资金流水对账后续接入
- `/api/commerce/refunds``/api/commerce/refunds/status``/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook全额退款成功会撤销订单来源权益`apps/worker --job commerce` 会定时补偿查询支付和退款状态`/api/commerce/reconciliation/*` 已支持租户资金对账导入、预览、批次/明细/异常查询和审计
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/VaultAPI 只返回 `secretRef` 和掩码状态。
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容、活动和勋章,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
@@ -260,7 +260,7 @@ GET /api/tenant-admin/audit-logs
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、杀毒扫描和视频水印。
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
3. 完成真实 OAuth provider 联调微信网页、微信小程序、QQ确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。
4.完整资金流水对账、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认退款通知主链路已完成。
4.微信/支付宝官方账单自动下载、差错处理工单、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认退款通知和资金对账导入比对主链路已完成。
5. 扩展 `apps/worker`日报统计、CRM 死信告警、公共题库同步失败告警和更完整冲突处理运营台;公共题库同步 worker 和同步通知已具备基础闭环。
6. 开始 Taro scaffold`supabaseApi` 抽到跨端包或适配层。

View File

@@ -30,7 +30,7 @@
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知补偿 worker | 完整资金流水对账、异常订单运营台、服务商/平台代收模式 |
| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知补偿 worker、资金对账导入比对和异常查询 | 官方账单自动下载、差错处理工单、异常订单运营台、服务商/平台代收模式 |
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI provider、JSON 报告 schema、PDF 报告生成 |
| Taro 跨端 | 未开始 | 旧 Web 新 API 适配开始 | `apps/taro`、共享 API client、H5/小程序统一构建 |

View File

@@ -30,7 +30,7 @@
| 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
| 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker全额退款权益撤销已有;缺完整资金流水对账、异常订单运营台和前端收银台/售后体验 |
| 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker全额退款权益撤销、资金对账手工/API 导入比对和异常查询已有;缺微信/支付宝官方账单自动下载、差错处理工单、异常订单运营台和前端收银台/售后体验 |
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势和勋章展示 API 已有;缺学习报告可视化 |
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |

View File

@@ -27,6 +27,7 @@
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
- 租户后台数据看板已完成首版聚合 API`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
- 资金对账基础闭环已完成:`commerce_reconciliation_batches/items``/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志。
- 内容资源复检 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,正常资源写回复检证据,异常资源自动置为 `failed + draft` 并写入审计和安全标记。
- 题库导出 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。
- 本地验证:`npm run check:refactor` 已通过。
@@ -70,7 +71,7 @@
- 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。
- 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、部分/全额退款状态、全额退款权益撤销和审计事件。
- 已完成支付/退款补偿 worker可兜底供应商漏通知、处理中退款和重复执行幂等。
- 继续补完整资金流水对账、账单下载比对和异常订单运营台。
- 已完成资金对账手工/API 导入比对、批次/明细/异常查询和审计;继续补微信/支付宝官方账单自动下载、差错处理工单、人工调整凭证、财务复核和异常订单运营台。
- 租户自有商户收款和平台代收/服务商模式。
2. 国内登录和短信
@@ -109,7 +110,7 @@
8. 订单和营销体验
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
- 已完成支付/退款补偿 worker继续补完整资金流水对账、异常订单运营台、优惠券核销报表和复杂活动规则。
- 已完成支付/退款补偿 worker 和资金对账导入比对;继续补异常订单运营台、优惠券核销报表和复杂活动规则。
9. 积分和反馈增强
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
@@ -224,5 +225,5 @@
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
6. 并行补真实登录、完整资金流水对账、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
6. 并行补真实登录、微信/支付宝官方账单自动下载与差错处理台、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -1752,7 +1752,77 @@ approved/processing -> failed
- 退款通知地址由支付账户或 `submit_provider_refund.providerNotifyUrl` 配置,后端公开接收路径为 `POST /api/commerce/refunds/notify/wechat_pay?tenantId=<tenantId>``POST /api/commerce/refunds/notify/alipay?tenantId=<tenantId>`。这是支付平台回调地址Taro 前端不要主动调用。
- 退款通知只会推进已经审核/处理中的退款申请;未审核的 `requested` 退款不能被外部通知直接落账。
- 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。
- 自动补偿 worker 已接入:支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。完整资金流水对账、账单下载比对和异常订单运营台后续继续补生产联调时仍需保留人工确认/失败登记入口。
- 自动补偿 worker 已接入:支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。资金对账已支持租户后台手工/API 导入供应商账单并查询差异;官方账单自动下载、差错处理工单和异常订单运营台后续继续补生产联调时仍需保留人工确认/失败登记入口。
### 租户后台资金对账
资金对账是租户后台/财务运营能力,学生端不要接。对账接口只生成差异台账和审计,不会自动修改订单、支付、退款或权益。前端不能根据对账结果自行开通、退款或撤销权益。
预览账单:
```text
POST /api/commerce/reconciliation/preview
权限tenant:reconciliation:read
body: {
"provider": "wechat_pay | alipay | manual",
"billDate": "2026-06-29",
"billType": "payment | refund | combined",
"sourceName": "wechat-bill-20260629.csv",
"rows": [
{
"transactionType": "payment",
"orderNo": "<本地 orderNo 或 out_trade_no>",
"providerTradeNo": "<微信/支付宝交易号>",
"amountCents": 990,
"providerStatus": "SUCCESS"
},
{
"transactionType": "refund",
"orderNo": "<orderNo>",
"refundNo": "<本地 refundNo 或 out_refund_no>",
"providerRefundNo": "<支付平台退款单号>",
"refundAmountCents": 100,
"providerStatus": "REFUND_SUCCESS"
}
],
"previewLimit": 200
}
```
确认导入:
```text
POST /api/commerce/reconciliation/import
权限tenant:reconciliation:write
```
查询批次、明细和异常:
```text
GET /api/commerce/reconciliation/batches?provider=wechat_pay&billDate=2026-06-29
GET /api/commerce/reconciliation/items?batchId=<batchId>&matchStatus=missing_provider
GET /api/commerce/reconciliation/anomalies?provider=wechat_pay
```
`matchStatus` 取值:
```text
matched 本地和供应商账单匹配
amount_mismatch 金额不一致
status_mismatch 状态不一致
missing_local 供应商账单有,本地没有
missing_provider 本地已支付/退款成功,供应商账单没有
duplicate 供应商账单重复行
ignored 无效行或不符合本次 billType
```
前端处理规则:
- 财务导入页建议使用 preview -> 人工确认 -> import -> anomalies 的流程。
- `sourceHash` 可作为同一文件内容的识别线索,但当前接口不会阻止重复导入;前端应展示最近同名/同 hash 批次提醒。
- 金额统一是分,前端不要传元。
- 对账差异只是运营判断依据,最终订单修正必须走退款、补偿、人工确认或后续差错处理接口。
- 当前后端支持 JSON 行导入CSV/Excel 可以先由前端或后续后端 parser 转成上述 `rows`。微信/支付宝官方账单自动下载仍是后续后端任务。
### 激活码预检查与兑换

View File

@@ -2044,6 +2044,175 @@ async function testCommerce() {
});
assert.equal(crossTenantOrderDetail.code, 'ORDER_NOT_FOUND', 'order detail must be tenant isolated');
const studentReconciliationDenied = await request('/api/commerce/reconciliation/batches', {
expectStatus: 403,
});
assert.equal(studentReconciliationDenied.code, 'TENANT_ADMIN_REQUIRED', 'students must not access finance reconciliation');
const reconMatchedOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.plan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
},
});
await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: reconMatchedOrder.item.orderNo,
amountCents: reconMatchedOrder.item.amountCents,
providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`,
},
});
const reconMissingProviderOrder = await request('/api/commerce/orders', {
method: 'POST',
body: {
planId: ids.plan,
payProvider: 'manual',
payMethod: 'manual',
regionId: ids.region,
},
});
await request('/api/commerce/payments/manual-confirm', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
orderNo: reconMissingProviderOrder.item.orderNo,
amountCents: reconMissingProviderOrder.item.amountCents,
providerTradeNo: `manual-recon-${reconMissingProviderOrder.item.orderNo}`,
},
});
const billDate = shanghaiDateKey();
const reconciliationRows = [
{
transactionType: 'payment',
orderNo: reconMatchedOrder.item.orderNo,
providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`,
amountCents: reconMatchedOrder.item.amountCents,
providerStatus: 'SUCCESS',
},
{
transactionType: 'payment',
orderNo: reconMatchedOrder.item.orderNo,
providerTradeNo: `manual-recon-${reconMatchedOrder.item.orderNo}`,
amountCents: reconMatchedOrder.item.amountCents,
providerStatus: 'SUCCESS',
},
{
transactionType: 'payment',
orderNo: reconMatchedOrder.item.orderNo,
providerTradeNo: `manual-recon-mismatch-${reconMatchedOrder.item.orderNo}`,
amountCents: reconMatchedOrder.item.amountCents + 1,
providerStatus: 'SUCCESS',
},
{
transactionType: 'payment',
orderNo: 'PROVIDER-ONLY-ORDER',
providerTradeNo: 'provider-only-trade',
amountCents: 1888,
providerStatus: 'SUCCESS',
},
];
const reconciliationPreview = await request('/api/commerce/reconciliation/preview', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
provider: 'manual',
billDate,
billType: 'payment',
sourceName: 'integration-manual-bill.json',
rows: reconciliationRows,
previewLimit: 20,
},
});
assert.equal(reconciliationPreview.item?.status, 'preview', 'reconciliation preview should not persist a completed batch');
assert.ok(
reconciliationPreview.items?.some(item => item.orderNo === reconMatchedOrder.item.orderNo && item.matchStatus === 'matched'),
'reconciliation preview should match a provider payment row to local payment',
);
assert.ok(
reconciliationPreview.items?.some(item => item.matchStatus === 'duplicate'),
'reconciliation preview should detect duplicate provider rows',
);
assert.ok(
reconciliationPreview.items?.some(item => item.matchStatus === 'amount_mismatch'),
'reconciliation preview should detect amount mismatches',
);
assert.ok(
reconciliationPreview.items?.some(item => item.matchStatus === 'missing_local'),
'reconciliation preview should detect provider rows missing local orders',
);
assert.ok(
reconciliationPreview.items?.some(item => item.orderNo === reconMissingProviderOrder.item.orderNo && item.matchStatus === 'missing_provider'),
'reconciliation preview should detect local paid orders missing from provider bill',
);
const reconciliationImport = await request('/api/commerce/reconciliation/import', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
provider: 'manual',
billDate,
billType: 'payment',
sourceName: 'integration-manual-bill.json',
rows: reconciliationRows,
metadata: { test: 'commerce-reconciliation' },
previewLimit: 20,
},
});
assert.ok(reconciliationImport.item?.id, 'tenant admin should import reconciliation batch');
assert.equal(
reconciliationImport.item?.status,
'completed_with_issues',
'reconciliation import should mark batches with anomalies as completed_with_issues',
);
assert.ok(reconciliationImport.item?.matchedCount >= 1, 'reconciliation batch should count matched rows');
assert.ok(reconciliationImport.item?.mismatchCount >= 1, 'reconciliation batch should count mismatch rows');
assert.ok(reconciliationImport.item?.missingLocalCount >= 1, 'reconciliation batch should count missing local rows');
assert.ok(reconciliationImport.item?.missingProviderCount >= 1, 'reconciliation batch should count missing provider rows');
assert.ok(reconciliationImport.item?.duplicateCount >= 1, 'reconciliation batch should count duplicate rows');
const reconciliationBatches = await request('/api/commerce/reconciliation/batches', {
userId: TENANT_ADMIN_USER_ID,
query: { provider: 'manual', billDate },
});
assert.ok(
reconciliationBatches.items?.some(item => item.id === reconciliationImport.item.id),
'reconciliation batches endpoint should list imported batch',
);
const reconciliationItems = await request('/api/commerce/reconciliation/items', {
userId: TENANT_ADMIN_USER_ID,
query: { batchId: reconciliationImport.item.id, matchStatus: 'missing_provider' },
});
assert.ok(
reconciliationItems.items?.some(item => item.orderNo === reconMissingProviderOrder.item.orderNo),
'reconciliation items endpoint should filter missing provider rows',
);
const reconciliationAnomalies = await request('/api/commerce/reconciliation/anomalies', {
userId: TENANT_ADMIN_USER_ID,
query: { provider: 'manual' },
});
assert.ok(
reconciliationAnomalies.items?.some(item => item.batchId === reconciliationImport.item.id && item.matchStatus === 'missing_local'),
'reconciliation anomalies endpoint should expose imported issues',
);
const crossTenantReconciliationDenied = await request('/api/commerce/reconciliation/items', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
query: { batchId: reconciliationImport.item.id },
expectStatus: 403,
});
assert.equal(crossTenantReconciliationDenied.code, 'TENANT_ADMIN_REQUIRED', 'reconciliation items must be tenant isolated');
const fakeWechatPay = await startFakeWechatPayServer();
const wechatAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID,
@@ -5583,6 +5752,8 @@ async function testTenantMemberPermissionsAndAudit() {
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'dashboard:read'), 'permission matrix should expose dashboard read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:read'), 'permission matrix should expose reconciliation read permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'tenant:reconciliation:write'), 'permission matrix should expose reconciliation write permission');
assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups');
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('dashboard:read'), 'tenant operator defaults should include dashboard read');
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults');

View File

@@ -0,0 +1,100 @@
create table if not exists public.commerce_reconciliation_batches (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
provider text not null,
bill_date date not null,
bill_type text not null default 'combined'
check (bill_type in ('payment', 'refund', 'combined')),
source text not null default 'manual_upload'
check (source in ('manual_upload', 'provider_download', 'api', 'worker')),
source_name text,
source_hash text not null,
status text not null default 'completed'
check (status in ('preview', 'pending', 'processing', 'completed', 'completed_with_issues', 'failed')),
total_count integer not null default 0 check (total_count >= 0),
matched_count integer not null default 0 check (matched_count >= 0),
mismatch_count integer not null default 0 check (mismatch_count >= 0),
missing_local_count integer not null default 0 check (missing_local_count >= 0),
missing_provider_count integer not null default 0 check (missing_provider_count >= 0),
duplicate_count integer not null default 0 check (duplicate_count >= 0),
ignored_count integer not null default 0 check (ignored_count >= 0),
amount_cents integer not null default 0 check (amount_cents >= 0),
refund_amount_cents integer not null default 0 check (refund_amount_cents >= 0),
fee_cents integer not null default 0,
created_by uuid references public.platform_users(id) on delete set null,
completed_at timestamptz,
error text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.commerce_reconciliation_items (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
batch_id uuid not null references public.commerce_reconciliation_batches(id) on delete cascade,
row_no integer not null check (row_no > 0),
provider text not null,
transaction_type text not null
check (transaction_type in ('payment', 'refund')),
provider_trade_no text,
provider_refund_no text,
order_no text,
refund_no text,
amount_cents integer not null default 0 check (amount_cents >= 0),
refund_amount_cents integer not null default 0 check (refund_amount_cents >= 0),
fee_cents integer not null default 0,
paid_at timestamptz,
refunded_at timestamptz,
provider_status text,
local_status text,
order_id uuid references public.orders(id) on delete set null,
payment_id uuid references public.payments(id) on delete set null,
refund_request_id uuid references public.commerce_refund_requests(id) on delete set null,
match_status text not null
check (match_status in ('matched', 'amount_mismatch', 'status_mismatch', 'missing_local', 'missing_provider', 'duplicate', 'ignored')),
severity text not null default 'info'
check (severity in ('info', 'warning', 'error', 'critical')),
issue_code text,
details jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
unique (batch_id, row_no)
);
create index if not exists idx_commerce_recon_batches_tenant_date
on public.commerce_reconciliation_batches(tenant_id, provider, bill_date desc, created_at desc);
create index if not exists idx_commerce_recon_batches_status
on public.commerce_reconciliation_batches(tenant_id, status, created_at desc);
create index if not exists idx_commerce_recon_items_batch_status
on public.commerce_reconciliation_items(tenant_id, batch_id, match_status, row_no);
create index if not exists idx_commerce_recon_items_order
on public.commerce_reconciliation_items(tenant_id, order_no, created_at desc);
create index if not exists idx_commerce_recon_items_refund
on public.commerce_reconciliation_items(tenant_id, refund_no, provider_refund_no, created_at desc);
create index if not exists idx_commerce_recon_items_trade
on public.commerce_reconciliation_items(tenant_id, provider, provider_trade_no, created_at desc);
alter table public.commerce_reconciliation_batches enable row level security;
alter table public.commerce_reconciliation_items enable row level security;
drop policy if exists tenant_isolation on public.commerce_reconciliation_batches;
create policy tenant_isolation on public.commerce_reconciliation_batches
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 policy if exists tenant_isolation on public.commerce_reconciliation_items;
create policy tenant_isolation on public.commerce_reconciliation_items
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.commerce_reconciliation_batches;
create trigger set_updated_at
before update on public.commerce_reconciliation_batches
for each row execute function app.touch_updated_at();