diff --git a/README.md b/README.md index a50f92e3..5aafa160 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,14 @@ - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。 - `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。 -- 订单售后基础闭环:退款请求、审核、处理状态流、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 +- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 - PocketBase schema/数据导入器雏形和导入后校验脚本。 - 本地 Supabase reset、烟测 seed、API 集成测试、完整重构检查命令。 还没有达到生产交付的部分: - Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。 -- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路和微信/支付宝发起退款已完成本地适配;微信网页登录、QQ 登录、手机号换绑、退款通知/查询确认、对账、支付补偿和真实生产账号联调还没接完。 +- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路和微信/支付宝发起退款/查询确认已完成本地适配;微信网页登录、QQ 登录、手机号换绑、退款通知 webhook、对账、支付补偿和真实生产账号联调还没接完。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。 - Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。 - 分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。 @@ -193,4 +193,4 @@ npm run check:refactor 2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。 3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。 4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。 -5. 微信网页/QQ 登录、退款通知/查询确认、支付对账、支付补偿、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。 +5. 微信网页/QQ 登录、退款通知 webhook、支付对账、支付补偿、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/commerce/providers.ts b/apps/api/src/features/commerce/providers.ts index 5c5c1865..74fe580f 100644 --- a/apps/api/src/features/commerce/providers.ts +++ b/apps/api/src/features/commerce/providers.ts @@ -38,6 +38,17 @@ export interface PaymentRefundInput { notifyUrl?: string | null; } +export interface PaymentRefundQueryInput { + tenantId: string; + orderId: string; + orderNo: string; + refundNo: string; + amountCents: number; + totalAmountCents: number; + providerTradeNo?: string | null; + providerRefundNo?: string | null; +} + export interface PaymentCreateResult { provider: PaymentProviderName; method: string; @@ -50,6 +61,7 @@ export interface PaymentRefundResult { provider: PaymentProviderName; status: 'processing' | 'succeeded' | 'failed'; providerRefundNo?: string; + failureReason?: string; raw: Record; } @@ -68,6 +80,7 @@ export interface PaymentProvider { name: PaymentProviderName; createPayment(input: PaymentOrderInput): Promise; createRefund(input: PaymentRefundInput): Promise; + queryRefund(input: PaymentRefundQueryInput): Promise; parseNotification(input: { headers: Record; body: Record; @@ -112,6 +125,39 @@ function safeJson(value: unknown) { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } +function wechatRefundStatus(value: unknown) { + const status = typeof value === 'string' ? value.toUpperCase() : ''; + if (status === 'SUCCESS') return 'succeeded'; + if (['CLOSED', 'ABNORMAL'].includes(status)) return 'failed'; + return 'processing'; +} + +function alipayRefundStatus(value: Record) { + 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'; +} + +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 assertProviderRefundAmount(actualCents: number | null, expectedCents: number, code = 'PAYMENT_PROVIDER_REFUND_AMOUNT_MISMATCH') { + if (actualCents !== null && actualCents !== expectedCents) { + throw new HttpError(502, 'Payment provider refund amount mismatch', code); + } +} + function canonicalForm(params: Record) { return Object.keys(params) .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '') @@ -266,10 +312,55 @@ class WechatPayProvider implements PaymentProvider { } const statusText = typeof raw.status === 'string' ? raw.status.toUpperCase() : ''; + const status = wechatRefundStatus(statusText); + const amount = objectValue(raw.amount); + assertProviderRefundAmount(optionalIntegerAmount(amount.refund), input.amountCents); return { provider: this.name, - status: ['SUCCESS', 'CLOSED', 'ABNORMAL'].includes(statusText) ? 'succeeded' : 'processing', + status, providerRefundNo: typeof raw.refund_id === 'string' ? raw.refund_id : undefined, + failureReason: status === 'failed' ? (statusText || 'WeChat refund failed') : undefined, + raw, + }; + } + + async queryRefund(input: PaymentRefundQueryInput): Promise { + const mchId = requirePublicString(this.config, ['merchantId', 'mchId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); + const merchantSerialNo = requirePublicString(this.config, ['merchantSerialNo'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); + const endpoint = providerEndpointForKeys( + this.config, + ['refundQueryEndpoint', 'refundEndpoint'], + 'https://api.mch.weixin.qq.com/v3/refund/domestic/refunds', + ['api.mch.weixin.qq.com'], + 'PAYMENT_ENDPOINT_NOT_ALLOWED', + ); + const url = new URL(`${endpoint.replace(/\/$/, '')}/${encodeURIComponent(input.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(this.config), message); + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${merchantSerialNo}"`, + }, + }); + const raw = safeJson(await response.json().catch(() => ({}))); + if (!response.ok) { + throw new HttpError(502, 'WeChat Pay refund query failed', 'PAYMENT_PROVIDER_REFUND_QUERY_FAILED'); + } + + const statusText = typeof raw.status === 'string' ? raw.status.toUpperCase() : ''; + const status = wechatRefundStatus(statusText); + const amount = objectValue(raw.amount); + assertProviderRefundAmount(optionalIntegerAmount(amount.refund), input.amountCents); + return { + provider: this.name, + status, + providerRefundNo: typeof raw.refund_id === 'string' ? raw.refund_id : input.providerRefundNo || undefined, + failureReason: status === 'failed' ? (statusText || 'WeChat refund failed') : undefined, raw, }; } @@ -446,14 +537,83 @@ class AlipayProvider implements PaymentProvider { if (code !== '10000') { throw new HttpError(502, 'Alipay refund was rejected', 'PAYMENT_PROVIDER_REFUND_REJECTED'); } + const status = alipayRefundStatus(responseBody); + assertProviderRefundAmount(optionalYuanToCents(responseBody.refund_fee), input.amountCents); return { provider: this.name, - status: 'succeeded', + status, providerRefundNo: (typeof responseBody.trade_no === 'string' && responseBody.trade_no) || (typeof responseBody.out_request_no === 'string' && responseBody.out_request_no) || undefined, + failureReason: status === 'failed' + ? (typeof responseBody.sub_msg === 'string' && responseBody.sub_msg) || 'Alipay refund failed' + : undefined, + raw, + }; + } + + async queryRefund(input: PaymentRefundQueryInput): Promise { + const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); + const gateway = providerEndpointForKeys( + this.config, + ['refundQueryEndpoint', 'refundEndpoint', 'endpoint'], + 'https://openapi.alipay.com/gateway.do', + ['openapi.alipay.com'], + 'PAYMENT_ENDPOINT_NOT_ALLOWED', + ); + const privateKey = normalizePem(requireSecretString(this.config, ['privateKey', 'appPrivateKey'], 'PAYMENT_SECRET_REQUIRED'), 'PRIVATE KEY'); + const bizContent: Record = { + out_trade_no: input.orderNo, + out_request_no: input.refundNo, + }; + if (input.providerTradeNo) { + delete bizContent.out_trade_no; + bizContent.trade_no = input.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 = await fetch(gateway, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + body: encodedForm(params), + }); + const raw = safeJson(await response.json().catch(() => ({}))); + if (!response.ok) { + throw new HttpError(502, '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 HttpError(502, 'Alipay refund query was rejected', 'PAYMENT_PROVIDER_REFUND_QUERY_REJECTED'); + } + const status = alipayRefundStatus(responseBody); + assertProviderRefundAmount(optionalYuanToCents(responseBody.refund_amount), input.amountCents); + return { + provider: this.name, + status, + providerRefundNo: + (typeof responseBody.trade_no === 'string' && responseBody.trade_no) + || (typeof responseBody.out_request_no === 'string' && responseBody.out_request_no) + || input.providerRefundNo + || undefined, + failureReason: status === 'failed' + ? (typeof responseBody.sub_msg === 'string' && responseBody.sub_msg) || 'Alipay refund failed' + : undefined, raw, }; } @@ -523,6 +683,10 @@ class ManualPaymentProvider implements PaymentProvider { throw new HttpError(501, 'Manual provider does not support online refunds', 'PAYMENT_PROVIDER_REFUND_NOT_SUPPORTED'); } + async queryRefund(): Promise { + throw new HttpError(501, 'Manual provider does not support online refund query', 'PAYMENT_PROVIDER_REFUND_NOT_SUPPORTED'); + } + async parseNotification(): Promise { throw new HttpError(501, 'Manual provider does not support webhook notifications', 'PAYMENT_PROVIDER_NOT_SUPPORTED'); } diff --git a/apps/api/src/features/commerce/routes.ts b/apps/api/src/features/commerce/routes.ts index 8dabda5d..2a0fff33 100644 --- a/apps/api/src/features/commerce/routes.ts +++ b/apps/api/src/features/commerce/routes.ts @@ -1373,6 +1373,7 @@ export async function updateRefundStatusRoute(ctx: RequestContext) { let eventType = action; let processResult: Record = {}; let providerResult: Record = {}; + let statusFailureReason = failureReason; const details: Record = { note, providerRefundNo, providerNotifyUrl, failureReason, metadata: metadataPatch }; if (action === 'approve') { @@ -1400,13 +1401,44 @@ export async function updateRefundStatusRoute(ctx: RequestContext) { notifyUrl: providerNotifyUrl, }); toStatus = submitted.nextStatus; - eventType = submitted.nextStatus === 'succeeded' ? 'provider_succeeded' : 'provider_submitted'; + eventType = submitted.nextStatus === 'succeeded' + ? 'provider_succeeded' + : submitted.nextStatus === 'failed' + ? 'provider_failed' + : 'provider_submitted'; providerResult = submitted.refundResult.raw; processResult = submitted.processResult; details.provider = submitted.refundResult.provider; details.providerStatus = submitted.refundResult.status; details.providerRefundNo = submitted.refundResult.providerRefundNo || providerRefundNo || null; details.processResult = processResult; + if (submitted.refundResult.failureReason) { + statusFailureReason = submitted.refundResult.failureReason; + details.failureReason = submitted.refundResult.failureReason; + } + } else if (action === 'query_provider_refund') { + if (fromStatus !== 'processing') { + throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID'); + } + const queried = await queryRefundFromProvider(client, { + tenantId: auth.tenantId, + refund, + actorUserId: auth.userId, + }); + toStatus = queried.nextStatus; + eventType = queried.nextStatus === 'succeeded' + ? 'provider_query_succeeded' + : queried.nextStatus === 'failed' + ? 'provider_query_failed' + : 'provider_query_processing'; + providerResult = queried.refundResult.raw; + processResult = queried.processResult; + details.provider = queried.refundResult.provider; + details.providerStatus = queried.refundResult.status; + details.providerRefundNo = queried.refundResult.providerRefundNo || providerRefundNo || null; + statusFailureReason = queried.refundResult.failureReason || failureReason; + details.failureReason = statusFailureReason; + details.processResult = processResult; } else if (action === 'mark_succeeded') { if (!['requested', 'approved', 'processing'].includes(fromStatus)) { throw new HttpError(409, `Refund status is ${fromStatus}`, 'REFUND_STATUS_INVALID'); @@ -1459,7 +1491,7 @@ export async function updateRefundStatusRoute(ctx: RequestContext) { toStatus, providerRefundNo, auth.userId, - failureReason, + statusFailureReason, JSON.stringify({ lastAction: action, note, processResult, providerResult, ...metadataPatch }), typeof details.providerRefundNo === 'string' ? details.providerRefundNo : null, ], @@ -1685,6 +1717,66 @@ async function submitRefundToProvider( }); return { refundResult, nextStatus: 'succeeded', processResult }; } + if (refundResult.status === 'failed') { + return { refundResult, nextStatus: 'failed', processResult: {} }; + } + + return { refundResult, nextStatus: 'processing', processResult: {} }; +} + +async function queryRefundFromProvider( + client: pg.PoolClient, + input: { + tenantId: string; + refund: RefundRequestRow; + actorUserId: string; + }, +) { + const paymentResult = await client.query<{ + id: string; + provider: string; + providerTradeNo: string | null; + amountCents: number; + }>( + ` + select id, provider, provider_trade_no as "providerTradeNo", amount_cents as "amountCents" + from public.payments + where tenant_id = $1 and id = $2 + limit 1 + `, + [input.tenantId, input.refund.paymentId], + ); + const payment = paymentResult.rows[0]; + if (!payment) throw new HttpError(404, 'Payment not found', 'PAYMENT_NOT_FOUND'); + if (normalizePaymentProvider(payment.provider) === 'manual') { + throw new HttpError(409, 'Manual payment refunds must be recorded manually', 'PAYMENT_PROVIDER_REFUND_NOT_SUPPORTED'); + } + + const provider = await loadPaymentProvider(input.tenantId, payment.provider); + const refundResult = await provider.queryRefund({ + tenantId: input.tenantId, + orderId: input.refund.orderId, + orderNo: input.refund.orderNo, + refundNo: input.refund.refundNo, + amountCents: input.refund.amountCents, + totalAmountCents: payment.amountCents, + providerTradeNo: payment.providerTradeNo, + providerRefundNo: input.refund.providerRefundNo, + }); + + if (refundResult.status === 'succeeded') { + const processResult = await applySuccessfulRefund(client, { + tenantId: input.tenantId, + refund: input.refund, + actorUserId: input.actorUserId, + providerRefundNo: refundResult.providerRefundNo || null, + }); + return { refundResult, nextStatus: 'succeeded', processResult }; + } + + if (refundResult.status === 'failed') { + return { refundResult, nextStatus: 'failed', processResult: {} }; + } return { refundResult, nextStatus: 'processing', processResult: {} }; } diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 10b958a2..e54352b2 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -108,8 +108,8 @@ | 激活码预检查/兑换 | 可联调 | `/api/commerce/activation-codes/check`、`redeem`;支持地区校验、自用码拒绝、已用码稳定 reasonCode | | 优惠券后台配置 | 可联调 | `/api/tenant-admin/coupons` | | 优惠券前台领取/下单抵扣 | 可联调 | `/api/commerce/coupons/claim`;支持同用户同券幂等领取、下单绑定、负数订单项、全额优惠自动开通权益 | -| 退款状态机和发起退款 | 可联调 | `/api/commerce/refunds`、`/api/commerce/refunds/status`;支持退款申请、审核、调用微信/支付宝发起退款、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 | -| 退款确认/补偿/对账 | 待补齐 | 微信退款通知解密、退款查询确认、支付宝退款查询、支付补偿任务、对账、异常订单自动处理和退款 worker | +| 退款状态机和供应商确认 | 可联调 | `/api/commerce/refunds`、`/api/commerce/refunds/status`;支持退款申请、审核、调用微信/支付宝发起退款、`query_provider_refund` 查询确认、处理中、成功/失败/拒绝/取消、退款金额累计、部分退款、全额退款权益撤销、退款事件和审计 | +| 退款通知/补偿/对账 | 待补齐 | 微信退款通知解密、支付宝异步通知映射、支付补偿任务、对账、异常订单自动处理和退款 worker | ## 租户后台与平台后台 diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 463ffd02..cf74ef30 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、对账、支付补偿、异常订单自动处理 | | 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信小程序/网页登录、QQ 登录、Supabase Auth | | 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker | | 内容导入 | 基础完成 | 题目、单词、知识手册 JSON preview/import、issue、job、审计、幂等 | Excel/CSV、分数线、视频导入,大批量异步 worker | @@ -83,7 +83,7 @@ ### P1:商用收费和运营能力 -- 退款通知/查询确认、对账、支付补偿任务和异常订单自动处理。 +- 退款通知 webhook、对账、支付补偿任务和异常订单自动处理。 - XPay 或其它实际支付网关 adapter。 - 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。 - 公共题库/地区题库版本同步,租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 0316078f..9665c79f 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -228,7 +228,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` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,全额退款成功会撤销订单来源权益;退款通知/查询确认、对账 worker 后续接入。 +- `/api/commerce/refunds` 和 `/api/commerce/refunds/status` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,并通过 `query_provider_refund` 主动查询确认供应商退款结果,全额退款成功会撤销订单来源权益;退款通知 webhook、对账 worker 后续接入。 - 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。 - `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。 - 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。 @@ -246,7 +246,7 @@ GET /api/tenant-admin/audit-logs 1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,对象存储上传后校验、PDF 预览、防盗链和视频水印。 2. 接入真实短信 provider:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。 3. 接入真实 OAuth provider:微信网页、微信小程序、QQ,并处理旧 PocketBase 身份映射。 -4. 补微信退款通知/查询确认、支付宝退款查询、支付补偿任务、对账、异常订单自动处理和优惠券核销报表。 +4. 补微信退款通知 webhook、支付补偿任务、对账、异常订单自动处理和优惠券核销报表;退款查询确认主链路已完成。 5. 扩展 `apps/worker`:支付补偿、日报统计、导入后检查、CRM 死信告警和公共题库同步。 6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 1c0a1418..c3125007 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -27,7 +27,7 @@ | 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON 预览/导入、平台公共题库授权、租户采纳快照已实现 | 核心 API 集成测试含导航、组卷、导入、公共题库授权和采纳后组卷断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳快照可联调;Excel 导入、公共题库全量/增量版本同步仍需补齐 | | 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 | | 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 | -| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,退款通知/查询确认、对账、支付补偿和异常订单自动处理待补 | +| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,退款通知 webhook、对账、支付补偿和异常订单自动处理待补 | | 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、每日复习计划、租户后台单词维护 API、旧模板/新模板 JSON 预览导入、排行榜已实现 | 核心 API 集成测试含导入和排行榜断言 | 学生端学习状态、后台维护、批量 JSON 导入和基础排行榜已实现,更细复习参数和后台统计待完善 | | 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 | | 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API 已实现 | 核心 API 集成测试 | 查询和后台维护基础闭环已实现,复杂动态筛选/批量导入待补 | @@ -247,7 +247,7 @@ platform-admin: 上线前至少还需要完成: 1. 正式鉴权:API 已支持 Supabase Auth JWT;生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id`、`x-platform-admin-key` 兼容入口。 -2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付和发起退款的租户级配置入口与本地 provider 验证已具备;微信网页登录、QQ 登录、真实生产账号联调、退款通知/查询确认、对账和支付补偿仍需实现。 +2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付、发起退款和退款查询确认的租户级配置入口与本地 provider 验证已具备;微信网页登录、QQ 登录、真实生产账号联调、退款通知 webhook、对账和支付补偿仍需实现。 3. 核心缺口 API:学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API;下一步重点是后台维护、权限、统计和真实业务验收。 4. 后台能力:题库录入、题目/单词/知识手册 JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;Excel 导入、分数线/视频导入、真实对象存储签名和前端操作台待补。 5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。 @@ -263,4 +263,4 @@ platform-admin: 3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 4. 补视频商用控制:SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。 5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。 -6. 接真实短信、微信网页/QQ 登录、退款通知/查询确认、退款对账和补偿任务,并开始 Taro scaffold。 +6. 接真实短信、微信网页/QQ 登录、退款通知 webhook、退款对账和补偿任务,并开始 Taro scaffold。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 4753f760..443a097f 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 主链路、内部退款状态机、微信/支付宝发起退款和全额退款权益撤销已有;缺退款通知/查询确认、对账/补偿任务和前端收银台/售后体验 | +| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认和全额退款权益撤销已有;缺退款通知 webhook、对账/补偿任务和前端收银台/售后体验 | | 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 | | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 | | AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index c759b28f..1a9d4a1e 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -59,8 +59,8 @@ 1. 支付 - 已完成微信支付 JSAPI、支付宝 WAP/H5 的创建支付参数和 webhook 幂等开通权益。 - - 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、部分/全额退款状态、全额退款权益撤销和审计事件。 - - 继续补微信退款通知/查询确认、支付宝退款查询、支付补偿任务、对账和异常订单自动处理。 + - 已完成内部退款状态机、退款申请/审核/处理接口、微信/支付宝发起退款、微信/支付宝退款查询确认、部分/全额退款状态、全额退款权益撤销和审计事件。 + - 继续补微信退款通知 webhook、支付补偿任务、对账和异常订单自动处理。 - 租户自有商户收款和平台代收/服务商模式。 2. 国内登录和短信 @@ -93,7 +93,7 @@ 7. 订单和营销体验 - 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。 - - 继续补退款通知/查询确认、支付补偿任务、对账、异常订单自动处理、优惠券核销报表和复杂活动规则。 + - 继续补退款通知 webhook、支付补偿任务、对账、异常订单自动处理、优惠券核销报表和复杂活动规则。 8. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 @@ -201,5 +201,5 @@ 2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。 4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。 -5. 并行补对象存储、真实登录、退款通知/查询确认、支付对账和公共题库版本同步 worker。 +5. 并行补对象存储、真实登录、退款通知 webhook、支付对账和公共题库版本同步 worker。 6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index fca576ca..0c49b6fd 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -903,7 +903,7 @@ body: { POST /api/commerce/refunds/status body: { "refundId": "", - "action": "approve | reject | submit_provider_refund | mark_processing | mark_succeeded | mark_failed | cancel", + "action": "approve | reject | submit_provider_refund | query_provider_refund | mark_processing | mark_succeeded | mark_failed | cancel", "providerRefundNo": "<支付平台退款单号,可选>", "providerNotifyUrl": "<微信退款通知地址,可选>", "note": "<处理备注>" @@ -915,6 +915,7 @@ body: { ```text requested -> approved -> processing -> succeeded requested -> approved -> submit_provider_refund -> processing/succeeded +processing -> query_provider_refund -> processing/succeeded/failed requested/approved -> rejected requested/approved -> cancelled approved/processing -> failed @@ -927,8 +928,10 @@ approved/processing -> failed - 后端会限制累计退款金额不能超过实付金额。 - 全额退款成功后订单和支付会进入 `refunded`,相关订单权益会被置为 `revoked`;部分退款进入 `partially_refunded`,默认不撤销权益。 - `submit_provider_refund` 会由后端使用租户支付账户密钥调用微信/支付宝;前端不要保存商户私钥、API v3 key 或支付宝应用私钥。 -- 微信退款通常先进入 `processing`,需要后续退款通知/查询确认;支付宝普通退款成功会同步进入 `succeeded`。前端应按接口返回状态展示,不要假设点击后立即到账。 -- 退款通知/查询确认、自动对账和补偿 worker 后续接入;当前生产联调时仍需运营后台保留人工确认/失败登记入口。 +- 微信退款通常先进入 `processing`,租户后台可以调用 `query_provider_refund` 主动向微信查询,确认成功后后端才会更新订单退款金额和权益。 +- 支付宝普通退款如果响应 `fund_change=Y` 会同步进入 `succeeded`;处于 `processing` 的退款也可以用 `query_provider_refund` 调用 `alipay.trade.fastpay.refund.query` 确认。 +- 已经 `succeeded` 的退款不能再次查询或再次标记成功,避免订单退款金额重复累加。前端应按接口返回状态展示,不要假设点击后立即到账。 +- 退款通知 webhook、自动对账和补偿 worker 后续接入;当前生产联调时仍需运营后台保留人工确认/失败登记入口。 ### 激活码预检查与兑换 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index a6415cbe..48458aef 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -338,6 +338,21 @@ async function startFakeWechatPayServer() { 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 || 'unknown'}`, + out_refund_no: outRefundNo, + status: 'SUCCESS', + amount: { refund: 100, total: 500, currency: 'CNY' }, + success_time: '2026-06-28T00:00:00+08:00', + }), + ); + return; + } + if (url.pathname !== '/v3/pay/transactions/jsapi') { res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ code: 'NOT_FOUND' })); @@ -406,12 +421,29 @@ async function startFakeAlipayServer() { pathname: req.url || '/', params, }); - if (params.method !== 'alipay.trade.refund') { + if (!['alipay.trade.refund', 'alipay.trade.fastpay.refund.query'].includes(params.method)) { res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error_response: { code: '404', msg: 'not found' } })); return; } const biz = JSON.parse(params.biz_content || '{}'); + if (params.method === 'alipay.trade.fastpay.refund.query') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + alipay_trade_fastpay_refund_query_response: { + code: '10000', + msg: 'Success', + trade_no: biz.trade_no || `ali-trade-${biz.out_trade_no || 'unknown'}`, + out_trade_no: biz.out_trade_no, + out_request_no: biz.out_request_no, + refund_amount: '5.00', + refund_status: 'REFUND_SUCCESS', + }, + }), + ); + return; + } res.writeHead(200, { 'content-type': 'application/json' }); res.end( JSON.stringify({ @@ -422,6 +454,7 @@ async function startFakeAlipayServer() { out_trade_no: biz.out_trade_no, out_request_no: biz.out_request_no, refund_fee: biz.refund_amount, + fund_change: 'Y', }, }), ); @@ -1722,6 +1755,23 @@ async function testCommerce() { assert.equal(wechatRefundRequest?.body?.out_refund_no, 'RF-WECHAT-PROVIDER-001', 'WeChat refund should use out_refund_no'); assert.equal(wechatRefundRequest?.body?.amount?.refund, 100, 'WeChat refund should send refund cents'); assert.equal(wechatRefundRequest?.body?.amount?.total, wechatOrder.item.amountCents, 'WeChat refund should send total cents'); + const wechatQueriedRefund = await request('/api/commerce/refunds/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + refundId: wechatRefund.item.id, + action: 'query_provider_refund', + }, + }); + assert.equal(wechatQueriedRefund.item?.status, 'succeeded', 'WeChat refund query should confirm provider success'); + assert.equal(wechatQueriedRefund.item?.providerRefundNo, 'refund-RF-WECHAT-PROVIDER-001', 'WeChat refund query should keep provider refund id'); + const wechatRefundQueryRequest = fakeWechatPay.requests.find(item => item.method === 'GET' && item.pathname.endsWith('/RF-WECHAT-PROVIDER-001')); + assert.ok(wechatRefundQueryRequest, 'WeChat refund query should call query-by-out-refund-no endpoint'); + const wechatPartiallyRefundedStatus = await request('/api/commerce/orders/status', { + query: { orderNo: wechatOrder.item.orderNo }, + }); + assert.equal(wechatPartiallyRefundedStatus.item?.status, 'partially_refunded', 'confirmed WeChat partial refund should update order status'); + assert.equal(wechatPartiallyRefundedStatus.item?.refundedAmountCents, 100, 'confirmed WeChat partial refund should update refunded amount'); const fakeAlipay = await startFakeAlipayServer(); const alipayAccount = await request('/api/tenant-admin/payment-accounts', { @@ -1827,6 +1877,20 @@ async function testCommerce() { query: { orderNo: alipayOrder.item.orderNo }, }); assert.equal(alipayRefundedStatus.item?.status, 'refunded', 'synchronous Alipay refund should update order status'); + const alipayRefundQueryRejected = await request('/api/commerce/refunds/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + refundId: alipayRefund.item.id, + action: 'query_provider_refund', + }, + expectStatus: 409, + }); + assert.equal(alipayRefundQueryRejected.code, 'REFUND_STATUS_INVALID', 'succeeded provider refund must not be queried and applied twice'); + assert.ok( + !fakeAlipay.requests.some(item => item.params?.method === 'alipay.trade.fastpay.refund.query'), + 'already succeeded refund query should be rejected before calling Alipay', + ); const tamperedAlipayNotify = await request('/api/commerce/payments/notify/alipay', { userId: false,