Files
Genarrative/docs/【技术方案】微信虚拟支付接入-2026-05-26.md
kdletters 7ea463ed08 退役旧创作模板业务并保留数据壳
保留 SpacetimeDB 历史表、迁移白名单与最小兼容读取定义
移除旧创作前后端、worker、业务过程及纯业务 crate 的编译依赖
恢复现役创作、项目、我的入口及桌面移动导航
收紧 Vite、TypeScript、ESLint、Vitest 与静态资源退役边界
补齐开发栈、网关、原生壳和文档退役约束
2026-07-18 22:02:22 +08:00

29 KiB
Raw Permalink Blame History

微信虚拟支付接入

更新时间:2026-07-13

接入口径

  • 泥点充值在微信小程序 WebView 内走 wechat_mp_virtual,由小程序页调用 wx.requestVirtualPaymentshort_series_coin 模式。
  • 会员商品在微信小程序 WebView 内同样走 wechat_mp_virtual,由小程序页调用 wx.requestVirtualPaymentshort_series_goods 模式,并在 signData 内带 productIdgoodsPrice
  • 微信内浏览器走 wechat_jsapi,复用微信支付 V3 JSAPI 下单返回的预支付参数并通过 WeixinJSBridge.invoke('getBrandWCPayRequest') 调起支付;普通 Web 统一走 wechat_native 二维码支付,不进入虚拟支付链路,也不依赖 H5 产品权限。wechat_h5 仅作为未来 H5 产品权限明确开通后的保留渠道。
  • session_key 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
  • 客户端支付成功回调只代表已拉起支付并返回成功;最终到账以后端虚拟支付消息推送或官方 /xpay/query_order 查单结果为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付不得误用普通微信支付 V3 查单。
  • 小程序 WebView 普通进入不预登录;H5 触发受保护入口或支付前必须保留 clientRuntime=wechat_mini_program 等宿主上下文,并用 MicroMessenger + miniProgram User-Agent 兜底识别首点 bridge 未就绪场景,再跳转小程序原生授权态,确保后端拿到带 session_key 的微信登录态。

关键文件

  • JSAPI 支付缺少当前用户 openid 时,前端调用 GET /api/auth/wechat/bind-start 发起 OAuth;后端把当前 user_id 写入 OAuth state,微信回调仍走 /api/auth/wechat/callback,但只把获得的微信身份绑定到当前账号,不走普通 /api/auth/wechat/start 的登录/切号流程。
  • 前端渠道选择:src/services/payment/paymentPlatform.ts
  • 充值入口:src/components/rpg-entry/RpgEntryHomeView.tsx
  • 小程序支付承接页:miniprogram/pages/wechat-pay/index.shared.js
  • API 契约:packages/shared/src/contracts/runtime.tsserver-rs/crates/shared-contracts/src/runtime.rs
  • 后端下单与订单编排:server-rs/crates/api-server/src/runtime_profile.rsserver-rs/crates/api-server/src/wechat/pay.rs
  • 微信支付 / 虚拟支付协议适配:server-rs/crates/platform-wechat/src/pay.rs
  • WebView 回流确认:GET /api/profile/recharge/orders/{orderId}/wechat/eventsPOST /api/profile/recharge/orders/{orderId}/wechat/confirm
  • 微信登录态保存:server-rs/crates/platform-auth/src/lib.rsserver-rs/crates/module-auth/src/lib.rs

后端配置

生产接入虚拟支付至少需要:

WECHAT_PAY_ENABLED=true
WECHAT_PAY_PROVIDER=real
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID=<微信虚拟支付 offerId>
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY=<现网 AppKey>
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY=<沙箱 AppKey,可选>
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT=https://api.weixin.qq.com/xpay/query_order
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT=https://api.weixin.qq.com/xpay/notify_provide_goods
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=<微信消息推送 Token>
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=<微信消息推送 EncodingAESKey>
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0

WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 表示现网,1 表示沙箱。后端会按 env 选择 AppKey,并生成:

  • signData:传给 wx.requestVirtualPayment 的订单数据。
  • paySigHMAC-SHA256(appKey, "requestVirtualPayment&" + signData) 的小写 hex。
  • signatureHMAC-SHA256(session_key, signData) 的小写 hex。
  • 服务端虚拟支付查单使用 POST https://api.weixin.qq.com/xpay/query_order:请求体固定携带 openidenvorder_id,查询参数携带小程序 access_tokenpay_sigpay_sig 按官方算法计算为 HMAC-SHA256(appKey, "/xpay/query_order&" + 实际发送的 JSON body) 的小写 hex;参与签名的 body 必须与 HTTP 实际发送字节一致。
  • 查单返回 status=2/3/4 分别表示“已支付待发货 / 发货中 / 已发货”,只有这三种状态可以补确认本地订单入账。入账前必须同时校验 order_id、支付类型 order_type=0/7order_fee 与合法 paid_time;不得用本机当前时间伪造结算时间。status=0/1/5..10 只记录供应方状态,不直接发放泥点或会员权益。
  • short_series_goods 查单恢复到 status=2 时,必须先完成本地幂等入账,再调用官方 /xpay/notify_provide_goods 补发货确认;status=3/4 不重复通知。发货确认失败时,本地订单已是 paid,后续用户确认、到期补偿重试或历史脚本必须允许只重试发货,不得再次发放权益。short_series_coin 不调用该现金单发货接口。
  • order_type 的官方枚举是 0=普通虚拟支付1=普通退款7=iOS 支付8=iOS 退款,不区分 short_series_coinshort_series_goods。普通和 iOS 的支付单分别使用 07;退款类型 1/8 绝不可触发充值入账。官方查单响应本身不提供可反查 coin / goods 的字段,不得自行发明数值映射;商品契约继续以本地订单快照和 order_fee 一致性作防线。
  • WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINTWECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT 默认使用微信官方地址,仅用于测试注入 mock endpoint,生产不应覆盖。WechatConfig 支持注入 stable token、query order 与 notify provide goods endpoint;缓存 token 遇到 40001/40014/42001 时只清除本次失败 token,强制刷新并最多重放一次。
  • 泥点属于微信虚拟支付代币(coin),short_series_coinbuyQuantity 必须使用当前泥点商品的 points_amount;例如 60 泥点商品应传 buyQuantity: 60
  • 会员直购 signData 额外包含 productIdgoodsPricegoodsPrice 使用后端商品配置价,和微信后台道具价格校验保持一致。
  • 微信小程序“开发者服务器接收消息推送”必须配置为安全模式,数据格式选 JSON,URL 统一指向 /api/profile/recharge/wechat/virtual-notify
  • WECHAT_MINIPROGRAM_MESSAGE_TOKENWECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY 由环境变量注入;GET URL 验证按官方规则用 Token/timestamp/nonce 校验 signature 并原样返回 echostrPOST 安全模式推送再校验 msg_signature、用 EncodingAESKey 解密 Encrypt,然后按虚拟支付事件入账。
  • 安全模式下,POST 推送会先解密再解析 xpay_goods_deliver_notify / xpay_coin_pay_notify;不要把 GET URL 验证里的 echostr 当密文解密。

通知诊断与退款保护

后端识别微信虚拟支付当前 9 类官方事件:xpay_goods_deliver_notifyxpay_coin_pay_notifyxpay_refund_notifyxpay_complaint_notifyxpay_wxpay_callback_notifyxpay_subscribe_signing_result_notifyxpay_subscribe_pay_fail_notifyxpay_apple_subscribe_signing_result_notifyxpay_subscribe_ios_refund_query_notify

通知 当前处理 微信应答
goods / coin 且存在同号 wechat_mp_virtual 本地充值订单 在 2.5 秒预算内调用 /xpay/query_order,校验订单号、金额、支付类型、状态和权威 paid_time 后幂等入账 成功时 ErrCode=0,查单或校验失败时 ErrCode=1
goods / coin 但没有同号本地充值订单,或带明确订阅标记 只记录诊断,不按普通充值入账;这是 Android 订阅与普通 goods 通知同形时的硬边界 ErrCode=0
退款结果、投诉、风控回调、签解约、订阅扣款失败、Apple 签约结果 只记录诊断,不改订单、泥点或会员权益 ErrCode=0
iOS 退款问询 未配置真实履约决策前不返回同意或拒绝建议 ErrCode=1,触发微信重试
未识别事件 不静默吞掉 ErrCode=1,触发微信重试

xpay_subscribe_ios_refund_query_notifyresult_code=0 表示建议退款,result_code=1 表示建议拒绝退款,两者都是正式业务决策,不存在“仅调试”的中立值。只有拿到可审计的发货、消耗或会员使用事实后,才能返回 ErrCode=0 + IosRefundQueryResponse;当前诊断阶段固定返回非零且不携带该对象。最终退款结果以 xpay_refund_notify 为准;正式退款落账与权益回收流程尚未接入,诊断处理不得提前回收权益。

Android 订阅成功通知可能与普通 xpay_goods_deliver_notify 字段完全相同,因此 AppleSubscriptionInfoOutContractCode 等 payload 标记只能用于快速分流,不能作为最终安全边界。入账前必须先确认通知订单号对应本地 wechat_mp_virtual 充值订单,再以官方查单结果校验。Apple 等非微信支付渠道的通知可能没有 WeChatPayInfo.PaidTime;不得用本机时间补齐,统一从 /xpay/query_order 获取权威 paid_time

诊断日志在验签和解密成功后记录 event、响应格式、payload 字节数、字段路径、payload 指纹、重试次数、环境、状态、金额、数量、产品和履约字段。Payload 指纹、未知事件名、未知字段名、OpenID、本地/微信/Apple 订单号、退款号、合同号等只记录使用消息 Token 和用途域生成的稳定 HMAC 引用;状态等非数值字符串、投诉详情、退款原因、AttachRemark 等只记录长度和 HMAC 引用。禁止把解密明文、签名、nonce、密文或密钥写入日志。

微信后台固定配置“安全模式 + JSON”。内层 XML 仅保留兼容解析和同格式应答,不代表外层加密 envelope 支持 XML;真实联调不得把后台数据格式切换为 XML。

微信支付 V3 退款结果通知

普通微信支付 V3 与虚拟支付是两套退款通知协议。V3 退款结果使用独立入口 POST /api/profile/recharge/wechat/refund-notifyWECHAT_PAY_NOTIFY_URL 仍只用于支付成功通知,不会自动让退款结果发到该入口。调用 POST /v3/refund/domestic/refunds 发起退款时,必须把 https://<公网 API 域名>/api/profile/recharge/wechat/refund-notify 作为本次退款请求的 notify_url。退款接口受理成功只表示生成退款单,最终状态以该通知和 GET /v3/refund/domestic/refunds/{out_refund_no} 查单为准。

V3 退款入口在正式落账的同时保留可用于真实联调的安全诊断:

  • 复用支付通知的原始 body 验签与 APIv3 密钥 AES-256-GCM 解密层;校验 Wechatpay-Timestamp 在正负 5 分钟窗口内,并拒绝签名探测、错误平台公钥序列号和篡改请求。
  • 只接受 resource_type=encrypt-resourceresource.algorithm=AEAD_AES_256_GCMresource.original_type=refund,并要求解密后的 mchid 与当前商户一致。
  • 只接受 REFUND.SUCCESSREFUND.ABNORMALREFUND.CLOSED,且分别要求解密后的 refund_statusSUCCESSABNORMALCLOSED;未知事件或状态不一致返回非 2xx,避免静默吞掉新契约。
  • 验签、解密和契约校验成功后,先把退款观察写入 SpacetimeDB 统一事务,再返回 HTTP 204;同一通知重复到达时仍稳定返回 204。外部现金退款已经成功但本地权益需要人工处理时也先保存事实再 ACK;只有验签、契约或持久化失败才返回 HTTP 400/502/503{"code":"FAIL","message":"失败"},让微信继续重试。
  • 日志只明文记录事件、退款状态、资源类型、创建/成功时间和四个金额字段。成功与失败通知都记录按 APIv3 密钥和用途域生成的稳定 request_ref,失败额外记录不含原值的阶段与原因码,便于关联微信重试。通知 ID、商户号、支付单号、商户订单号、微信退款单号、商户退款单号只记录 HMAC 引用;user_received_account 只记录字节数和 HMAC 引用。禁止记录外层 body、解密明文、签名、nonce、密文、平台序列号或密钥。
  • 成功退款会进入正式退款事务:部分退款保留原订单 paid,累计全额退款改为 refunded;泥点商品只回收可用永久泥点,会员退款转人工。系统不在公网暴露匿名或普通用户“发起退款”接口;退款申请只能通过受控运维或后续管理员鉴权流程,以稳定 out_refund_no 串联申请、回调、主动查单和账单数据。

微信支付 V3 退款正式落账与对账契约

正式链路使用“退款单事实 + 订单级权益结算摘要 + 观察记录 + 账单检查点”,不把部分退款、对账游标或权益异常塞进原充值订单。四个事实入口统一调用同一个 SpacetimeDB 事务:受控退款 API 的已验签响应、退款回调、主动查询和交易账单发现。out_refund_no 是商户幂等键,重试必须复用;provider_refund_id 是微信退款单号。任何入口重复、乱序或并发到达都必须校验订单号、微信支付单号、微信退款单号、订单总额和本次退款金额等不可变字段,不能只因主键已存在就直接成功。

  1. profile_recharge_refund 每行保存一张普通 V3 退款单,状态只使用 processing / success / abnormal / closedABNORMAL 后可由查询推进为 SUCCESSCLOSEDSUCCESS 终态不得被旧观察回退。最新来源和 observation ID 只用于定位最近观察,完整来源历史以 observation 表为准。
  2. profile_recharge_refund_observation 追加保存来源、观察指纹、微信状态和观察时间,不保存回调密文、签名、APIv3 Key、私钥、完整下载地址或原始 CSV。回调观察用微信通知 ID 幂等;API、查询和账单观察用稳定事实指纹幂等。
  3. profile_recharge_order_refund_settlement 按原充值订单聚合累计成功退款金额、应回收泥点、已回收泥点、未回收泥点和权益处理状态。累计成功退款小于订单金额时充值订单仍为 paid;等于订单金额时才改为 refunded;累计金额大于订单金额必须转人工并告警。退款不能恢复首充资格,历史成功支付事实以 paid_at 是否存在判定。
  4. 退款金额按分保存。泥点商品的累计应回收量按 floor(points_delta * 累计成功退款分 / 订单金额分) 计算并用 u128 防溢出;累计全额退款时强制等于完整 points_delta,避免部分退款逐单舍入造成遗漏。自动回收只能扣普通永久泥点,绝不消耗每日免费泥点或会员周期限时泥点;余额不足时回收当前可用永久泥点,余量写入 unrecovered_points,状态为 shortfall,且正式钱包消费入口在欠款清零前 fail-closed。回收流水使用独立来源 recharge_refund_recovery 和确定性流水号。
  5. 会员购买、续费和升级目前聚合写入单行 profile_membership,没有按订单保存可逆 grant 或升级前快照。会员退款无论全额还是部分都只记录现金退款事实并标记 manual_review,不得猜测缩短有效期、降档或扣周期泥点。现金退款已经成功时,即使权益回收不足或需要人工处理,也必须持久化退款事实并正常 ACK,不能依赖微信重复通知解决本地权益问题。
  6. 主动查询 worker 只由 HTTP 角色运行。对 processing / abnormal 退款按 1、5、10、20、30 分钟衰减查询 GET /v3/refund/domestic/refunds/{out_refund_no};查询响应先验微信签名,再进入统一观察事务。success / closed 停止外部查询;本地仍有泥点欠款的订单只重试本地权益结算,不重复请求微信。成功退款先于支付通知到达而产生的 order_missing / order_not_paid 会继续重试,会员退款和金额、渠道、交易号冲突不自动解除人工复核。候选退款按分钟轮转分页,单批超过 100 条时也必须最终覆盖,失败日志只记录哈希引用和静态分类,不回显可能含商户退款单号的请求 URL。
  7. 商户平台手工退款不假设会发送本系统 notify_url。北京时间次日 10 点后按 30 个稳定分片轮转请求 GET /v3/bill/tradebill?bill_date=YYYY-MM-DD&bill_type=REFUND&tar_type=GZIP,每 30 分钟覆盖微信 API 可查询的近 90 天窗口;申请账单响应必须验微信签名。下载地址仅短时有效,下载请求需要按 V3 规则签名,下载响应无签名头,解压后按响应中的 SHA1 校验。CSV 必须用 CSV parser 读取并移除字段前导反引号,金额以十进制定点从元转分,禁止浮点换算。交易状态 REFUND 且退款类型 PLATFORM-ORIGINAL / PLATFORM-BALANCE 是商户平台退款发现依据;账单状态 SUCCESS / PROCESSING / FAIL / CHANGE 不能直接当成查询状态,发现后使用商户退款单号主动查单再落当前终态。单行失败时继续处理其他行和日期,但当日不写完成 checkpoint;昨日返回 NO_STATEMENT_EXIST 时至少延迟到次日 10 点后再确认空账单。profile_recharge_refund_bill_checkpoint 只保存已完成日期、账单哈希、处理行数和完成时间;多实例可能重复下载,但 observation 与 checkpoint 写入均幂等,重启后不会重复结算权益。超过近 90 天 API 窗口的历史账单只能从商户平台下载并受控核对。
  8. 退款申请只允许管理员鉴权或受控运维入口。platform-wechat 提供已签名且验响应签名的 POST /v3/refund/domestic/refunds 能力,但当前不新增普通用户或匿名公网退款路由;受控调用必须先核对本地 paid / refunded 订单和剩余可退金额,复用稳定 out_refund_no,并携带本退款回调 URL。
  9. 普通 V3 支付通知和退款通知都必须校验本地订单的商户号、应用 ID、订单号、微信支付单号和金额。晚到的支付通知遇到已全额退款订单只作为已支付事实幂等确认,不得重新发放权益;同一订单出现不同微信支付单号必须拒绝。

主动查询与账单 reconciliation 默认关闭。只有部署环境具备真实商户私钥、平台公钥和运行时服务身份时,才设置 WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true;回调落账不依赖该开关,始终在退款通知路由验签成功后执行。

后台充值订单与退款编排契约

首期后台退款只支持普通微信 V3 的泥点充值订单;wechat_mp_virtual、会员商品、未支付订单、支付单号缺失、存在未完成退款或未完成退款占用、已经全额退款的订单必须拒绝。后台是唯一常规退款入口,商户平台只用于应急退款;已知应急退款必须登记商户退款单号 out_refund_no 后主动查单,未知退款继续由回调或 T+1 账单发现。微信 V3 单笔退款查询不支持按微信退款单号 refund_id 查询,后台必须明确区分这两个字段。编号前缀和长度只能作为 refund_id 的疑似提示,不能在查单前硬拒绝一个满足 out_refund_no 契约的输入;只有按 out_refund_no 查询确认不存在后,才提示改填商户退款单号或等待回调、退款账单建立映射。

  1. 后台退款分为预检和执行。预检必须验管理员会话,读取本地订单、累计成功退款、活动退款占用和钱包分桶,并实时调用微信支付订单查询。微信支付查单的 trade_state=REFUND 只表示发生过退款,不表示已经全额退款:SUCCESS 可以进入退款预检;REFUND 只有在已登记的成功退款经主动查单刷新、本地累计成功退款大于 0 且小于订单总额、并且不存在未完成退款、活动占用或欠账时,才允许继续退本地计算出的剩余额度。REFUND 没有对应本地成功退款事实时必须阻止,提示登记 out_refund_no 或等待账单对账。
  2. 执行请求必须携带客户端生成并在重试时复用的 request_id。服务端由订单号和 request_id 派生稳定 out_refund_no,先在 SpacetimeDB 事务中创建 profile_recharge_refund_hold,再调用微信退款。占用金额使用累计退款公式计算本次增量应追回泥点;部分退款额外预留 1 泥点作为并发外部退款跨越累计 floor 边界的安全缓冲,预检仍展示真实应追回量,结算后自动释放未使用缓冲;全额退款精确占用剩余全部订单泥点。永久泥点不足时事务直接拒绝,不能调用微信。
  3. 活动 hold 不直接改钱包总额,但所有普通负向流水都必须把活动占用从可消费余额中扣除。退款 SUCCESS observation 在同一事务里扣除对应占用的永久泥点并把 hold 改为 settled;退款 CLOSED 释放为 releasedPROCESSING / ABNORMAL 保持占用。没有 provider 结果的活动 hold 由 reconciliation 按稳定 out_refund_no 查单;占用创建至少 10 分钟且连续 3 次退款查单收到微信官方 RESOURCE_NOT_EXISTS 后才自动释放,进程重启会清空连续次数并重新观察。适配层兼容同类 ORDER_NOT_EXIST 错误,但不能把超时、签名、配置、解析和其他上游错误当成退款不存在。
  4. 已经发生的外部退款没有 hold 时沿用现有结算:回收当前未被其他 hold 占用的永久泥点,余额不足部分继续以 profile_recharge_order_refund_settlement.unrecovered_points 作为唯一退款欠账真相,状态为 shortfall 并限制消费。后续永久泥点到账后在同一钱包事务内按最早退款单自动继续追回;每日免费和会员周期泥点不参与。不得再建一张平行 debt 表重复累计欠账。
  5. 人工钱包冻结单独使用 profile_wallet_manual_restriction,保存当前是否冻结、原因、操作管理员和操作时间。普通消费同时检查人工冻结、退款欠账和活动 hold;解除人工冻结不能解除仍存在的退款欠账。
  6. 后台 API 统一位于管理员鉴权下:充值订单列表与详情、用户详情、退款预检、退款执行、应急 out_refund_no 登记、钱包人工冻结/解冻。任何接口都不得返回原始手机号、商户私钥、APIv3 Key、微信签名、回调密文或账单下载 URL。
  7. 通用用户详情通过内部 user_id 或陶泥号解析同一认证用户,展示头像、昵称、陶泥号、内部 ID、脱敏手机号、登录/微信绑定状态、钱包总额、可消费余额、活动占用、退款欠账、冻结原因和最近充值订单。后台语义明确的用户 ID 或陶泥号旁统一使用图标按钮打开同一个弹窗,不复制页面级用户查询逻辑。

真实联调时显式开启该模块的 debug 日志:

npm run dev -- --log 'info,api_server::wechat::pay=debug,tower_http=info'

验收命令

npm exec vitest run miniprogram/pages/wechat-pay/index.test.js src/services/payment/paymentPlatform.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
cargo test -p platform-wechat virtual_payment_debug_summary --manifest-path server-rs/Cargo.toml
cargo test -p platform-wechat parse_virtual_payment_notify --manifest-path server-rs/Cargo.toml
cargo test -p api-server virtual_payment_debug_routing --manifest-path server-rs/Cargo.toml
cargo test -p api-server virtual_payment_ios_refund_query_has_no_fake_decision_response --manifest-path server-rs/Cargo.toml
cargo test -p platform-wechat v3_refund_notify --manifest-path server-rs/Cargo.toml
cargo test -p platform-wechat v3_transaction_notify --manifest-path server-rs/Cargo.toml
cargo test -p api-server v3_refund_notify_failure --manifest-path server-rs/Cargo.toml
cargo check -p api-server --manifest-path server-rs/Cargo.toml
cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml create_profile_recharge_order_response_serializes_virtual_wechat_payloads
npm run typecheck
npm run check:encoding

历史订单逐单核对

升级前已存在的 wechat_mp_virtual pending 订单没有 expiration timer,不会被到期 catch-up 自动遍历。这类订单使用受控脚本逐单处理,不得批量改状态:

# openid 只放入当次临时文件,不作为 CLI 参数或仓库文件。
install -m 0600 /dev/null /run/genarrative-virtual-payment-openid

# 第一次固定 dry-run;人工核对订单、金额、微信状态和 applyFingerprint。
npm run spacetime:wechat-virtual-payment:reconcile -- \
  --database genarrative-prod \
  --server-url http://127.0.0.1:3101 \
  --order-id <orderId> \
  --openid-file /run/genarrative-virtual-payment-openid \
  --env-file /etc/genarrative/api-server.env

# 只有 dry-run 显示 eligibleForCredit=true 时,使用同一订单追加 --apply 和当次指纹。
# dry-run 结束会删除 openid 临时文件;apply 前需按同样的 0600 要求重新准备该文件。
npm run spacetime:wechat-virtual-payment:reconcile -- \
  --database genarrative-prod \
  --server-url http://127.0.0.1:3101 \
  --order-id <orderId> \
  --openid-file /run/genarrative-virtual-payment-openid \
  --env-file /etc/genarrative/api-server.env \
  --apply \
  --confirm <applyFingerprint>

脚本每次重新读取本地订单并重新向微信查单;--apply 要求事实指纹与前一次 dry-run 完全一致,只对 status=2/3/4 且单号、金额、支付类型 order_type=0/7paid_time 一致的订单调用既有 mark_profile_recharge_order_paid_and_return。本地已 paidshort_series_goods 订单仍允许重跑同一 dry-run/apply 门禁,以便对微信 status=2 只补发货确认。脚本使用 /etc/genarrative/api-server.env 内的 GENARRATIVE_SPACETIME_TOKEN 通过显式 --server-url 调用 procedure,不复用 migration operator 身份;不输出 openid、AppSecret、AppKey、access token 或 SpacetimeDB token,且 apply 默认拒绝非微信官方 endpoint。读取成功后的临时 openid 文件无论处理成功失败都会删除。

注意事项

  • 旧微信登录快照可能没有 session_key;普通进入小程序 WebView 仍允许匿名打开,虚拟支付会由后端拦截并提示用户在小程序内重新登录。H5 内部导航不得清理 clientTypeclientRuntimeminiProgramEnv,且首点登录要用小程序 User-Agent 兜底识别,否则登录和支付会误判为普通网页环境。
  • 小程序充值商品全部映射到虚拟支付;泥点使用 short_series_coin,会员使用 short_series_goods
  • short_series_coin 只用于代币购买,后端从本次下单返回的充值中心商品快照读取 points_amount 并写入 buyQuantity;不要把 coin 商品当成道具,也不要把 buyQuantity 固定为 1。
  • 后台新增的会员类充值商品会直接把商品 productId 作为微信 short_series_goods 的道具 ID;例如微信后台道具 ID 为 item01 时,后台会员商品 productId 也应配置为 item01,且商品价格需要与微信后台道具价格一致。
  • 小程序页必须保留普通支付与虚拟支付双分支,按 pay params 字段判断调用 wx.requestPaymentwx.requestVirtualPayment
  • 小程序支付承接页回传 wx_pay_result 时必须携带 requestId:status:orderId[:error],并同时写入上一页 hash 与本地 storageWebView onShow 会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。
  • 微信虚拟支付消息推送使用独立后端入口 /api/profile/recharge/wechat/virtual-notify,按 xpay_goods_deliver_notifyxpay_coin_pay_notify 推进充值订单入账;回包需按入站格式返回 ErrCode=0 / ErrMsg=successJSON 入站回 JSON,XML 入站回 XML),错误时带具体 ErrMsg 便于微信侧重试与排障。
  • 沙箱或基础库失败会把微信返回的 errCode / errMsg 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。
  • Web 侧在拉起虚拟支付后会短时轮询 wx_pay_result,即使小程序 web-view 回写 hash 没触发浏览器 hashchange,也必须展示回写的微信错误内容。
  • WebView 返回但没有拿到 wx_pay_result 时,前端必须主动调用订单确认接口,并接入 /api/profile/recharge/orders/{orderId}/wechat/events 的 SSE 事件流作为服务端推送兜底;虚拟支付确认接口会使用当前用户后端保存的小程序 openid 调用官方 /xpay/query_order,查到已支付且契约校验通过后写入订单。后端通过消息推送或查单入账后都会发布订单更新,SSE 先推当前订单快照,再在订单结束时推 done
  • Web Native 二维码弹窗也必须在展示后立即订阅同一订单 SSE,收到支付回调入账后的 paid 快照时自动关闭二维码、刷新充值中心与全局余额并展示一次成功结果;“我已支付”只作为主动查单兜底,不能是扫码付款后的唯一状态推进入口。SSE 在等待窗口结束或短暂断线时按订单过期时间重连,关闭弹窗时必须取消订阅。
  • WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。