diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bd998f442..b2f4725e4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -75,7 +75,7 @@ ## 2026-07-09 充值订单过期改为 SpacetimeDB scheduled 表触发 - 背景:旧充值过期处理使用 api-server 后台轮询 worker claim 普通 schedule 表,非 HTTP 的 external-generation-worker / controller 进程也可能启动同一过期任务;扩外部生成 worker 会意外放大微信查单 / 关单流量,并且本地过期后若微信仍可支付,容易出现“微信扣款但本地拒绝入账”的风险。 -- 决策:新建原生 scheduled 表 `profile_recharge_order_expiration_timer`,创建真实微信 pending 充值订单时写入 5 分钟 timer;scheduled reducer 到点只把仍为 `pending` 的订单改为 `expired` 并写 `expired_at`。HTTP `api-server` 订阅 `profile_recharge_order` 的 `Pending -> Expired` 更新并执行微信查单补偿;`SUCCESS` 允许 `Expired -> Paid` 入账,未支付或远端已终态只记录检查结果,本地保持 `expired`。`external-generation-worker` 和 controller 不处理充值过期。 +- 决策:新建原生 scheduled 表 `profile_recharge_order_expiration_timer`,创建真实微信 pending 充值订单时写入 5 分钟 timer;scheduled reducer 到点只把仍为 `pending` 的订单改为 `expired` 并写 `expired_at`。HTTP `api-server` 只订阅活跃 timer 表的删除事件,按 `order_id` 重新读取订单并仅对 `expired` 执行微信查单补偿;支付或主动关闭导致的 timer 删除会被状态判断忽略,断线窗口由未检查过期订单 catch-up 补齐,不订阅完整 `profile_recharge_order` 历史表。`SUCCESS` 允许 `Expired -> Paid` 入账,未支付或远端已终态只记录检查结果,本地保持 `expired`。`external-generation-worker` 和 controller 不处理充值过期。 - 影响范围:`profile_recharge_order`、`profile_recharge_order_expiration_timer`、充值订单状态契约、`spacetime-client` bindings/facade、`api-server` 充值过期监听器、微信支付查单 / 关单、个人中心充值前端、后台表查询和运维文档。 - 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、充值过期 listener / 微信支付 / shared contracts / 前端充值定向测试、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5aedaed41..1622b3eff 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2101,7 +2101,7 @@ - 现象:外部生成 worker/controller 扩容后,微信充值过期查单和关单流量也被同步放大;排查时还会误去外部生成 worker 日志里找支付过期任务。 - 原因:支付过期是账户资金链路,不是外部内容生成队列;旧实现把充值过期轮询 worker 挂在通用后台任务启动函数里,非 HTTP 角色也会启动。 -- 处理:充值订单过期由 SpacetimeDB 原生 `profile_recharge_order_expiration_timer` 到点把 `pending` 改为 `expired`,只有 HTTP `api-server` 订阅 `profile_recharge_order` 的 `Pending -> Expired` 更新并查微信补偿。未支付终态本地保持 `expired`,不要再改写成 `closed`;微信成功支付通知或补偿查单仍可把 `Expired -> Paid` 入账。 +- 处理:充值订单过期由 SpacetimeDB 原生 `profile_recharge_order_expiration_timer` 到点把 `pending` 改为 `expired`,只有 HTTP `api-server` 订阅活跃 timer 表的删除事件,按 `order_id` 重新读取订单并仅对 `expired` 查微信补偿;支付或主动关闭导致的删除信号会被状态判断忽略,断线窗口由未检查过期订单 catch-up 补齐。未支付终态本地保持 `expired`,不要再改写成 `closed`;微信成功支付通知或补偿查单仍可把 `Expired -> Paid` 入账。 - 验证:确认 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` / `external-generation-controller` 不启动充值过期监听;创建 pending 充值单后只由 scheduled reducer 产生 `expired`,HTTP api-server listener 记录 `expiration_checked_at` 或补入账。 - 关联:`server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 @@ -2109,7 +2109,7 @@ - 现象:API 已 ready,但日志每 5 秒出现 `profile recharge expiration listener failed to subscribe`,并提示 `pending` 不能解析为 `profile_recharge_order.status` 的枚举类型;scheduled reducer 仍会把订单改成 `expired`,但微信查单补偿监听没有运行。 - 原因:SpacetimeDB 2.6 不会把订阅 SQL 中的 `'pending'` / `'expired'` 字符串自动转换为生成绑定的 sum-type enum;两个按状态过滤的订阅都在应用阶段失败。 -- 处理:后端订阅完整 `profile_recharge_order` 表,并继续只在 SDK `on_update` 中处理 `Pending -> Expired`。完整订阅让同一行跨状态变化时稳定产生 update callback,也避免依赖不受支持的枚举字符串 SQL。 +- 处理:不要改成订阅完整 `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`。 ## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 28f6af8ea..31526116d 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -202,7 +202,7 @@ npm run check:server-rs-ddd 12. access JWT 只携带最小设备快照 `device.client_type`、`device.client_runtime`、`device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`;移动网页和微信内 H5 走 `wechat_h5`;桌面网页和桌面微信走 `wechat_native`;`wechat_jsapi` 仅保留后端能力,未接微信开放平台前不由前端自动选择。历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付。 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` 订阅 `profile_recharge_order` 的 `Pending -> Expired` 更新并执行微信查单补偿:`SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired`;`CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`external-generation-worker` / controller 不处理充值过期;`wechat_mp_virtual` 到期只记录虚拟渠道不可查,后续真实支付通知仍允许 `Expired -> Paid`。 +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` 只记录检查结果。`external-generation-worker` / controller 不处理充值过期;`wechat_mp_virtual` 到期只记录虚拟渠道不可查,后续真实支付通知仍允许 `Expired -> Paid`。 ## 创作入口泥点扣费契约 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 98f476cc3..38c55fe08 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -79,7 +79,7 @@ Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发 微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 -普通微信充值订单本地有效期为 5 分钟。SpacetimeDB 原生 `profile_recharge_order_expiration_timer` 到点后只把仍为 `pending` 的订单改为 `expired`;HTTP `api-server` 通过订阅 `profile_recharge_order` 的 `Pending -> Expired` 更新执行微信查单补偿。`external-generation-worker` 和 `external-generation-controller` 不运行充值过期逻辑,也不应因为扩容外部生成 worker 放大微信查单或关单流量。查账时本地未支付终态保持 `expired`,不再改写为 `closed`;`expiration_checked_at`、`expiration_provider_state`、`expiration_last_error` 用于判断 HTTP 监听器是否已经完成补偿。 +普通微信充值订单本地有效期为 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 监听器是否已经完成补偿。 微信小程序订阅消息生成结果通知使用 `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`。当前已接入拼图、敲木鱼、抓大鹅、跳一跳、方洞、视觉小说的草稿生成终态;分槽素材生成或发布动作不得直接复用生成结果通知,避免一次作品生成产生多条订阅消息。 diff --git a/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs b/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs index 370c0de27..976870934 100644 --- a/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs +++ b/server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs @@ -38,7 +38,22 @@ async fn run_profile_recharge_expiration_listener(state: AppState) { loop { match subscription.recv().await { - Ok(order) => { + Ok(order_id) => { + let order = match state + .spacetime_client() + .get_profile_recharge_order(order_id.clone()) + .await + { + Ok((_, order)) => order, + Err(error) => { + warn!( + order_id, + error = %error, + "profile recharge expiration listener failed to read signaled order" + ); + break; + } + }; let state = state.clone(); tokio::spawn(async move { process_expired_profile_recharge_order_with_retries(state, order) diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index ae78a9496..c29d71cde 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -304,7 +304,6 @@ pub(crate) use self::runtime_profile::{ map_runtime_profile_recharge_order_expiration_claim_procedure_result, map_runtime_profile_recharge_order_expiration_complete_procedure_result, map_runtime_profile_recharge_order_procedure_result, - map_runtime_profile_recharge_order_table_row, map_runtime_profile_recharge_product_admin_list_procedure_result, map_runtime_profile_recharge_product_admin_procedure_result, map_runtime_profile_redeem_code_admin_list_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs index 6fce9412c..74f898096 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs @@ -1122,38 +1122,6 @@ pub(crate) fn map_runtime_profile_recharge_order_snapshot( } } -pub(crate) fn map_runtime_profile_recharge_order_table_row( - row: ProfileRechargeOrder, -) -> RuntimeProfileRechargeOrderRecord { - module_runtime::build_runtime_profile_recharge_order_record( - module_runtime::RuntimeProfileRechargeOrderSnapshot { - order_id: row.order_id, - user_id: row.user_id, - product_id: row.product_id, - product_title: row.product_title, - kind: map_runtime_profile_recharge_product_kind_back(row.kind), - amount_cents: row.amount_cents, - status: map_runtime_profile_recharge_order_status_back(row.status), - payment_channel: row.payment_channel, - paid_at_micros: row.paid_at.map(|value| value.to_micros_since_unix_epoch()), - provider_transaction_id: row.provider_transaction_id, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - points_delta: row.points_delta, - membership_expires_at_micros: row - .membership_expires_at - .map(|value| value.to_micros_since_unix_epoch()), - expired_at_micros: row - .expired_at - .map(|value| value.to_micros_since_unix_epoch()), - expiration_checked_at_micros: row - .expiration_checked_at - .map(|value| value.to_micros_since_unix_epoch()), - expiration_provider_state: row.expiration_provider_state, - expiration_last_error: row.expiration_last_error, - }, - ) -} - pub(crate) fn map_runtime_profile_recharge_order_expiration_schedule_snapshot( snapshot: RuntimeProfileRechargeOrderExpirationScheduleSnapshot, ) -> module_runtime::RuntimeProfileRechargeOrderExpirationScheduleSnapshot { diff --git a/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs b/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs index b376ab52d..7bab87ddc 100644 --- a/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs +++ b/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs @@ -1,21 +1,20 @@ use super::*; -use spacetimedb_sdk::TableWithPrimaryKey; +use spacetimedb_sdk::Table; use tokio::sync::mpsc; -// SpacetimeDB 2.6 cannot parse string SQL literals as generated sum-type enum values. -// Keeping the row subscribed across the status transition also guarantees an update callback. +// The timer table contains only active expiration timers, so this avoids retaining recharge history. const PROFILE_RECHARGE_EXPIRATION_EVENT_SUBSCRIPTION_QUERIES: [&str; 1] = - ["SELECT * FROM profile_recharge_order"]; + ["SELECT * FROM profile_recharge_order_expiration_timer"]; #[cfg(test)] mod tests { use super::PROFILE_RECHARGE_EXPIRATION_EVENT_SUBSCRIPTION_QUERIES; #[test] - fn expiration_listener_keeps_orders_subscribed_across_enum_status_updates() { + fn expiration_listener_subscribes_only_to_active_timer_rows() { assert_eq!( PROFILE_RECHARGE_EXPIRATION_EVENT_SUBSCRIPTION_QUERIES, - ["SELECT * FROM profile_recharge_order"] + ["SELECT * FROM profile_recharge_order_expiration_timer"] ); } } @@ -23,15 +22,13 @@ mod tests { pub struct ProfileRechargeExpirationSubscription { connection: DbConnection, _subscriptions: Vec, - _update_callback: ProfileRechargeOrderUpdateCallbackId, + _delete_callback: ProfileRechargeOrderExpirationTimerDeleteCallbackId, runner: Option>, - receiver: mpsc::UnboundedReceiver, + receiver: mpsc::UnboundedReceiver, } impl ProfileRechargeExpirationSubscription { - pub async fn recv( - &mut self, - ) -> Result { + pub async fn recv(&mut self) -> Result { self.receiver .recv() .await @@ -96,19 +93,13 @@ impl SpacetimeClient { .map_err(|_| SpacetimeClientError::ConnectDropped)??; let (event_sender, event_receiver) = mpsc::unbounded_channel(); - let update_sender = event_sender.clone(); - let update_callback = - connection - .db() - .profile_recharge_order() - .on_update(move |_, old, new| { - if old.status == RuntimeProfileRechargeOrderStatus::Pending - && new.status == RuntimeProfileRechargeOrderStatus::Expired - { - let _ = update_sender - .send(map_runtime_profile_recharge_order_table_row(new.clone())); - } - }); + let delete_sender = event_sender.clone(); + let delete_callback = connection + .db() + .profile_recharge_order_expiration_timer() + .on_delete(move |_, timer| { + let _ = delete_sender.send(timer.order_id.clone()); + }); let mut subscriptions = Vec::new(); for query in PROFILE_RECHARGE_EXPIRATION_EVENT_SUBSCRIPTION_QUERIES { @@ -142,7 +133,7 @@ impl SpacetimeClient { Ok(ProfileRechargeExpirationSubscription { connection, _subscriptions: subscriptions, - _update_callback: update_callback, + _delete_callback: delete_callback, runner: Some(runner), receiver: event_receiver, })