Merge branch 'master' into extract-color-schema

This commit is contained in:
2026-07-14 17:01:00 +08:00
124 changed files with 20247 additions and 362 deletions
@@ -4057,10 +4057,31 @@
## 2026-07-13 公开作品资产使用派生精确读授权
- 背景:资产 ACL 严格执行后,已登记为 `private` 的作品封面和正式资产不能再依赖 generated 前缀匿名读取;但公开作品仍需要允许访客读取它实际展示和运行的资产。
- 决策:已登记 `asset_object` 继续保持 `private`,新增匿名派生 view `public_work_asset_read_grant`。view 只从 `Published + visible` 作品(`custom-world` 另要求未删除)正式发布快照中收集实际使用的资产,历史作品随 view 计算自动补齐;资产读取 procedure 在同一事务快照内组合 `asset_object` 与该 view,不从连接级长期订阅 cache 判断 ACL。
- 决策:已登记 `asset_object` 继续保持 `private`,新增匿名派生 view `public_work_asset_read_grant`。view 只从 `Published + visible` 作品(`custom-world` 另要求未删除)正式发布快照中收集实际使用的资产,历史作品随 view 计算自动补齐;资产读取 procedure 在同一事务快照内先取资产 owner,再使用各玩法 owner 索引定向计算该作者的 grant,不为每张图片执行全站 view不从连接级长期订阅 cache 判断 ACL。
- 授权边界:grant 携带作品 owner,API 只有在它与 `asset_object.owner_user_id` 一致,且 `asset_object_id` 或精确 `object_key` 命中时才允许匿名读取。隐藏、删除或取消发布会使 grant 自动消失;参考图、未选中候选图和 `generationInputs` 明确排除。Custom World 只遍历角色、地标、营地、章节和 opening CG 等已知正式根,不能递归 legacy payload 的未知预览 / 编辑字段。
- 禁止项:不得通过放开 `generated-*` 前缀或批量把历史对象改为 `PublicRead` 修复公开作品,两种方式都会让作品可见性生命周期与资产授权脱节,并重新引入跨账号读取。
- 影响范围:`module-assets` 公开资产授权判定、`spacetime-module` 跨玩法公开资产 view 与权威读取 procedure、`spacetime-client` facade 和 `api-server` 资产读取 ACL。
- 权威查询:`asset_object` 不进入 client 长期订阅。API 通过仅 runtime service identity 可调用的 procedure,按主键或 `(bucket, object_key)` 服务端索引读取事务内 metadata;只有位置查询明确返回不存在时才允许进入 legacy curated 前缀兼容,procedure 失败、超时或重复位置一律失败关闭。
- 一致性:隐藏、删除或取消发布提交后,后续读取 procedure 的事务快照立即按新状态判断,不等待任意池连接追上订阅水位。公开派生授权、`PublicRead` 和 legacy 兼容读取签名 URL 的有效期最多 600 秒,因此该能力仍不是对既有签名的瞬时吊销机制;owner / admin 读取保持原有效期口径。
- 验证方式:公开可见作品的正式资产可匿名读取;未选候选图、参考图、跨 owner 伪造 key 仍返回不存在;隐藏、删除或取消发布后新的读取请求立即拒绝,再恢复公开可见时新的读取请求立即恢复;超长公开 `expireSeconds` 被截断为 600 秒。
## 2026-07-13 普通微信支付 V3 退款使用统一观察事务闭环
- 背景:普通微信支付 V3 的退款申请响应、退款结果回调、主动查单和商户平台手工退款发现可能重复、乱序或只出现其中一种;原充值订单只有单一终态,无法表达多次部分退款、权益回收欠款和会员人工处理。
- 退款事实:新增 `profile_recharge_refund``profile_recharge_refund_observation``profile_recharge_order_refund_settlement``profile_recharge_refund_bill_checkpoint`。所有已验签退款事实统一调用 `record_profile_recharge_refund_observation_and_return``out_refund_no` 是商户幂等键,微信退款单号保持唯一,重复 observation 必须核对原订单、微信支付单、金额、状态和事实指纹,不能仅按主键吞掉冲突。
- 订单与权益:部分退款保持原充值订单 `paid`,累计成功退款等于订单金额时才改为 `refunded``paid_at` 永久保留,退款不恢复首充资格。泥点按累计退款比例计算目标回收量,全额时精确收口原 `points_delta`;自动回收只扣普通永久泥点,不动每日免费和会员周期泥点。永久泥点不足时记录 `shortfall` 并冻结正式钱包消费,流水来源为 `recharge_refund_recovery`;会员退款统一 `manual_review`,不自动猜测有效期、档位或周期泥点回滚。
- 回调与现金事实:退款回调使用独立 `/api/profile/recharge/wechat/refund-notify`,不能复用支付 `WECHAT_PAY_NOTIFY_URL`。验签、解密、契约校验和 SpacetimeDB 持久化成功后返回 `204`;现金退款已成功但本地权益不足、订单冲突或会员待复核时仍先保存事实并 ACK,只有签解密、契约或持久化失败才让微信重试。诊断日志只保存脱敏结构化摘要和稳定引用。
- 查单与账单:`WECHAT_PAY_REFUND_RECONCILIATION_ENABLED` 代码默认关闭,只有具备真实商户凭据和 runtime service identity 的 HTTP 角色可开启;生产 env 示例与 deploy 会补 `true`,真实支付显式关闭时发布失败。非终态退款按 1 / 5 / 10 / 20 / 30 分钟衰减查单;成功退款早于支付通知时只对 `order_missing / order_not_paid` 继续重试,其他人工复核不自动放行;候选列表按分钟轮转分页,失败日志不回显 provider URL。北京时间次日 10 点后按 30 个稳定分片轮转补扫微信 API 可查询的近 90 天 `bill_type=REFUND` 交易账单,每 30 分钟覆盖完整窗口。单行失败不阻塞其他行和日期,但当日不写完成 checkpoint;昨日 `NO_STATEMENT_EXIST` 至少延迟到次日 10 点后再确认。`PLATFORM-ORIGINAL / PLATFORM-BALANCE` 只用于发现商户平台退款,发现后仍必须主动查单取得当前状态;账单申请响应验签,GZIP 内容按 SHA1 验真并使用 CSV parser 和十进制定点金额解析。
- 历史与入口边界:正式落账前已经 ACK 的旧退款通知不会因升级自动重放。已知商户退款单号通过受控服务端查单后进入统一事务,未知手工退款由 T+1 账单发现;超过微信 API 近 90 天窗口的历史数据需从商户平台导出核对后逐笔受控查单,禁止直接 SQL 写退款表。当前不开放匿名或普通用户退款、补录接口,退款申请只允许受控运维或后续管理员鉴权流程。
- 影响范围:`module-runtime` 退款领域策略与钱包来源、`spacetime-module` 退款表和事务、`spacetime-client` facade、`platform-wechat` 退款 / 查单 / 交易账单协议、`api-server` 退款回调与 reconciliation worker。
- 验证方式:退款相关 `module-runtime` / `platform-wechat` / `api-server` 定向测试,`npm run check:spacetime-schema``npm run check:spacetime-runtime-access``npm run check:server-rs-ddd``npm run check:encoding``git diff --check`;真实联调后只读核对退款单、observation、订单级 settlement 和账单 checkpoint。
## 2026-07-13 后台充值退款使用钱包占用与统一用户详情
- 背景:普通 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`,解除人工冻结不解除退款欠账限制。
- 人工复核:交易号或订单总额冲突只允许管理员确认退款归属,退款行追加不可变的管理员、原因、时间后重新运行标准结算;该操作不是“直接解冻”,余额不足仍形成欠账。会员退款、未知错误和非法结算计划不显示该入口,也不能复用泥点钱包冻结语义。
- 后台边界:充值订单、预检、执行、应急退款号登记、用户详情和钱包冻结均只挂在管理员鉴权路由。用户详情由 `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`
+65 -1
View File
@@ -47,6 +47,14 @@
- 验证:`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`
- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs``server-rs/crates/spacetime-client/src/editor_project.rs``server-rs/crates/api-server/src/admin.rs`
## 后台素材缩略图不要在首次挂载时全量换签
- 现象:后台“素材查询”首批缩略图正常,继续向下滚动或读取更多后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`
- 原因:列表一次挂载 80 条私有素材时,每个缩略图同时换签,会在同秒突发请求。production Nginx 的 `genarrative_admin_rps``30r/s burst=16`,超出部分在进入 api-server 前已返回 `429`,因此仅查 api-server 日志会漏掉失败请求。
- 处理:缩略图使用 `IntersectionObserver` 在进入视口附近时再调用管理端换签;对 `429` 使用有上限的退避重试,并在条目卸载后停止更新状态和安排重试。不得为单页突发放大 Nginx 通用管理端限流,也不得在单次限流失败后永久保留无图占位。
- 验证:前端定向测试覆盖首屏外的后续行进入可见区后才换签、“读取更多”追加行可继续显示缩略图、`429` 后有限重试恢复、卸载后不再重试;真实浏览器滚动验收时同时核对 Nginx access/error log、api-server journald 和 Network 面板,不以单一日志面判定成功。
- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx``apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`
## 陶泥儿精选重复先查同源同媒体画布副本
- 现象:每次从项目素材中把同一个生成素材拖到画布上,`陶泥儿精选` 都多出一张看起来相同的素材。
@@ -2120,6 +2128,30 @@
- 处理:不要改成订阅完整 `profile_recharge_order` 历史表。后端订阅只保留活跃五分钟定时器的 `profile_recharge_order_expiration_timer`,监听 timer 删除后按 `order_id` 通过 procedure 读取订单,只处理当前状态为 `expired` 的记录;支付 / 关闭信号会被忽略,断线窗口继续由未检查过期订单 catch-up 补齐。这样既不依赖不受支持的枚举 SQL,也不会把充值历史常驻 API 客户端缓存。
- 验证:运行 `cargo test -p spacetime-client profile_recharge_expiration --manifest-path server-rs/Cargo.toml`,发布后确认 API 日志不再出现订阅解析错误,并用真实 pending 订单验证 scheduled reducer 过期后写入 `expiration_checked_at`
## 微信 Native 已入账但二维码弹窗不关闭
- 现象:微信支付回调已经返回 `204`,本地充值订单为 `paid` 且泥点已到账,但网页仍停留在“微信扫码支付”,必须点击“我已支付”才刷新。
- 原因:通用页面恢复确认逻辑在存在 `nativeWechatPayment` 时直接跳过,Native 分支创建二维码后也没有订阅订单 SSE,因此服务端回调发布的订单更新没有前端消费者。
- 处理:Native 二维码出现后立即调用 `watchWechatRpgProfileRechargeOrder` 订阅当前订单;收到终态后更新充值中心、关闭二维码、清理 pending ref、刷新全局余额并只展示一次结果。SSE 超时或暂时失败时在二维码过期前重连,手动确认与 SSE 并发时以 pending order ref 保证只有首个终态生效。
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖不点击“我已支付”也会在 SSE 返回 `paid` 后自动关闭;再运行根级 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联:`src/components/platform-entry/usePlatformProfileCenterController.ts``src/services/rpg-entry/rpgProfileClient.ts``server-rs/crates/api-server/src/runtime_profile.rs`
## 商户平台退款登记不要混淆 refund_id 与 out_refund_no
- 现象:在“登记商户平台退款”里填写 `50000000000000000000000000000` 一类微信退款单号后提示找不到退款。
- 原因:该编号是微信侧 `refund_id`;V3 单笔退款查询路径只接受商户退款单号 `out_refund_no`。把 `refund_id` 放进路径不会自动转换,退款查单会返回 `RESOURCE_NOT_EXISTS`。不过微信没有承诺 `refund_id` 固定为 `50` 开头的 29 位数字,合法 `out_refund_no` 也可能是纯数字,因此形状判断不能代替真实查单。
- 处理:登记表单明确标注 `out_refund_no`,所有满足官方字符和长度约束的输入都交给服务端真实查询;只有查询确认不存在后,才把 `50` 开头的 29 位纯数字作为“疑似 refund_id”给出定向提示。只有 `refund_id` 时等待退款回调或 T+1 退款账单建立映射;不要调用异常退款申请接口冒充查询。`out_refund_no` 字符校验须覆盖官方允许的数字、大小写字母和 `_ - | * @`
- 验证:后台页面测试断言疑似编号仍交给服务端;平台适配器测试锁定 `RESOURCE_NOT_EXISTS` 映射和 `@` 字符,并确认真正的 `out_refund_no` 仍调用 `GET /v3/refund/domestic/refunds/{out_refund_no}`
- 关联:`apps/admin-web/src/pages/AdminRechargeOrderPage.tsx``server-rs/crates/api-server/src/admin_recharge.rs``server-rs/crates/platform-wechat/src/pay.rs`
## 微信支付查单的 REFUND 不等于已经全额退款
- 现象:一笔 6 元充值在商户平台成功退 3 元并登记 `out_refund_no` 后,本地显示累计已退 3 元、剩余可退 3 元,但后台再次预检仍显示“未核验 / REFUND”并禁止退款。
- 原因:微信支付订单查单的 `trade_state=REFUND` 只说明该支付订单发生过退款,不携带累计退款明细,也不表示已经全额退款。若后台把 `verified` 硬编码为 `trade_state == SUCCESS`,任何已成功部分退款的订单都会永久失去继续退款能力;反过来,仅看到 `REFUND` 就直接放行又可能漏掉未登记的商户平台退款。
- 处理:预检先查支付订单并校验商户订单号、支付单号和总金额,再主动刷新全部已知 `out_refund_no` 并重读本地退款 settlement。`SUCCESS` 可继续预检;`REFUND` 仅在本地累计成功退款大于 0 且小于订单总额,并且没有 `PROCESSING / ABNORMAL` 退款、活动 hold、退款欠账或人工冻结时,允许继续退本地剩余额度。没有本地成功退款能解释 `REFUND` 时使用独立原因码阻止并要求登记或对账,不能冒充“订单未支付”。
- 验证:后端策略测试覆盖 `SUCCESS + 0/600``REFUND + 0/600``REFUND + 300/600``REFUND + 600/600`;后台页面测试覆盖 `已核验 / REFUND` 时剩余额度可提交。真实联调核对累计退款、已追回泥点、活动占用和欠账均与退款明细一致。
- 关联:`server-rs/crates/api-server/src/admin_recharge.rs``server-rs/crates/spacetime-module/src/runtime/profile.rs``apps/admin-web/src/pages/AdminRechargeOrderPage.tsx`
## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布
- 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。
@@ -3012,7 +3044,39 @@
- 现象:资产 ACL 收紧后,公开页面读取其他作者作品资产集中返回 `404`;对象在 OSS 中真实存在,但已登记 `asset_object.access_policy = private`
- 原因:“作品公开”不等于“作者账号下所有 generated 对象永久公开”。只按 profile / session 关联也会误公开同会话的未选候选图、参考图或生成输入;批量改 `PublicRead` 则无法随作品隐藏、删除或取消发布自动撤销。
- 处理:已登记对象继续保持 `private`,通过 `public_work_asset_read_grant` 只派生 `Published + visible``custom-world` 还必须未删除)正式发布快照实际使用资产的匿名读授权。API 必须同时校验 grant owner 与资产 owner 一致,以及 `asset_object_id` 或精确 `object_key` 命中;明确排除参考图、未选候选图和 `generationInputs`。Custom World 只能扫描角色、地标、营地、章节和 opening CG 等正式根,不能遍历 legacy payload 的未知根。历史作品交给 view 现算补齐,不做永久 ACL 数据补丁。
- 权威查询边界:不能从 `asset_object``public_work_asset_read_grant` 的连接级订阅 cache 推断当前 ACL;池连接水位不一致会让刚撤销的 grant 继续签发 URL,也会让刚公开的作品短暂 404。资产定位和公开授权必须通过受 runtime service identity 限制的 procedure 在同一事务快照中计算,失败时拒绝读取;同时不要在每个池连接订阅复制全量 private 资产表。公开派生授权、`PublicRead` 和 legacy 兼容读取的签名 URL 最长 600 秒,owner / admin 不受该公开上限影响。
- 权威查询边界:不能从 `asset_object``public_work_asset_read_grant` 的连接级订阅 cache 推断当前 ACL;池连接水位不一致会让刚撤销的 grant 继续签发 URL,也会让刚公开的作品短暂 404。资产定位和公开授权必须通过受 runtime service identity 限制的 procedure 在同一事务快照中计算,失败时拒绝读取;procedure 先按 asset owner 使用各玩法 owner 索引缩小到该作者作品,再匹配候选 `asset_object_id` / 精确 key,不能每张图都执行全站公开 view,也不要在每个池连接订阅复制全量 private 资产表。公开派生授权、`PublicRead` 和 legacy 兼容读取的签名 URL 最长 600 秒,owner / admin 不受该公开上限影响。
- Remix 边界:拼图、Custom World 和大鱼现有 Remix 会把源资产引用复制到新 owner,但没有持久化不可伪造的资产来源。不得因此放宽跨 owner grant;源作品隐藏后仍公开的 Remix 资产,需要后续通过 Remix 时复制资产或持久化 provenance 解决。
- 验证:资产 owner 本人仍可读;公开可见作品的正式资产可匿名读;跨 owner、只命中前缀、参考图、未选候选图和 `generationInputs` 仍返回不存在;作品隐藏、删除或取消发布后 grant 消失。
- 关联:`server-rs/crates/spacetime-module/src/public_asset_access.rs``server-rs/crates/spacetime-client/src/assets.rs``server-rs/crates/api-server/src/assets.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## iOS 退款问询的 result_code 不是 debug 状态
- 现象:为了先观察真实 iOS 退款通知,回调返回 `ErrCode=0 + IosRefundQueryResponse.result_code=1`,并把 evidence 写成“调试阶段不执行自动退款决策”,看起来像安全 ACK,实际已经向微信建议拒绝退款。
- 原因:`xpay_subscribe_ios_refund_query_notify` 只有 `result_code=0`(建议退款)和 `1`(建议拒绝)两种正式决策;`evidence` 必须是可审计的履约或消耗事实,不存在中立调试值。与此同时,解密后的完整 payload 含 OpenID、Apple 交易号、退款原因和票据,不能为了排障直接落日志。
- 处理:未接入真实履约决策时返回非零 `ErrCode` 让微信重试,不携带 `IosRefundQueryResponse`;所有事件只写脱敏结构化摘要,payload、未知事件/字段、标识符和字符串值使用消息 Token 加用途域派生的稳定 HMAC 引用,自由文本只写长度和 HMAC 引用。Android 订阅成功和普通 goods 通知可能同形,payload marker 只能快速分流;只有存在同号 `wechat_mp_virtual` 本地充值订单,并在 2.5 秒内通过 `/xpay/query_order` 校验订单号、金额、`order_type=0/7`、支付状态和权威 `paid_time`,才允许入账。Apple 通知缺少 `WeChatPayInfo.PaidTime` 时走查单,绝不能用本机时间补齐。
- 验证:`cargo test -p platform-wechat virtual_payment_debug_summary --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`
- 关联:`server-rs/crates/platform-wechat/src/pay.rs``server-rs/crates/api-server/src/wechat/pay.rs``docs/【技术方案】微信虚拟支付接入-2026-05-26.md`
## 微信支付 V3 的支付 notify_url 不会自动接收退款结果或发现全部手工退款
- 现象:普通微信支付成功回调已经配置并可达,但在商户平台或代码里发起退款后,`/api/profile/recharge/wechat/notify` 收不到退款单状态变化;商户平台手工退款也可能没有请求本系统的退款回调入口。
- 原因:V3 支付成功通知与退款结果通知是不同契约;代码发起退款时,退款通知地址来自每次 `POST /v3/refund/domestic/refunds` 请求里的 `notify_url`,支付下单使用的 `WECHAT_PAY_NOTIFY_URL` 不会自动复用。商户平台手工退款不能假设会携带本系统按 API 请求传入的回调地址;退款接口返回成功也只表示受理,不能当成退款终态。
- 处理:代码退款显式传入公网 `https://<API 域名>/api/profile/recharge/wechat/refund-notify`,并用稳定 `out_refund_no` 串联申请、重复通知和主动查单。回调先用原始 body 验签、检查正负 5 分钟时间窗,再用 APIv3 密钥解密;校验事件、资源类型、商户号和退款状态后,将 callback observation 写入统一 SpacetimeDB 事务,持久化成功才返回 `204`。正式链路不再是“debug 只记日志”:部分 / 全额退款、泥点回收、欠款冻结和会员人工复核均由事务收口;未知事件、校验或持久化失败返回微信 `FAIL` 响应。另开启 `WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true`,对 `order_missing / order_not_paid` 继续等待晚到支付通知,候选退款按分钟轮转分页且错误日志不回显 provider URL;次日 10 点后按分片补扫微信 API 可查询的近 90 天 `bill_type=REFUND` 交易账单,并在落账前再主动查单。单行失败不能阻塞其他行或日期,也不能提前写完成 checkpoint;昨日 `NO_STATEMENT_EXIST` 至少延迟到次日 10 点后再确认;不要为联调开放未鉴权公网退款或补录接口。
- 验证:`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`;真实联调后只读核对 `profile_recharge_refund``profile_recharge_refund_observation``profile_recharge_order_refund_settlement``profile_recharge_refund_bill_checkpoint`
- 关联:`server-rs/crates/platform-wechat/src/pay.rs``server-rs/crates/api-server/src/wechat/pay.rs``server-rs/crates/api-server/src/app.rs``docs/【技术方案】微信虚拟支付接入-2026-05-26.md`
## 已 ACK 的历史退款通知不会因正式落账上线而自动重放
- 现象:微信侧退款已经是 `SUCCESS`,旧 debug 回调也曾返回 `204`,但部署正式退款表和权益回收事务后,本地充值订单仍为 `paid`,退款表没有记录。
- 原因:微信收到成功应答后会把该次通知视为已送达;服务升级不会让已经 ACK 的历史通知自动重放。主动 reconciliation 只能继续查询本地已经知道 `out_refund_no` 的非终态退款,不能凭空枚举所有历史退款。
- 处理:已知 `out_refund_no` 时,由持有真实商户凭据的受控服务端先调用单笔退款查询,验微信响应签名后写入统一 observation 事务;未知的商户平台退款等待 T+1 `REFUND` 交易账单发现,再查单落账。自动账单按分片补扫微信 API 可查询的近 90 天,超过窗口的数据需从商户平台导出候选后逐笔受控查单。禁止用 SQL 直接把订单改为 `refunded`,也禁止直接插入退款表或按账单 CSV 状态扣泥点,这些做法会绕过不可变字段冲突校验、累计部分退款和权益结算。
- 验证:核对退款 observation 的 `source``resolution_code` 与金额,再核对订单级 settlement 的累计退款、`recovery_status``unrecovered_points``wallet_frozen`;全额退款应保留原订单 `paid_at`,防止错误恢复首充资格。
- 关联:`server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs``server-rs/crates/spacetime-module/src/runtime/profile.rs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 退款请求结果未知时不能立即释放钱包占用
- 现象:后台调用微信退款超时或连接中断,页面提示状态未知;如果服务端立即释放泥点占用,用户可以继续消费,而微信稍后仍可能完成退款,最终形成可避免的退款欠账。反过来,永久保留占用又会让一次明确未创建的退款长期冻结余额。
- 原因:HTTP 错误只能说明客户端没有拿到确定响应,不能证明微信没有受理;单次退款查单 `RESOURCE_NOT_EXISTS` 也可能处于短暂传播窗口。只有使用原 `out_refund_no` 主动查单才能继续判定。
- 处理:网络结果未知时保留活动 hold,页面把原 `requestId`、订单、金额、原因和已知 `out_refund_no` 保存到当前后台标签页、当前管理员会话隔离的 `sessionStorage`,有效期 2 小时;刷新后必须与后端 active hold 的金额、原因和退款号对账一致才允许直接复用原请求,服务端明确拒绝时清理上下文。没有原请求上下文、上下文过期或管理员会话已切换时只开放预填 `out_refund_no` 的安全查单登记,不生成新 ID 硬撞活动 hold。worker 在占用创建至少 10 分钟后查同一退款号,查到退款就将验签事实写入统一 observation,只有连续 3 次查单收到官方 `RESOURCE_NOT_EXISTS` 才释放。进程重启清空连续次数并重新观察;超时、签名、配置、解析等错误一律重置次数并继续占用。
- 补充:退款查单适配器必须保留微信 `RESOURCE_NOT_EXISTS` 业务码,并兼容同类 `ORDER_NOT_EXIST`,不能把所有非 2xx 都抹平成通用上游错误;签名有效的退款申请/查询响应仍须与本次 `out_refund_no`、订单号、交易号和金额做关联校验。已释放 hold 复用旧 `requestId` 时必须在调用微信前拒绝,并要求重新预检生成新的请求 ID。
- 关联:`server-rs/crates/api-server/src/admin_recharge.rs``server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs``profile_recharge_refund_hold`
@@ -203,6 +203,16 @@ 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 必须校验订单、交易、金额、状态和指纹,不允许仅按主键直接吞掉冲突。
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`,不猜测回滚档位、有效期或周期泥点,也不复用泥点退款冻结语义。
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。
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 返回的正式状态。
## 创作入口泥点扣费契约
@@ -309,14 +319,14 @@ npm run check:server-rs-ddd
- Rust 结构体:`AssetObject`
- 源码:`server-rs/crates/spacetime-module/src/asset_metadata/objects.rs`
- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table;资产读取 ACL 使用 `get_asset_read_access_by_location_and_return` 在同一事务快照内同时返回位置查询与公开作品派生授权,procedure 只允许 runtime service identity 调用。`spacetime-client` 不订阅全量 `asset_object`,也不把公开资产授权 view 的连接级 cache 当成安全判断。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。只有权威位置查询返回不存在的历史对象才能进入 curated legacy 白名单兼容。
- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table;资产读取 ACL 使用 `get_asset_read_access_by_location_and_return` 在同一事务快照内同时返回位置查询与公开作品派生授权,procedure 只允许 runtime service identity 调用。授权判断先取得资产 owner,再按各玩法现有 owner 索引定向扫描该作者公开作品,并仅对候选 `asset_object_id` / 精确 `object_key` grant ID 做匹配,不得为每张图片生成全站公开作品 grant 列表。`spacetime-client` 不订阅全量 `asset_object`,也不把公开资产授权 view 的连接级 cache 当成安全判断。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。只有权威位置查询返回不存在的历史对象才能进入 curated legacy 白名单兼容。
### SpacetimeDB view`public_work_asset_read_grant`
- Rust view`public_work_asset_read_grant`
- 返回类型:`Vec<PublicWorkAssetReadGrant>`
- 源码:`server-rs/crates/spacetime-module/src/public_asset_access.rs`
- 说明:匿名公开派生授权投影,仅从 `Published + visible` 作品(`custom-world` 还必须满足未删除)的正式发布快照收集实际使用的已登记资产。每条 grant 都携带作品 owner,读取时必须与 `asset_object.owner_user_id` 一致,并且只能按 `asset_object_id` 或精确 `object_key` 命中;跨 owner、同前缀或相似 key 不构成授权。历史公开作品由 view 现算自动补齐;资产读取 procedure 在自己的事务快照中直接执行该 view作品隐藏、删除或取消发布提交后不能再被不同连接的陈旧订阅 cache 继续授权。已签发的公开 URL 最长保留 600 秒,能力本身不承诺撤销已经签出的 OSS URL。投影明确排除参考图、未选中候选图和 `generationInputs`Custom World 只扫描角色、地标、营地、章节和 opening CG 等正式根,不递归 legacy payload 未知字段,避免把预览、编辑输入或同会话其他私有资产扩大为公开资产。
- 说明:匿名公开派生授权投影,仅从 `Published + visible` 作品(`custom-world` 还必须满足未删除)的正式发布快照收集实际使用的已登记资产。每条 grant 都携带作品 owner,读取时必须与 `asset_object.owner_user_id` 一致,并且只能按 `asset_object_id` 或精确 `object_key` 命中;跨 owner、同前缀或相似 key 不构成授权。历史公开作品由 view 现算自动补齐;资产读取 procedure 与 view 复用同一套玩法资产采集器,但安全判断按资产 owner 索引定向计算,不执行全站 view作品隐藏、删除或取消发布提交后,后续事务立即拒绝授权。已签发的公开 URL 最长保留 600 秒,能力本身不承诺撤销已经签出的 OSS URL。投影明确排除参考图、未选中候选图和 `generationInputs`Custom World 只扫描角色、地标、营地、章节和 opening CG 等正式根,不递归 legacy payload 未知字段,避免把预览、编辑输入或同会话其他私有资产扩大为公开资产。
### `auth_identity`
@@ -533,7 +543,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`EditorAsset`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind``generation_inputs_json`、可选 `source_resource_id``generation_cost_mud_points`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind``generation_inputs_json`、可选 `source_resource_id``generation_cost_mud_points`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`角色动作生成保留原始绿幕视频中间素材,同时把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧列表、FPS、时长和预览视频写入 `generation_inputs_json.characterAnimation`,不把每帧拆成独立素材。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。
- 索引:`by_editor_asset_owner_user_id``by_editor_asset_folder_id`
### `editor_showcase_asset`
@@ -748,6 +758,47 @@ npm run check:server-rs-ddd
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:账户充值订单事实源。`status` 包含 `pending``paid``failed``closed``refunded``expired`;过期补偿字段 `expired_at``expiration_checked_at``expiration_provider_state``expiration_last_error` 用于记录本地过期和微信查单结果。
### `profile_recharge_refund`
- Rust 结构体:`ProfileRechargeRefund`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:普通微信支付 V3 退款单聚合。以 `out_refund_no` 为主键、`provider_refund_id` 唯一,保存原订单/微信支付单、四个分金额、微信状态、最近观察、权益目标/已回收/未回收量和人工处理错误码。交易号或订单总额冲突经管理员确认归属后,追加保存处理管理员、非空原因和处理时间;三字段只写一次,作为重新执行正式结算的审计事实。数据库迁移导入旧退款行时,缺失的三个审计字段必须补 `null`,保证升级前冷备可恢复。
- 索引:`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。
- 索引:`by_profile_recharge_refund_observation_out_refund_no``by_profile_recharge_refund_observation_order_id`
### `profile_recharge_order_refund_settlement`
- Rust 结构体:`ProfileRechargeOrderRefundSettlement`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:原充值订单维度的退款与权益结算摘要,保存累计成功退款金额、目标/已回收/未回收泥点、权益状态和钱包冻结事实。部分退款不改订单终态;累计全额才把订单改为 `refunded`
- 索引:主键 `order_id``by_profile_recharge_order_refund_settlement_user_id` 用于钱包消费前检查退款欠款。
### `profile_recharge_refund_hold`
- Rust 结构体:`ProfileRechargeRefundHold`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:后台主动退款调用微信前的永久泥点占用。活动占用保护退款所需泥点但不改钱包总额;匹配退款成功后结算,关闭或确认未创建的退款释放。相同 `out_refund_no` 只允许内容完全一致的幂等重放。
- 索引:主键 `out_refund_no``by_profile_recharge_refund_hold_order_id``by_profile_recharge_refund_hold_user_id``by_profile_recharge_refund_hold_status`
### `profile_wallet_manual_restriction`
- Rust 结构体:`ProfileWalletManualRestriction`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:后台人工钱包冻结当前态,保存冻结原因、创建/更新管理员和时间。它不承载退款欠账;退款欠账仍来自订单退款 settlement,两个来源任一有效都阻断普通消费。
- 索引:主键 `user_id`
### `profile_recharge_refund_bill_checkpoint`
- Rust 结构体:`ProfileRechargeRefundBillCheckpoint`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:按交易账单日期记录已完成的退款 reconciliation,保存账单 SHA1(确认稳定无账单时保存 `NO_STATEMENT_EXIST`)、处理退款行数和完成时间。任一退款行失败时不写完成 checkpoint,成功前缀依赖 observation 幂等重放;重复下载、多实例执行或进程重启不会重复回收权益。
### `profile_recharge_order_expiration_schedule`
- Rust 结构体:`ProfileRechargeOrderExpirationSchedule`
@@ -81,6 +81,53 @@ Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发
普通微信充值订单本地有效期为 5 分钟。SpacetimeDB 原生 `profile_recharge_order_expiration_timer` 到点后只把仍为 `pending` 的订单改为 `expired`HTTP `api-server` 只订阅活跃 timer 表的删除事件,按事件中的 `order_id` 重新读取订单并仅对 `expired` 执行微信查单补偿,不订阅完整充值订单历史表。支付或主动关闭也会删除 timer,但读取到非 `expired` 后直接忽略;监听断线窗口由未检查过期订单 catch-up 补齐。`external-generation-worker``external-generation-controller` 不运行充值过期逻辑,也不应因为扩容外部生成 worker 放大微信查单或关单流量。查账时本地未支付终态保持 `expired`,不再改写为 `closed``expiration_checked_at``expiration_provider_state``expiration_last_error` 用于判断 HTTP 监听器是否已经完成补偿。
### 普通微信支付 V3 退款联调与对账
普通微信支付 V3 的退款结果入口固定为 `POST /api/profile/recharge/wechat/refund-notify`。支付下单的 `WECHAT_PAY_NOTIFY_URL` 只接收支付结果,不能替代退款入口;通过 `POST /v3/refund/domestic/refunds` 发起退款时,必须在该次请求的 `notify_url` 中显式传入公网退款入口。回调不做用户登录态校验,但必须保留原始 body 和 `Wechatpay-*` 请求头供验签、解密;验签、契约校验和统一退款 observation 持久化成功后才返回 `204`。重复回调、主动查单、已验签退款申请响应和交易账单发现都进入 `record_profile_recharge_refund_observation_and_return`,依赖稳定 `out_refund_no`、微信退款单号和 observation 指纹幂等,禁止直接改充值订单或手写退款表。
主动查单和退款交易账单 worker 默认关闭,只由 `api` / `all` 这类 HTTP 角色运行。真实部署具备商户私钥、商户证书序列号、平台公钥及序列号、APIv3 密钥和 SpacetimeDB runtime service identity 后,才在服务私密环境中开启:
```bash
WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true
```
`deploy/env/api-server.env.example` 已把生产值固定为 `true``production-api-deploy.sh` 会为存量 env 缺失项补齐该值;若同时配置 `WECHAT_PAY_ENABLED=true``WECHAT_PAY_PROVIDER=real` 却显式关闭 reconciliation,部署在切换 `current` 前失败并保持 fail-closed。env 出现重复键时必须与 systemd `EnvironmentFile` 一致按最后一次赋值判断,不能让前面的 `true` 掩盖运行态最终生效的 `false`。发布后应在 API 启动日志确认 `wechat pay refund reconciliation worker is enabled`,不能只看 `/readyz`
开启后,`processing / abnormal` 退款按 1、5、10、20、30 分钟衰减主动查单;`success` 且权益尚未收口时只重试本地回收,不重复请求微信。成功退款早于支付通知时,`order_missing / order_not_paid` 会继续等待晚到支付事实;会员退款及金额、渠道、交易号冲突保持人工复核。候选列表按分钟轮转分页,超过单批 100 条也不会长期饿死,失败日志只输出哈希引用与静态分类。北京时间次日 10 点后,worker 按 30 个稳定分片轮转补扫微信 API 可查询的近 90 天 `bill_type=REFUND` 交易账单,每 30 分钟覆盖完整窗口;`PLATFORM-ORIGINAL / PLATFORM-BALANCE` 用于发现商户平台手工退款,但账单行不能直接决定本地终态,必须再按 `out_refund_no` 查单并验响应签名。单个坏行只让该日保持可重试,不阻塞其余行或日期,也不写完成 checkpoint;昨日返回 `NO_STATEMENT_EXIST` 时至少延迟到次日 10 点后再确认空账单。`profile_recharge_refund_bill_checkpoint` 已存在的日期不会重复处理;多实例或重启造成的重复 observation 仍由统一事务幂等收口。超过近 90 天 API 窗口的历史账单需从商户平台下载后受控核对。
公网联调可使用临时 HTTPS tunnel,但必须确认 tunnel 正在转发当前 `api-server` 实际监听端口,且公网 `/healthz` 与本地 `/healthz` 指向同一进程。`notify_url` 不能带查询参数;临时域名变化后,只影响之后新提交的退款请求,已经提交给微信的旧退款单仍绑定旧地址。无签名探测只能验证路由可达,不能伪造成功回调:
```bash
curl -i https://<公网域名>/healthz
curl -i -X POST 'https://<公网域名>/api/profile/recharge/wechat/refund-notify' \
-H 'Content-Type: application/json' \
--data '{}'
```
第二个请求在真实 provider 配置下应因缺少微信签名返回 `4xx` 与微信 `FAIL` envelope;若返回 `404`,优先检查 tunnel 目标端口和运行中的二进制是否已包含退款路由。不要把 ngrok inspect 页面、商户私钥、APIv3 密钥、平台公钥原文、签名、密文或解密 payload 写入文档、工单和日志。
真实联调时可开启支付 handler 与退款 reconciliation 的 debug 日志。日志应出现“收到微信支付 V3 退款结果通知,开始验签解密”“退款结果通知已持久化”或 `wechat pay refund observation persisted`,并通过稳定 HMAC / SHA256 引用关联重试;不得期待日志输出商户订单号、微信订单号、退款号或原始 payload:
```bash
npm run dev -- --log 'info,api_server::wechat::pay=debug,api_server::profile_recharge_refund_reconciliation=debug,tower_http=info'
```
联调后使用有权读取目标库私有表的 SpacetimeDB 身份做只读核对。以下查询中的占位符必须替换为目标环境值;不要用 SQL `INSERT / UPDATE / DELETE` 补退款,否则会绕过 observation 冲突校验、订单级累计退款和权益回收事务:
```bash
spacetime sql <database> "SELECT * FROM profile_recharge_order WHERE order_id = '<order_id>'" --server <server-url>
spacetime sql <database> "SELECT * FROM profile_recharge_refund WHERE order_id = '<order_id>'" --server <server-url>
spacetime sql <database> "SELECT * FROM profile_recharge_refund_observation WHERE out_refund_no = '<out_refund_no>'" --server <server-url>
spacetime sql <database> "SELECT * FROM profile_recharge_order_refund_settlement WHERE order_id = '<order_id>'" --server <server-url>
spacetime sql <database> "SELECT * FROM profile_recharge_refund_bill_checkpoint" --server <server-url>
```
核对规则:部分退款时原订单保持 `paid`;累计退款等于订单金额后才为 `refunded`,但 `paid_at` 继续保留,因此不会恢复首充资格。泥点退款只回收普通永久泥点;每日免费泥点和会员周期泥点不动。永久泥点不足时 `recovery_status=shortfall``unrecovered_points>0``wallet_frozen=true`,正式钱包消费在欠款清零前 fail-closed;会员订单统一为 `manual_review`,不得自动缩短有效期或扣周期泥点。
后台充值订单退款必须通过管理员鉴权接口执行,不得从数据库页面直接改表:列表 `GET /admin/api/profile/recharge-orders`、用户详情 `GET /admin/api/profile/users/detail`、预检 `POST /admin/api/profile/recharge-refunds/preview`、执行 `POST /admin/api/profile/recharge-refunds/execute`、应急退款号登记 `POST /admin/api/profile/recharge-refunds/register`、人工冻结/解冻 `POST /admin/api/profile/wallet-restriction`。预检返回微信支付状态、本地累计退款、剩余可退金额、预计追回泥点、钱包总额、可消费余额、活动占用和退款欠账;只有预检允许且二次确认后才提交退款。提交使用稳定 `requestId`,接口超时后重试必须复用同一值。若返回“退款处理中”,先查同一 `out_refund_no`,不要换号再次发起。商户平台应急退款完成后,在后台登记原 `out_refund_no` 触发验签查单;微信已退款但缺少退款号时等待 T+1 账单,不得凭截图或支付订单 `REFUND` 状态直接手写退款事实。
正式落账上线前已经被旧 debug handler 返回成功的退款回调不会因部署新版本自动重放。已知 `out_refund_no` 的历史退款应由具备真实商户凭据的受控服务端操作先调用单笔退款查询,验签后写入同一 observation 事务;未知的商户平台退款等待次日交易账单发现。自动账单按分片补扫微信 API 可查询的近 90 天,超出窗口的历史退款需从商户平台导出核对后逐笔受控查单补录,不能直接把商户平台截图或 CSV 行当作退款终态,也不能开放匿名或普通用户补录 / 退款入口。
微信小程序订阅消息生成结果通知使用 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED``WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID``WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 配置。当前模板为 `AI创作生成结果通知`;H5 在生成动作发起前先进入生成进度态并立即继续生成动作,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权,用户接受、拒绝或返回都不能阻塞生成,且原生页不改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。后端只在玩法草稿生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning,不影响生成主链路。模板 `thing1` 字段发送玩法模板名,例如 `拼图``敲木鱼``抓大鹅``number6` 字段发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`。模板 `time4` 字段固定发送北京时间 `YYYY-MM-DD HH:mm`,不要使用内部微秒时间戳、秒级时间戳或带时区后缀的 RFC3339 字符串,否则微信会返回 `argument invalid! data.time4.value invalid`。当前已接入拼图、敲木鱼、抓大鹅、跳一跳、方洞、视觉小说的草稿生成终态;分槽素材生成或发布动作不得直接复用生成结果通知,避免一次作品生成产生多条订阅消息。
如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。
@@ -1,6 +1,6 @@
# 微信虚拟支付接入
更新时间:`2026-05-26`
更新时间:`2026-07-13`
## 接入口径
@@ -60,10 +60,84 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0
- `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;只有验签、契约或持久化失败才返回 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 / closed``ABNORMAL` 后可由查询推进为 `SUCCESS``CLOSED``SUCCESS` 终态不得被旧观察回退。最新来源和 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` 释放为 `released``PROCESSING / 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 日志:
```bash
npm run dev -- --log 'info,api_server::wechat::pay=debug,tower_http=info'
```
## 验收命令
```bash
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
@@ -112,5 +186,6 @@ npm run spacetime:wechat-virtual-payment:reconcile -- \
- 沙箱或基础库失败会把微信返回的 `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 在等待窗口结束或短暂断线时按订单过期时间重连,关闭弹窗时必须取消订阅。
- 小程序订阅消息用于 AI 创作生成结果通知:H5 在生成动作发起前先把页面切到生成进度态并立即调用生成 action,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权;授权接受、拒绝或页面返回都不得阻塞或取消生成。原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。通知发送只允许发生在玩法草稿生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。模板 `thing1` 发送玩法模板名,`number6` 发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`;模板 `time4` 字段必须是北京时间 `YYYY-MM-DD HH:mm``WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。
- WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。