账单不再计入实际变动为 0 的泥点流水

- spacetime-module:每日免费 / 会员周期换期改为先算「换期后余额 + 实际变动」(新增 resolve_expiring_points_wallet_transition),实际变动为 0 时只更新余额状态、不再写 profile_wallet_ledger
- spacetime-module:通用结算入口 apply_profile_wallet_signed_delta 在 amount_delta 为 0 时直接返回当前余额,不再落一条 0 变动流水
- spacetime-module:账单列表 list_profile_wallet_ledger_entries 在按余额结算链排序之后、截断 50 条上限之前剔除 amount_delta 为 0 的存量行,历史 0 流水立即从账单消失且不占用列表上限
- spacetime-module:新增 3 条定向单测覆盖换期实际变动与账单 0 变动过滤(去 0 后不改变其余条目顺序)
- 文档:后端数据契约 profile_wallet_ledger 补「实际变动为 0 不进账单」口径并修正源码路径为 runtime/active/profile.rs;decision-log 追加 2026-10-01 条目说明根因是旧判据看入参而不是实际余额变动
This commit is contained in:
kdletters
2026-10-01 13:23:29 +08:00
parent 66e12cc3fd
commit dbc3355e3a
3 changed files with 80 additions and 7 deletions
@@ -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`。
@@ -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`
@@ -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<_>>(),
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<RuntimeProfileWalletLedgerEntrySnapshot>,
) {
entries.retain(|entry| entry.amount_delta != 0);
}
fn sort_profile_wallet_ledger_entries(
entries: &mut Vec<RuntimeProfileWalletLedgerEntrySnapshot>,
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