diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a7b1adf7e..0bf78d647 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2112,6 +2112,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。钱包明细每次展开都重新读取充值中心 BFF,打开期间实时总额变化时继续补读;图片画板的生成扣费或退款完成后同时刷新总额与充值中心拆分。充值中心读请求必须使用 revision 门禁,支付创建、到账确认等权威响应写入时使旧读失效,避免旧响应覆盖新的每日免费 / 不限时明细。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。 - 2026-07-17 追加:主站、图片画板与 AI 游戏创作独立 App 的泥点账单统一复用 `packages/shared/src/components/PlatformProfileWalletLedgerModal`。共享组件只依赖 `ProfileWalletLedgerResponse`,承接来源 label、金额正负号、UTC 日期、余额兜底和 loading / empty / error 展示;`/api/profile/wallet-ledger` 请求、鉴权、打开状态与重试生命周期继续由各宿主持有,不把账户事实或后端副作用下沉到共享 UI。 - 2026-09-07 追加:资产扣费在既有钱包流水 metadata 中记录服务端确定的 `assetKind`,`GET /api/profile/wallet-ledger` 只把白名单类型映射为可选用户文案 `reason`,不暴露原始 metadata、资源 ID、任务 ID 或未知内部枚举。共享账单组件优先展示非空 `reason`;历史、未知和空 metadata 继续按 `sourceType` 回退为“资产操作消耗”,不得由客户端猜测业务类型。 +- 2026-10-01 追加:账单不计入实际变动为 0 的条目,规则落在 SpacetimeDB 侧而不是展示层。写入侧只在余额真的变化时落账:每日免费 / 会员周期换期按 `resolve_expiring_points_wallet_transition` 先算出“换期后余额 + 实际变动”,变动为 0(含到期额度等于发放额度、余额不足以扣回到期额度、额度与发放都为 0)时只更新余额状态、不写 `profile_wallet_ledger`;通用结算入口 `apply_profile_wallet_signed_delta` 在 0 变动时同样直接返回当前余额。读取侧 `list_profile_wallet_ledger_entries` 在按余额结算链排序之后、截断 50 条上限之前剔除 `amount_delta == 0` 的存量行,因此历史遗留的 0 流水也立即从账单消失,且不占用列表上限、不改变其余条目的先后与 `balance_after` 语义。此前 `update_profile_wallet_balance_for_expiring_points` 只判断“到期额度与发放额度都为 0”,判的是入参而不是实际余额变动,所以余额不变的换期流水仍会落账并显示成 0。 - 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。 - 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 4f61f7f79..adb9499c7 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -812,8 +812,9 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `profile_wallet_ledger` - Rust 结构体:`ProfileWalletLedger` -- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs` - 说明:账号钱包流水表。`created_at` 表示钱包事务实际结算时间,列表先按当前余额反向校验 `balance_after - amount_delta` 的结算链,再以该时间倒序兜底,避免支付回调或退款重放延迟时出现余额顺序倒置;支付平台确认时间继续保存在充值订单 `paid_at`。`metadata_json` 为可选 JSON 对象字符串,旧行缺失时读取层按 `{}` 归一;外部生成扣费 / 退款写入 `externalGenerationJobId`,使退款记录可以追溯到对应 `external_generation_job`。 +- 实际变动为 0 的条目不进账单:写入侧只在余额真的变化时落账,每日免费 / 会员周期换期先按 `previous_balance - expired_points + granted_points`(饱和运算)算出实际变动,变动为 0 时只更新余额状态、不写本表;通用结算入口 `apply_profile_wallet_signed_delta` 在 `amount_delta == 0` 时同样不落账。读取侧 `list_profile_wallet_ledger_entries` 在结算链排序之后、截断 50 条上限之前剔除 `amount_delta == 0` 的存量行,因此历史遗留的 0 变动流水不会出现在 `GET /api/profile/wallet-ledger`,也不占用列表上限。该规则只影响展示与落账,不改变幂等 ledger、余额结算、消费投影和退款语义。 ### `profile_wallet_consumption_total` diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index 52f02feca..39b91746a 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -2879,6 +2879,52 @@ mod tests { ); } + #[test] + fn wallet_ledger_list_drops_zero_change_entries_without_reordering_the_rest() { + let mut entries = vec![ + wallet_ledger_snapshot("daily-free-reset", 0, 97, 4), + wallet_ledger_snapshot("recharge-60", 60, 97, 3), + wallet_ledger_snapshot("membership-period-reset", 0, 37, 2), + wallet_ledger_snapshot("recharge-180-delayed", 180, 37, 1), + ]; + + retain_profile_wallet_ledger_entries_with_change(&mut entries); + + assert_eq!( + entries + .iter() + .map(|entry| entry.wallet_ledger_id.as_str()) + .collect::>(), + vec!["recharge-60", "recharge-180-delayed"] + ); + } + + #[test] + fn expiring_points_transition_reports_zero_change_when_balance_is_unchanged() { + assert_eq!( + resolve_expiring_points_wallet_transition(0, 0, 0), + (0, 0), + "没有额度和发放时不产生账单" + ); + assert_eq!( + resolve_expiring_points_wallet_transition(5, 5, 5), + (5, 0), + "每日免费 / 会员周期到期额度等于发放额度时余额不变" + ); + assert_eq!( + resolve_expiring_points_wallet_transition(0, 5, 0), + (0, 0), + "余额不足以扣回到期额度时不产生负向账单" + ); + } + + #[test] + fn expiring_points_transition_reports_real_balance_change() { + assert_eq!(resolve_expiring_points_wallet_transition(0, 0, 5), (5, 5)); + assert_eq!(resolve_expiring_points_wallet_transition(5, 5, 0), (0, -5)); + assert_eq!(resolve_expiring_points_wallet_transition(3, 5, 2), (2, -1)); + } + #[test] fn historical_wallet_spend_counts_asset_and_llm_consumption() { assert_eq!( @@ -4453,11 +4499,20 @@ fn list_profile_wallet_ledger_entries( let current_balance = profile_wallet_balance(ctx, &validated_input.user_id); sort_profile_wallet_ledger_entries(&mut entries, current_balance); + retain_profile_wallet_ledger_entries_with_change(&mut entries); entries.truncate(PROFILE_WALLET_LEDGER_LIST_LIMIT); Ok(entries) } +// 账单只呈现真实余额变化:历史遗留的 0 变动流水在这里统一剔除,排序链已经按余额反推完成, +// 去掉 0 变动行不改变其余条目的先后与 `balance_after` 语义,也不占用列表上限。 +fn retain_profile_wallet_ledger_entries_with_change( + entries: &mut Vec, +) { + entries.retain(|entry| entry.amount_delta != 0); +} + fn sort_profile_wallet_ledger_entries( entries: &mut Vec, current_balance: u64, @@ -8843,6 +8898,18 @@ fn membership_cycle_metadata( .unwrap_or_else(|_| PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string()) } +// 每日免费 / 会员周期额度换期的余额迁移:返回换期后的余额与实际变动。 +// 到期额度和发放额度相同时余额不变,调用方不得再落一条 0 变动账单。 +fn resolve_expiring_points_wallet_transition( + previous_balance: u64, + expired_points: u64, + granted_points: u64, +) -> (u64, i64) { + let after_expiry = previous_balance.saturating_sub(expired_points); + let next_balance = after_expiry.saturating_add(granted_points); + (next_balance, next_balance as i64 - previous_balance as i64) +} + fn update_profile_wallet_balance_for_expiring_points( ctx: &ReducerContext, user_id: &str, @@ -8853,9 +8920,6 @@ fn update_profile_wallet_balance_for_expiring_points( created_at: Timestamp, metadata_json: String, ) { - if expired_points == 0 && granted_points == 0 { - return; - } if ctx .db .profile_wallet_ledger() @@ -8872,8 +8936,11 @@ fn update_profile_wallet_balance_for_expiring_points( .user_id() .find(&user_id.to_string()); let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); - let after_expiry = previous_balance.saturating_sub(expired_points); - let next_balance = after_expiry.saturating_add(granted_points); + let (next_balance, amount_delta) = + resolve_expiring_points_wallet_transition(previous_balance, expired_points, granted_points); + if amount_delta == 0 { + return; + } let created_state_at = current .as_ref() .map(|row| row.created_at) @@ -8908,7 +8975,7 @@ fn update_profile_wallet_balance_for_expiring_points( ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { wallet_ledger_id: ledger_id.to_string(), user_id: user_id.to_string(), - amount_delta: next_balance as i64 - previous_balance as i64, + amount_delta, balance_after: next_balance, source_type, created_at, @@ -10422,6 +10489,10 @@ fn apply_profile_wallet_signed_delta( let settled_at = ctx.timestamp; let ledger_recorded_at = profile_wallet_ledger_recorded_at(created_at, settled_at); refresh_profile_wallet_expiring_points(ctx, user_id, settled_at); + // 实际变动为 0 的结算不产生账单流水:既不改变余额,也不该在账单里占一条 0。 + if amount_delta == 0 { + return Ok(profile_wallet_balance(ctx, user_id)); + } if idempotent { if let Some(existing) = ctx .db