保留 SpacetimeDB 历史表、迁移白名单与最小兼容读取定义 移除旧创作前后端、worker、业务过程及纯业务 crate 的编译依赖 恢复现役创作、项目、我的入口及桌面移动导航 收紧 Vite、TypeScript、ESLint、Vitest 与静态资源退役边界 补齐开发栈、网关、原生壳和文档退役约束
29 KiB
微信虚拟支付接入
更新时间:2026-07-13
接入口径
- 泥点充值在微信小程序 WebView 内走
wechat_mp_virtual,由小程序页调用wx.requestVirtualPayment的short_series_coin模式。 - 会员商品在微信小程序 WebView 内同样走
wechat_mp_virtual,由小程序页调用wx.requestVirtualPayment的short_series_goods模式,并在signData内带productId与goodsPrice。 - 微信内浏览器走
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 + miniProgramUser-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.ts、server-rs/crates/shared-contracts/src/runtime.rs - 后端下单与订单编排:
server-rs/crates/api-server/src/runtime_profile.rs、server-rs/crates/api-server/src/wechat/pay.rs - 微信支付 / 虚拟支付协议适配:
server-rs/crates/platform-wechat/src/pay.rs - WebView 回流确认:
GET /api/profile/recharge/orders/{orderId}/wechat/events、POST /api/profile/recharge/orders/{orderId}/wechat/confirm - 微信登录态保存:
server-rs/crates/platform-auth/src/lib.rs、server-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的订单数据。paySig:HMAC-SHA256(appKey, "requestVirtualPayment&" + signData)的小写 hex。signature:HMAC-SHA256(session_key, signData)的小写 hex。- 服务端虚拟支付查单使用
POST https://api.weixin.qq.com/xpay/query_order:请求体固定携带openid、env和order_id,查询参数携带小程序access_token与pay_sig。pay_sig按官方算法计算为HMAC-SHA256(appKey, "/xpay/query_order&" + 实际发送的 JSON body)的小写 hex;参与签名的 body 必须与 HTTP 实际发送字节一致。 - 查单返回
status=2/3/4分别表示“已支付待发货 / 发货中 / 已发货”,只有这三种状态可以补确认本地订单入账。入账前必须同时校验order_id、支付类型order_type=0/7、order_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_coin与short_series_goods。普通和 iOS 的支付单分别使用0和7;退款类型1/8绝不可触发充值入账。官方查单响应本身不提供可反查 coin / goods 的字段,不得自行发明数值映射;商品契约继续以本地订单快照和order_fee一致性作防线。WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT与WECHAT_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_coin的buyQuantity必须使用当前泥点商品的points_amount;例如 60 泥点商品应传buyQuantity: 60。 - 会员直购
signData额外包含productId和goodsPrice;goodsPrice使用后端商品配置价,和微信后台道具价格校验保持一致。 - 微信小程序“开发者服务器接收消息推送”必须配置为安全模式,数据格式选 JSON,URL 统一指向
/api/profile/recharge/wechat/virtual-notify。 WECHAT_MINIPROGRAM_MESSAGE_TOKEN和WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY由环境变量注入;GET URL 验证按官方规则用Token/timestamp/nonce校验signature并原样返回echostr,POST 安全模式推送再校验msg_signature、用EncodingAESKey解密Encrypt,然后按虚拟支付事件入账。- 安全模式下,POST 推送会先解密再解析
xpay_goods_deliver_notify/xpay_coin_pay_notify;不要把 GET URL 验证里的echostr当密文解密。
通知诊断与退款保护
后端识别微信虚拟支付当前 9 类官方事件:xpay_goods_deliver_notify、xpay_coin_pay_notify、xpay_refund_notify、xpay_complaint_notify、xpay_wxpay_callback_notify、xpay_subscribe_signing_result_notify、xpay_subscribe_pay_fail_notify、xpay_apple_subscribe_signing_result_notify、xpay_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_notify 的 result_code=0 表示建议退款,result_code=1 表示建议拒绝退款,两者都是正式业务决策,不存在“仅调试”的中立值。只有拿到可审计的发货、消耗或会员使用事实后,才能返回 ErrCode=0 + IosRefundQueryResponse;当前诊断阶段固定返回非零且不携带该对象。最终退款结果以 xpay_refund_notify 为准;正式退款落账与权益回收流程尚未接入,诊断处理不得提前回收权益。
Android 订阅成功通知可能与普通 xpay_goods_deliver_notify 字段完全相同,因此 AppleSubscriptionInfo、OutContractCode 等 payload 标记只能用于快速分流,不能作为最终安全边界。入账前必须先确认通知订单号对应本地 wechat_mp_virtual 充值订单,再以官方查单结果校验。Apple 等非微信支付渠道的通知可能没有 WeChatPayInfo.PaidTime;不得用本机时间补齐,统一从 /xpay/query_order 获取权威 paid_time。
诊断日志在验签和解密成功后记录 event、响应格式、payload 字节数、字段路径、payload 指纹、重试次数、环境、状态、金额、数量、产品和履约字段。Payload 指纹、未知事件名、未知字段名、OpenID、本地/微信/Apple 订单号、退款号、合同号等只记录使用消息 Token 和用途域生成的稳定 HMAC 引用;状态等非数值字符串、投诉详情、退款原因、Attach、Remark 等只记录长度和 HMAC 引用。禁止把解密明文、签名、nonce、密文或密钥写入日志。
微信后台固定配置“安全模式 + JSON”。内层 XML 仅保留兼容解析和同格式应答,不代表外层加密 envelope 支持 XML;真实联调不得把后台数据格式切换为 XML。
微信支付 V3 退款结果通知
普通微信支付 V3 与虚拟支付是两套退款通知协议。V3 退款结果使用独立入口 POST /api/profile/recharge/wechat/refund-notify;WECHAT_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-resource、resource.algorithm=AEAD_AES_256_GCM、resource.original_type=refund,并要求解密后的mchid与当前商户一致。 - 只接受
REFUND.SUCCESS、REFUND.ABNORMAL、REFUND.CLOSED,且分别要求解密后的refund_status为SUCCESS、ABNORMAL、CLOSED;未知事件或状态不一致返回非 2xx,避免静默吞掉新契约。 - 验签、解密和契约校验成功后,先把退款观察写入 SpacetimeDB 统一事务,再返回 HTTP
204;同一通知重复到达时仍稳定返回204。外部现金退款已经成功但本地权益需要人工处理时也先保存事实再 ACK;只有验签、契约或持久化失败才返回 HTTP400/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 是微信退款单号。任何入口重复、乱序或并发到达都必须校验订单号、微信支付单号、微信退款单号、订单总额和本次退款金额等不可变字段,不能只因主键已存在就直接成功。
profile_recharge_refund每行保存一张普通 V3 退款单,状态只使用processing / success / abnormal / closed。ABNORMAL后可由查询推进为SUCCESS或CLOSED;SUCCESS终态不得被旧观察回退。最新来源和 observation ID 只用于定位最近观察,完整来源历史以 observation 表为准。profile_recharge_refund_observation追加保存来源、观察指纹、微信状态和观察时间,不保存回调密文、签名、APIv3 Key、私钥、完整下载地址或原始 CSV。回调观察用微信通知 ID 幂等;API、查询和账单观察用稳定事实指纹幂等。profile_recharge_order_refund_settlement按原充值订单聚合累计成功退款金额、应回收泥点、已回收泥点、未回收泥点和权益处理状态。累计成功退款小于订单金额时充值订单仍为paid;等于订单金额时才改为refunded;累计金额大于订单金额必须转人工并告警。退款不能恢复首充资格,历史成功支付事实以paid_at是否存在判定。- 退款金额按分保存。泥点商品的累计应回收量按
floor(points_delta * 累计成功退款分 / 订单金额分)计算并用u128防溢出;累计全额退款时强制等于完整points_delta,避免部分退款逐单舍入造成遗漏。自动回收只能扣普通永久泥点,绝不消耗每日免费泥点或会员周期限时泥点;余额不足时回收当前可用永久泥点,余量写入unrecovered_points,状态为shortfall,且正式钱包消费入口在欠款清零前 fail-closed。回收流水使用独立来源recharge_refund_recovery和确定性流水号。 - 会员购买、续费和升级目前聚合写入单行
profile_membership,没有按订单保存可逆 grant 或升级前快照。会员退款无论全额还是部分都只记录现金退款事实并标记manual_review,不得猜测缩短有效期、降档或扣周期泥点。现金退款已经成功时,即使权益回收不足或需要人工处理,也必须持久化退款事实并正常 ACK,不能依赖微信重复通知解决本地权益问题。 - 主动查询 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。 - 商户平台手工退款不假设会发送本系统
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 窗口的历史账单只能从商户平台下载并受控核对。 - 退款申请只允许管理员鉴权或受控运维入口。
platform-wechat提供已签名且验响应签名的POST /v3/refund/domestic/refunds能力,但当前不新增普通用户或匿名公网退款路由;受控调用必须先核对本地paid / refunded订单和剩余可退金额,复用稳定out_refund_no,并携带本退款回调 URL。 - 普通 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 查询确认不存在后,才提示改填商户退款单号或等待回调、退款账单建立映射。
- 后台退款分为预检和执行。预检必须验管理员会话,读取本地订单、累计成功退款、活动退款占用和钱包分桶,并实时调用微信支付订单查询。微信支付查单的
trade_state=REFUND只表示发生过退款,不表示已经全额退款:SUCCESS可以进入退款预检;REFUND只有在已登记的成功退款经主动查单刷新、本地累计成功退款大于 0 且小于订单总额、并且不存在未完成退款、活动占用或欠账时,才允许继续退本地计算出的剩余额度。REFUND没有对应本地成功退款事实时必须阻止,提示登记out_refund_no或等待账单对账。 - 执行请求必须携带客户端生成并在重试时复用的
request_id。服务端由订单号和request_id派生稳定out_refund_no,先在 SpacetimeDB 事务中创建profile_recharge_refund_hold,再调用微信退款。占用金额使用累计退款公式计算本次增量应追回泥点;部分退款额外预留 1 泥点作为并发外部退款跨越累计floor边界的安全缓冲,预检仍展示真实应追回量,结算后自动释放未使用缓冲;全额退款精确占用剩余全部订单泥点。永久泥点不足时事务直接拒绝,不能调用微信。 - 活动 hold 不直接改钱包总额,但所有普通负向流水都必须把活动占用从可消费余额中扣除。退款
SUCCESSobservation 在同一事务里扣除对应占用的永久泥点并把 hold 改为settled;退款CLOSED释放为released;PROCESSING / ABNORMAL保持占用。没有 provider 结果的活动 hold 由 reconciliation 按稳定out_refund_no查单;占用创建至少 10 分钟且连续 3 次退款查单收到微信官方RESOURCE_NOT_EXISTS后才自动释放,进程重启会清空连续次数并重新观察。适配层兼容同类ORDER_NOT_EXIST错误,但不能把超时、签名、配置、解析和其他上游错误当成退款不存在。 - 已经发生的外部退款没有 hold 时沿用现有结算:回收当前未被其他 hold 占用的永久泥点,余额不足部分继续以
profile_recharge_order_refund_settlement.unrecovered_points作为唯一退款欠账真相,状态为shortfall并限制消费。后续永久泥点到账后在同一钱包事务内按最早退款单自动继续追回;每日免费和会员周期泥点不参与。不得再建一张平行 debt 表重复累计欠账。 - 人工钱包冻结单独使用
profile_wallet_manual_restriction,保存当前是否冻结、原因、操作管理员和操作时间。普通消费同时检查人工冻结、退款欠账和活动 hold;解除人工冻结不能解除仍存在的退款欠账。 - 后台 API 统一位于管理员鉴权下:充值订单列表与详情、用户详情、退款预检、退款执行、应急
out_refund_no登记、钱包人工冻结/解冻。任何接口都不得返回原始手机号、商户私钥、APIv3 Key、微信签名、回调密文或账单下载 URL。 - 通用用户详情通过内部
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/7 和 paid_time 一致的订单调用既有 mark_profile_recharge_order_paid_and_return。本地已 paid 的 short_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 内部导航不得清理clientType、clientRuntime、miniProgramEnv,且首点登录要用小程序 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.requestPayment或wx.requestVirtualPayment。 - 小程序支付承接页回传
wx_pay_result时必须携带requestId:status:orderId[:error],并同时写入上一页 hash 与本地 storage;WebViewonShow会立即检查一次、延迟二次检查一次,且同名 hash 参数必须替换,避免支付状态停留在处理中或重复处理。 - 微信虚拟支付消息推送使用独立后端入口
/api/profile/recharge/wechat/virtual-notify,按xpay_goods_deliver_notify和xpay_coin_pay_notify推进充值订单入账;回包需按入站格式返回ErrCode=0/ErrMsg=success(JSON 入站回 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 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。