From c70a33d682b8d4d61e453728bca7807b40ea20b0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 18:09:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=80=E6=AC=BE=E5=A4=8D?= =?UTF-8?q?=E6=A0=B8=E4=B8=8E=E6=B8=A0=E9=81=93=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 退款 observation ID 按来源与事实指纹稳定区分 人工复核只豁免本次批准的订单冲突并持久化错误码 普通微信渠道在查单前拒绝虚拟支付、会员与非正式渠道 网络结果未知时当前弹窗直接复用原请求继续核对 按 release 首次上线基线移除未发布退款结构兼容并同步文档与绑定 --- .../src/pages/AdminRechargeOrderPage.test.tsx | 13 +- .../src/pages/AdminRechargeOrderPage.tsx | 1 + .../shared-memory/decision-log.md | 3 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 10 +- .../crates/api-server/src/admin_recharge.rs | 128 +++++++++++++++--- .../profile_recharge_refund_type.rs | 7 + .../crates/spacetime-module/src/migration.rs | 64 --------- .../spacetime-module/src/runtime/profile.rs | 70 ++++++++-- 8 files changed, 193 insertions(+), 103 deletions(-) diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 92224d082..450949062 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -274,7 +274,7 @@ test('已登记的部分退款允许在 REFUND 支付状态下继续退剩余额 ).toBe(false); }); -test('网络结果不明后重新预检会复用稳定 requestId 且双击只提交一次', async () => { +test('网络结果不明后当前弹窗直接复用稳定 requestId', async () => { const user = userEvent.setup(); vi.mocked(executeAdminRechargeRefund) .mockRejectedValueOnce(new Error('network disconnected')) @@ -284,21 +284,26 @@ test('网络结果不明后重新预检会复用稳定 requestId 且双击只提 await user.type(screen.getByLabelText('退款金额(元)'), '3.00'); await user.type(screen.getByLabelText('退款原因'), '用户申请'); await user.click(screen.getByRole('button', { name: '核验支付账单' })); - await user.dblClick(screen.getByRole('button', { name: '确认退款' })); + await user.click(screen.getByRole('button', { name: '确认退款' })); expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy(); expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(1); const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock .calls[0]?.[1].requestId; - await user.click(screen.getByRole('button', { name: '核验支付账单' })); - await user.dblClick(screen.getByRole('button', { name: '确认退款' })); + expect( + screen + .getByRole('button', { name: '核验支付账单' }) + .hasAttribute('disabled'), + ).toBe(true); + await user.click(screen.getByRole('button', { name: '继续核对退款' })); await waitFor(() => expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2), ); expect( vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1].requestId, ).toBe(firstRequestId); + expect(previewAdminRechargeRefund).toHaveBeenCalledTimes(1); }); test('刷新后根据 active hold 恢复原退款请求并复用 requestId', async () => { diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index 5ad5a1a25..26a5f32d7 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -345,6 +345,7 @@ export function AdminRechargeOrderPage({ if (!isAdminApiError(error)) { setProviderStatusUnknown(true); setPreview(null); + setResumableRefundRequest(requestContext); setRefundError(unknownProviderStatusMessage); } else { removePersistedRefundRequestContext(token, refundOrder.orderId); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8f5ace7b2..04a67ec11 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4081,7 +4081,8 @@ - 背景:普通 V3 退款已经能从回调、查单和账单收口现金事实,但后台主动退款若先调微信再扣泥点,会在用户余额不足时产生本可避免的欠账;后台各页面也没有统一查询用户余额、绑定状态和充值订单的入口。 - 决策:新增 `profile_recharge_refund_hold`,后台执行退款前按累计部分退款公式原子占用本次应追回的永久泥点,再使用客户端稳定 `requestId` 派生 `out_refund_no` 调微信。部分退款额外占用 1 泥点并发舍入缓冲,防止占用创建后到达的外部退款跨越累计 `floor` 边界;全额退款不加缓冲。`SUCCESS` 扣款并结算匹配占用,`CLOSED` 释放,`PROCESSING / ABNORMAL` 保持;结果未知时不擅自释放,至少等待 10 分钟并连续 3 次退款查单收到官方 `RESOURCE_NOT_EXISTS` 才由 worker 释放。普通消费必须排除全部活动占用。 - 欠账与冻结:支付侧已经成功退款时不能回滚现金事实;永久泥点不足部分继续只写订单 settlement 的 `unrecovered_points`,限制普通消费,后续永久泥点优先自动偿还。每日免费和会员周期泥点不参与。人工冻结单独使用 `profile_wallet_manual_restriction`,解除人工冻结不解除退款欠账限制。 -- 人工复核:交易号或订单总额冲突只允许管理员确认退款归属,退款行追加不可变的管理员、原因、时间后重新运行标准结算;该操作不是“直接解冻”,余额不足仍形成欠账。会员退款、未知错误和非法结算计划不显示该入口,也不能复用泥点钱包冻结语义。 +- 人工复核:交易号或订单总额冲突只允许管理员确认退款归属,退款行追加不可变的管理员、原因、时间和获批错误码后重新运行标准结算;一次确认只豁免对应冲突,其他不变量继续 fail-closed。该操作不是“直接解冻”,余额不足仍形成欠账。会员退款、未知错误和非法结算计划不显示该入口,也不能复用泥点钱包冻结语义。 +- 发布基线:退款功能和退款事实表尚未进入 release,首次上线只以 release 已有 schema 和数据为迁移基线;不为 master 上未发布的退款中间结构保留旧行 normalization 或历史人工复核兼容。 - 后台边界:充值订单、预检、执行、应急退款号登记、用户详情和钱包冻结均只挂在管理员鉴权路由。用户详情由 `user_id` 或陶泥号经认证服务解析,返回头像、昵称、脱敏手机号、绑定状态、钱包分桶、占用、欠账和最近订单;后台语义明确的用户字段复用同一个图标按钮和弹窗,管理员主体及 `admin:*` 合成 ID 不打开用户详情。 - 部分退款预检:微信支付查单 `trade_state=REFUND` 只表示已发生退款,不代表全额退款。刷新已登记退款后,本地累计成功退款大于 0 且小于订单总额、且不存在非终态退款、活动 hold、欠账或人工冻结时,可以继续退本地剩余额度;没有本地成功退款事实能解释 `REFUND` 时继续失败关闭并要求登记或账单对账。 - 影响范围:`module-runtime`、`spacetime-module`、`spacetime-client`、`api-server` 管理员 BFF / refund worker、`shared-contracts` 与 `apps/admin-web`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 95588882a..0796c8489 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -203,13 +203,13 @@ npm run check:server-rs-ddd 13. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。 14. 微信 JSAPI / H5 / 小程序 / Native 下单统一显式传 5 分钟 `time_expire`,格式为 RFC3339 秒级时间;Native 额外通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。 15. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 只订阅这张活跃 timer 表的删除事件,收到 `order_id` 后通过 procedure 重新读取订单,只有状态确认为 `expired` 才执行微信查单补偿;支付或主动关闭同样会删除 timer,但会被状态判断忽略。监听断线期间遗漏的删除事件由未检查过期订单 catch-up 补齐,不订阅完整 `profile_recharge_order` 历史表。普通微信支付查单中 `SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired`;`CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`wechat_mp_virtual` 使用小程序 `access_token` 和虚拟支付 AppKey 调用官方 `/xpay/query_order`,只在返回单号、支付类型 `order_type=0/7`、金额、合法 `paid_time` 与本地契约一致且状态为 `2/3/4` 时补入账;退款类型 `1/8` 不得触发充值,其余已知状态只记录检查结果。`short_series_goods` 从 `status=2` 恢复时先幂等入账,再调用 `/xpay/notify_provide_goods`,失败后允许基于本地 `paid` 状态只重试发货;`short_series_coin` 不调用现金单发货接口。`external-generation-worker` / controller 不处理充值过期。 -16. 普通微信支付 V3 退款事实由 `profile_recharge_refund` 保存,回调、退款 API 响应、主动查单和交易账单发现统一调用 `record_profile_recharge_refund_observation_and_return`。`out_refund_no` 是商户幂等键,`provider_refund_id` 唯一;重复 observation 必须校验订单、交易、金额、状态和指纹,不允许仅按主键直接吞掉冲突。 +16. 普通微信支付 V3 退款事实由 `profile_recharge_refund` 保存,回调、退款 API 响应、主动查单和交易账单发现统一调用 `record_profile_recharge_refund_observation_and_return`。`out_refund_no` 是商户幂等键,`provider_refund_id` 唯一;退款申请响应和主动查单等生产者生成 observation ID 时必须同时纳入来源和事实指纹,同一来源同一事实稳定重放、不同来源不得复用 ID;重复 observation 必须校验订单、交易、金额、状态、来源和指纹,不允许仅按主键直接吞掉冲突。 17. `profile_recharge_order_refund_settlement` 按原订单聚合累计成功退款金额和权益回收。部分退款不改充值订单 `paid`;累计金额等于订单金额时才改为 `refunded`。历史支付和首充资格以 `paid_at` 是否存在判断,退款不把用户重新变成首充。 -18. 泥点退款按累计成功退款比例计算目标回收量,全额退款强制精确回收原 `points_delta`。自动回收只扣普通永久泥点,每日免费和会员周期限时泥点保持不变;不足部分持久化为 `shortfall` 并冻结正式钱包消费,后续 worker 只重试本地回收。已成功退款的泥点订单若出现微信交易号、订单总额冲突或结算计划无效,必须将 settlement 标记为 `wallet_frozen` 并阻断普通消费。管理员只能对交易号或订单总额冲突执行“确认退款归属”:procedure 在退款行尾部不可变记录管理员、原因和时间后重新运行标准结算,能追回的永久泥点照常扣除,余额不足继续形成欠账;不能直接清空冻结或伪造已追回量。非法结算计划仍保持冻结等待数据/代码修复。流水来源为 `recharge_refund_recovery`。会员充值没有可逆 grant 快照,退款统一标记 `manual_review`,不猜测回滚档位、有效期或周期泥点,也不复用泥点退款冻结语义。 +18. 泥点退款按累计成功退款比例计算目标回收量,全额退款强制精确回收原 `points_delta`。自动回收只扣普通永久泥点,每日免费和会员周期限时泥点保持不变;不足部分持久化为 `shortfall` 并冻结正式钱包消费,后续 worker 只重试本地回收。已成功退款的泥点订单若出现微信交易号、订单总额冲突或结算计划无效,必须将 settlement 标记为 `wallet_frozen` 并阻断普通消费。管理员只能对交易号或订单总额冲突执行“确认退款归属”:procedure 在退款行尾部不可变记录管理员、原因、时间和本次获批的错误码,再重新运行标准结算;一次确认只豁免对应的交易号或订单总额冲突,渠道、支付状态及其他未获批冲突继续 fail-closed。能追回的永久泥点照常扣除,余额不足继续形成欠账;不能直接清空冻结或伪造已追回量。非法结算计划仍保持冻结等待数据/代码修复。流水来源为 `recharge_refund_recovery`。会员充值没有可逆 grant 快照,退款统一标记 `manual_review`,不猜测回滚档位、有效期或周期泥点,也不复用泥点退款冻结语义。 19. 外部现金退款 `SUCCESS` 必须先持久化并 ACK,即使本地订单缺失、金额冲突、权益不足或会员需要人工处理,也不能回滚已经发生的现金事实。冲突 observation 记录 resolution code 并进入告警;只有验签、解密、契约解析或 SpacetimeDB 持久化失败才让微信重试。 20. 主动查询与退款交易账单 worker 只由 HTTP 角色运行并由 `WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true` 显式开启。生产 env 示例和 API deploy 会为缺失配置补 `true`;当 `WECHAT_PAY_ENABLED=true + WECHAT_PAY_PROVIDER=real` 时显式关闭该开关必须阻断发布,启动日志也必须明确记录启用或异常关闭状态。非终态退款按 1 / 5 / 10 / 20 / 30 分钟衰减查单;北京时间次日 10 点后按 30 个稳定分片轮转补扫微信 API 可查询的近 90 天 `bill_type=REFUND` 交易账单,每 30 分钟覆盖完整窗口。单行失败不阻塞同日其他退款或其他日期,但该日不写完成 checkpoint 并继续重试;昨日返回 `NO_STATEMENT_EXIST` 时至少延迟到次日 10 点后再确认空账单。账单申请响应验签,GZIP 解压后按 SHA1 验真,CSV 用结构化 parser 和十进制定点金额解析;发现手工退款后必须再查单取得当前状态。 21. 普通 V3 支付通知同时校验 AppID、商户号、本地订单渠道、金额和微信支付单号;`success_time` 缺失或非法时拒绝,不能用本机时间补齐。晚到通知遇到 `refunded` 订单只做交易号一致性幂等校验,不再次发放权益。 -22. 后台主动退款只支持普通 V3 泥点订单。`api-server` 先做微信支付订单查单预检,再调用 SpacetimeDB procedure 原子创建退款 hold;只有 hold 成功才允许调用微信退款。虚拟支付、会员、未支付、对账未完成、退款已满额、人工冻结、退款欠账或永久泥点不足必须 fail-closed。 +22. 后台主动退款只支持 `wechat_mp`、`wechat_jsapi`、`wechat_h5`、`wechat_native` 普通 V3 泥点订单。`api-server` 必须先按正式支付渠道和商品类型拦截不支持的订单,再做微信支付订单查单预检,然后调用 SpacetimeDB procedure 原子创建退款 hold;只有 hold 成功才允许调用微信退款。`wechat_mp_virtual`、历史非正式渠道值、会员、未支付、对账未完成、退款已满额、人工冻结、退款欠账或永久泥点不足必须在调用普通 V3 provider 前 fail-closed。 23. `profile_recharge_refund_hold` 以稳定 `out_refund_no` 为主键,保存订单、用户、本次退款金额、占用永久泥点、管理员、原因和 `active / settled / released` 状态。部分退款的 hold 在累计应追回增量之外额外保留 1 泥点并发舍入缓冲,全额退款不加缓冲;活动 hold 不改变钱包总额,但普通钱包消费必须预留全部活动 hold;成功退款 observation 扣款并结算匹配 hold,关闭退款释放 hold,外部退款追回不得消耗其他活动 hold。 24. 退款欠账继续以 `profile_recharge_order_refund_settlement.unrecovered_points` 为唯一真相;不新增平行 debt 累计。`profile_wallet_manual_restriction` 只保存人工冻结,普通消费同时检查人工冻结与退款欠账。后续永久泥点到账后继续偿还欠账,每日免费与会员周期泥点不参与;解除人工冻结不得清除退款欠账限制。 25. 管理员充值订单、用户详情、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,并调用 runtime service identity 受限 procedure;前端不得自行改 settlement 或钱包冻结。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。 @@ -762,14 +762,14 @@ npm run check:server-rs-ddd - Rust 结构体:`ProfileRechargeRefund` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` -- 作用:普通微信支付 V3 退款单聚合。以 `out_refund_no` 为主键、`provider_refund_id` 唯一,保存原订单/微信支付单、四个分金额、微信状态、最近观察、权益目标/已回收/未回收量和人工处理错误码。交易号或订单总额冲突经管理员确认归属后,追加保存处理管理员、非空原因和处理时间;三字段只写一次,作为重新执行正式结算的审计事实。数据库迁移导入旧退款行时,缺失的三个审计字段必须补 `null`,保证升级前冷备可恢复。 +- 作用:普通微信支付 V3 退款单聚合。以 `out_refund_no` 为主键、`provider_refund_id` 唯一,保存原订单/微信支付单、四个分金额、微信状态、最近观察、权益目标/已回收/未回收量和人工处理错误码。交易号或订单总额冲突经管理员确认归属后,追加保存处理管理员、非空原因、处理时间和本次获批的错误码;四字段只写一次,作为重新执行正式结算的审计事实。 - 索引:`by_profile_recharge_refund_order_id`、`by_profile_recharge_refund_status_updated_at`。 ### `profile_recharge_refund_observation` - Rust 结构体:`ProfileRechargeRefundObservation` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` -- 作用:退款事实的追加观察记录。保存 callback / api_request / query / trade_bill 来源、稳定 observation ID、金额、状态、脱敏通知引用、事实指纹和 resolution code;不保存回调密文、签名、密钥、原始 CSV 或短时下载 URL。 +- 作用:退款事实的追加观察记录。保存 callback / api_request / query / trade_bill 来源、按“来源 + 事实指纹”稳定生成的 observation ID、金额、状态、脱敏通知引用、事实指纹和 resolution code;同一事实从不同来源进入时使用不同 ID,不保存回调密文、签名、密钥、原始 CSV 或短时下载 URL。 - 索引:`by_profile_recharge_refund_observation_out_refund_no`、`by_profile_recharge_refund_observation_order_id`。 ### `profile_recharge_order_refund_settlement` diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs index 289510f95..f4ec6c325 100644 --- a/server-rs/crates/api-server/src/admin_recharge.rs +++ b/server-rs/crates/api-server/src/admin_recharge.rs @@ -8,8 +8,11 @@ use axum::{ }; use module_auth::AuthUser; use module_runtime::{ - RuntimeProfileAdminWalletSnapshot, RuntimeProfileRechargeOrderAdminEntrySnapshot, - RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeRefundHoldSnapshot, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileAdminWalletSnapshot, + RuntimeProfileRechargeOrderAdminEntrySnapshot, RuntimeProfileRechargeOrderStatus, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundHoldSnapshot, RuntimeProfileRechargeRefundHoldStatus, RuntimeProfileRechargeRefundObservationSource, RuntimeProfileRechargeRefundRecoveryStatus, RuntimeProfileRechargeRefundSnapshot, RuntimeProfileRechargeRefundStatus, build_runtime_profile_admin_wallet_get_input, @@ -255,6 +258,8 @@ pub async fn admin_execute_recharge_refund( let out_refund_no = build_out_refund_no(&payload.order_id, request_id); let refund_reason = normalize_refund_reason(payload.reason.as_deref()); let mut entry = load_order(&state, &request_context, &payload.order_id).await?; + validate_refund_provider_route(&entry) + .map_err(|error| error_response(&request_context, error))?; let has_matching_hold = entry.active_hold.as_ref().is_some_and(|hold| { hold.out_refund_no == out_refund_no && hold.refund_cents == payload.refund_amount_cents }); @@ -540,6 +545,8 @@ async fn reconcile_and_validate_order( Response, > { let mut entry = load_order(state, request_context, &order_id).await?; + validate_refund_provider_route(&entry) + .map_err(|error| error_response(request_context, error))?; let mut payment_check = query_and_validate_payment(state, &entry) .await .map_err(|error| error_response(request_context, error))?; @@ -688,10 +695,7 @@ async fn persist_refund_fact( ) -> Result<(), SpacetimeClientError> { let fingerprint = refund_fact_fingerprint(refund); let observation = build_wechat_pay_refund_observation( - format!( - "admin-refund-observation-{}", - short_hash(fingerprint.as_bytes()) - ), + admin_refund_observation_id(source, &fingerprint), source, None, fingerprint, @@ -728,11 +732,10 @@ fn validate_refund_amount( fn order_refund_block_reason( entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, ) -> Option<&'static str> { - if entry.order.kind.as_str() != "points" { - return Some("membership_not_supported"); - } - if !is_ordinary_wechat_v3_channel(&entry.order.payment_channel) { - return Some("payment_channel_not_supported"); + if let Some(reason_code) = + refund_provider_route_block_reason(entry.order.kind, &entry.order.payment_channel) + { + return Some(reason_code); } if !matches!( entry.order.status, @@ -1125,13 +1128,53 @@ fn short_hash(value: &[u8]) -> String { hex::encode(&Sha256::digest(value)[..8]) } +fn admin_refund_observation_id( + source: RuntimeProfileRechargeRefundObservationSource, + fingerprint: &str, +) -> String { + format!( + "admin-refund-observation-{}-{}", + source.as_str(), + short_hash(fingerprint.as_bytes()) + ) +} + fn is_ordinary_wechat_v3_channel(value: &str) -> bool { matches!( value, - "wechat_mini_program" | "wechat_jsapi" | "wechat_h5" | "wechat_native" + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE ) } +fn refund_provider_route_block_reason( + kind: RuntimeProfileRechargeProductKind, + payment_channel: &str, +) -> Option<&'static str> { + if kind != RuntimeProfileRechargeProductKind::Points { + return Some("membership_not_supported"); + } + if !is_ordinary_wechat_v3_channel(payment_channel) { + return Some("payment_channel_not_supported"); + } + None +} + +fn validate_refund_provider_route( + entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, +) -> Result<(), AppError> { + if let Some(reason_code) = + refund_provider_route_block_reason(entry.order.kind, &entry.order.payment_channel) + { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_message(refund_block_message(reason_code)) + .with_details(json!({"reasonCode": reason_code}))); + } + Ok(()) +} + fn classify_refund_block_reason(message: &str) -> &'static str { if message.contains("永久泥点不足") || message.contains("可用") { "insufficient_permanent_points" @@ -1195,13 +1238,17 @@ mod tests { body::Body, http::{Request, StatusCode}, }; - use module_runtime::RuntimeProfileRechargeRefundHoldStatus; + use module_runtime::{ + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundHoldStatus, + RuntimeProfileRechargeRefundObservationSource, + }; use tower::ServiceExt; use super::{ - build_out_refund_no, classify_refund_block_reason, is_likely_wechat_refund_id, - normalize_request_id, payment_check_block_reason_code, - payment_trade_state_allows_additional_refund, refund_block_message, + admin_refund_observation_id, build_out_refund_no, classify_refund_block_reason, + is_likely_wechat_refund_id, is_ordinary_wechat_v3_channel, normalize_request_id, + payment_check_block_reason_code, payment_trade_state_allows_additional_refund, + refund_block_message, refund_provider_route_block_reason, validate_prepared_refund_hold_status, }; use crate::{app::build_router, config::AppConfig, state::AppState}; @@ -1229,6 +1276,55 @@ mod tests { assert!(!is_likely_wechat_refund_id("refund-1001")); } + #[test] + fn admin_refund_observation_ids_are_stable_per_source_and_fact() { + let fingerprint = "sha256:refund-fact"; + let api_request = admin_refund_observation_id( + RuntimeProfileRechargeRefundObservationSource::ApiRequest, + fingerprint, + ); + let repeated_api_request = admin_refund_observation_id( + RuntimeProfileRechargeRefundObservationSource::ApiRequest, + fingerprint, + ); + let query = admin_refund_observation_id( + RuntimeProfileRechargeRefundObservationSource::Query, + fingerprint, + ); + + assert_eq!(api_request, repeated_api_request); + assert_ne!(api_request, query); + } + + #[test] + fn refund_provider_route_uses_canonical_payment_channels() { + assert!(is_ordinary_wechat_v3_channel("wechat_mp")); + assert!(is_ordinary_wechat_v3_channel("wechat_jsapi")); + assert!(!is_ordinary_wechat_v3_channel("wechat_mini_program")); + assert!(!is_ordinary_wechat_v3_channel("wechat_mp_virtual")); + assert_eq!( + refund_provider_route_block_reason( + RuntimeProfileRechargeProductKind::Points, + "wechat_mp" + ), + None + ); + assert_eq!( + refund_provider_route_block_reason( + RuntimeProfileRechargeProductKind::Points, + "wechat_mp_virtual" + ), + Some("payment_channel_not_supported") + ); + assert_eq!( + refund_provider_route_block_reason( + RuntimeProfileRechargeProductKind::Membership, + "wechat_native" + ), + Some("membership_not_supported") + ); + } + #[test] fn reconciled_partial_refund_keeps_the_remaining_amount_refundable() { assert!(payment_trade_state_allows_additional_refund( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs index b71253bba..9eeba7cd5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs @@ -36,6 +36,7 @@ pub struct ProfileRechargeRefund { pub manual_review_resolved_by_admin_user_id: Option, pub manual_review_resolution_reason: Option, pub manual_review_resolved_at: Option<__sdk::Timestamp>, + pub manual_review_resolved_error_code: Option, } impl __sdk::InModule for ProfileRechargeRefund { @@ -82,6 +83,8 @@ pub struct ProfileRechargeRefundCols { __sdk::__query_builder::Col>, pub manual_review_resolved_at: __sdk::__query_builder::Col>, + pub manual_review_resolved_error_code: + __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ProfileRechargeRefund { @@ -137,6 +140,10 @@ impl __sdk::__query_builder::HasCols for ProfileRechargeRefund { table_name, "manual_review_resolved_at", ), + manual_review_resolved_error_code: __sdk::__query_builder::Col::new( + table_name, + "manual_review_resolved_error_code", + ), } } } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 96a0506c6..c67a80267 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1345,20 +1345,6 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert(serde_json::Value::Null); } } - if table_name == "profile_recharge_refund" { - if let Some(object) = next_value.as_object_mut() { - // 中文注释:退款人工复核审计字段晚于退款事实表加入,旧迁移包按尚未处理兼容。 - object - .entry("manual_review_resolved_by_admin_user_id".to_string()) - .or_insert(serde_json::Value::Null); - object - .entry("manual_review_resolution_reason".to_string()) - .or_insert(serde_json::Value::Null); - object - .entry("manual_review_resolved_at".to_string()) - .or_insert(serde_json::Value::Null); - } - } if table_name == "external_generation_job_summary" { if let Some(object) = next_value.as_object_mut() { // 中文注释:非阻断完成告警晚于轻量任务摘要表加入,旧迁移包按无告警兼容。 @@ -1676,56 +1662,6 @@ mod migration_bootstrap_secret_tests { assert_eq!(normalized["warning_message"], serde_json::Value::Null); } - #[test] - fn old_profile_recharge_refund_rows_import_with_empty_manual_review_audit() { - let row = ProfileRechargeRefund { - out_refund_no: "refund-legacy".to_string(), - provider_refund_id: "wx-refund-legacy".to_string(), - order_id: "order-legacy".to_string(), - provider_transaction_id: "wx-transaction-legacy".to_string(), - user_id: Some("user-legacy".to_string()), - provider_status: RuntimeProfileRechargeRefundStatus::Success, - total_cents: 600, - refund_cents: 300, - payer_total_cents: 600, - payer_refund_cents: 300, - success_at: Some(Timestamp::from_micros_since_unix_epoch(200)), - first_observed_at: Timestamp::from_micros_since_unix_epoch(100), - updated_at: Timestamp::from_micros_since_unix_epoch(200), - last_observation_source: RuntimeProfileRechargeRefundObservationSource::Query, - last_observation_id: "observation-legacy".to_string(), - order_settled_at: None, - target_recovery_points: 30, - recovered_points: 30, - unrecovered_points: 0, - recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Applied, - last_recovery_ledger_id: Some("ledger-legacy".to_string()), - last_error_code: None, - manual_review_resolved_by_admin_user_id: None, - manual_review_resolution_reason: None, - manual_review_resolved_at: None, - }; - let mut legacy_value = row_to_json(&row).expect("退款旧行应可序列化"); - let object = legacy_value - .as_object_mut() - .expect("退款迁移行应为 JSON object"); - object.remove("manual_review_resolved_by_admin_user_id"); - object.remove("manual_review_resolution_reason"); - object.remove("manual_review_resolved_at"); - - let normalized = normalize_migration_row("profile_recharge_refund", &legacy_value); - let restored = row_from_json::( - "profile_recharge_refund", - &normalized, - &mut Vec::new(), - ) - .expect("缺少人工复核字段的旧退款行应可导入"); - - assert!(restored.manual_review_resolved_by_admin_user_id.is_none()); - assert!(restored.manual_review_resolution_reason.is_none()); - assert!(restored.manual_review_resolved_at.is_none()); - } - #[test] fn bootstrap_secret_sha256_is_stable_and_does_not_contain_plaintext() { let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 706139824..678980d8d 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -512,6 +512,8 @@ pub struct ProfileRechargeRefund { pub(crate) manual_review_resolution_reason: Option, #[default(None::)] pub(crate) manual_review_resolved_at: Option, + #[default(None::)] + pub(crate) manual_review_resolved_error_code: Option, } #[spacetimedb::table( @@ -2727,6 +2729,7 @@ mod tests { manual_review_resolved_by_admin_user_id: None, manual_review_resolution_reason: None, manual_review_resolved_at: None, + manual_review_resolved_error_code: None, } } @@ -2751,7 +2754,7 @@ mod tests { } #[test] - fn confirmed_manual_review_suppresses_only_the_order_match_conflict() { + fn confirmed_manual_review_suppresses_only_the_approved_order_match_conflict() { let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); let mut refund = recharge_refund_for_order(&order); refund.provider_transaction_id = "wx-transaction-other".to_string(); @@ -2761,6 +2764,8 @@ mod tests { ); refund.manual_review_resolved_at = Some(Timestamp::from_micros_since_unix_epoch(300)); + refund.manual_review_resolved_error_code = + Some("provider_transaction_id_mismatch".to_string()); assert_eq!( unresolved_profile_recharge_refund_order_match_error(&order, &refund), None @@ -2769,6 +2774,18 @@ mod tests { validate_profile_recharge_refund_order_match(&order, &refund), Err("provider_transaction_id_mismatch".to_string()) ); + + refund.total_cents = order.amount_cents + 1; + assert_eq!( + unresolved_profile_recharge_refund_order_match_error(&order, &refund), + Some("order_total_mismatch".to_string()) + ); + + refund.manual_review_resolved_error_code = Some("order_total_mismatch".to_string()); + assert_eq!( + unresolved_profile_recharge_refund_order_match_error(&order, &refund), + Some("provider_transaction_id_mismatch".to_string()) + ); } #[test] @@ -4617,10 +4634,10 @@ fn resolve_profile_recharge_refund_manual_review( } let review_code = refund .last_error_code - .as_deref() + .clone() .ok_or_else(|| "退款缺少人工复核原因".to_string())?; if !matches!( - review_code, + review_code.as_str(), "provider_transaction_id_mismatch" | "order_total_mismatch" ) { return Err(format!( @@ -4636,10 +4653,20 @@ fn resolve_profile_recharge_refund_manual_review( if order.kind != RuntimeProfileRechargeProductKind::Points { return Err("会员退款不能使用泥点钱包人工复核入口".to_string()); } + if let Some(remaining_code) = profile_recharge_refund_order_match_error_except( + &order, + &refund, + Some(review_code.as_str()), + ) { + return Err(format!( + "确认当前退款冲突后仍存在未获批准的订单不匹配: {remaining_code}" + )); + } refund.manual_review_resolved_by_admin_user_id = Some(validated.admin_user_id); refund.manual_review_resolution_reason = Some(validated.reason); refund.manual_review_resolved_at = Some(ctx.timestamp); + refund.manual_review_resolved_error_code = Some(review_code); refund.updated_at = ctx.timestamp; let (settlement, resolution_code) = reconcile_profile_recharge_refund_success(ctx, &mut refund); if refund.recovery_status == RuntimeProfileRechargeRefundRecoveryStatus::ManualReview { @@ -5041,6 +5068,7 @@ fn record_profile_recharge_refund_observation( manual_review_resolved_by_admin_user_id: None, manual_review_resolution_reason: None, manual_review_resolved_at: None, + manual_review_resolved_error_code: None, }, }; @@ -5211,10 +5239,19 @@ fn reconcile_profile_recharge_refund_success( (Some(settlement), resolution_code.to_string()) } +#[cfg(test)] fn validate_profile_recharge_refund_order_match( order: &ProfileRechargeOrder, refund: &ProfileRechargeRefund, ) -> Result<(), String> { + profile_recharge_refund_order_match_error_except(order, refund, None).map_or(Ok(()), Err) +} + +fn profile_recharge_refund_order_match_error_except( + order: &ProfileRechargeOrder, + refund: &ProfileRechargeRefund, + approved_error_code: Option<&str>, +) -> Option { if !matches!( order.payment_channel.as_str(), PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM @@ -5222,7 +5259,7 @@ fn validate_profile_recharge_refund_order_match( | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE ) { - return Err("payment_channel_not_v3".to_string()); + return Some("payment_channel_not_v3".to_string()); } if order.paid_at.is_none() || !matches!( @@ -5230,24 +5267,31 @@ fn validate_profile_recharge_refund_order_match( RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded ) { - return Err("order_not_paid".to_string()); + return Some("order_not_paid".to_string()); } - if order.provider_transaction_id.as_deref() != Some(refund.provider_transaction_id.as_str()) { - return Err("provider_transaction_id_mismatch".to_string()); + if order.provider_transaction_id.as_deref() != Some(refund.provider_transaction_id.as_str()) + && approved_error_code != Some("provider_transaction_id_mismatch") + { + return Some("provider_transaction_id_mismatch".to_string()); } - if order.amount_cents != refund.total_cents { - return Err("order_total_mismatch".to_string()); + if order.amount_cents != refund.total_cents + && approved_error_code != Some("order_total_mismatch") + { + return Some("order_total_mismatch".to_string()); } - Ok(()) + None } fn unresolved_profile_recharge_refund_order_match_error( order: &ProfileRechargeOrder, refund: &ProfileRechargeRefund, ) -> Option { - validate_profile_recharge_refund_order_match(order, refund) - .err() - .filter(|_| refund.manual_review_resolved_at.is_none()) + let approved_error_code = refund + .manual_review_resolved_at + .is_some() + .then_some(refund.manual_review_resolved_error_code.as_deref()) + .flatten(); + profile_recharge_refund_order_match_error_except(order, refund, approved_error_code) } fn mark_profile_recharge_refund_manual_review(