新增后台新增用户付费率

扩展 Dashboard 聚合与契约,统计筛选期新增用户的真实付费转化
生成 SpacetimeDB typed bindings 并补充后端聚合测试
后台新增付费率卡片与响应式布局,覆盖零分母和时段文案
同步 Dashboard 运营口径与项目决策记录
This commit is contained in:
2026-07-15 22:14:52 +08:00
parent ea8d0ef064
commit b303605bf2
15 changed files with 338 additions and 48 deletions
+7
View File
@@ -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;
@@ -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(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
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 () => {
+48 -13
View File
@@ -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({
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<h3></h3>
<span>{dashboard?.range.periodLabel ?? '-'}</span>
</div>
<div className="admin-dashboard-retention-grid">
<RetentionCard label="次日留存" metric={metrics?.day1Retention} />
<RetentionCard label="七日留存" metric={metrics?.day7Retention} />
<RateCard
label={`${rangePrefix(granularity)}新增用户付费率`}
numeratorLabel="付费人数"
denominatorLabel="新增人数"
numerator={metrics?.newUserPaymentConversion?.paidUsers}
denominator={
metrics?.newUserPaymentConversion?.newRegisteredUsers
}
rateBasisPoints={
metrics?.newUserPaymentConversion?.rateBasisPoints
}
/>
<RateCard
label="次日留存"
numeratorLabel="留存人数"
denominatorLabel="可观察新增人数"
numerator={metrics?.day1Retention.retainedUsers}
denominator={metrics?.day1Retention.eligibleUsers}
rateBasisPoints={metrics?.day1Retention.rateBasisPoints}
/>
<RateCard
label="七日留存"
numeratorLabel="留存人数"
denominatorLabel="可观察新增人数"
numerator={metrics?.day7Retention.retainedUsers}
denominator={metrics?.day7Retention.eligibleUsers}
rateBasisPoints={metrics?.day7Retention.rateBasisPoints}
/>
</div>
</section>
@@ -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 (
<article className="admin-dashboard-retention-card">
<span>{label}</span>
<strong>
{hasEligibleUsers && metric
? formatRateBasisPoints(metric.rateBasisPoints)
{hasDenominator && rateBasisPoints !== undefined
? formatRateBasisPoints(rateBasisPoints)
: '-'}
</strong>
<div>
<small> / </small>
<small>
{numeratorLabel} / {denominatorLabel}
</small>
<b>
{metric
? `${formatNumber(metric.retainedUsers)} / ${formatNumber(metric.eligibleUsers)}`
{numerator !== undefined && denominator !== undefined
? `${formatNumber(numerator)} / ${formatNumber(denominator)}`
: '-'}
</b>
</div>
+9 -1
View File
@@ -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;
}
@@ -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 内存。
@@ -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 分钟才采样一次。
+26 -9
View File
@@ -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<String, u64>,
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]
@@ -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..=100001 表示 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..=100001 表示 0.01%。
+19 -19
View File
@@ -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,
@@ -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::{
@@ -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<AdminDashboardBreakdownRecord>,
pub new_user_payment_conversion: AdminDashboardPaymentConversionRecord,
}
impl From<AdminDashboardStatsRecordInput> 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]
@@ -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;
@@ -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;
}
@@ -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<AdminDashboardBreakdownSnapshot>,
pub new_user_payment_conversion: AdminDashboardPaymentConversionSnapshot,
}
impl __sdk::InModule for AdminDashboardStatsSnapshot {
@@ -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<AdminDashboardBreakdownSnapshot>,
pub new_user_payment_conversion: AdminDashboardPaymentConversionSnapshot,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -141,6 +149,48 @@ struct AdminDashboardEventAccumulator {
module_visit_breakdown: BTreeMap<String, u64>,
}
struct AdminDashboardPaymentConversionAccumulator<'a> {
selected_registrations: &'a BTreeMap<String, i64>,
now_micros: i64,
paid_users: BTreeSet<String>,
}
impl<'a> AdminDashboardPaymentConversionAccumulator<'a> {
fn new(selected_registrations: &'a BTreeMap<String, i64>, now_micros: i64) -> Self {
Self {
selected_registrations,
now_micros,
paid_users: BTreeSet::new(),
}
}
fn apply(&mut self, user_id: &str, paid_at_micros: Option<i64>) {
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(&registrations, 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([