收窄充值过期监听范围
改为订阅活跃充值过期定时器删除事件并按订单编号回读状态 移除不再使用的充值订单表行映射 同步后端契约、生产运维与项目共享记忆
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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 链接必须转存后再试玩或发布
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 创作入口泥点扣费契约
|
||||
|
||||
|
||||
@@ -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`。当前已接入拼图、敲木鱼、抓大鹅、跳一跳、方洞、视觉小说的草稿生成终态;分槽素材生成或发布动作不得直接复用生成结果通知,避免一次作品生成产生多条订阅消息。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<SubscriptionHandle>,
|
||||
_update_callback: ProfileRechargeOrderUpdateCallbackId,
|
||||
_delete_callback: ProfileRechargeOrderExpirationTimerDeleteCallbackId,
|
||||
runner: Option<JoinHandle<()>>,
|
||||
receiver: mpsc::UnboundedReceiver<RuntimeProfileRechargeOrderRecord>,
|
||||
receiver: mpsc::UnboundedReceiver<String>,
|
||||
}
|
||||
|
||||
impl ProfileRechargeExpirationSubscription {
|
||||
pub async fn recv(
|
||||
&mut self,
|
||||
) -> Result<RuntimeProfileRechargeOrderRecord, SpacetimeClientError> {
|
||||
pub async fn recv(&mut self) -> Result<String, SpacetimeClientError> {
|
||||
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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user