From 0dbd279dfc79e586630a9bdb22854ffe891e540c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:56:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=20Dashboard=20=E6=B6=88?= =?UTF-8?q?=E8=80=97=E6=B3=A5=E7=82=B9=E6=94=B9=E4=B8=BA=E5=AF=B9=E5=86=B2?= =?UTF-8?q?=E9=80=80=E8=BF=98=E5=90=8E=E7=9A=84=E5=87=80=E6=B6=88=E8=80=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consumedMudPoints 改为净额:按北京时间业务日先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的丢弃,每日净额非负且区间合计严格等于「消耗 − 退还」 - 退还口径并入 asset_operation_refund 正向流水(生成失败退还、精选审核返还)与 llm_router_consume 正向冲正流水;充值退款追回、余额重置、赠送和 hold 仍不计入 - 契约新增 refundedMudPoints,后端新增「退还泥点」趋势图,后台时段数据并列「退还泥点数」卡,毛消耗可由「消耗 + 退还」核出 - 新增 4 条净额单测(同日对冲 / 回溯抵扣 / 超额归零 / 无退还保原值)与 Dashboard 用例断言 - 同步 Dashboard 运营看板方案指标口径与决策记录 - 验证:cargo test -p api-server --bin api-server admin(136 passed / 0 failed / 1 ignored)、npm run admin-web:typecheck、npx vitest run apps/admin-web/src(218 passed)、cargo fmt --all --check、npm run check:encoding、npm run check:doc-index、git diff --check 全部通过 --- apps/admin-web/src/api/adminApiTypes.ts | 3 + .../src/pages/AdminDashboardPage.test.tsx | 3 + .../src/pages/AdminDashboardPage.tsx | 6 + .../shared-memory/decision-log.md | 8 + ...台管理】Dashboard运营看板方案-2026-06-23.md | 4 +- server-rs/crates/api-server/src/admin.rs | 160 +++++++++++++++--- .../crates/shared-contracts/src/admin.rs | 3 + 7 files changed, 167 insertions(+), 20 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 71159500c..85ca7961a 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -193,7 +193,10 @@ export interface AdminDashboardRangePayload { export interface AdminDashboardMetricsPayload { generatedAssets: number; + /** 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 */ consumedMudPoints: number; + /** 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 */ + refundedMudPoints: number; totalRegisteredUsers: number; newRegisteredUsers: number; newUserPaymentConversion: AdminDashboardPaymentConversionPayload; diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index a278a19dc..3653df930 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -34,6 +34,7 @@ const dashboardResponse: AdminDashboardResponse = { metrics: { generatedAssets: 12, consumedMudPoints: 88, + refundedMudPoints: 24, totalRegisteredUsers: 1200, newRegisteredUsers: 16, newUserPaymentConversion: { @@ -105,6 +106,8 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = expect(await screen.findByText('总计数据')).toBeTruthy(); expect(screen.getByText('时段数据')).toBeTruthy(); expect(screen.getByText('本日生产素材数')).toBeTruthy(); + expect(screen.getByText('本日消耗泥点数')).toBeTruthy(); + expect(screen.getByText('本日退还泥点数')).toBeTruthy(); expect(screen.getByText('总注册用户')).toBeTruthy(); expect(screen.getByText('本日新增用户数')).toBeTruthy(); expect(screen.getByText('新增用户转化与留存')).toBeTruthy(); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index 933abf45a..111e7e9a0 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -128,6 +128,12 @@ export function AdminDashboardPage({ value: metrics?.consumedMudPoints ?? 0, unit: '泥点', }, + { + id: 'refunded-mud-points', + label: `${rangePrefix(granularity)}退还泥点数`, + value: metrics?.refundedMudPoints ?? 0, + unit: '泥点', + }, { id: 'new-registered-users', label: `${rangePrefix(granularity)}新增用户数`, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a11c2b6ff..a7cfa58de 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9269,3 +9269,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,controller/useDirectThreadChatSubscription.ts,controller/useDirectProjectChatController.ts}` 与 `apps/ai-game-creator-shell/tests/{directThreadChat.test.ts,appSurface/chat-composer.suite.ts}`。 - 验证:reducer 新增 2 条用例(兜底收口后同名 `turn.started` 不复活且真终态仍能补上结束时间;身份不同的回合不动),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证:拿掉 controller 里的兜底收口调用后该用例变红(界面仍显示「陶泥儿正在处理」),恢复即绿。 - 边界(未做):根因仍在宿主侧——要在进程内保证开闭配对,应由 Rust 在回合函数退出(含 panic / 任务中止)时补一条终态事件(drop 守卫);本次只做到前端不再跟着说谎。另:兜底收口的回合没有终态时间,仍会落进「`finished` 但拿不到终态时间」那个已知缺口(终态文案要不要藏,见 `DirectProjectTurn.tsx` 与 `DirectChatTurnState` 注释里的 A 项)。 + +## 2026-09-23 后台 Dashboard「消耗泥点」改为对冲退还后的净消耗 + +- 背景:Dashboard 的「消耗泥点数」只累计负向消费流水,生成失败退还、精选审核返还和 LLM Router 正向冲正都不参与抵扣,运营看到的「总消耗」明显高于用户实际花费(用户现场反馈)。 +- 决策:`GET /admin/api/dashboard` 的 `consumedMudPoints` 改为净消耗。先按北京时间业务日累计 `asset_operation_consume` / `llm_router_consume` 的负向流水绝对值,再用同期 `asset_operation_refund` 正向流水和 `llm_router_consume` 正向冲正流水按日抵扣:先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的退还丢弃。因此每日净额非负,区间合计严格等于「消耗 − 退还」。新增 `refundedMudPoints` 与「退还泥点」趋势图,前台「消耗泥点数」卡旁并列「退还泥点数」卡,毛消耗可由「消耗 + 退还」核出,不把退还金额藏进净额。 +- 边界(本次不改):用户详情「历史花费」(`profile_wallet_consumption_total` 投影与手动对账)维持既有「退款不冲减」决策,仍只累计负向消费流水;若要改成净额,必须单独走投影语义 + 对账口径变更,不能顺手改这一处。充值退款追回、余额重置、赠送和 hold 继续不计入消耗。 +- 影响范围:`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`、`apps/admin-web/src/pages/AdminDashboardPage.tsx`、对应用例与 `docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`。 +- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server dashboard_consumption`(新增 4 条净额用例全绿);`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin`(136 passed / 0 failed / 1 ignored);`npm run admin-web:typecheck`;`npx vitest run apps/admin-web/src`(218 passed);`npm run check:encoding`、`git diff --check`。 diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md index 0c951c3d2..b26f58358 100644 --- a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -22,7 +22,8 @@ ## 指标口径 - 生产素材数:`editor_project_resource` 中 `source_type = 'generated'` 的资源,按 `created_at` 映射到北京时间业务日。 -- 消耗泥点数:`profile_wallet_ledger` 中 `source_type = asset_operation_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。 +- 消耗泥点数(净消耗):`profile_wallet_ledger` 中 `source_type` 为 `asset_operation_consume` / `llm_router_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日后,再按日对冲同期退还泥点——先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的退还丢弃,因此每日净额非负,区间合计严格等于「消耗 − 退还」。充值退款追回、余额重置、赠送和 hold 都不计入。 +- 退还泥点数:`source_type = asset_operation_refund` 的正向流水(生成失败退还、精选审核返还)与 `llm_router_consume` 的正向冲正流水,按 `created_at` 映射到北京时间业务日。它只对冲本看板的消耗口径,不改变用户详情「历史花费」按既有决策的退款不冲减口径。 - 总注册用户:`profile_dashboard_state` 行数。 - 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月快捷日期范围。 - 新增用户付费率:分母为当前筛选时间窗内的新增用户,分子为这些用户中截至本次查询时已至少完成一次真实支付的去重人数。真实支付以 `profile_recharge_order.paid_at` 存在且不晚于本次查询时刻为准;只创建订单或未支付订单不计,已退款订单仍表示曾经发生过付费转化,因此保留在分子。返回付费人数、新增用户数和四舍五入后的基点率;分母为 0 时 DTO 返回 0,前端百分比显示 `-`。 @@ -33,6 +34,7 @@ - 留存活跃:用户在注册日恰好 `D+1` / `D+7` 的 `tracking_daily_stat` 中存在上述有效 user scope 行;同一用户同日多个事件只计一次,不按“1 / 7 天内累计回访”计算。筛选范围约束注册 cohort,观察日允许晚于筛选结束日。 - 留存成熟条件:目标观察日必须早于当前北京时间业务日;观察日为今天时因当天尚未完整结束而排除。D1、D7 的可观察人数通常不同,分别返回 `eligibleUsers`、`retainedUsers` 与 `rateBasisPoints = round(retainedUsers * 10000 / eligibleUsers)`;分母为 0 时 DTO 返回 0,前端百分比显示 `-`。汇总率按总人数加权,不平均每日百分比。 - 运营汇总页签:复用同一时间窗,展示运营指标卡、素材类型分布和访问模块分布。 +- 趋势图:`消耗泥点` 图展示按日对冲之后的净额,区间合计与「消耗泥点数」指标一致;`退还泥点` 图展示同期退还金额,毛消耗可由「消耗 + 退还」核出,避免把退还金额藏进净额里。 ## 前后端文件 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 2f9838531..176c0e39a 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2383,6 +2383,7 @@ struct AdminDashboardAssetStats { #[derive(Default)] struct AdminDashboardWalletStats { consumed_mud_points: AdminDashboardSeries, + refunded_mud_points: AdminDashboardSeries, recharged_mud_points: u64, warnings: Vec, } @@ -2419,12 +2420,22 @@ async fn build_admin_dashboard( let user_stats = fetch_admin_dashboard_user_stats(state, &range, now_micros).await?; + let day_keys = range.day_keys(); + // 消耗泥点按日对冲退还:每日净额不为负,区间合计等于「消耗 − 退还」。 + let net_consumed_mud_points = net_admin_dashboard_consumed_mud_points( + &wallet_stats.consumed_mud_points, + &wallet_stats.refunded_mud_points, + &day_keys, + ); + let generated_assets = asset_stats.generated_assets.total(); - let consumed_mud_points = wallet_stats.consumed_mud_points.total(); + let consumed_mud_points = net_consumed_mud_points.total(); + let refunded_mud_points = wallet_stats.refunded_mud_points.total(); let new_registered_users = user_stats.new_registered_users.total(); let metrics = AdminDashboardMetricsPayload { generated_assets, consumed_mud_points, + refunded_mud_points, total_registered_users: user_stats.total_registered_users, new_registered_users, day1_retention: user_stats.day1_retention.clone(), @@ -2450,8 +2461,16 @@ async fn build_admin_dashboard( "consumed-mud-points", "消耗泥点", "泥点", - &wallet_stats.consumed_mud_points, - wallet_stats.consumed_mud_points.total(), + &net_consumed_mud_points, + net_consumed_mud_points.total(), + &range, + ), + build_admin_dashboard_chart( + "refunded-mud-points", + "退还泥点", + "泥点", + &wallet_stats.refunded_mud_points, + refunded_mud_points, &range, ), build_admin_dashboard_chart( @@ -2719,6 +2738,10 @@ async fn fetch_admin_dashboard_wallet_stats( .consumed_mud_points .add(day_key, amount_delta.unsigned_abs()); } + // 生成失败 / 精选审核返还,以及 LLM Router 的正向冲正流水,都按退还对冲消耗。 + "asset_operation_refund" | "llm_router_consume" if amount_delta > 0 => { + stats.refunded_mud_points.add(day_key, amount_delta as u64); + } "points_recharge" if amount_delta > 0 => { stats.recharged_mud_points = stats .recharged_mud_points @@ -2982,6 +3005,40 @@ impl AdminDashboardSeries { } } +/// 消耗泥点按日对冲退还:先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日。 +/// 抵扣不完的退还直接丢弃,保证每日净额非负,且区间合计等于「消耗 − 退还」。 +fn net_admin_dashboard_consumed_mud_points( + consumed: &AdminDashboardSeries, + refunded: &AdminDashboardSeries, + day_keys: &[i64], +) -> AdminDashboardSeries { + let mut remaining: Vec = day_keys + .iter() + .map(|day_key| consumed.value(*day_key)) + .collect(); + for (index, day_key) in day_keys.iter().enumerate() { + let mut pending = refunded.value(*day_key); + if pending == 0 { + continue; + } + for back in (0..=index).rev() { + if pending == 0 { + break; + } + let applied = remaining[back].min(pending); + remaining[back] -= applied; + pending -= applied; + } + } + let mut series = AdminDashboardSeries::default(); + for (index, day_key) in day_keys.iter().enumerate() { + if remaining[index] > 0 { + series.add(*day_key, remaining[index]); + } + } + series +} + fn build_spacetime_schema_url(server_root: &str, database: &str) -> String { format!("{server_root}/v1/database/{database}/schema?{SPACETIME_SCHEMA_VERSION_QUERY}") } @@ -4786,28 +4843,29 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess #[cfg(test)] mod tests { use super::{ - AdminDashboardGranularity, AdminDisplayNameDirectory, EditorShowcaseAssetRecord, - admin_dashboard_user_stats_from_record, admin_dashboard_user_stats_from_result, - admin_editor_asset_group_payload, admin_editor_asset_payload_from_record, - admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk, - apply_admin_database_table_filters, build_admin_asset_read_url_audit, - build_admin_dashboard_chart, build_admin_database_table_row, - build_admin_editor_showcase_campaign_image_confirm_request, + AdminDashboardGranularity, AdminDashboardSeries, AdminDisplayNameDirectory, + EditorShowcaseAssetRecord, admin_dashboard_user_stats_from_record, + admin_dashboard_user_stats_from_result, admin_editor_asset_group_payload, + admin_editor_asset_payload_from_record, admin_editor_showcase_asset_payload_from_record, + append_spacetime_sql_response_chunk, apply_admin_database_table_filters, + build_admin_asset_read_url_audit, build_admin_dashboard_chart, + build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request, build_admin_external_api_key_sql, build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, clamp_admin_tracking_event_limit, enforce_admin_request_permission, fetch_admin_database_table_rows, finalize_admin_database_table_rows_response, group_admin_editor_asset_records, hash_admin_password, is_admin_account_not_found, - is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error, - paginate_admin_editor_asset_records, parse_admin_database_table_rows_sql_response, - parse_admin_external_api_key_row, parse_admin_tracking_event_keys_sql_response, - parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, - parse_timestamp_text_to_micros, resolve_admin_dashboard_range, - resolve_admin_dashboard_range_at, resolve_admin_database_table_sql_limit, - resolve_admin_editor_asset_filters, timestamp_value_to_micros, trim_preview, - validate_admin_editor_asset_cursor, validate_admin_external_api_key_query, - verify_admin_password, wallet_ledger_source_type_to_string, + is_safe_spacetime_table_name, net_admin_dashboard_consumed_mud_points, + normalize_debug_path, normalize_table_count_error, paginate_admin_editor_asset_records, + parse_admin_database_table_rows_sql_response, parse_admin_external_api_key_row, + parse_admin_tracking_event_keys_sql_response, parse_admin_tracking_events_sql_response, + parse_spacetime_sql_count_response, parse_timestamp_text_to_micros, + resolve_admin_dashboard_range, resolve_admin_dashboard_range_at, + resolve_admin_database_table_sql_limit, resolve_admin_editor_asset_filters, + timestamp_value_to_micros, trim_preview, validate_admin_editor_asset_cursor, + validate_admin_external_api_key_query, verify_admin_password, + wallet_ledger_source_type_to_string, }; use axum::{ http::{Method, StatusCode}, @@ -5732,6 +5790,70 @@ mod tests { ); } + /// 消耗泥点必须按退还对冲,且每日净额非负、区间合计等于「消耗 − 退还」。 + #[test] + fn dashboard_consumption_offsets_refunds_in_the_same_day() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 40); + let mut refunded = AdminDashboardSeries::default(); + refunded.add(100, 15); + + let net = net_admin_dashboard_consumed_mud_points(&consumed, &refunded, &[100, 101]); + + assert_eq!(net.value(100), 25); + assert_eq!(net.total(), 25); + } + + #[test] + fn dashboard_consumption_refund_offsets_earlier_consumption_day() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 40); + consumed.add(101, 10); + // 精选审核返还发生在消耗之后的业务日,必须回溯抵扣仍有净额的最近一天。 + let mut refunded = AdminDashboardSeries::default(); + refunded.add(102, 30); + + let net = net_admin_dashboard_consumed_mud_points(&consumed, &refunded, &[100, 101, 102]); + + assert_eq!(net.value(100), 20); + assert_eq!(net.value(101), 0); + assert_eq!(net.value(102), 0); + assert_eq!(net.total(), 20); + } + + #[test] + fn dashboard_consumption_refund_beyond_consumption_stops_at_zero() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 6); + consumed.add(101, 4); + let mut refunded = AdminDashboardSeries::default(); + refunded.add(101, 40); + + let net = net_admin_dashboard_consumed_mud_points(&consumed, &refunded, &[100, 101]); + + assert_eq!(net.value(100), 0); + assert_eq!(net.value(101), 0); + assert_eq!(net.total(), 0); + } + + #[test] + fn dashboard_consumption_without_refunds_keeps_daily_values() { + let mut consumed = AdminDashboardSeries::default(); + consumed.add(100, 40); + consumed.add(101, 8); + + let net = net_admin_dashboard_consumed_mud_points( + &consumed, + &AdminDashboardSeries::default(), + &[100, 101, 102], + ); + + assert_eq!(net.value(100), 40); + assert_eq!(net.value(101), 8); + assert_eq!(net.value(102), 0); + assert_eq!(net.total(), 48); + } + #[test] fn timestamp_value_to_micros_accepts_sql_shapes() { assert_eq!( diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index f9730feb4..a86e202c7 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -731,7 +731,10 @@ pub struct AdminDashboardRangePayload { #[serde(rename_all = "camelCase")] pub struct AdminDashboardMetricsPayload { pub generated_assets: u64, + /// 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 pub consumed_mud_points: u64, + /// 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 + pub refunded_mud_points: u64, pub total_registered_users: u64, pub new_registered_users: u64, pub day1_retention: AdminDashboardRetentionPayload,