From e40d41d6086c90244191e4e3a9b892680ad35ee4 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 7 Sep 2026 19:34:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B3=A5=E7=82=B9=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E7=B4=A0=E6=9D=90=E5=8E=9F=E5=9B=A0=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录资产扣费类型并保留队列任务元数据 按白名单返回用户可见原因且未知历史流水继续回退 扩展共享契约与账单展示并补齐回归测试和团队决策记录 --- .../shared-memory/decision-log.md | 1 + .../index.test.tsx | 41 +++++++++ .../PlatformProfileWalletLedgerModal/model.ts | 3 +- packages/shared/src/contracts/runtime.ts | 1 + .../crates/api-server/src/asset_billing.rs | 40 ++++++--- .../crates/api-server/src/runtime_profile.rs | 90 ++++++++++++++++++- .../crates/shared-contracts/src/runtime.rs | 13 +++ 7 files changed, 172 insertions(+), 17 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 00fe2141a..8dcc07aa3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1507,6 +1507,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` 回退为“资产操作消耗”,不得由客户端猜测业务类型。 - 影响范围:`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/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx index a2d15aea6..2c3369106 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx @@ -131,4 +131,45 @@ test('builds wallet ledger presentation with stable source fallbacks', () => { }, ], }); + + for (const reason of [ + '生成美术素材', + '编辑美术素材', + '提取美术素材', + '生成角色动画', + '生成视频素材', + '生成音效素材', + '生成背景音乐', + ]) { + expect( + buildWalletLedgerPresentation( + { + entries: [ + buildLedgerEntry({ + amountDelta: -12, + sourceType: 'asset_operation_consume', + reason, + }), + ], + }, + 12, + ).entries[0]?.sourceLabel, + `unexpected source label for ${reason}`, + ).toBe(reason); + } + + expect( + buildWalletLedgerPresentation( + { + entries: [ + buildLedgerEntry({ + amountDelta: -12, + sourceType: 'asset_operation_consume', + reason: ' ', + }), + ], + }, + 12, + ).entries[0]?.sourceLabel, + ).toBe('资产操作消耗'); }); diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts index 40bd70e41..a18adce79 100644 --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts @@ -103,7 +103,8 @@ export function buildWalletLedgerPresentation( createdAtLabel: formatWalletLedgerDate(entry.createdAt), id: entry.id, isIncome: entry.amountDelta > 0, - sourceLabel: getWalletLedgerSourceLabel(entry.sourceType), + sourceLabel: + entry.reason?.trim() || getWalletLedgerSourceLabel(entry.sourceType), })), }; } diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 1fa899e4d..eee7978ed 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -84,6 +84,7 @@ export type ProfileWalletLedgerEntry = { | 'puzzle_author_incentive_claim' | 'daily_task_reward'; createdAt: string; + reason?: string; }; export type ProfileWalletLedgerResponse = { diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index e0ce18d6d..f9eb99573 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -202,6 +202,7 @@ where points_cost, &billing_plan.current.consume_ledger_id, wallet_metadata_json( + asset_kind, billing_plan.external_generation_job_id.as_deref(), billing_plan.external_generation_claim_attempt, ), @@ -642,9 +643,10 @@ fn resolve_asset_operation_points_cost(configured_points_cost: u64) -> u64 { } #[cfg(test)] -fn current_wallet_metadata_json() -> String { +fn current_wallet_metadata_json(asset_kind: &str) -> String { let billing_context = current_external_generation_billing_context(); wallet_metadata_json( + asset_kind, billing_context .as_ref() .map(|context| context.job_id.as_str()), @@ -655,21 +657,21 @@ fn current_wallet_metadata_json() -> String { } fn wallet_metadata_json( + asset_kind: &str, external_generation_job_id: Option<&str>, external_generation_claim_attempt: Option, ) -> String { - let Some(external_generation_job_id) = external_generation_job_id + let mut metadata = json!({ + "assetKind": asset_kind.trim(), + }); + if let Some(external_generation_job_id) = external_generation_job_id .map(str::trim) .filter(|value| !value.is_empty()) - else { - return module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(); - }; - - json!({ - "externalGenerationJobId": external_generation_job_id, - "externalGenerationClaimAttempt": external_generation_claim_attempt, - }) - .to_string() + { + metadata["externalGenerationJobId"] = json!(external_generation_job_id); + metadata["externalGenerationClaimAttempt"] = json!(external_generation_claim_attempt); + } + metadata.to_string() } pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError { @@ -971,13 +973,20 @@ mod tests { async { ( resolve_asset_operation_points_cost(99), - current_wallet_metadata_json(), + current_wallet_metadata_json("editor_generated_image"), ) }, ) .await; assert_eq!(points_cost, 37); + assert_eq!( + serde_json::from_str::(&metadata_json) + .expect("worker metadata should be valid JSON") + .get("assetKind") + .and_then(serde_json::Value::as_str), + Some("editor_generated_image") + ); assert_eq!( serde_json::from_str::(&metadata_json) .expect("worker metadata should be valid JSON") @@ -994,8 +1003,11 @@ mod tests { ); assert_eq!(resolve_asset_operation_points_cost(99), 99); assert_eq!( - current_wallet_metadata_json(), - module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON + serde_json::from_str::(¤t_wallet_metadata_json( + "editor_image_edit" + )) + .expect("ordinary metadata should be valid JSON"), + json!({"assetKind": "editor_image_edit"}) ); } diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index b7413f45a..db27bb20f 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -2064,15 +2064,46 @@ fn build_redeem_profile_reward_code_response( fn build_profile_wallet_ledger_entry_response( record: module_runtime::RuntimeProfileWalletLedgerEntryRecord, ) -> ProfileWalletLedgerEntryResponse { + let reason = profile_wallet_ledger_reason(record.source_type, &record.metadata_json); ProfileWalletLedgerEntryResponse { id: record.wallet_ledger_id, amount_delta: record.amount_delta, balance_after: record.balance_after, source_type: format_profile_wallet_ledger_source_type(record.source_type).to_string(), created_at: record.created_at, + reason, } } +fn profile_wallet_ledger_reason( + source_type: RuntimeProfileWalletLedgerSourceType, + metadata_json: &str, +) -> Option { + if source_type != RuntimeProfileWalletLedgerSourceType::AssetOperationConsume { + return None; + } + let metadata = serde_json::from_str::(metadata_json).ok()?; + let asset_kind = metadata.get("assetKind")?.as_str()?.trim(); + let reason = match asset_kind { + "editor_scene_image" + | "editor_character_image" + | "editor_spec_image" + | "editor_quick_edit_image" + | "editor_ui_design_image" + | "editor_publication_material" + | "editor_generated_image" + | "editor_icon_spritesheet" => "生成美术素材", + "editor_image_edit" => "编辑美术素材", + "editor_ui_design_asset_extraction" => "提取美术素材", + "editor_character_animation" => "生成角色动画", + "editor_video" => "生成视频素材", + "editor_sound_effect" => "生成音效素材", + "editor_background_music" => "生成背景音乐", + _ => return None, + }; + Some(reason.to_string()) +} + fn build_profile_task_center_response( record: RuntimeProfileTaskCenterRecord, ) -> ProfileTaskCenterResponse { @@ -2433,7 +2464,8 @@ mod tests { format_profile_wallet_ledger_source_type, is_wechat_profile_recharge_order_terminal_for_confirmation, map_runtime_profile_client_error, normalize_admin_invite_code_metadata, - parse_admin_profile_code_time_field, should_notify_virtual_payment_goods_delivery, + parse_admin_profile_code_time_field, profile_wallet_ledger_reason, + should_notify_virtual_payment_goods_delivery, }; #[test] @@ -2501,7 +2533,7 @@ mod tests { use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; - use serde_json::Value; + use serde_json::{Value, json}; use spacetime_client::SpacetimeClientError; use std::time::Duration; use time::OffsetDateTime; @@ -2619,6 +2651,60 @@ mod tests { ); } + #[test] + fn profile_wallet_ledger_reason_only_exposes_known_asset_operations() { + for (asset_kind, expected) in [ + ("editor_scene_image", "生成美术素材"), + ("editor_character_image", "生成美术素材"), + ("editor_spec_image", "生成美术素材"), + ("editor_quick_edit_image", "生成美术素材"), + ("editor_ui_design_image", "生成美术素材"), + ("editor_publication_material", "生成美术素材"), + ("editor_generated_image", "生成美术素材"), + ("editor_icon_spritesheet", "生成美术素材"), + ("editor_image_edit", "编辑美术素材"), + ("editor_ui_design_asset_extraction", "提取美术素材"), + ("editor_character_animation", "生成角色动画"), + ("editor_video", "生成视频素材"), + ("editor_sound_effect", "生成音效素材"), + ("editor_background_music", "生成背景音乐"), + ] { + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, + &json!({"assetKind": asset_kind}).to_string(), + ) + .as_deref(), + Some(expected), + "unexpected reason for {asset_kind}", + ); + } + + for metadata in [ + "{}", + "not-json", + r#"{"assetKind":""}"#, + r#"{"assetKind":" "}"#, + r#"{"assetKind":"unknown_internal_operation"}"#, + r#"{"assetKind":123}"#, + ] { + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationConsume, + metadata, + ), + None, + ); + } + assert_eq!( + profile_wallet_ledger_reason( + RuntimeProfileWalletLedgerSourceType::AssetOperationRefund, + r#"{"assetKind":"editor_generated_image"}"#, + ), + None, + ); + } + #[tokio::test] async fn profile_dashboard_requires_authentication() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index d46843ff3..704a5d444 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -225,6 +225,8 @@ pub struct ProfileWalletLedgerEntryResponse { pub balance_after: u64, pub source_type: String, pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1430,6 +1432,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD .to_string(), created_at: "2026-04-22T09:59:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-2".to_string(), @@ -1437,6 +1440,7 @@ mod tests { balance_after: 80, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(), created_at: "2026-04-22T10:00:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-3".to_string(), @@ -1445,6 +1449,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD .to_string(), created_at: "2026-04-22T10:01:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-4".to_string(), @@ -1453,6 +1458,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD .to_string(), created_at: "2026-04-22T10:02:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-5".to_string(), @@ -1460,6 +1466,7 @@ mod tests { balance_after: 200, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE.to_string(), created_at: "2026-04-22T10:03:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-6".to_string(), @@ -1468,6 +1475,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME .to_string(), created_at: "2026-04-22T10:04:00Z".to_string(), + reason: Some("生成美术素材".to_string()), }, ProfileWalletLedgerEntryResponse { id: "ledger-7".to_string(), @@ -1476,6 +1484,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND .to_string(), created_at: "2026-04-22T10:05:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-8".to_string(), @@ -1484,6 +1493,7 @@ mod tests { source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM .to_string(), created_at: "2026-04-22T10:06:00Z".to_string(), + reason: None, }, ProfileWalletLedgerEntryResponse { id: "ledger-9".to_string(), @@ -1491,6 +1501,7 @@ mod tests { balance_after: 212, source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD.to_string(), created_at: "2026-04-22T10:07:00Z".to_string(), + reason: None, }, ], }) @@ -1522,6 +1533,8 @@ mod tests { payload["entries"][5]["sourceType"], json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME) ); + assert_eq!(payload["entries"][5]["reason"], json!("生成美术素材")); + assert!(payload["entries"][0].get("reason").is_none()); assert_eq!( payload["entries"][6]["sourceType"], json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND)