From b303605bf29e292795161c78cc4a003f64d75e45 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 15 Jul 2026 22:14:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=90=8E=E5=8F=B0=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=94=A8=E6=88=B7=E4=BB=98=E8=B4=B9=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展 Dashboard 聚合与契约,统计筛选期新增用户的真实付费转化 生成 SpacetimeDB typed bindings 并补充后端聚合测试 后台新增付费率卡片与响应式布局,覆盖零分母和时段文案 同步 Dashboard 运营口径与项目决策记录 --- apps/admin-web/src/api/adminApiTypes.ts | 7 ++ .../src/pages/AdminDashboardPage.test.tsx | 47 ++++++- .../src/pages/AdminDashboardPage.tsx | 61 +++++++-- apps/admin-web/src/styles/admin.css | 10 +- .../shared-memory/decision-log.md | 7 ++ ...台管理】Dashboard运营看板方案-2026-06-23.md | 1 + server-rs/crates/api-server/src/admin.rs | 35 ++++-- .../crates/shared-contracts/src/admin.rs | 10 ++ server-rs/crates/spacetime-client/src/lib.rs | 38 +++--- .../crates/spacetime-client/src/mapper.rs | 3 +- .../src/mapper/admin_dashboard.rs | 29 +++++ .../spacetime-client/src/module_bindings.rs | 2 + ...hboard_payment_conversion_snapshot_type.rs | 17 +++ .../admin_dashboard_stats_snapshot_type.rs | 2 + .../spacetime-module/src/admin_dashboard.rs | 117 +++++++++++++++++- 15 files changed, 338 insertions(+), 48 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_payment_conversion_snapshot_type.rs diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 67d5740f0..2aff05de0 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -128,6 +128,7 @@ export interface AdminDashboardMetricsPayload { consumedMudPoints: number; totalRegisteredUsers: number; newRegisteredUsers: number; + newUserPaymentConversion: AdminDashboardPaymentConversionPayload; day1Retention: AdminDashboardRetentionMetricPayload; day7Retention: AdminDashboardRetentionMetricPayload; visitUsers: number; @@ -137,6 +138,12 @@ export interface AdminDashboardMetricsPayload { currentUsers: number; } +export interface AdminDashboardPaymentConversionPayload { + paidUsers: number; + newRegisteredUsers: number; + rateBasisPoints: number; +} + export interface AdminDashboardRetentionMetricPayload { eligibleUsers: number; retainedUsers: number; diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index 958dd723a..e8aa77b61 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -35,6 +35,11 @@ const dashboardResponse: AdminDashboardResponse = { consumedMudPoints: 88, totalRegisteredUsers: 1200, newRegisteredUsers: 16, + newUserPaymentConversion: { + paidUsers: 5, + newRegisteredUsers: 16, + rateBasisPoints: 3125, + }, day1Retention: { eligibleUsers: 12, retainedUsers: 3, @@ -101,7 +106,20 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = expect(screen.getByText('本日生产素材数')).toBeTruthy(); expect(screen.getByText('总注册用户')).toBeTruthy(); expect(screen.getByText('本日新增用户数')).toBeTruthy(); - expect(screen.getByText('新增用户留存')).toBeTruthy(); + expect(screen.getByText('新增用户转化与留存')).toBeTruthy(); + const paymentRateCard = screen + .getByText('本日新增用户付费率') + .closest('article'); + expect(paymentRateCard).toBeTruthy(); + expect( + within(paymentRateCard as HTMLElement).getByText('31.25%'), + ).toBeTruthy(); + expect( + within(paymentRateCard as HTMLElement).getByText('付费人数 / 新增人数'), + ).toBeTruthy(); + expect( + within(paymentRateCard as HTMLElement).getByText('5 / 16 人'), + ).toBeTruthy(); expect(screen.getByText('次日留存')).toBeTruthy(); expect(screen.getByText('25%')).toBeTruthy(); expect(screen.getByText('3 / 12 人')).toBeTruthy(); @@ -218,6 +236,33 @@ test('Dashboard 手动选择起止日期时使用本时段查询', async () => { }); }); expect(screen.getByText('本时段新增用户数')).toBeTruthy(); + expect(screen.getByText('本时段新增用户付费率')).toBeTruthy(); +}); + +test('Dashboard 新增用户付费率分母为零时显示横线', async () => { + vi.mocked(getAdminDashboard).mockResolvedValue({ + ...dashboardResponse, + metrics: { + ...dashboardResponse.metrics, + newRegisteredUsers: 0, + newUserPaymentConversion: { + paidUsers: 0, + newRegisteredUsers: 0, + rateBasisPoints: 0, + }, + }, + }); + + render(); + + const paymentRateCard = ( + await screen.findByText('本日新增用户付费率') + ).closest('article'); + expect(paymentRateCard).toBeTruthy(); + expect(within(paymentRateCard as HTMLElement).getByText('-')).toBeTruthy(); + expect( + within(paymentRateCard as HTMLElement).getByText('0 / 0 人'), + ).toBeTruthy(); }); test('Dashboard 手动选择日期时不允许查询北京时间今天之后', async () => { diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index d075e1ea4..933abf45a 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -6,7 +6,6 @@ import type { AdminDashboardBreakdownRowPayload, AdminDashboardChartPayload, AdminDashboardGranularity, - AdminDashboardRetentionMetricPayload, AdminDashboardResponse, } from '../api/adminApiTypes'; import { handlePageError } from './pageUtils'; @@ -292,12 +291,38 @@ export function AdminDashboardPage({
-

新增用户留存

+

新增用户转化与留存

{dashboard?.range.periodLabel ?? '-'}
- - + + +
@@ -406,27 +431,37 @@ function MetricCard({ ); } -function RetentionCard({ +function RateCard({ label, - metric, + numeratorLabel, + denominatorLabel, + numerator, + denominator, + rateBasisPoints, }: { label: string; - metric?: AdminDashboardRetentionMetricPayload; + numeratorLabel: string; + denominatorLabel: string; + numerator?: number; + denominator?: number; + rateBasisPoints?: number; }) { - const hasEligibleUsers = Boolean(metric?.eligibleUsers); + const hasDenominator = Boolean(denominator); return (
{label} - {hasEligibleUsers && metric - ? formatRateBasisPoints(metric.rateBasisPoints) + {hasDenominator && rateBasisPoints !== undefined + ? formatRateBasisPoints(rateBasisPoints) : '-'}
- 留存人数 / 可观察新增人数 + + {numeratorLabel} / {denominatorLabel} + - {metric - ? `${formatNumber(metric.retainedUsers)} / ${formatNumber(metric.eligibleUsers)} 人` + {numerator !== undefined && denominator !== undefined + ? `${formatNumber(numerator)} / ${formatNumber(denominator)} 人` : '-'}
diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 1e6e477b9..3012d78fa 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -363,7 +363,7 @@ button:disabled { .admin-dashboard-retention-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } @@ -2120,6 +2120,14 @@ button:disabled { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .admin-dashboard-retention-card:nth-child(odd) { + border-left: 0; + } + + .admin-dashboard-retention-card:nth-child(n + 3) { + border-top: 1px solid #ead8ca; + } + .admin-field-compact { max-width: none; } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7c6dec1b2..826c4db62 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4114,6 +4114,13 @@ - 影响范围:SpacetimeDB Dashboard 聚合 procedure、`spacetime-client` facade、`/admin/api/dashboard` 与 shared contracts、`apps/admin-web` Dashboard 页面和运营文档。 - 验证方式:SpacetimeDB 聚合与 api-server 定向 Rust 测试、Dashboard Vitest、`npm run admin-web:typecheck`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:encoding`、`git diff --check`,并用桌面 / 移动浏览器核对留存、日期、每日 UV、零值与滚动同步。 +## 2026-07-15 后台 Dashboard 增加新增用户付费率 + +- 背景:Dashboard 已能展示筛选时段的新增用户数和 D1 / D7 留存,但缺少同一新增 cohort 的真实付费转化指标。 +- 决策:新增用户付费率的分母为筛选期内 `profile_dashboard_state.created_at` 归属的新增用户,分子为其中截至查询时至少有一笔 `profile_recharge_order.paid_at` 的去重用户;已退款仍代表曾发生付费转化,未支付订单不计。 +- 聚合边界:继续扩展仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure,不新增持久化表或字段,前端只展示 BFF 返回的付费人数、新增用户数和基点率。 +- 验证方式:SpacetimeDB Dashboard 聚合测试、api-server admin 测试、Dashboard Vitest、`npm run admin-web:typecheck`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:encoding`、`git diff --check`,并用桌面 / 移动浏览器核对付费率卡片。 + ## 2026-07-14 SpacetimeDB 调用池与缓存读连接分离 - 背景:HTTP 角色原先在每个 `GENARRATIVE_SPACETIME_POOL_SIZE` 槽位首次建连时订阅同一批 read model;release 配置为 8 时会保存 8 份相同行缓存和 subscription handles,放大 api-server 内存。 diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md index 1d4ca26e2..0c951c3d2 100644 --- a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -25,6 +25,7 @@ - 消耗泥点数:`profile_wallet_ledger` 中 `source_type = asset_operation_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。 - 总注册用户:`profile_dashboard_state` 行数。 - 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月快捷日期范围。 +- 新增用户付费率:分母为当前筛选时间窗内的新增用户,分子为这些用户中截至本次查询时已至少完成一次真实支付的去重人数。真实支付以 `profile_recharge_order.paid_at` 存在且不晚于本次查询时刻为准;只创建订单或未支付订单不计,已退款订单仍表示曾经发生过付费转化,因此保留在分子。返回付费人数、新增用户数和四舍五入后的基点率;分母为 0 时 DTO 返回 0,前端百分比显示 `-`。 - 访问次数:`tracking_daily_stat` 中 `scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。 - 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat` 中 `scope_kind = user`、`count > 0` 且 `scope_id` 非空、非 `anonymous` 的用户去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。时段指标按整个筛选范围跨日去重,趋势图按 `day_key + scope_id` 每日去重,不能把时段 UV 塞到终止日,也不能把每日 UV 之和当成时段 UV。 - 近 5 分钟活跃用户:以请求时刻为终点,最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。这是滚动窗口,不是每 5 分钟才采样一次。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 0427614ae..0af3200a9 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -26,13 +26,14 @@ use shared_contracts::admin::{ AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, AdminCreationEntryConfigResponse, AdminCreationEntryTypeConfigPayload, AdminDashboardBreakdownRowPayload, AdminDashboardChartBucketPayload, AdminDashboardChartPayload, AdminDashboardMetricsPayload, - AdminDashboardOperationMetricPayload, AdminDashboardOperationsPayload, AdminDashboardQuery, - AdminDashboardRangePayload, AdminDashboardResponse, AdminDashboardRetentionPayload, - AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload, - AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, - AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, - AdminDirectUploadTicketPayload, AdminEditorAssetListQuery, AdminEditorAssetListResponse, - AdminEditorAssetPayload, AdminEditorShowcaseAssetPayload, AdminEditorShowcaseAssetResponse, + AdminDashboardOperationMetricPayload, AdminDashboardOperationsPayload, + AdminDashboardPaymentConversionPayload, AdminDashboardQuery, AdminDashboardRangePayload, + AdminDashboardResponse, AdminDashboardRetentionPayload, AdminDatabaseOverviewPayload, + AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload, AdminDatabaseTableRowsQuery, + AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload, AdminDebugHeaderInput, + AdminDebugHttpRequest, AdminDebugHttpResponse, AdminDirectUploadTicketPayload, + AdminEditorAssetListQuery, AdminEditorAssetListResponse, AdminEditorAssetPayload, + AdminEditorShowcaseAssetPayload, AdminEditorShowcaseAssetResponse, AdminEditorShowcaseCampaignPayload, AdminEditorShowcaseCampaignResponse, AdminEditorShowcaseDisplayRequest, AdminEditorShowcaseListQuery, AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest, @@ -1839,6 +1840,7 @@ struct AdminDashboardUserStats { total_visit_users: u64, current_users: u64, module_visit_breakdown: BTreeMap, + new_user_payment_conversion: AdminDashboardPaymentConversionPayload, } async fn build_admin_dashboard( @@ -1872,6 +1874,7 @@ async fn build_admin_dashboard( visit_count: user_stats.visit_count.total(), total_visit_count: user_stats.total_visit_count, current_users: user_stats.current_users, + new_user_payment_conversion: user_stats.new_user_payment_conversion.clone(), }; let charts = vec![ @@ -2217,6 +2220,11 @@ fn admin_dashboard_user_stats_from_record( .into_iter() .map(|entry| (entry.key, entry.count)) .collect(), + new_user_payment_conversion: AdminDashboardPaymentConversionPayload { + paid_users: record.new_user_payment_conversion.paid_users, + new_registered_users: record.new_user_payment_conversion.new_registered_users, + rate_basis_points: record.new_user_payment_conversion.rate_basis_points, + }, ..Default::default() }; for entry in record.new_registered_users { @@ -3952,8 +3960,9 @@ mod tests { use shared_contracts::assets::GetReadUrlQuery; use spacetime_client::{ AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord, - AdminDashboardRetentionRecord, AdminDashboardStatsRecord, AdminEditorAssetRecord, - SpacetimeClientError, SpacetimeClientStage, + AdminDashboardPaymentConversionRecord, AdminDashboardRetentionRecord, + AdminDashboardStatsRecord, AdminEditorAssetRecord, SpacetimeClientError, + SpacetimeClientStage, }; use crate::{config::AppConfig, state::AppState}; @@ -4550,6 +4559,11 @@ mod tests { key: "profile".to_string(), count: 50_001, }], + new_user_payment_conversion: AdminDashboardPaymentConversionRecord { + paid_users: 12, + new_registered_users: 30, + rate_basis_points: 4_000, + }, }); assert_eq!(stats.total_registered_users, 591); @@ -4558,6 +4572,9 @@ mod tests { assert_eq!(stats.total_visit_users, 410); assert_eq!(stats.day1_retention.rate_basis_points, 3_700); assert_eq!(stats.module_visit_breakdown["profile"], 50_001); + assert_eq!(stats.new_user_payment_conversion.paid_users, 12); + assert_eq!(stats.new_user_payment_conversion.new_registered_users, 30); + assert_eq!(stats.new_user_payment_conversion.rate_basis_points, 4_000); } #[test] diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 482b879e2..b893fe4e5 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -519,6 +519,16 @@ pub struct AdminDashboardMetricsPayload { pub visit_count: u64, pub total_visit_count: u64, pub current_users: u64, + pub new_user_payment_conversion: AdminDashboardPaymentConversionPayload, +} + +/// dashboard 新增用户付费转化;rate_basis_points 取值 0..=10000,1 表示 0.01%。 +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminDashboardPaymentConversionPayload { + pub paid_users: u64, + pub new_registered_users: u64, + pub rate_basis_points: u32, } /// dashboard 新增用户留存;rate_basis_points 取值 0..=10000,1 表示 0.01%。 diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 0c2119489..a8c73dc43 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -8,25 +8,25 @@ use mapper::*; pub use mapper::{ AdminAccountCreateRecordInput, AdminAccountCredentialRecord, AdminAccountRecord, AdminAccountUpdateRecordInput, AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord, - AdminDashboardRetentionRecord, AdminDashboardStatsRecord, AdminDashboardStatsRecordInput, - AdminEditorAssetListRecordInput, AdminEditorAssetRecord, AdminWorkVisibilityRecord, - AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, - AiTextChunkRecord, BarkBattleDraftConfigRecord, BarkBattleRunRecord, - BarkBattleRuntimeConfigRecord, BattleStateRecord, BigFishAgentMessageRecord, - BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, - BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, - BigFishDraftCompileRecordInput, BigFishGameDraftRecord, BigFishInputSubmitRecordInput, - BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput, BigFishMessageFinalizeRecordInput, - BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRunStartRecordInput, - BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord, - BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record, - BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, CreationEntryConfigRecord, - CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput, - CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput, - CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, - CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord, - CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, - CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, + AdminDashboardPaymentConversionRecord, AdminDashboardRetentionRecord, + AdminDashboardStatsRecord, AdminDashboardStatsRecordInput, AdminEditorAssetListRecordInput, + AdminEditorAssetRecord, AdminWorkVisibilityRecord, AiResultReferenceRecord, + AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, AiTextChunkRecord, + BarkBattleDraftConfigRecord, BarkBattleRunRecord, BarkBattleRuntimeConfigRecord, + BattleStateRecord, BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, + BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, + BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord, + BigFishInputSubmitRecordInput, BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput, + BigFishMessageFinalizeRecordInput, BigFishMessageSubmitRecordInput, + BigFishPlayReportRecordInput, BigFishRunStartRecordInput, BigFishRuntimeEntityRecord, + BigFishRuntimeParamsRecord, BigFishRuntimeRunRecord, BigFishSessionCreateRecordInput, + BigFishSessionRecord, BigFishVector2Record, BigFishWorkRemixRecordInput, + BigFishWorkSummaryRecord, CreationEntryConfigRecord, CustomWorldAgentActionExecuteRecord, + CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, + CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, + CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, + CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, + CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord, CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 41228fbdc..0eb851f95 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -34,7 +34,8 @@ pub use self::admin_account::{ AdminAccountUpdateRecordInput, }; pub use self::admin_dashboard::{ - AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord, AdminDashboardRetentionRecord, + AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord, + AdminDashboardPaymentConversionRecord, AdminDashboardRetentionRecord, AdminDashboardStatsRecord, AdminDashboardStatsRecordInput, }; pub use self::ai::{ diff --git a/server-rs/crates/spacetime-client/src/mapper/admin_dashboard.rs b/server-rs/crates/spacetime-client/src/mapper/admin_dashboard.rs index d482d4b85..a0694266d 100644 --- a/server-rs/crates/spacetime-client/src/mapper/admin_dashboard.rs +++ b/server-rs/crates/spacetime-client/src/mapper/admin_dashboard.rs @@ -26,6 +26,13 @@ pub struct AdminDashboardRetentionRecord { pub rate_basis_points: u32, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AdminDashboardPaymentConversionRecord { + pub paid_users: u64, + pub new_registered_users: u64, + pub rate_basis_points: u32, +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct AdminDashboardStatsRecord { pub total_registered_users: u64, @@ -39,6 +46,7 @@ pub struct AdminDashboardStatsRecord { pub day1_retention: AdminDashboardRetentionRecord, pub day7_retention: AdminDashboardRetentionRecord, pub module_visit_breakdown: Vec, + pub new_user_payment_conversion: AdminDashboardPaymentConversionRecord, } impl From for AdminDashboardStatsInput { @@ -97,6 +105,9 @@ fn map_admin_dashboard_stats_snapshot( count: entry.count, }) .collect(), + new_user_payment_conversion: map_admin_dashboard_payment_conversion( + snapshot.new_user_payment_conversion, + ), } } @@ -119,6 +130,16 @@ fn map_admin_dashboard_retention( } } +fn map_admin_dashboard_payment_conversion( + conversion: AdminDashboardPaymentConversionSnapshot, +) -> AdminDashboardPaymentConversionRecord { + AdminDashboardPaymentConversionRecord { + paid_users: conversion.paid_users, + new_registered_users: conversion.new_registered_users, + rate_basis_points: conversion.rate_basis_points, + } +} + #[cfg(test)] mod tests { use super::*; @@ -160,6 +181,11 @@ mod tests { key: "profile".to_string(), count: 7, }], + new_user_payment_conversion: AdminDashboardPaymentConversionSnapshot { + paid_users: 2, + new_registered_users: 5, + rate_basis_points: 4_000, + }, }), error_message: None, }) @@ -168,6 +194,9 @@ mod tests { assert_eq!(record.total_visit_count, 99); assert_eq!(record.day1_retention.rate_basis_points, 5_000); assert_eq!(record.module_visit_breakdown[0].key, "profile"); + assert_eq!(record.new_user_payment_conversion.paid_users, 2); + assert_eq!(record.new_user_payment_conversion.new_registered_users, 5); + assert_eq!(record.new_user_payment_conversion.rate_basis_points, 4_000); } #[test] diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 948cb9adc..ad0f6cc8e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -22,6 +22,7 @@ pub mod admin_account_type; pub mod admin_account_update_input_type; pub mod admin_dashboard_breakdown_snapshot_type; pub mod admin_dashboard_daily_count_snapshot_type; +pub mod admin_dashboard_payment_conversion_snapshot_type; pub mod admin_dashboard_retention_snapshot_type; pub mod admin_dashboard_stats_input_type; pub mod admin_dashboard_stats_procedure_result_type; @@ -1453,6 +1454,7 @@ pub use admin_account_type::AdminAccount; pub use admin_account_update_input_type::AdminAccountUpdateInput; pub use admin_dashboard_breakdown_snapshot_type::AdminDashboardBreakdownSnapshot; pub use admin_dashboard_daily_count_snapshot_type::AdminDashboardDailyCountSnapshot; +pub use admin_dashboard_payment_conversion_snapshot_type::AdminDashboardPaymentConversionSnapshot; pub use admin_dashboard_retention_snapshot_type::AdminDashboardRetentionSnapshot; pub use admin_dashboard_stats_input_type::AdminDashboardStatsInput; pub use admin_dashboard_stats_procedure_result_type::AdminDashboardStatsProcedureResult; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_payment_conversion_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_payment_conversion_snapshot_type.rs new file mode 100644 index 000000000..40a4f35aa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_payment_conversion_snapshot_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AdminDashboardPaymentConversionSnapshot { + pub paid_users: u64, + pub new_registered_users: u64, + pub rate_basis_points: u32, +} + +impl __sdk::InModule for AdminDashboardPaymentConversionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_snapshot_type.rs index 48a1ad108..5fe8d3a2b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_dashboard_stats_snapshot_type.rs @@ -6,6 +6,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; use super::admin_dashboard_breakdown_snapshot_type::AdminDashboardBreakdownSnapshot; use super::admin_dashboard_daily_count_snapshot_type::AdminDashboardDailyCountSnapshot; +use super::admin_dashboard_payment_conversion_snapshot_type::AdminDashboardPaymentConversionSnapshot; use super::admin_dashboard_retention_snapshot_type::AdminDashboardRetentionSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] @@ -22,6 +23,7 @@ pub struct AdminDashboardStatsSnapshot { pub day_1_retention: AdminDashboardRetentionSnapshot, pub day_7_retention: AdminDashboardRetentionSnapshot, pub module_visit_breakdown: Vec, + pub new_user_payment_conversion: AdminDashboardPaymentConversionSnapshot, } impl __sdk::InModule for AdminDashboardStatsSnapshot { diff --git a/server-rs/crates/spacetime-module/src/admin_dashboard.rs b/server-rs/crates/spacetime-module/src/admin_dashboard.rs index 16bec5379..d5d1bfa1a 100644 --- a/server-rs/crates/spacetime-module/src/admin_dashboard.rs +++ b/server-rs/crates/spacetime-module/src/admin_dashboard.rs @@ -31,6 +31,13 @@ pub struct AdminDashboardRetentionSnapshot { pub rate_basis_points: u32, } +#[derive(Clone, Debug, Default, PartialEq, Eq, SpacetimeType)] +pub struct AdminDashboardPaymentConversionSnapshot { + pub paid_users: u64, + pub new_registered_users: u64, + pub rate_basis_points: u32, +} + #[derive(Clone, Debug, Default, PartialEq, Eq, SpacetimeType)] pub struct AdminDashboardStatsSnapshot { pub total_registered_users: u64, @@ -44,6 +51,7 @@ pub struct AdminDashboardStatsSnapshot { pub day1_retention: AdminDashboardRetentionSnapshot, pub day7_retention: AdminDashboardRetentionSnapshot, pub module_visit_breakdown: Vec, + pub new_user_payment_conversion: AdminDashboardPaymentConversionSnapshot, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -141,6 +149,48 @@ struct AdminDashboardEventAccumulator { module_visit_breakdown: BTreeMap, } +struct AdminDashboardPaymentConversionAccumulator<'a> { + selected_registrations: &'a BTreeMap, + now_micros: i64, + paid_users: BTreeSet, +} + +impl<'a> AdminDashboardPaymentConversionAccumulator<'a> { + fn new(selected_registrations: &'a BTreeMap, now_micros: i64) -> Self { + Self { + selected_registrations, + now_micros, + paid_users: BTreeSet::new(), + } + } + + fn apply(&mut self, user_id: &str, paid_at_micros: Option) { + let Some(paid_at_micros) = paid_at_micros else { + return; + }; + if !self.selected_registrations.contains_key(user_id) { + return; + } + if paid_at_micros > self.now_micros { + return; + } + self.paid_users.insert(user_id.to_string()); + } + + fn snapshot(self) -> AdminDashboardPaymentConversionSnapshot { + let paid_users = self.paid_users.len() as u64; + let new_registered_users = self.selected_registrations.len() as u64; + AdminDashboardPaymentConversionSnapshot { + paid_users, + new_registered_users, + rate_basis_points: payment_conversion_rate_basis_points( + paid_users, + new_registered_users, + ), + } + } +} + impl AdminDashboardEventAccumulator { fn new(start_day_key: i64, end_day_key: i64, now_micros: i64) -> Self { Self { @@ -218,14 +268,30 @@ fn build_admin_dashboard_stats( for row in ctx.db.profile_dashboard_state().iter() { total_registered_users = total_registered_users.saturating_add(1); - let registration_day_key = - runtime_profile_beijing_day_key(row.created_at.to_micros_since_unix_epoch()); + let registration_micros = row.created_at.to_micros_since_unix_epoch(); + let registration_day_key = runtime_profile_beijing_day_key(registration_micros); if day_key_is_in_range(registration_day_key, input.start_day_key, input.end_day_key) { increment_count(&mut new_registered_users, registration_day_key, 1); selected_registrations.insert(row.user_id, registration_day_key); } } + let mut payment_conversion = + AdminDashboardPaymentConversionAccumulator::new(&selected_registrations, input.now_micros); + for user_id in selected_registrations.keys() { + for row in ctx + .db + .profile_recharge_order() + .by_profile_recharge_order_user_id() + .filter(user_id.as_str()) + { + payment_conversion.apply( + &row.user_id, + row.paid_at.map(|value| value.to_micros_since_unix_epoch()), + ); + } + } + let mut daily_stats = AdminDashboardDailyStatsAccumulator::new( input.start_day_key, input.end_day_key, @@ -275,6 +341,7 @@ fn build_admin_dashboard_stats( .into_iter() .map(|(key, count)| AdminDashboardBreakdownSnapshot { key, count }) .collect(), + new_user_payment_conversion: payment_conversion.snapshot(), }) } @@ -333,6 +400,15 @@ fn retention_rate_basis_points(retained_users: u64, eligible_users: u64) -> u32 (numerator / u128::from(eligible_users)).min(10_000) as u32 } +fn payment_conversion_rate_basis_points(paid_users: u64, new_registered_users: u64) -> u32 { + if new_registered_users == 0 { + return 0; + } + ((u128::from(paid_users) * 10_000 + u128::from(new_registered_users) / 2) + / u128::from(new_registered_users)) + .min(10_000) as u32 +} + fn day_key_is_in_range(day_key: i64, start_day_key: i64, end_day_key: i64) -> bool { day_key >= start_day_key && day_key <= end_day_key } @@ -374,8 +450,10 @@ fn daily_unique_counts( mod tests { use super::{ AdminDashboardDailyStatsAccumulator, AdminDashboardEventAccumulator, - AdminDashboardStatsInput, RuntimeTrackingScopeKind, calculate_admin_dashboard_retention, - daily_unique_counts, retention_rate_basis_points, validate_admin_dashboard_stats_input, + AdminDashboardPaymentConversionAccumulator, AdminDashboardStatsInput, + RuntimeTrackingScopeKind, calculate_admin_dashboard_retention, daily_unique_counts, + payment_conversion_rate_basis_points, retention_rate_basis_points, + validate_admin_dashboard_stats_input, }; use std::collections::{BTreeMap, BTreeSet, HashSet}; @@ -412,6 +490,37 @@ mod tests { assert_eq!(retention_rate_basis_points(4, 3), 10_000); } + #[test] + fn new_user_payment_conversion_deduplicates_paid_users_through_query_time() { + let registrations = BTreeMap::from([ + ("paid".to_string(), 100), + ("refunded-with-paid-at".to_string(), 100), + ("pending".to_string(), 100), + ("paid-late".to_string(), 200), + ]); + let mut conversion = AdminDashboardPaymentConversionAccumulator::new(®istrations, 500); + + conversion.apply("paid", Some(99)); + conversion.apply("paid", Some(150)); + conversion.apply("paid", Some(160)); + conversion.apply("refunded-with-paid-at", Some(180)); + conversion.apply("pending", None); + conversion.apply("paid-late", Some(501)); + conversion.apply("outside-cohort", Some(150)); + + let snapshot = conversion.snapshot(); + assert_eq!(snapshot.paid_users, 2); + assert_eq!(snapshot.new_registered_users, 4); + assert_eq!(snapshot.rate_basis_points, 5_000); + } + + #[test] + fn new_user_payment_conversion_rate_is_zero_without_new_users_and_rounds() { + assert_eq!(payment_conversion_rate_basis_points(0, 0), 0); + assert_eq!(payment_conversion_rate_basis_points(2, 3), 6_667); + assert_eq!(payment_conversion_rate_basis_points(4, 3), 10_000); + } + #[test] fn daily_visit_users_are_deduplicated_per_day_without_cross_day_collapsing() { let counts = daily_unique_counts(BTreeMap::from([