优化泥点流水素材原因展示
记录资产扣费类型并保留队列任务元数据 按白名单返回用户可见原因且未知历史流水继续回退 扩展共享契约与账单展示并补齐回归测试和团队决策记录
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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('资产操作消耗');
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export type ProfileWalletLedgerEntry = {
|
||||
| 'puzzle_author_incentive_claim'
|
||||
| 'daily_task_reward';
|
||||
createdAt: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ProfileWalletLedgerResponse = {
|
||||
|
||||
@@ -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<u32>,
|
||||
) -> 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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(¤t_wallet_metadata_json(
|
||||
"editor_image_edit"
|
||||
))
|
||||
.expect("ordinary metadata should be valid JSON"),
|
||||
json!({"assetKind": "editor_image_edit"})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
if source_type != RuntimeProfileWalletLedgerSourceType::AssetOperationConsume {
|
||||
return None;
|
||||
}
|
||||
let metadata = serde_json::from_str::<Value>(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"));
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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)
|
||||
|
||||
Reference in New Issue
Block a user