From 4c9f742b31a2ae22db1da2993813c06724430132 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 05:58:08 +0800 Subject: [PATCH] feat: reconcile commerce payments --- README.md | 17 +- apps/worker/package.json | 3 +- apps/worker/src/config.ts | 6 + apps/worker/src/index.ts | 19 +- apps/worker/src/jobs/commerce.ts | 1504 +++++++++++++++++++ docs/refactor/backend-capability-status.md | 3 +- docs/refactor/backend-handoff-roadmap.md | 4 +- docs/refactor/backend-progress.md | 8 +- docs/refactor/legacy-feature-gap-matrix.md | 4 +- docs/refactor/next-development-todo.md | 8 +- docs/refactor/taro-frontend-integration.md | 4 +- package.json | 1 + scripts/commerce-worker-integration-test.js | 397 +++++ 13 files changed, 1956 insertions(+), 22 deletions(-) create mode 100644 apps/worker/src/jobs/commerce.ts create mode 100644 scripts/commerce-worker-integration-test.js diff --git a/README.md b/README.md index 8700fb53..8aed5bdf 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。 -- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志。 +- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态,兜底漏通知订单。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。 - 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 - PocketBase schema/数据导入器雏形和导入后校验脚本。 @@ -26,7 +26,7 @@ 还没有达到生产交付的部分: - Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。 -- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路和微信/支付宝发起退款/查询确认/退款通知已完成本地适配;微信网页登录、QQ 登录、手机号换绑、对账、支付补偿和真实生产账号联调还没接完。 +- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;微信网页登录、QQ 登录、手机号换绑、完整资金流水对账和真实生产账号联调还没接完。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。 - Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。 - 分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。 @@ -56,7 +56,7 @@ ```text apps/api/ Node.js 业务 API -apps/worker/ 后台异步任务:CRM webhook、后续支付补偿/导入复检等 +apps/worker/ 后台异步任务:CRM webhook、支付/退款补偿、后续导入复检等 packages/config/ 共享配置 packages/db/ PostgreSQL 连接池和查询封装 packages/domain/ 领域常量和共享类型 @@ -95,6 +95,12 @@ npm run dev:api npm --workspace @tiku-saas/worker run crm:once ``` +单次运行支付/退款补偿 worker: + +```bash +npm --workspace @tiku-saas/worker run commerce:once +``` + 默认本地数据库: ```text @@ -133,6 +139,7 @@ npm run check:importer npm run pb:import:validate npm run test:api npm run test:worker:crm +npm run test:worker:commerce ``` ## API 模块 @@ -173,7 +180,7 @@ API 身份上下文: - 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL。 - 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。 - 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册导入已走这套后台校验管线,前端只做预检查和预览展示。 -- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用。 +- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款。 ## 最近一次验证 @@ -193,4 +200,4 @@ npm run check:refactor 2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。 3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。 4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。 -5. 微信网页/QQ 登录、支付对账、支付补偿、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。 +5. 微信网页/QQ 登录、完整资金流水对账、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/worker/package.json b/apps/worker/package.json index 206d4914..a29ad44d 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -8,7 +8,8 @@ "start": "node dist/apps/worker/src/index.js --loop", "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json", "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": { "pg": "^8.16.3" diff --git a/apps/worker/src/config.ts b/apps/worker/src/config.ts index b2bba513..af2974b5 100644 --- a/apps/worker/src/config.ts +++ b/apps/worker/src/config.ts @@ -10,6 +10,9 @@ export interface WorkerConfig { crmBackoffSeconds: number[]; crmRequestTimeoutMs: number; crmAllowInsecureLocalhost: boolean; + commerceBatchSize: number; + commerceMinAgeSeconds: number; + commerceRequestTimeoutMs: number; } export const config: WorkerConfig = { @@ -22,4 +25,7 @@ export const config: WorkerConfig = { .filter((value: number) => Number.isFinite(value) && value > 0), crmRequestTimeoutMs: envNumber('WORKER_CRM_REQUEST_TIMEOUT_MS', 10_000), 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), }; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 2e3228a9..6411c671 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1,6 +1,7 @@ import { closePool } from './db.js'; import { config } from './config.js'; import { processCrmBatch } from './jobs/crm.js'; +import { processCommerceBatch } from './jobs/commerce.js'; function hasArg(name: string) { return process.argv.includes(name); @@ -13,11 +14,21 @@ function argValue(name: string, fallback = '') { async function runOnce() { const job = argValue('--job', 'crm'); - if (job !== 'crm') { - throw new Error(`Unsupported worker job: ${job}`); + if (job === 'crm') { + 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(); - console.log(`[worker] crm batch processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`); + if (job === 'commerce') { + 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() { diff --git a/apps/worker/src/jobs/commerce.ts b/apps/worker/src/jobs/commerce.ts new file mode 100644 index 00000000..7828b278 --- /dev/null +++ b/apps/worker/src/jobs/commerce.ts @@ -0,0 +1,1504 @@ +import crypto from 'node:crypto'; +import type pg from 'pg'; +import { pool } from '../db.js'; +import { config } from '../config.js'; + +type CommerceProvider = 'wechat_pay' | 'alipay'; +type PaymentQueryStatus = 'paid' | 'pending' | 'closed' | 'failed'; +type RefundQueryStatus = 'succeeded' | 'processing' | 'failed'; + +interface SecretRow { + secretValue: string | null; + secretJson: Record | null; +} + +interface ProviderConfig { + tenantId: string; + provider: CommerceProvider; + configPublic: Record; + secret: SecretRow | null; +} + +interface PaymentCandidate { + id: string; + tenantId: string; + orderId: string; + orderNo: string; + provider: CommerceProvider; + paymentStatus: string; + orderStatus: string; + amountCents: number; + userId: string | null; + days: number | null; + regionId: string | null; +} + +interface RefundCandidate { + id: string; + tenantId: string; + refundNo: string; + orderId: string; + paymentId: string | null; + orderNo: string; + provider: CommerceProvider; + providerRefundNo: string | null; + status: string; + amountCents: number; + reason: string | null; + entitlementAction: string; + paymentAmountCents: number; + providerTradeNo: string | null; +} + +interface PaymentQueryResult { + provider: CommerceProvider; + status: PaymentQueryStatus; + eventType: string; + orderNo: string; + providerTradeNo?: string | null; + amountCents?: number | null; + paidAt?: string | null; + failureReason?: string | null; + raw: Record; +} + +interface RefundQueryResult { + provider: CommerceProvider; + status: RefundQueryStatus; + eventType: string; + providerRefundNo?: string | null; + failureReason?: string | null; + raw: Record; +} + +interface ProcessResult { + processed: number; + payments: { + processed: number; + paid: number; + pending: number; + closed: number; + failed: number; + errors: number; + }; + refunds: { + processed: number; + succeeded: number; + processing: number; + failed: number; + errors: number; + }; +} + +class CommerceWorkerError extends Error { + readonly code: string; + + constructor(message: string, code = 'COMMERCE_WORKER_ERROR') { + super(message); + this.code = code; + } +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function optionalPublicString(providerConfig: ProviderConfig, keys: string[]) { + for (const key of keys) { + const value = providerConfig.configPublic[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +} + +function requirePublicString(providerConfig: ProviderConfig, keys: string[]) { + const value = optionalPublicString(providerConfig, keys); + if (value) return value; + throw new CommerceWorkerError(`${providerConfig.provider} public config is missing ${keys[0]}`, 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); +} + +function secretString(providerConfig: ProviderConfig, keys: string[]) { + const secret = providerConfig.secret; + if (!secret) return ''; + if (typeof secret.secretValue === 'string' && secret.secretValue.trim()) return secret.secretValue.trim(); + const json = objectValue(secret.secretJson); + for (const key of keys) { + const value = json[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +} + +function requireSecretString(providerConfig: ProviderConfig, keys: string[]) { + const value = secretString(providerConfig, keys); + if (value) return value; + throw new CommerceWorkerError(`${providerConfig.provider} secret is missing ${keys[0]}`, 'PAYMENT_SECRET_REQUIRED'); +} + +function normalizeProvider(value: string | null | undefined): CommerceProvider | null { + const normalized = String(value || '').toLowerCase().replace(/[-\s]/g, '_'); + if (['wechat', 'wechatpay', 'wxpay', 'wx_pay', 'wechat_pay'].includes(normalized)) return 'wechat_pay'; + if (['alipay', 'ali_pay'].includes(normalized)) return 'alipay'; + return null; +} + +function providerAliases(provider: CommerceProvider) { + return provider === 'wechat_pay' + ? ['wechat_pay', 'wechat-pay', 'wechatpay', 'wxpay'] + : ['alipay', 'ali_pay']; +} + +function parseSecretRef(value: unknown) { + if (typeof value !== 'string' || !value.trim()) return null; + const parts = value.trim().split(':'); + if (parts.length !== 3 || parts[0] !== 'app_private.tenant_secrets') return null; + if (parts[1] !== 'payment' || !parts[2]) return null; + return { scope: parts[1], key: parts[2] }; +} + +function truncate(value: unknown, max = 1900) { + return String(value ?? '').slice(0, max); +} + +function normalizePem(value: string, label: 'PRIVATE KEY' | 'PUBLIC KEY') { + if (value.includes('-----BEGIN')) return value; + const wrapped = value.match(/.{1,64}/g)?.join('\n') || value; + return `-----BEGIN ${label}-----\n${wrapped}\n-----END ${label}-----`; +} + +function rsaSignSha256(privateKey: string, message: string) { + return crypto.createSign('RSA-SHA256').update(message, 'utf8').sign(privateKey, 'base64'); +} + +function randomNonce(size = 16) { + return crypto.randomBytes(size).toString('base64url'); +} + +function canonicalForm(params: Record) { + return Object.keys(params) + .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '') + .sort() + .map(key => `${key}=${params[key]}`) + .join('&'); +} + +function encodedForm(params: Record) { + return Object.keys(params) + .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '') + .sort() + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) + .join('&'); +} + +function safeJson(value: unknown) { + return objectValue(value); +} + +function optionalIntegerAmount(value: unknown) { + const amount = typeof value === 'number' ? value : Number.NaN; + return Number.isInteger(amount) && amount >= 0 ? amount : null; +} + +function optionalYuanToCents(value: unknown) { + if (typeof value !== 'string' && typeof value !== 'number') return null; + const amount = Number(value); + if (!Number.isFinite(amount) || amount < 0) return null; + return Math.round(amount * 100); +} + +function nowIso() { + return new Date().toISOString(); +} + +function isLocalDevHost(hostname: string) { + return ['127.0.0.1', 'localhost', '::1'].includes(hostname); +} + +function isAllowedHost(hostname: string, allowedHosts: string[]) { + return allowedHosts.some(host => hostname === host || hostname.endsWith(`.${host}`)); +} + +function providerEndpointForKeys( + providerConfig: ProviderConfig, + keys: string[], + fallback: string, + allowedHosts: string[], +) { + const raw = optionalPublicString(providerConfig, keys) || fallback; + let endpoint: URL; + try { + endpoint = new URL(raw); + } catch { + throw new CommerceWorkerError(`${providerConfig.provider} endpoint is invalid`, 'PAYMENT_ENDPOINT_INVALID'); + } + + const localDev = process.env.NODE_ENV !== 'production' && isLocalDevHost(endpoint.hostname); + if (!localDev && endpoint.protocol !== 'https:') { + throw new CommerceWorkerError(`${providerConfig.provider} endpoint must use HTTPS`, 'PAYMENT_ENDPOINT_NOT_ALLOWED'); + } + if (!localDev && !isAllowedHost(endpoint.hostname, allowedHosts)) { + throw new CommerceWorkerError(`${providerConfig.provider} endpoint host is not allowed`, 'PAYMENT_ENDPOINT_NOT_ALLOWED'); + } + endpoint.username = ''; + endpoint.password = ''; + return endpoint.toString(); +} + +function wechatPrivateKey(providerConfig: ProviderConfig) { + return normalizePem(requireSecretString(providerConfig, ['privateKey', 'merchantPrivateKey']), 'PRIVATE KEY'); +} + +function wechatTerminalStatus(value: unknown): PaymentQueryStatus { + const status = typeof value === 'string' ? value.toUpperCase() : ''; + if (status === 'SUCCESS') return 'paid'; + if (['CLOSED', 'REVOKED'].includes(status)) return 'closed'; + if (status === 'PAYERROR') return 'failed'; + return 'pending'; +} + +function wechatRefundStatus(value: unknown): RefundQueryStatus { + const status = typeof value === 'string' ? value.toUpperCase() : ''; + if (status === 'SUCCESS') return 'succeeded'; + if (['CLOSED', 'ABNORMAL'].includes(status)) return 'failed'; + return 'processing'; +} + +function alipayTradeStatus(value: unknown): PaymentQueryStatus { + const status = typeof value === 'string' ? value.toUpperCase() : ''; + if (['TRADE_SUCCESS', 'TRADE_FINISHED'].includes(status)) return 'paid'; + if (status === 'TRADE_CLOSED') return 'closed'; + return 'pending'; +} + +function alipayRefundStatus(value: Record): RefundQueryStatus { + const refundStatus = typeof value.refund_status === 'string' ? value.refund_status.toUpperCase() : ''; + const fundChange = typeof value.fund_change === 'string' ? value.fund_change.toUpperCase() : ''; + if (refundStatus === 'REFUND_SUCCESS' || fundChange === 'Y') return 'succeeded'; + if (['REFUND_CLOSED', 'REFUND_FAIL'].includes(refundStatus)) return 'failed'; + return 'processing'; +} + +async function loadSecret(client: pg.PoolClient, tenantId: string, configPublic: Record, provider: CommerceProvider) { + const secretRef = parseSecretRef(configPublic.secretRef); + const secretKey = secretRef?.key || provider; + const result = await client.query( + ` + select secret_value as "secretValue", secret_json as "secretJson" + from app_private.tenant_secrets + where tenant_id = $1 + and secret_scope = 'payment' + and secret_key = $2 + limit 1 + `, + [tenantId, secretKey], + ); + return result.rows[0] || null; +} + +async function loadPaymentProviderConfig(client: pg.PoolClient, tenantId: string, provider: CommerceProvider): Promise { + const aliases = providerAliases(provider); + const accountResult = await client.query<{ + provider: string; + configPublic: Record | null; + }>( + ` + select provider, config_public as "configPublic" + from public.tenant_payment_accounts + where tenant_id = $1 + and provider = any($2::text[]) + and status = 'active' + order by array_position($2::text[], provider) + limit 1 + `, + [tenantId, aliases], + ); + const account = accountResult.rows[0]; + if (!account) throw new CommerceWorkerError(`${provider} provider is not configured`, 'PAYMENT_PROVIDER_NOT_CONFIGURED'); + const normalized = normalizeProvider(account.provider); + if (!normalized) throw new CommerceWorkerError(`${account.provider} provider is unsupported`, 'PAYMENT_PROVIDER_NOT_SUPPORTED'); + const configPublic = objectValue(account.configPublic); + const secret = await loadSecret(client, tenantId, configPublic, normalized); + return { tenantId, provider: normalized, configPublic, secret }; +} + +async function fetchJson(url: string, init: RequestInit, timeoutMs: number) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + const raw = safeJson(await response.json().catch(() => ({}))); + return { response, raw }; + } finally { + clearTimeout(timeout); + } +} + +async function queryWechatPayment(providerConfig: ProviderConfig, payment: PaymentCandidate): Promise { + const mchId = requirePublicString(providerConfig, ['merchantId', 'mchId']); + const merchantSerialNo = requirePublicString(providerConfig, ['merchantSerialNo']); + const endpoint = providerEndpointForKeys( + providerConfig, + ['paymentQueryEndpoint', 'transactionQueryEndpoint'], + 'https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no', + ['api.mch.weixin.qq.com'], + ); + const url = new URL(`${endpoint.replace(/\/$/, '')}/${encodeURIComponent(payment.orderNo)}`); + url.searchParams.set('mchid', mchId); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonce = randomNonce(); + const message = ['GET', `${url.pathname}${url.search}`, timestamp, nonce, ''].join('\n') + '\n'; + const signature = rsaSignSha256(wechatPrivateKey(providerConfig), message); + + const { response, raw } = await fetchJson(url.toString(), { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${merchantSerialNo}"`, + }, + }, config.commerceRequestTimeoutMs); + if (!response.ok) { + if (response.status === 404) { + return { + provider: 'wechat_pay', + status: 'pending', + eventType: 'TRANSACTION.NOT_FOUND', + orderNo: payment.orderNo, + raw: { httpStatus: response.status, response: raw }, + }; + } + throw new CommerceWorkerError('WeChat Pay transaction query failed', 'PAYMENT_PROVIDER_QUERY_FAILED'); + } + + const status = wechatTerminalStatus(raw.trade_state); + const amount = objectValue(raw.amount); + return { + provider: 'wechat_pay', + status, + eventType: typeof raw.trade_state === 'string' ? `TRANSACTION.${raw.trade_state}` : 'TRANSACTION.QUERY', + orderNo: payment.orderNo, + providerTradeNo: typeof raw.transaction_id === 'string' ? raw.transaction_id : null, + amountCents: optionalIntegerAmount(amount.total), + paidAt: typeof raw.success_time === 'string' ? raw.success_time : null, + failureReason: status === 'failed' || status === 'closed' ? String(raw.trade_state || status) : null, + raw, + }; +} + +async function queryWechatRefund(providerConfig: ProviderConfig, refund: RefundCandidate): Promise { + const mchId = requirePublicString(providerConfig, ['merchantId', 'mchId']); + const merchantSerialNo = requirePublicString(providerConfig, ['merchantSerialNo']); + const endpoint = providerEndpointForKeys( + providerConfig, + ['refundQueryEndpoint', 'refundEndpoint'], + 'https://api.mch.weixin.qq.com/v3/refund/domestic/refunds', + ['api.mch.weixin.qq.com'], + ); + const url = new URL(`${endpoint.replace(/\/$/, '')}/${encodeURIComponent(refund.refundNo)}`); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonce = randomNonce(); + const message = ['GET', `${url.pathname}${url.search}`, timestamp, nonce, ''].join('\n') + '\n'; + const signature = rsaSignSha256(wechatPrivateKey(providerConfig), message); + + const { response, raw } = await fetchJson(url.toString(), { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${merchantSerialNo}"`, + }, + }, config.commerceRequestTimeoutMs); + if (!response.ok) { + throw new CommerceWorkerError('WeChat Pay refund query failed', 'PAYMENT_PROVIDER_REFUND_QUERY_FAILED'); + } + + const amount = objectValue(raw.amount); + const amountCents = optionalIntegerAmount(amount.refund); + if (amountCents !== null && amountCents !== refund.amountCents) { + throw new CommerceWorkerError('WeChat Pay refund amount mismatch', 'PAYMENT_PROVIDER_REFUND_AMOUNT_MISMATCH'); + } + const status = wechatRefundStatus(raw.status); + return { + provider: 'wechat_pay', + status, + eventType: typeof raw.status === 'string' ? `REFUND.${raw.status}` : 'REFUND.QUERY', + providerRefundNo: typeof raw.refund_id === 'string' ? raw.refund_id : refund.providerRefundNo, + failureReason: status === 'failed' ? String(raw.status || 'WeChat refund failed') : null, + raw, + }; +} + +async function queryAlipayPayment(providerConfig: ProviderConfig, payment: PaymentCandidate): Promise { + const appId = requirePublicString(providerConfig, ['appId']); + const gateway = providerEndpointForKeys( + providerConfig, + ['paymentQueryEndpoint', 'endpoint'], + 'https://openapi.alipay.com/gateway.do', + ['openapi.alipay.com'], + ); + const privateKey = normalizePem(requireSecretString(providerConfig, ['privateKey', 'appPrivateKey']), 'PRIVATE KEY'); + const params: Record = { + app_id: appId, + method: 'alipay.trade.query', + charset: 'utf-8', + sign_type: 'RSA2', + timestamp: new Date().toISOString().replace('T', ' ').slice(0, 19), + version: '1.0', + biz_content: JSON.stringify({ out_trade_no: payment.orderNo }), + }; + params.sign = rsaSignSha256(privateKey, canonicalForm(params)); + + const { response, raw } = await fetchJson(gateway, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + body: encodedForm(params), + }, config.commerceRequestTimeoutMs); + if (!response.ok) throw new CommerceWorkerError('Alipay trade query failed', 'PAYMENT_PROVIDER_QUERY_FAILED'); + + const responseBody = objectValue(raw.alipay_trade_query_response); + const code = typeof responseBody.code === 'string' ? responseBody.code : ''; + if (code !== '10000') { + const subCode = typeof responseBody.sub_code === 'string' ? responseBody.sub_code : ''; + if (subCode === 'ACQ.TRADE_NOT_EXIST') { + return { + provider: 'alipay', + status: 'pending', + eventType: 'TRADE.NOT_EXIST', + orderNo: payment.orderNo, + raw, + }; + } + throw new CommerceWorkerError('Alipay trade query was rejected', 'PAYMENT_PROVIDER_QUERY_REJECTED'); + } + + const status = alipayTradeStatus(responseBody.trade_status); + return { + provider: 'alipay', + status, + eventType: typeof responseBody.trade_status === 'string' ? responseBody.trade_status : 'TRADE.QUERY', + orderNo: payment.orderNo, + providerTradeNo: typeof responseBody.trade_no === 'string' ? responseBody.trade_no : null, + amountCents: optionalYuanToCents(responseBody.total_amount), + paidAt: typeof responseBody.send_pay_date === 'string' ? responseBody.send_pay_date : null, + failureReason: status === 'failed' || status === 'closed' ? String(responseBody.trade_status || status) : null, + raw, + }; +} + +async function queryAlipayRefund(providerConfig: ProviderConfig, refund: RefundCandidate): Promise { + const appId = requirePublicString(providerConfig, ['appId']); + const gateway = providerEndpointForKeys( + providerConfig, + ['refundQueryEndpoint', 'refundEndpoint', 'endpoint'], + 'https://openapi.alipay.com/gateway.do', + ['openapi.alipay.com'], + ); + const privateKey = normalizePem(requireSecretString(providerConfig, ['privateKey', 'appPrivateKey']), 'PRIVATE KEY'); + const bizContent: Record = { + out_trade_no: refund.orderNo, + out_request_no: refund.refundNo, + }; + if (refund.providerTradeNo) { + delete bizContent.out_trade_no; + bizContent.trade_no = refund.providerTradeNo; + } + const params: Record = { + app_id: appId, + method: 'alipay.trade.fastpay.refund.query', + charset: 'utf-8', + sign_type: 'RSA2', + timestamp: new Date().toISOString().replace('T', ' ').slice(0, 19), + version: '1.0', + biz_content: JSON.stringify(bizContent), + }; + params.sign = rsaSignSha256(privateKey, canonicalForm(params)); + + const { response, raw } = await fetchJson(gateway, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + body: encodedForm(params), + }, config.commerceRequestTimeoutMs); + if (!response.ok) throw new CommerceWorkerError('Alipay refund query failed', 'PAYMENT_PROVIDER_REFUND_QUERY_FAILED'); + + const responseBody = objectValue(raw.alipay_trade_fastpay_refund_query_response); + const code = typeof responseBody.code === 'string' ? responseBody.code : ''; + if (code !== '10000') throw new CommerceWorkerError('Alipay refund query was rejected', 'PAYMENT_PROVIDER_REFUND_QUERY_REJECTED'); + const amountCents = optionalYuanToCents(responseBody.refund_amount); + if (amountCents !== null && amountCents !== refund.amountCents) { + throw new CommerceWorkerError('Alipay refund amount mismatch', 'PAYMENT_PROVIDER_REFUND_AMOUNT_MISMATCH'); + } + const status = alipayRefundStatus(responseBody); + return { + provider: 'alipay', + status, + eventType: 'REFUND.QUERY', + providerRefundNo: + (typeof responseBody.trade_no === 'string' && responseBody.trade_no) + || (typeof responseBody.out_request_no === 'string' && responseBody.out_request_no) + || refund.providerRefundNo, + failureReason: status === 'failed' + ? (typeof responseBody.sub_msg === 'string' && responseBody.sub_msg) || 'Alipay refund failed' + : null, + raw, + }; +} + +async function queryPayment(providerConfig: ProviderConfig, payment: PaymentCandidate) { + return providerConfig.provider === 'wechat_pay' + ? queryWechatPayment(providerConfig, payment) + : queryAlipayPayment(providerConfig, payment); +} + +async function queryRefund(providerConfig: ProviderConfig, refund: RefundCandidate) { + return providerConfig.provider === 'wechat_pay' + ? queryWechatRefund(providerConfig, refund) + : queryAlipayRefund(providerConfig, refund); +} + +function eventError(error: unknown) { + return error instanceof Error ? truncate(error.message) : truncate(error); +} + +async function claimPaymentCandidates(client: pg.PoolClient, limit: number, claimId: string) { + const result = await client.query( + ` + with due as ( + select p.id + from public.payments p + join public.orders o on o.tenant_id = p.tenant_id and o.id = p.order_id + where p.status = 'pending' + and o.status = 'pending' + and p.provider <> 'manual' + and p.created_at <= now() - ($2::int * interval '1 second') + and coalesce((p.raw_payload->'commerceWorker'->>'claimExpiresAt')::timestamptz, '-infinity'::timestamptz) <= now() + order by p.created_at asc + limit $1 + for update skip locked + ) + update public.payments p + set raw_payload = coalesce(p.raw_payload, '{}'::jsonb) || jsonb_build_object( + 'commerceWorker', + jsonb_build_object( + 'claimId', $3::text, + 'claimedAt', now(), + 'claimExpiresAt', now() + interval '5 minutes' + ) + ), + updated_at = now() + from due + join public.orders o on o.id = (select order_id from public.payments where id = due.id) + where p.id = due.id + returning p.id, + p.tenant_id as "tenantId", + p.order_id as "orderId", + o.order_no as "orderNo", + p.provider as "provider", + p.status as "paymentStatus", + o.status as "orderStatus", + p.amount_cents as "amountCents", + o.user_id as "userId", + o.days, + o.region_id as "regionId" + `, + [limit, config.commerceMinAgeSeconds, claimId], + ); + return result.rows + .map(row => ({ ...row, provider: normalizeProvider(row.provider) })) + .filter((row): row is PaymentCandidate => row.provider !== null); +} + +async function claimRefundCandidates(client: pg.PoolClient, limit: number, claimId: string) { + const result = await client.query( + ` + with due as ( + select rr.id + from public.commerce_refund_requests rr + join public.payments p on p.tenant_id = rr.tenant_id and p.id = rr.payment_id + where rr.status = 'processing' + and p.provider <> 'manual' + and rr.processed_at <= now() - ($2::int * interval '1 second') + and coalesce((rr.metadata->'commerceWorker'->>'claimExpiresAt')::timestamptz, '-infinity'::timestamptz) <= now() + order by rr.processed_at asc nulls first, rr.created_at asc + limit $1 + for update skip locked + ) + update public.commerce_refund_requests rr + set metadata = coalesce(rr.metadata, '{}'::jsonb) || jsonb_build_object( + 'commerceWorker', + jsonb_build_object( + 'claimId', $3::text, + 'claimedAt', now(), + 'claimExpiresAt', now() + interval '5 minutes' + ) + ), + updated_at = now() + from due + join public.orders o on o.id = (select order_id from public.commerce_refund_requests where id = due.id) + join public.payments p on p.id = (select payment_id from public.commerce_refund_requests where id = due.id) + where rr.id = due.id + returning rr.id, + rr.tenant_id as "tenantId", + rr.refund_no as "refundNo", + rr.order_id as "orderId", + rr.payment_id as "paymentId", + o.order_no as "orderNo", + coalesce(rr.provider, p.provider) as "provider", + rr.provider_refund_no as "providerRefundNo", + rr.status, + rr.amount_cents as "amountCents", + rr.reason, + rr.entitlement_action as "entitlementAction", + p.amount_cents as "paymentAmountCents", + p.provider_trade_no as "providerTradeNo" + `, + [limit, config.commerceMinAgeSeconds, claimId], + ); + return result.rows + .map(row => ({ ...row, provider: normalizeProvider(row.provider) || normalizeProvider(String(row.provider || '')) })) + .filter((row): row is RefundCandidate => row.provider !== null); +} + +async function recordPaymentEvent( + client: pg.PoolClient, + input: { + tenantId: string; + paymentId?: string | null; + provider: CommerceProvider; + eventType: string; + eventId: string; + payload: Record; + processed?: boolean; + error?: string | null; + }, +) { + await client.query( + ` + insert into public.payment_events ( + tenant_id, payment_id, provider, event_type, event_id, signature_valid, payload, processed_at, error + ) + values ($1, $2, $3, $4, $5, true, $6::jsonb, case when $7 then now() else null end, $8) + on conflict (provider, event_id) + do update set payload = excluded.payload, + payment_id = coalesce(public.payment_events.payment_id, excluded.payment_id), + processed_at = case + when excluded.processed_at is not null then excluded.processed_at + else public.payment_events.processed_at + end, + error = excluded.error + `, + [ + input.tenantId, + input.paymentId || null, + input.provider, + input.eventType, + input.eventId, + JSON.stringify(input.payload), + Boolean(input.processed), + input.error || null, + ], + ); +} + +async function recordAudit( + client: pg.PoolClient, + input: { + tenantId: string; + action: string; + targetType: string; + targetId: string | null; + details?: Record; + }, +) { + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, null, $2, $3, $4, $5::jsonb) + `, + [ + input.tenantId, + input.action, + input.targetType, + input.targetId, + JSON.stringify(input.details || {}), + ], + ); +} + +async function grantOrderEntitlementIfMissing( + client: pg.PoolClient, + input: { + tenantId: string; + orderId: string; + orderNo: string; + userId: string; + days: number; + regionId: string | null; + provider: CommerceProvider; + providerTradeNo?: string | null; + }, +) { + const existing = await client.query<{ id: string }>( + ` + select id + from public.entitlements + where tenant_id = $1 + and source_type = 'order' + and source_id = $2 + limit 1 + `, + [input.tenantId, input.orderId], + ); + if (existing.rows[0]) return existing.rows[0]; + + const days = Math.trunc(input.days || 0); + const expiresAtSql = + days < 0 ? `'2099-12-31T23:59:59Z'::timestamptz` : `$6::timestamptz + ($7::text || ' days')::interval`; + const scopeType = input.regionId ? 'region' : 'tenant'; + const startsAtResult = await client.query<{ startsAt: string }>( + ` + select greatest(now(), coalesce(max(expires_at), now())) as "startsAt" + from public.entitlements + where tenant_id = $1 + and user_id = $2 + and entitlement_type = 'svip' + and scope_type = $3 + and (($4::uuid is null and scope_id is null) or scope_id = $4::uuid) + and status = 'active' + `, + [input.tenantId, input.userId, scopeType, input.regionId], + ); + const inserted = await client.query<{ id: string }>( + ` + insert into public.entitlements ( + tenant_id, user_id, entitlement_type, scope_type, scope_id, + source_type, source_id, starts_at, expires_at, status, metadata + ) + values ( + $1, $2, 'svip', $3, $4, + 'order', $5, $6::timestamptz, + ${expiresAtSql}, + 'active', $8::jsonb + ) + returning id + `, + [ + input.tenantId, + input.userId, + scopeType, + input.regionId, + input.orderId, + startsAtResult.rows[0]?.startsAt || nowIso(), + days, + JSON.stringify({ + orderNo: input.orderNo, + paymentProvider: input.provider, + providerTradeNo: input.providerTradeNo || null, + source: 'commerce_worker_reconciliation', + }), + ], + ); + return inserted.rows[0]; +} + +async function settlePaidPayment(client: pg.PoolClient, payment: PaymentCandidate, providerResult: PaymentQueryResult) { + const orderResult = await client.query<{ + id: string; + orderNo: string; + status: string; + amountCents: number; + userId: string | null; + days: number | null; + regionId: string | null; + }>( + ` + select id, order_no as "orderNo", status, amount_cents as "amountCents", + user_id as "userId", days, region_id as "regionId" + from public.orders + where tenant_id = $1 and id = $2 + limit 1 + for update + `, + [payment.tenantId, payment.orderId], + ); + const order = orderResult.rows[0]; + if (!order) throw new CommerceWorkerError('Order not found', 'ORDER_NOT_FOUND'); + if (!order.userId) throw new CommerceWorkerError('Order has no user', 'ORDER_USER_MISSING'); + if (providerResult.amountCents !== null && providerResult.amountCents !== undefined && providerResult.amountCents !== order.amountCents) { + throw new CommerceWorkerError('Payment amount mismatch', 'PAYMENT_AMOUNT_MISMATCH'); + } + + const paymentResult = await client.query<{ + id: string; + status: string; + amountCents: number; + }>( + ` + select id, status, amount_cents as "amountCents" + from public.payments + where tenant_id = $1 and id = $2 + limit 1 + for update + `, + [payment.tenantId, payment.id], + ); + const currentPayment = paymentResult.rows[0]; + if (!currentPayment) throw new CommerceWorkerError('Payment not found', 'PAYMENT_NOT_FOUND'); + if (providerResult.amountCents !== null && providerResult.amountCents !== undefined && providerResult.amountCents !== currentPayment.amountCents) { + throw new CommerceWorkerError('Payment amount mismatch', 'PAYMENT_AMOUNT_MISMATCH'); + } + + if (order.status === 'paid') { + await client.query( + ` + update public.payments + set status = case when status = 'pending' then 'paid' else status end, + provider_trade_no = coalesce($3, provider_trade_no), + paid_at = coalesce(paid_at, $4::timestamptz), + raw_payload = coalesce(raw_payload, '{}'::jsonb) || $5::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [ + payment.tenantId, + payment.id, + providerResult.providerTradeNo || null, + providerResult.paidAt || nowIso(), + JSON.stringify({ lastProviderQuery: providerResult.raw, commerceWorker: { lastSettledAt: nowIso() } }), + ], + ); + return { idempotent: true, entitlementId: null }; + } + + if (order.status !== 'pending') { + throw new CommerceWorkerError(`Order status is ${order.status}`, 'ORDER_NOT_PAYABLE'); + } + + await client.query( + ` + update public.orders + set status = 'paid', + trade_no = $3, + paid_at = $4::timestamptz, + pay_provider = $5, + updated_at = now(), + raw_payload = coalesce(raw_payload, '{}'::jsonb) || $6::jsonb + where tenant_id = $1 and id = $2 + `, + [ + payment.tenantId, + order.id, + providerResult.providerTradeNo || null, + providerResult.paidAt || nowIso(), + providerResult.provider, + JSON.stringify({ lastProviderQuery: providerResult.raw, commerceWorker: { reconciledAt: nowIso() } }), + ], + ); + + await client.query( + ` + update public.payments + set status = 'paid', + provider_trade_no = coalesce($3, provider_trade_no), + paid_at = $4::timestamptz, + raw_payload = coalesce(raw_payload, '{}'::jsonb) || $5::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [ + payment.tenantId, + payment.id, + providerResult.providerTradeNo || null, + providerResult.paidAt || nowIso(), + JSON.stringify({ lastProviderQuery: providerResult.raw, commerceWorker: { reconciledAt: nowIso() } }), + ], + ); + + const entitlement = await grantOrderEntitlementIfMissing(client, { + tenantId: payment.tenantId, + orderId: order.id, + orderNo: order.orderNo, + userId: order.userId, + days: order.days || 0, + regionId: order.regionId, + provider: providerResult.provider, + providerTradeNo: providerResult.providerTradeNo || null, + }); + + await recordAudit(client, { + tenantId: payment.tenantId, + action: 'commerce.payment.reconciled_paid', + targetType: 'order', + targetId: order.id, + details: { + paymentId: payment.id, + orderNo: order.orderNo, + provider: providerResult.provider, + providerTradeNo: providerResult.providerTradeNo || null, + entitlementId: entitlement.id, + }, + }); + + return { idempotent: false, entitlementId: entitlement.id }; +} + +async function settleTerminalPayment(client: pg.PoolClient, payment: PaymentCandidate, providerResult: PaymentQueryResult) { + const nextOrderStatus = providerResult.status === 'closed' ? 'closed' : 'failed'; + const nextPaymentStatus = providerResult.status === 'closed' ? 'cancelled' : 'failed'; + await client.query( + ` + update public.orders + set status = $3, + updated_at = now(), + raw_payload = coalesce(raw_payload, '{}'::jsonb) || $4::jsonb + where tenant_id = $1 + and id = $2 + and status = 'pending' + `, + [ + payment.tenantId, + payment.orderId, + nextOrderStatus, + JSON.stringify({ + lastProviderQuery: providerResult.raw, + commerceWorker: { + reconciledAt: nowIso(), + terminalStatus: providerResult.status, + failureReason: providerResult.failureReason || null, + }, + }), + ], + ); + await client.query( + ` + update public.payments + set status = $3, + raw_payload = coalesce(raw_payload, '{}'::jsonb) || $4::jsonb, + updated_at = now() + where tenant_id = $1 + and id = $2 + and status = 'pending' + `, + [ + payment.tenantId, + payment.id, + nextPaymentStatus, + JSON.stringify({ + lastProviderQuery: providerResult.raw, + commerceWorker: { + reconciledAt: nowIso(), + terminalStatus: providerResult.status, + failureReason: providerResult.failureReason || null, + }, + }), + ], + ); + await recordAudit(client, { + tenantId: payment.tenantId, + action: `commerce.payment.reconciled_${nextOrderStatus}`, + targetType: 'order', + targetId: payment.orderId, + details: { + paymentId: payment.id, + orderNo: payment.orderNo, + provider: providerResult.provider, + reason: providerResult.failureReason || null, + }, + }); +} + +async function markPaymentPending(client: pg.PoolClient, payment: PaymentCandidate, providerResult: PaymentQueryResult) { + await client.query( + ` + update public.payments + set raw_payload = coalesce(raw_payload, '{}'::jsonb) || $3::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [ + payment.tenantId, + payment.id, + JSON.stringify({ + lastProviderQuery: providerResult.raw, + commerceWorker: { lastCheckedAt: nowIso(), lastStatus: providerResult.status }, + }), + ], + ); +} + +async function processPayment(payment: PaymentCandidate) { + const client = await pool.connect(); + const eventId = `${payment.tenantId}:payment-query:${payment.orderNo}`; + try { + const providerConfig = await loadPaymentProviderConfig(client, payment.tenantId, payment.provider); + const providerResult = await queryPayment(providerConfig, payment); + await client.query('begin'); + if (providerResult.status === 'paid') { + const settlement = await settlePaidPayment(client, payment, providerResult); + await recordPaymentEvent(client, { + tenantId: payment.tenantId, + paymentId: payment.id, + provider: payment.provider, + eventType: providerResult.eventType, + eventId, + payload: { + source: 'commerce_worker', + providerResult: providerResult.raw, + idempotent: settlement.idempotent, + entitlementId: settlement.entitlementId, + }, + processed: true, + }); + await client.query('commit'); + return 'paid'; + } + if (providerResult.status === 'closed' || providerResult.status === 'failed') { + await settleTerminalPayment(client, payment, providerResult); + await recordPaymentEvent(client, { + tenantId: payment.tenantId, + paymentId: payment.id, + provider: payment.provider, + eventType: providerResult.eventType, + eventId, + payload: { source: 'commerce_worker', providerResult: providerResult.raw }, + processed: true, + }); + await client.query('commit'); + return providerResult.status; + } + await markPaymentPending(client, payment, providerResult); + await recordPaymentEvent(client, { + tenantId: payment.tenantId, + paymentId: payment.id, + provider: payment.provider, + eventType: providerResult.eventType, + eventId, + payload: { source: 'commerce_worker', providerResult: providerResult.raw }, + processed: false, + }); + await client.query('commit'); + return 'pending'; + } catch (error) { + await client.query('rollback').catch(() => {}); + await client.query( + ` + update public.payments + set raw_payload = coalesce(raw_payload, '{}'::jsonb) || $3::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [ + payment.tenantId, + payment.id, + JSON.stringify({ commerceWorker: { lastCheckedAt: nowIso(), lastError: eventError(error) } }), + ], + ).catch(() => {}); + await recordPaymentEvent(client, { + tenantId: payment.tenantId, + paymentId: payment.id, + provider: payment.provider, + eventType: 'PAYMENT.QUERY.ERROR', + eventId, + payload: { source: 'commerce_worker' }, + processed: false, + error: eventError(error), + }).catch(() => {}); + return 'error'; + } finally { + client.release(); + } +} + +async function applySuccessfulRefund( + client: pg.PoolClient, + refund: RefundCandidate, + providerResult: RefundQueryResult, +) { + const orderResult = await client.query<{ + id: string; + status: string; + amountCents: number; + refundedAmountCents: number; + }>( + ` + select id, status, amount_cents as "amountCents", refunded_amount_cents as "refundedAmountCents" + from public.orders + where tenant_id = $1 and id = $2 + limit 1 + for update + `, + [refund.tenantId, refund.orderId], + ); + const order = orderResult.rows[0]; + if (!order) throw new CommerceWorkerError('Order not found', 'ORDER_NOT_FOUND'); + if (!['paid', 'partially_refunded'].includes(order.status)) { + throw new CommerceWorkerError(`Order status is ${order.status}`, 'ORDER_NOT_REFUNDABLE'); + } + + const nextRefundedAmount = order.refundedAmountCents + refund.amountCents; + if (nextRefundedAmount > order.amountCents) { + throw new CommerceWorkerError('Refund amount exceeds paid amount', 'REFUND_AMOUNT_EXCEEDS_PAID'); + } + const finalOrderStatus = nextRefundedAmount === order.amountCents ? 'refunded' : 'partially_refunded'; + + await client.query( + ` + update public.orders + set status = $4, + refunded_amount_cents = $3, + updated_at = now(), + raw_payload = coalesce(raw_payload, '{}'::jsonb) || $5::jsonb + where tenant_id = $1 and id = $2 + `, + [ + refund.tenantId, + order.id, + nextRefundedAmount, + finalOrderStatus, + JSON.stringify({ + lastRefund: { + refundNo: refund.refundNo, + amountCents: refund.amountCents, + providerRefundNo: providerResult.providerRefundNo || refund.providerRefundNo || null, + source: 'commerce_worker_reconciliation', + }, + }), + ], + ); + + if (refund.paymentId) { + const paymentResult = await client.query<{ + id: string; + amountCents: number; + refundedAmountCents: number; + }>( + ` + select id, amount_cents as "amountCents", refunded_amount_cents as "refundedAmountCents" + from public.payments + where tenant_id = $1 and id = $2 + limit 1 + for update + `, + [refund.tenantId, refund.paymentId], + ); + const payment = paymentResult.rows[0]; + if (!payment) throw new CommerceWorkerError('Payment not found', 'PAYMENT_NOT_FOUND'); + const paymentRefundedAmount = payment.refundedAmountCents + refund.amountCents; + if (paymentRefundedAmount > payment.amountCents) { + throw new CommerceWorkerError('Refund amount exceeds payment amount', 'REFUND_AMOUNT_EXCEEDS_PAID'); + } + await client.query( + ` + update public.payments + set status = $4, + refunded_amount_cents = $3, + raw_payload = coalesce(raw_payload, '{}'::jsonb) || $5::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [ + refund.tenantId, + payment.id, + paymentRefundedAmount, + paymentRefundedAmount === payment.amountCents ? 'refunded' : 'partially_refunded', + JSON.stringify({ + lastRefund: { + refundNo: refund.refundNo, + amountCents: refund.amountCents, + providerRefundNo: providerResult.providerRefundNo || refund.providerRefundNo || null, + source: 'commerce_worker_reconciliation', + }, + }), + ], + ); + } + + let revokedEntitlements = 0; + if (refund.entitlementAction === 'revoke_on_success' && finalOrderStatus === 'refunded') { + const revoked = await client.query( + ` + update public.entitlements + set status = 'revoked', + revoked_at = now(), + revoked_by = null, + revoked_reason = $3, + metadata = coalesce(metadata, '{}'::jsonb) || $4::jsonb + where tenant_id = $1 + and source_type = 'order' + and source_id = $2 + and status = 'active' + `, + [ + refund.tenantId, + refund.orderId, + `Full refund ${refund.refundNo}`, + JSON.stringify({ revokedBy: 'commerce_worker_reconciliation', refundNo: refund.refundNo }), + ], + ); + revokedEntitlements = revoked.rowCount || 0; + } + + return { orderStatus: finalOrderStatus, refundedAmountCents: nextRefundedAmount, revokedEntitlements }; +} + +async function recordRefundEvent( + client: pg.PoolClient, + input: { + tenantId: string; + refundRequestId: string; + fromStatus: string | null; + toStatus: string; + eventType: string; + details?: Record; + }, +) { + await client.query( + ` + insert into public.commerce_refund_events ( + tenant_id, refund_request_id, from_status, to_status, event_type, actor_user_id, details + ) + values ($1, $2, $3, $4, $5, null, $6::jsonb) + `, + [ + input.tenantId, + input.refundRequestId, + input.fromStatus, + input.toStatus, + input.eventType, + JSON.stringify(input.details || {}), + ], + ); +} + +async function settleRefund(client: pg.PoolClient, refund: RefundCandidate, providerResult: RefundQueryResult) { + const currentResult = await client.query<{ + id: string; + status: string; + }>( + ` + select id, status + from public.commerce_refund_requests + where tenant_id = $1 and id = $2 + limit 1 + for update + `, + [refund.tenantId, refund.id], + ); + const current = currentResult.rows[0]; + if (!current) throw new CommerceWorkerError('Refund request not found', 'REFUND_NOT_FOUND'); + if (current.status === 'succeeded') return { idempotent: true, nextStatus: 'succeeded', processResult: {} }; + if (current.status !== 'processing') { + throw new CommerceWorkerError(`Refund status is ${current.status}`, 'REFUND_STATUS_INVALID'); + } + + if (providerResult.status === 'succeeded') { + const processResult = await applySuccessfulRefund(client, refund, providerResult); + await client.query( + ` + update public.commerce_refund_requests + set status = 'succeeded', + provider_refund_no = coalesce($3, provider_refund_no), + succeeded_at = coalesce(succeeded_at, now()), + processed_at = coalesce(processed_at, now()), + metadata = coalesce(metadata, '{}'::jsonb) || $4::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [ + refund.tenantId, + refund.id, + providerResult.providerRefundNo || null, + JSON.stringify({ + lastAction: 'commerce_worker_refund_query', + providerResult: providerResult.raw, + processResult, + }), + ], + ); + await recordRefundEvent(client, { + tenantId: refund.tenantId, + refundRequestId: refund.id, + fromStatus: current.status, + toStatus: 'succeeded', + eventType: 'provider_query_succeeded', + details: { + source: 'commerce_worker', + provider: providerResult.provider, + providerRefundNo: providerResult.providerRefundNo || null, + processResult, + }, + }); + return { idempotent: false, nextStatus: 'succeeded', processResult }; + } + + if (providerResult.status === 'failed') { + await client.query( + ` + update public.commerce_refund_requests + set status = 'failed', + provider_refund_no = coalesce($3, provider_refund_no), + failed_at = coalesce(failed_at, now()), + failure_reason = $4, + metadata = coalesce(metadata, '{}'::jsonb) || $5::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [ + refund.tenantId, + refund.id, + providerResult.providerRefundNo || null, + providerResult.failureReason || 'Provider refund failed', + JSON.stringify({ + lastAction: 'commerce_worker_refund_query', + providerResult: providerResult.raw, + }), + ], + ); + await recordRefundEvent(client, { + tenantId: refund.tenantId, + refundRequestId: refund.id, + fromStatus: current.status, + toStatus: 'failed', + eventType: 'provider_query_failed', + details: { + source: 'commerce_worker', + provider: providerResult.provider, + providerRefundNo: providerResult.providerRefundNo || null, + failureReason: providerResult.failureReason || null, + }, + }); + return { idempotent: false, nextStatus: 'failed', processResult: {} }; + } + + await client.query( + ` + update public.commerce_refund_requests + set metadata = coalesce(metadata, '{}'::jsonb) || $3::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [ + refund.tenantId, + refund.id, + JSON.stringify({ + lastAction: 'commerce_worker_refund_query', + providerResult: providerResult.raw, + commerceWorker: { lastCheckedAt: nowIso(), lastStatus: providerResult.status }, + }), + ], + ); + await recordRefundEvent(client, { + tenantId: refund.tenantId, + refundRequestId: refund.id, + fromStatus: current.status, + toStatus: 'processing', + eventType: 'provider_query_processing', + details: { + source: 'commerce_worker', + provider: providerResult.provider, + providerRefundNo: providerResult.providerRefundNo || null, + }, + }); + return { idempotent: false, nextStatus: 'processing', processResult: {} }; +} + +async function processRefund(refund: RefundCandidate) { + const client = await pool.connect(); + const eventId = `${refund.tenantId}:refund-query:${refund.refundNo}`; + try { + const providerConfig = await loadPaymentProviderConfig(client, refund.tenantId, refund.provider); + const providerResult = await queryRefund(providerConfig, refund); + await client.query('begin'); + const settlement = await settleRefund(client, refund, providerResult); + await recordPaymentEvent(client, { + tenantId: refund.tenantId, + paymentId: refund.paymentId, + provider: refund.provider, + eventType: providerResult.eventType, + eventId, + payload: { + source: 'commerce_worker', + refundNo: refund.refundNo, + providerResult: providerResult.raw, + settlement, + }, + processed: providerResult.status !== 'processing', + }); + await client.query('commit'); + return settlement.nextStatus; + } catch (error) { + await client.query('rollback').catch(() => {}); + await client.query( + ` + update public.commerce_refund_requests + set metadata = coalesce(metadata, '{}'::jsonb) || $3::jsonb, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [ + refund.tenantId, + refund.id, + JSON.stringify({ commerceWorker: { lastCheckedAt: nowIso(), lastError: eventError(error) } }), + ], + ).catch(() => {}); + await recordPaymentEvent(client, { + tenantId: refund.tenantId, + paymentId: refund.paymentId, + provider: refund.provider, + eventType: 'REFUND.QUERY.ERROR', + eventId, + payload: { source: 'commerce_worker', refundNo: refund.refundNo }, + processed: false, + error: eventError(error), + }).catch(() => {}); + return 'error'; + } finally { + client.release(); + } +} + +export async function processCommerceBatch(limit = config.commerceBatchSize): Promise { + const claimId = crypto.randomUUID(); + const client = await pool.connect(); + let payments: PaymentCandidate[] = []; + let refunds: RefundCandidate[] = []; + try { + await client.query('begin'); + payments = await claimPaymentCandidates(client, limit, claimId); + refunds = await claimRefundCandidates(client, limit, claimId); + await client.query('commit'); + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); + } + + const result: ProcessResult = { + processed: payments.length + refunds.length, + payments: { processed: payments.length, paid: 0, pending: 0, closed: 0, failed: 0, errors: 0 }, + refunds: { processed: refunds.length, succeeded: 0, processing: 0, failed: 0, errors: 0 }, + }; + + for (const payment of payments) { + const status = await processPayment(payment); + if (status === 'paid') result.payments.paid += 1; + else if (status === 'pending') result.payments.pending += 1; + else if (status === 'closed') result.payments.closed += 1; + else if (status === 'failed') result.payments.failed += 1; + else result.payments.errors += 1; + } + + for (const refund of refunds) { + const status = await processRefund(refund); + if (status === 'succeeded') result.refunds.succeeded += 1; + else if (status === 'processing') result.refunds.processing += 1; + else if (status === 'failed') result.refunds.failed += 1; + else result.refunds.errors += 1; + } + + return result; +} diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 5f58d079..f5cea6a6 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -109,7 +109,8 @@ | 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons` | | 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 | | 退款状态机和供应商确认 | 可联调 | `/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` 覆盖幂等和密钥不泄露 | +| 完整资金流水对账 | 待补齐 | 后续补微信/支付宝账单下载、平台账单比对、差错处理、异常订单运营台 | ## 租户后台与平台后台 diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index da555239..a7201aef 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -29,7 +29,7 @@ | 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护 | 批量导入、复杂筛选、AI 择校上下文 | | 视频解析 | 部分完成 | 单题视频、批量查询、后台视频绑定 | 会员播放权限、播放次数扣减、签名 URL、防盗链、水印 | | 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名 | 上传后对象校验、PDF 预览、防盗链、视频水印 | -| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、权益发放 | 对账、支付补偿、异常订单自动处理 | +| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 | | 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信小程序/网页登录、QQ 登录、Supabase Auth | | 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker | | 内容导入 | 基础完成 | 题目、单词、知识手册 JSON preview/import、issue、job、审计、幂等 | Excel/CSV、分数线、视频导入,大批量异步 worker | @@ -83,7 +83,7 @@ ### P1:商用收费和运营能力 -- 对账、支付补偿任务和异常订单自动处理。 +- 完整资金流水对账、账单下载比对和异常订单运营台。 - XPay 或其它实际支付网关 adapter。 - 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。 - 公共题库/地区题库版本同步,租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index de80af8b..c1525725 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -29,6 +29,7 @@ - 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。 - 已新增 `npm run test:api`,自动 seed、构建、启动临时 API,并断言核心学生端接口、内容导航/组卷、租户隔离、资源权限和题目导入。 - 已新增 `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` 权益开通逻辑。 - 优惠券领取同用户同券幂等;下单后优惠券 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,全额退款成功会撤销订单来源权益;对账 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` 和掩码状态。 - `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。 - 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。 @@ -246,8 +247,8 @@ GET /api/tenant-admin/audit-logs 1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,对象存储上传后校验、PDF 预览、防盗链和视频水印。 2. 接入真实短信 provider:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。 3. 接入真实 OAuth provider:微信网页、微信小程序、QQ,并处理旧 PocketBase 身份映射。 -4. 补支付补偿任务、对账、异常订单自动处理和优惠券核销报表;退款查询确认和退款通知主链路已完成。 -5. 扩展 `apps/worker`:支付补偿、日报统计、导入后检查、CRM 死信告警和公共题库同步。 +4. 补完整资金流水对账、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认和退款通知主链路已完成。 +5. 扩展 `apps/worker`:日报统计、导入后检查、CRM 死信告警和公共题库同步。 6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。 ## 测试命令 @@ -255,5 +256,6 @@ GET /api/tenant-admin/audit-logs ```text npm run test:api npm run test:worker:crm +npm run test:worker:commerce npm run check:refactor ``` diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index e6e4f558..c5206a9e 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -30,7 +30,7 @@ | 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 | | 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 | | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 | -| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook 和全额退款权益撤销已有;缺对账/补偿任务和前端收银台/售后体验 | +| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker 和全额退款权益撤销已有;缺完整资金流水对账、异常订单运营台和前端收银台/售后体验 | | 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 | | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 | | AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 | @@ -100,7 +100,7 @@ 3. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。 4. 导入扩展:Excel/CSV、分数线、视频批量导入和大批量异步 worker。 5. 公共题库商业化:平台公共/地区题库授权和租户快照采纳已完成基础闭环;还需版本同步、租户自改冲突处理和运营后台 UI。 -6. CRM/销售结算:分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺真实 CRM worker、轮询/定向分配、打款导出、凭证和销售结算看板。 +6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。 7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 30578538..ac915ada 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -23,6 +23,7 @@ - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 - 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session。 - 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。 +- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。 - 本地验证:`npm run check:refactor` 已通过。 当前更适合进入前端联调前阅读的总览文档: @@ -60,7 +61,8 @@ 1. 支付 - 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。 - 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、部分/全额退款状态、全额退款权益撤销和审计事件。 - - 继续补支付补偿任务、对账和异常订单自动处理。 + - 已完成支付/退款补偿 worker,可兜底供应商漏通知、处理中退款和重复执行幂等。 + - 继续补完整资金流水对账、账单下载比对和异常订单运营台。 - 租户自有商户收款和平台代收/服务商模式。 2. 国内登录和短信 @@ -93,7 +95,7 @@ 7. 订单和营销体验 - 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。 - - 继续补支付补偿任务、对账、异常订单自动处理、优惠券核销报表和复杂活动规则。 + - 已完成支付/退款补偿 worker;继续补完整资金流水对账、异常订单运营台、优惠券核销报表和复杂活动规则。 8. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 @@ -201,5 +203,5 @@ 2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。 4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。 -5. 并行补对象存储、真实登录、支付对账和公共题库版本同步 worker。 +5. 并行补对象存储、真实登录、完整资金流水对账和公共题库版本同步 worker。 6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index ea066fc7..e6337d00 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -872,6 +872,8 @@ GET /api/commerce/entitlements 订单详情会返回 `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`,并提供客服/工单入口。租户后台或运营后台才接退款接口。 @@ -934,7 +936,7 @@ approved/processing -> failed - 退款通知地址由支付账户或 `submit_provider_refund.providerNotifyUrl` 配置,后端公开接收路径为 `POST /api/commerce/refunds/notify/wechat_pay?tenantId=`、`POST /api/commerce/refunds/notify/alipay?tenantId=`。这是支付平台回调地址,Taro 前端不要主动调用。 - 退款通知只会推进已经审核/处理中的退款申请;未审核的 `requested` 退款不能被外部通知直接落账。 - 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。 -- 自动对账和补偿 worker 后续接入;当前生产联调时仍需运营后台保留人工确认/失败登记入口。 +- 自动补偿 worker 已接入:支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。完整资金流水对账、账单下载比对和异常订单运营台后续继续补;生产联调时仍需保留人工确认/失败登记入口。 ### 激活码预检查与兑换 diff --git a/package.json b/package.json index ea87974e..303f4cf4 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "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: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", "pb:schema:summary": "npm --workspace @tiku-saas/import-pocketbase run schema:summary", "pb:schema:risk": "npm --workspace @tiku-saas/import-pocketbase run schema:risk", diff --git a/scripts/commerce-worker-integration-test.js b/scripts/commerce-worker-integration-test.js new file mode 100644 index 00000000..7ac18bf3 --- /dev/null +++ b/scripts/commerce-worker-integration-test.js @@ -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); +});