forked from wangziqi/gongxue-base
feat: reconcile commerce payments
This commit is contained in:
17
README.md
17
README.md
@@ -17,7 +17,7 @@
|
|||||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
||||||
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。
|
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
||||||
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志。
|
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态,兜底漏通知订单。
|
||||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
||||||
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
||||||
- PocketBase schema/数据导入器雏形和导入后校验脚本。
|
- PocketBase schema/数据导入器雏形和导入后校验脚本。
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
还没有达到生产交付的部分:
|
还没有达到生产交付的部分:
|
||||||
|
|
||||||
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
||||||
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路和微信/支付宝发起退款/查询确认/退款通知已完成本地适配;微信网页登录、QQ 登录、手机号换绑、对账、支付补偿和真实生产账号联调还没接完。
|
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;微信网页登录、QQ 登录、手机号换绑、完整资金流水对账和真实生产账号联调还没接完。
|
||||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。
|
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。
|
||||||
- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
|
- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
|
||||||
- 分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。
|
- 分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
apps/api/ Node.js 业务 API
|
apps/api/ Node.js 业务 API
|
||||||
apps/worker/ 后台异步任务:CRM webhook、后续支付补偿/导入复检等
|
apps/worker/ 后台异步任务:CRM webhook、支付/退款补偿、后续导入复检等
|
||||||
packages/config/ 共享配置
|
packages/config/ 共享配置
|
||||||
packages/db/ PostgreSQL 连接池和查询封装
|
packages/db/ PostgreSQL 连接池和查询封装
|
||||||
packages/domain/ 领域常量和共享类型
|
packages/domain/ 领域常量和共享类型
|
||||||
@@ -95,6 +95,12 @@ npm run dev:api
|
|||||||
npm --workspace @tiku-saas/worker run crm:once
|
npm --workspace @tiku-saas/worker run crm:once
|
||||||
```
|
```
|
||||||
|
|
||||||
|
单次运行支付/退款补偿 worker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm --workspace @tiku-saas/worker run commerce:once
|
||||||
|
```
|
||||||
|
|
||||||
默认本地数据库:
|
默认本地数据库:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -133,6 +139,7 @@ npm run check:importer
|
|||||||
npm run pb:import:validate
|
npm run pb:import:validate
|
||||||
npm run test:api
|
npm run test:api
|
||||||
npm run test:worker:crm
|
npm run test:worker:crm
|
||||||
|
npm run test:worker:commerce
|
||||||
```
|
```
|
||||||
|
|
||||||
## API 模块
|
## API 模块
|
||||||
@@ -173,7 +180,7 @@ API 身份上下文:
|
|||||||
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL。
|
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL。
|
||||||
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
||||||
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册导入已走这套后台校验管线,前端只做预检查和预览展示。
|
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册导入已走这套后台校验管线,前端只做预检查和预览展示。
|
||||||
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用。
|
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款。
|
||||||
|
|
||||||
## 最近一次验证
|
## 最近一次验证
|
||||||
|
|
||||||
@@ -193,4 +200,4 @@ npm run check:refactor
|
|||||||
2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。
|
2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。
|
||||||
3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
||||||
4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。
|
4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。
|
||||||
5. 微信网页/QQ 登录、支付对账、支付补偿、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。
|
5. 微信网页/QQ 登录、完整资金流水对账、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"start": "node dist/apps/worker/src/index.js --loop",
|
"start": "node dist/apps/worker/src/index.js --loop",
|
||||||
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
||||||
"check": "tsc -p tsconfig.json --noEmit",
|
"check": "tsc -p tsconfig.json --noEmit",
|
||||||
"crm:once": "tsx src/index.ts --once --job crm"
|
"crm:once": "tsx src/index.ts --once --job crm",
|
||||||
|
"commerce:once": "tsx src/index.ts --once --job commerce"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg": "^8.16.3"
|
"pg": "^8.16.3"
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export interface WorkerConfig {
|
|||||||
crmBackoffSeconds: number[];
|
crmBackoffSeconds: number[];
|
||||||
crmRequestTimeoutMs: number;
|
crmRequestTimeoutMs: number;
|
||||||
crmAllowInsecureLocalhost: boolean;
|
crmAllowInsecureLocalhost: boolean;
|
||||||
|
commerceBatchSize: number;
|
||||||
|
commerceMinAgeSeconds: number;
|
||||||
|
commerceRequestTimeoutMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config: WorkerConfig = {
|
export const config: WorkerConfig = {
|
||||||
@@ -22,4 +25,7 @@ export const config: WorkerConfig = {
|
|||||||
.filter((value: number) => Number.isFinite(value) && value > 0),
|
.filter((value: number) => Number.isFinite(value) && value > 0),
|
||||||
crmRequestTimeoutMs: envNumber('WORKER_CRM_REQUEST_TIMEOUT_MS', 10_000),
|
crmRequestTimeoutMs: envNumber('WORKER_CRM_REQUEST_TIMEOUT_MS', 10_000),
|
||||||
crmAllowInsecureLocalhost: envBoolean('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false),
|
crmAllowInsecureLocalhost: envBoolean('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false),
|
||||||
|
commerceBatchSize: envNumber('WORKER_COMMERCE_BATCH_SIZE', 20),
|
||||||
|
commerceMinAgeSeconds: envNumber('WORKER_COMMERCE_MIN_AGE_SECONDS', 300),
|
||||||
|
commerceRequestTimeoutMs: envNumber('WORKER_COMMERCE_REQUEST_TIMEOUT_MS', 10_000),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { closePool } from './db.js';
|
import { closePool } from './db.js';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { processCrmBatch } from './jobs/crm.js';
|
import { processCrmBatch } from './jobs/crm.js';
|
||||||
|
import { processCommerceBatch } from './jobs/commerce.js';
|
||||||
|
|
||||||
function hasArg(name: string) {
|
function hasArg(name: string) {
|
||||||
return process.argv.includes(name);
|
return process.argv.includes(name);
|
||||||
@@ -13,11 +14,21 @@ function argValue(name: string, fallback = '') {
|
|||||||
|
|
||||||
async function runOnce() {
|
async function runOnce() {
|
||||||
const job = argValue('--job', 'crm');
|
const job = argValue('--job', 'crm');
|
||||||
if (job !== 'crm') {
|
if (job === 'crm') {
|
||||||
throw new Error(`Unsupported worker job: ${job}`);
|
const result = await processCrmBatch();
|
||||||
|
console.log(`[worker] crm batch processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const result = await processCrmBatch();
|
if (job === 'commerce') {
|
||||||
console.log(`[worker] crm batch processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`);
|
const result = await processCommerceBatch();
|
||||||
|
console.log(
|
||||||
|
`[worker] commerce batch processed=${result.processed}`
|
||||||
|
+ ` payments=${result.payments.processed} paid=${result.payments.paid} pending=${result.payments.pending} closed=${result.payments.closed} failed=${result.payments.failed} paymentErrors=${result.payments.errors}`
|
||||||
|
+ ` refunds=${result.refunds.processed} succeeded=${result.refunds.succeeded} processing=${result.refunds.processing} refundFailed=${result.refunds.failed} refundErrors=${result.refunds.errors}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported worker job: ${job}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLoop() {
|
async function runLoop() {
|
||||||
|
|||||||
1504
apps/worker/src/jobs/commerce.ts
Normal file
1504
apps/worker/src/jobs/commerce.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -109,7 +109,8 @@
|
|||||||
| 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons` |
|
| 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons` |
|
||||||
| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
|
| 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 |
|
||||||
| 退款状态机和供应商确认 | 可联调 | `/api/commerce/refunds`、`/api/commerce/refunds/status`、`/api/commerce/refunds/notify/{provider}`;支持退款申请、审核、调用微信/支付宝发起退款、`query_provider_refund` 查询确认、微信/支付宝退款通知、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 |
|
| 退款状态机和供应商确认 | 可联调 | `/api/commerce/refunds`、`/api/commerce/refunds/status`、`/api/commerce/refunds/notify/{provider}`;支持退款申请、审核、调用微信/支付宝发起退款、`query_provider_refund` 查询确认、微信/支付宝退款通知、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 |
|
||||||
| 退款补偿/对账 | 待补齐 | 支付补偿任务、对账、异常订单自动处理和退款 worker |
|
| 支付/退款补偿 worker | 可联调 | `apps/worker --job commerce` 查询微信/支付宝订单和处理中退款,补偿漏通知支付、补发权益、确认退款、全额退款撤销权益;`npm run test:worker:commerce` 覆盖幂等和密钥不泄露 |
|
||||||
|
| 完整资金流水对账 | 待补齐 | 后续补微信/支付宝账单下载、平台账单比对、差错处理、异常订单运营台 |
|
||||||
|
|
||||||
## 租户后台与平台后台
|
## 租户后台与平台后台
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护 | 批量导入、复杂筛选、AI 择校上下文 |
|
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护 | 批量导入、复杂筛选、AI 择校上下文 |
|
||||||
| 视频解析 | 部分完成 | 单题视频、批量查询、后台视频绑定 | 会员播放权限、播放次数扣减、签名 URL、防盗链、水印 |
|
| 视频解析 | 部分完成 | 单题视频、批量查询、后台视频绑定 | 会员播放权限、播放次数扣减、签名 URL、防盗链、水印 |
|
||||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名 | 上传后对象校验、PDF 预览、防盗链、视频水印 |
|
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名 | 上传后对象校验、PDF 预览、防盗链、视频水印 |
|
||||||
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、权益发放 | 对账、支付补偿、异常订单自动处理 |
|
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 |
|
||||||
| 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信小程序/网页登录、QQ 登录、Supabase Auth |
|
| 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信小程序/网页登录、QQ 登录、Supabase Auth |
|
||||||
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
|
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
|
||||||
| 内容导入 | 基础完成 | 题目、单词、知识手册 JSON preview/import、issue、job、审计、幂等 | Excel/CSV、分数线、视频导入,大批量异步 worker |
|
| 内容导入 | 基础完成 | 题目、单词、知识手册 JSON preview/import、issue、job、审计、幂等 | Excel/CSV、分数线、视频导入,大批量异步 worker |
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
|
|
||||||
### P1:商用收费和运营能力
|
### P1:商用收费和运营能力
|
||||||
|
|
||||||
- 对账、支付补偿任务和异常订单自动处理。
|
- 完整资金流水对账、账单下载比对和异常订单运营台。
|
||||||
- XPay 或其它实际支付网关 adapter。
|
- XPay 或其它实际支付网关 adapter。
|
||||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。
|
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。
|
||||||
- 公共题库/地区题库版本同步,租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
- 公共题库/地区题库版本同步,租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
- 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。
|
- 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。
|
||||||
- 已新增 `npm run test:api`,自动 seed、构建、启动临时 API,并断言核心学生端接口、内容导航/组卷、租户隔离、资源权限和题目导入。
|
- 已新增 `npm run test:api`,自动 seed、构建、启动临时 API,并断言核心学生端接口、内容导航/组卷、租户隔离、资源权限和题目导入。
|
||||||
- 已新增 `apps/worker` 和 `npm run test:worker:crm`,用于消费 CRM webhook 队列,验证本地 fake webhook、队列状态、日志和密钥不泄露。
|
- 已新增 `apps/worker` 和 `npm run test:worker:crm`,用于消费 CRM webhook 队列,验证本地 fake webhook、队列状态、日志和密钥不泄露。
|
||||||
|
- 已新增 commerce worker 和 `npm run test:worker:commerce`,用于补偿查询微信/支付宝支付、处理中退款和漏通知场景;支付成功会幂等更新订单/支付并开通权益,退款成功会幂等更新退款/订单/支付并在全额退款时撤销订单权益,测试覆盖密钥不泄露和重复执行不重复开通。
|
||||||
|
|
||||||
## 已验证接口
|
## 已验证接口
|
||||||
|
|
||||||
@@ -228,7 +229,7 @@ GET /api/tenant-admin/audit-logs
|
|||||||
- 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
|
- 激活码兑换、支付成功和零元优惠订单都走同一套 `grantSvipEntitlement` 权益开通逻辑。
|
||||||
- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。
|
- 优惠券领取同用户同券幂等;下单后优惠券 redemption 会绑定订单并进入 `used`,订单明细会写入负数 `coupon_discount` 项。
|
||||||
- `/api/commerce/payments/manual-confirm` 是线下收款/迁移期能力,只允许租户后台具备 `tenant:payment:write` 的成员调用,普通学生不能伪造手工支付成功。
|
- `/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,全额退款成功会撤销订单来源权益;对账 worker 后续接入。
|
- `/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` 会定时补偿查询支付和退款状态,完整资金流水对账后续接入。
|
||||||
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。
|
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。
|
||||||
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
|
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
|
||||||
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
||||||
@@ -246,8 +247,8 @@ GET /api/tenant-admin/audit-logs
|
|||||||
1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
||||||
2. 接入真实短信 provider:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
2. 接入真实短信 provider:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
||||||
3. 接入真实 OAuth provider:微信网页、微信小程序、QQ,并处理旧 PocketBase 身份映射。
|
3. 接入真实 OAuth provider:微信网页、微信小程序、QQ,并处理旧 PocketBase 身份映射。
|
||||||
4. 补支付补偿任务、对账、异常订单自动处理和优惠券核销报表;退款查询确认和退款通知主链路已完成。
|
4. 补完整资金流水对账、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认和退款通知主链路已完成。
|
||||||
5. 扩展 `apps/worker`:支付补偿、日报统计、导入后检查、CRM 死信告警和公共题库同步。
|
5. 扩展 `apps/worker`:日报统计、导入后检查、CRM 死信告警和公共题库同步。
|
||||||
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
||||||
|
|
||||||
## 测试命令
|
## 测试命令
|
||||||
@@ -255,5 +256,6 @@ GET /api/tenant-admin/audit-logs
|
|||||||
```text
|
```text
|
||||||
npm run test:api
|
npm run test:api
|
||||||
npm run test:worker:crm
|
npm run test:worker:crm
|
||||||
|
npm run test:worker:commerce
|
||||||
npm run check:refactor
|
npm run check:refactor
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
|
| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
|
||||||
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
|
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
|
||||||
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
|
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
|
||||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook 和全额退款权益撤销已有;缺对账/补偿任务和前端收银台/售后体验 |
|
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker 和全额退款权益撤销已有;缺完整资金流水对账、异常订单运营台和前端收银台/售后体验 |
|
||||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 |
|
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 |
|
||||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
|
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
|
||||||
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
3. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。
|
3. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。
|
||||||
4. 导入扩展:Excel/CSV、分数线、视频批量导入和大批量异步 worker。
|
4. 导入扩展:Excel/CSV、分数线、视频批量导入和大批量异步 worker。
|
||||||
5. 公共题库商业化:平台公共/地区题库授权和租户快照采纳已完成基础闭环;还需版本同步、租户自改冲突处理和运营后台 UI。
|
5. 公共题库商业化:平台公共/地区题库授权和租户快照采纳已完成基础闭环;还需版本同步、租户自改冲突处理和运营后台 UI。
|
||||||
6. CRM/销售结算:分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺真实 CRM worker、轮询/定向分配、打款导出、凭证和销售结算看板。
|
6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
||||||
7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
|
7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
|
||||||
8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
|
8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
||||||
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session。
|
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session。
|
||||||
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
||||||
|
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
||||||
- 本地验证:`npm run check:refactor` 已通过。
|
- 本地验证:`npm run check:refactor` 已通过。
|
||||||
|
|
||||||
当前更适合进入前端联调前阅读的总览文档:
|
当前更适合进入前端联调前阅读的总览文档:
|
||||||
@@ -60,7 +61,8 @@
|
|||||||
1. 支付
|
1. 支付
|
||||||
- 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。
|
- 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。
|
||||||
- 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、部分/全额退款状态、全额退款权益撤销和审计事件。
|
- 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、部分/全额退款状态、全额退款权益撤销和审计事件。
|
||||||
- 继续补支付补偿任务、对账和异常订单自动处理。
|
- 已完成支付/退款补偿 worker,可兜底供应商漏通知、处理中退款和重复执行幂等。
|
||||||
|
- 继续补完整资金流水对账、账单下载比对和异常订单运营台。
|
||||||
- 租户自有商户收款和平台代收/服务商模式。
|
- 租户自有商户收款和平台代收/服务商模式。
|
||||||
|
|
||||||
2. 国内登录和短信
|
2. 国内登录和短信
|
||||||
@@ -93,7 +95,7 @@
|
|||||||
|
|
||||||
7. 订单和营销体验
|
7. 订单和营销体验
|
||||||
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
||||||
- 继续补支付补偿任务、对账、异常订单自动处理、优惠券核销报表和复杂活动规则。
|
- 已完成支付/退款补偿 worker;继续补完整资金流水对账、异常订单运营台、优惠券核销报表和复杂活动规则。
|
||||||
|
|
||||||
8. 积分和反馈增强
|
8. 积分和反馈增强
|
||||||
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
||||||
@@ -201,5 +203,5 @@
|
|||||||
2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||||
4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。
|
4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。
|
||||||
5. 并行补对象存储、真实登录、支付对账和公共题库版本同步 worker。
|
5. 并行补对象存储、真实登录、完整资金流水对账和公共题库版本同步 worker。
|
||||||
6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||||
|
|||||||
@@ -872,6 +872,8 @@ GET /api/commerce/entitlements
|
|||||||
|
|
||||||
订单详情会返回 `pricing`、`payments`、`items`、`couponRedemptions`,可用于收银台、订单详情页和售后排查。订单状态轮询页只需消费 `status/payment`,避免频繁拉取全量明细。
|
订单详情会返回 `pricing`、`payments`、`items`、`couponRedemptions`,可用于收银台、订单详情页和售后排查。订单状态轮询页只需消费 `status/payment`,避免频繁拉取全量明细。
|
||||||
|
|
||||||
|
后端已提供 commerce worker 作为兜底补偿:如果微信/支付宝支付成功但 webhook 漏通知,worker 会按租户商户配置查询供应商订单并幂等更新订单、支付和权益。前端仍然只轮询 `orders/status` 或 `orders/detail`,不要直接调用供应商查询接口,也不要在页面里自行开通会员。
|
||||||
|
|
||||||
### 退款和售后
|
### 退款和售后
|
||||||
|
|
||||||
学生端不直接发起后台退款命令。普通用户订单页只展示 `GET /api/commerce/orders/status` 和 `GET /api/commerce/orders/detail` 返回的订单状态、支付状态、`refundedAmountCents`,并提供客服/工单入口。租户后台或运营后台才接退款接口。
|
学生端不直接发起后台退款命令。普通用户订单页只展示 `GET /api/commerce/orders/status` 和 `GET /api/commerce/orders/detail` 返回的订单状态、支付状态、`refundedAmountCents`,并提供客服/工单入口。租户后台或运营后台才接退款接口。
|
||||||
@@ -934,7 +936,7 @@ 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 前端不要主动调用。
|
- 退款通知地址由支付账户或 `submit_provider_refund.providerNotifyUrl` 配置,后端公开接收路径为 `POST /api/commerce/refunds/notify/wechat_pay?tenantId=<tenantId>`、`POST /api/commerce/refunds/notify/alipay?tenantId=<tenantId>`。这是支付平台回调地址,Taro 前端不要主动调用。
|
||||||
- 退款通知只会推进已经审核/处理中的退款申请;未审核的 `requested` 退款不能被外部通知直接落账。
|
- 退款通知只会推进已经审核/处理中的退款申请;未审核的 `requested` 退款不能被外部通知直接落账。
|
||||||
- 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。
|
- 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。
|
||||||
- 自动对账和补偿 worker 后续接入;当前生产联调时仍需运营后台保留人工确认/失败登记入口。
|
- 自动补偿 worker 已接入:支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。完整资金流水对账、账单下载比对和异常订单运营台后续继续补;生产联调时仍需保留人工确认/失败登记入口。
|
||||||
|
|
||||||
### 激活码预检查与兑换
|
### 激活码预检查与兑换
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"smoke:core-api": "node scripts/smoke-core-api.js",
|
"smoke:core-api": "node scripts/smoke-core-api.js",
|
||||||
"test:api": "npm run db:smoke-seed && npm run build:api && node scripts/api-integration-test.js --start-server",
|
"test:api": "npm run db:smoke-seed && npm run build:api && node scripts/api-integration-test.js --start-server",
|
||||||
"test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js",
|
"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:api:remote": "node scripts/api-integration-test.js",
|
"test:api:remote": "node scripts/api-integration-test.js",
|
||||||
"pb:schema:summary": "npm --workspace @tiku-saas/import-pocketbase run schema:summary",
|
"pb:schema:summary": "npm --workspace @tiku-saas/import-pocketbase run schema:summary",
|
||||||
"pb:schema:risk": "npm --workspace @tiku-saas/import-pocketbase run schema:risk",
|
"pb:schema:risk": "npm --workspace @tiku-saas/import-pocketbase run schema:risk",
|
||||||
|
|||||||
397
scripts/commerce-worker-integration-test.js
Normal file
397
scripts/commerce-worker-integration-test.js
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import http from 'node:http';
|
||||||
|
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 tenantId = '00000000-0000-0000-0000-000000000001';
|
||||||
|
const studentUserId = '00000000-0000-0000-0000-000000000101';
|
||||||
|
const planId = '00000000-0000-0000-0000-000000000201';
|
||||||
|
const regionId = '00000000-0000-0000-0000-000000000301';
|
||||||
|
|
||||||
|
const ids = {
|
||||||
|
paymentOrder: '10000000-0000-0000-0000-000000000701',
|
||||||
|
payment: '10000000-0000-0000-0000-000000000702',
|
||||||
|
refundOrder: '10000000-0000-0000-0000-000000000703',
|
||||||
|
refundPayment: '10000000-0000-0000-0000-000000000704',
|
||||||
|
refundRequest: '10000000-0000-0000-0000-000000000705',
|
||||||
|
refundEntitlement: '10000000-0000-0000-0000-000000000706',
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderNos = {
|
||||||
|
payment: 'CW-WX-PAY-001',
|
||||||
|
refund: 'CW-WX-REFUND-001',
|
||||||
|
};
|
||||||
|
const refundNo = 'RF-CW-WX-001';
|
||||||
|
|
||||||
|
const paymentFixture = (() => {
|
||||||
|
const wechatMerchant = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
return {
|
||||||
|
wechatMerchantPrivateKey: wechatMerchant.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||||
|
wechatApiV3Key: '12345678901234567890123456789012',
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
function getFreePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = http.createServer();
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address();
|
||||||
|
server.close(() => resolve(address.port));
|
||||||
|
});
|
||||||
|
server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFakeWechatPayServer() {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const requests = [];
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url || '/', baseUrl);
|
||||||
|
let raw = '';
|
||||||
|
req.on('data', chunk => {
|
||||||
|
raw += chunk.toString();
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
requests.push({ method: req.method, pathname: url.pathname, search: url.search, body: raw ? JSON.parse(raw) : {} });
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname.startsWith('/v3/pay/transactions/out-trade-no/')) {
|
||||||
|
const outTradeNo = decodeURIComponent(url.pathname.split('/').pop() || '');
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
appid: 'wx-worker-appid',
|
||||||
|
mchid: 'wx-worker-mchid',
|
||||||
|
out_trade_no: outTradeNo,
|
||||||
|
transaction_id: `wx-trade-${outTradeNo}`,
|
||||||
|
trade_state: 'SUCCESS',
|
||||||
|
success_time: '2026-06-29T08:00:00+08:00',
|
||||||
|
amount: { total: outTradeNo === orderNos.refund ? 500 : 990, currency: 'CNY' },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname.startsWith('/v3/refund/domestic/refunds/')) {
|
||||||
|
const outRefundNo = decodeURIComponent(url.pathname.split('/').pop() || '');
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
refund_id: `refund-${outRefundNo}`,
|
||||||
|
out_refund_no: outRefundNo,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
amount: { refund: 500, total: 500, currency: 'CNY' },
|
||||||
|
success_time: '2026-06-29T09:00:00+08:00',
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ code: 'NOT_FOUND' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(port, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
paymentQueryEndpoint: `${baseUrl}/v3/pay/transactions/out-trade-no`,
|
||||||
|
refundEndpoint: `${baseUrl}/v3/refund/domestic/refunds`,
|
||||||
|
requests,
|
||||||
|
close: () => new Promise(resolve => server.close(resolve)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWorkerOnce() {
|
||||||
|
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'commerce'], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
|
WORKER_COMMERCE_BATCH_SIZE: '20',
|
||||||
|
WORKER_COMMERCE_MIN_AGE_SECONDS: '0',
|
||||||
|
WORKER_COMMERCE_REQUEST_TIMEOUT_MS: '5000',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
let output = '';
|
||||||
|
child.stdout.on('data', chunk => {
|
||||||
|
output += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on('data', chunk => {
|
||||||
|
output += chunk.toString();
|
||||||
|
});
|
||||||
|
const code = await new Promise(resolve => child.on('exit', resolve));
|
||||||
|
assert.equal(code, 0, `worker should exit 0\n${output}`);
|
||||||
|
assert.match(output, /commerce batch processed=\d+/, 'worker output should include commerce summary');
|
||||||
|
assert.ok(!output.includes(paymentFixture.wechatApiV3Key), 'worker output must not leak WeChat API v3 key');
|
||||||
|
assert.ok(!output.includes('PRIVATE KEY'), 'worker output must not leak merchant private key');
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(pool) {
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
delete from public.payment_events
|
||||||
|
where tenant_id = $1
|
||||||
|
and (
|
||||||
|
event_id like $2
|
||||||
|
or event_id like $3
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[tenantId, `${tenantId}:payment-query:CW-WX-%`, `${tenantId}:refund-query:RF-CW-%`],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
delete from public.commerce_refund_events
|
||||||
|
where tenant_id = $1 and refund_request_id = $2
|
||||||
|
`,
|
||||||
|
[tenantId, ids.refundRequest],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
delete from public.commerce_refund_requests
|
||||||
|
where tenant_id = $1 and id = $2
|
||||||
|
`,
|
||||||
|
[tenantId, ids.refundRequest],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
delete from public.entitlements
|
||||||
|
where tenant_id = $1
|
||||||
|
and (
|
||||||
|
id = $2
|
||||||
|
or source_id in ($3::uuid, $4::uuid)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[tenantId, ids.refundEntitlement, ids.paymentOrder, ids.refundOrder],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
delete from public.payments
|
||||||
|
where tenant_id = $1 and id in ($2::uuid, $3::uuid)
|
||||||
|
`,
|
||||||
|
[tenantId, ids.payment, ids.refundPayment],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
delete from public.orders
|
||||||
|
where tenant_id = $1 and id in ($2::uuid, $3::uuid)
|
||||||
|
`,
|
||||||
|
[tenantId, ids.paymentOrder, ids.refundOrder],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seed(pool, fakeWechat) {
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
insert into app_private.tenant_secrets (
|
||||||
|
tenant_id, secret_scope, secret_key, secret_json, provider, last_rotated_at
|
||||||
|
)
|
||||||
|
values ($1, 'payment', 'wechat_pay', $2::jsonb, 'wechat_pay', now())
|
||||||
|
on conflict (tenant_id, secret_scope, secret_key)
|
||||||
|
do update set secret_json = excluded.secret_json,
|
||||||
|
provider = excluded.provider,
|
||||||
|
last_rotated_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
`,
|
||||||
|
[tenantId, JSON.stringify({
|
||||||
|
privateKey: paymentFixture.wechatMerchantPrivateKey,
|
||||||
|
apiV3Key: paymentFixture.wechatApiV3Key,
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
insert into public.tenant_payment_accounts (
|
||||||
|
tenant_id, provider, mode, display_name, status, config_public
|
||||||
|
)
|
||||||
|
values ($1, 'wechat_pay', 'tenant_collect', 'Worker WeChat Pay', 'active', $2::jsonb)
|
||||||
|
on conflict (tenant_id, provider)
|
||||||
|
do update set mode = excluded.mode,
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
status = excluded.status,
|
||||||
|
config_public = excluded.config_public,
|
||||||
|
updated_at = now()
|
||||||
|
`,
|
||||||
|
[tenantId, JSON.stringify({
|
||||||
|
appId: 'wx-worker-appid',
|
||||||
|
merchantId: 'wx-worker-mchid',
|
||||||
|
merchantSerialNo: 'wx-worker-serial',
|
||||||
|
paymentQueryEndpoint: fakeWechat.paymentQueryEndpoint,
|
||||||
|
refundEndpoint: fakeWechat.refundEndpoint,
|
||||||
|
refundQueryEndpoint: fakeWechat.refundEndpoint,
|
||||||
|
secretRef: 'app_private.tenant_secrets:payment:wechat_pay',
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
insert into public.orders (
|
||||||
|
id, tenant_id, user_id, order_no, status, product_type, product_name,
|
||||||
|
amount_cents, pay_method, pay_provider, plan_id, days, region_id, raw_payload,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
values
|
||||||
|
($1, $2, $3, $4, 'pending', 'svip', 'Worker 补偿月卡', 990, 'jsapi', 'wechat_pay', $5, 30, $6, '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes'),
|
||||||
|
($7, $2, $3, $8, 'paid', 'svip', 'Worker 退款月卡', 500, 'jsapi', 'wechat_pay', $5, 30, $6, '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes')
|
||||||
|
`,
|
||||||
|
[ids.paymentOrder, tenantId, studentUserId, orderNos.payment, planId, regionId, ids.refundOrder, orderNos.refund],
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
insert into public.payments (
|
||||||
|
id, tenant_id, order_id, provider, method, status, amount_cents,
|
||||||
|
provider_trade_no, paid_at, raw_payload, created_at, updated_at
|
||||||
|
)
|
||||||
|
values
|
||||||
|
($1, $2, $3, 'wechat_pay', 'jsapi', 'pending', 990, null, null, '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes'),
|
||||||
|
($4, $2, $5, 'wechat_pay', 'jsapi', 'paid', 500, $6, now() - interval '9 minutes', '{"source":"commerce-worker-test"}'::jsonb, now() - interval '10 minutes', now() - interval '10 minutes')
|
||||||
|
`,
|
||||||
|
[ids.payment, tenantId, ids.paymentOrder, ids.refundPayment, ids.refundOrder, `wx-trade-${orderNos.refund}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
insert into public.entitlements (
|
||||||
|
id, tenant_id, user_id, entitlement_type, scope_type, scope_id,
|
||||||
|
source_type, source_id, starts_at, expires_at, status, metadata
|
||||||
|
)
|
||||||
|
values ($1, $2, $3, 'svip', 'region', $4, 'order', $5, now() - interval '9 minutes', now() + interval '30 days', 'active', '{"source":"commerce-worker-test"}'::jsonb)
|
||||||
|
`,
|
||||||
|
[ids.refundEntitlement, tenantId, studentUserId, regionId, ids.refundOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
insert into public.commerce_refund_requests (
|
||||||
|
id, tenant_id, order_id, payment_id, refund_no, provider, provider_refund_no,
|
||||||
|
status, amount_cents, reason, entitlement_action,
|
||||||
|
requested_by, reviewed_by, processed_by,
|
||||||
|
requested_at, reviewed_at, processed_at, metadata
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
$1, $2, $3, $4, $5, 'wechat_pay', null,
|
||||||
|
'processing', 500, 'commerce worker integration refund', 'revoke_on_success',
|
||||||
|
null, null, null,
|
||||||
|
now() - interval '8 minutes', now() - interval '8 minutes', now() - interval '8 minutes',
|
||||||
|
'{"source":"commerce-worker-test"}'::jsonb
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[ids.refundRequest, tenantId, ids.refundOrder, ids.refundPayment, refundNo],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const fakeWechat = await startFakeWechatPayServer();
|
||||||
|
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||||
|
let seeded = false;
|
||||||
|
try {
|
||||||
|
await pool.query('begin');
|
||||||
|
await cleanup(pool);
|
||||||
|
await seed(pool, fakeWechat);
|
||||||
|
await pool.query('commit');
|
||||||
|
seeded = true;
|
||||||
|
|
||||||
|
const output = await runWorkerOnce();
|
||||||
|
assert.match(output, /paid=1/, 'worker should reconcile one paid payment');
|
||||||
|
assert.match(output, /succeeded=1/, 'worker should reconcile one succeeded refund');
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
fakeWechat.requests.some(item => item.method === 'GET' && item.pathname.endsWith(`/${orderNos.payment}`)),
|
||||||
|
'worker should query WeChat payment by out_trade_no',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
fakeWechat.requests.some(item => item.method === 'GET' && item.pathname.endsWith(`/${refundNo}`)),
|
||||||
|
'worker should query WeChat refund by out_refund_no',
|
||||||
|
);
|
||||||
|
|
||||||
|
const paymentOrder = await pool.query(
|
||||||
|
`
|
||||||
|
select o.status, o.trade_no, p.status as payment_status, p.provider_trade_no,
|
||||||
|
count(e.id)::int as entitlement_count
|
||||||
|
from public.orders o
|
||||||
|
join public.payments p on p.tenant_id = o.tenant_id and p.order_id = o.id
|
||||||
|
left join public.entitlements e on e.tenant_id = o.tenant_id and e.source_type = 'order' and e.source_id = o.id
|
||||||
|
where o.tenant_id = $1 and o.id = $2
|
||||||
|
group by o.status, o.trade_no, p.status, p.provider_trade_no
|
||||||
|
`,
|
||||||
|
[tenantId, ids.paymentOrder],
|
||||||
|
);
|
||||||
|
assert.equal(paymentOrder.rows[0]?.status, 'paid', 'payment compensation should mark order paid');
|
||||||
|
assert.equal(paymentOrder.rows[0]?.payment_status, 'paid', 'payment compensation should mark payment paid');
|
||||||
|
assert.equal(paymentOrder.rows[0]?.trade_no, `wx-trade-${orderNos.payment}`, 'payment compensation should record trade number');
|
||||||
|
assert.equal(paymentOrder.rows[0]?.entitlement_count, 1, 'payment compensation should grant one order entitlement');
|
||||||
|
|
||||||
|
const refund = await pool.query(
|
||||||
|
`
|
||||||
|
select rr.status, rr.provider_refund_no, o.status as order_status,
|
||||||
|
o.refunded_amount_cents, p.status as payment_status,
|
||||||
|
p.refunded_amount_cents as payment_refunded_amount_cents,
|
||||||
|
e.status as entitlement_status
|
||||||
|
from public.commerce_refund_requests rr
|
||||||
|
join public.orders o on o.tenant_id = rr.tenant_id and o.id = rr.order_id
|
||||||
|
join public.payments p on p.tenant_id = rr.tenant_id and p.id = rr.payment_id
|
||||||
|
left join public.entitlements e on e.tenant_id = rr.tenant_id and e.source_type = 'order' and e.source_id = rr.order_id
|
||||||
|
where rr.tenant_id = $1 and rr.id = $2
|
||||||
|
`,
|
||||||
|
[tenantId, ids.refundRequest],
|
||||||
|
);
|
||||||
|
assert.equal(refund.rows[0]?.status, 'succeeded', 'refund compensation should mark refund succeeded');
|
||||||
|
assert.equal(refund.rows[0]?.provider_refund_no, `refund-${refundNo}`, 'refund compensation should record provider refund number');
|
||||||
|
assert.equal(refund.rows[0]?.order_status, 'refunded', 'full refund compensation should mark order refunded');
|
||||||
|
assert.equal(refund.rows[0]?.payment_status, 'refunded', 'full refund compensation should mark payment refunded');
|
||||||
|
assert.equal(refund.rows[0]?.refunded_amount_cents, 500, 'full refund compensation should update order refunded amount');
|
||||||
|
assert.equal(refund.rows[0]?.payment_refunded_amount_cents, 500, 'full refund compensation should update payment refunded amount');
|
||||||
|
assert.equal(refund.rows[0]?.entitlement_status, 'revoked', 'full refund compensation should revoke order entitlement');
|
||||||
|
|
||||||
|
await runWorkerOnce();
|
||||||
|
const secondPass = await pool.query(
|
||||||
|
`
|
||||||
|
select count(*)::int as entitlement_count
|
||||||
|
from public.entitlements
|
||||||
|
where tenant_id = $1 and source_type = 'order' and source_id = $2
|
||||||
|
`,
|
||||||
|
[tenantId, ids.paymentOrder],
|
||||||
|
);
|
||||||
|
assert.equal(secondPass.rows[0]?.entitlement_count, 1, 'second worker pass must not grant duplicate entitlement');
|
||||||
|
|
||||||
|
const events = await pool.query(
|
||||||
|
`
|
||||||
|
select payload, error
|
||||||
|
from public.payment_events
|
||||||
|
where tenant_id = $1
|
||||||
|
and (
|
||||||
|
event_id = $2
|
||||||
|
or event_id = $3
|
||||||
|
)
|
||||||
|
order by created_at desc
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
`${tenantId}:payment-query:${orderNos.payment}`,
|
||||||
|
`${tenantId}:refund-query:${refundNo}`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.ok(events.rows.length >= 2, 'worker should record payment events for reconciliation');
|
||||||
|
assert.ok(!JSON.stringify(events.rows).includes(paymentFixture.wechatApiV3Key), 'payment event payload must not leak API v3 key');
|
||||||
|
assert.ok(!JSON.stringify(events.rows).includes('PRIVATE KEY'), 'payment event payload must not leak private key');
|
||||||
|
|
||||||
|
console.log('Commerce worker integration test complete.');
|
||||||
|
} catch (error) {
|
||||||
|
await pool.query('rollback').catch(() => {});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (seeded) {
|
||||||
|
await cleanup(pool).catch(() => {});
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
|
await fakeWechat.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user