From 1d9d8c2e4138f2b61d513bfaaa19529e52b85069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8E=86=E5=86=B0=E9=83=81-hermes=E7=89=88?= Date: Mon, 4 May 2026 16:29:11 +0800 Subject: [PATCH] feat: add analytics metric granularity query --- ...ATE_DIMENSION_IMPLEMENTATION_2026-05-04.md | 79 +++++++++++++ packages/shared/src/contracts/runtime.ts | 19 ++++ server-rs/crates/api-server/src/app.rs | 15 ++- .../crates/api-server/src/runtime_profile.rs | 83 +++++++++++++- .../crates/module-runtime/src/application.rs | 76 +++++++++++++ .../crates/module-runtime/src/commands.rs | 18 +++ server-rs/crates/module-runtime/src/domain.rs | 81 ++++++++++++++ .../tests/analytics_granularity.rs | 105 ++++++++++++++++++ .../crates/shared-contracts/src/runtime.rs | 29 +++++ server-rs/crates/spacetime-client/src/lib.rs | 2 + .../crates/spacetime-client/src/mapper.rs | 50 +++++++++ .../analytics_bucket_metric_type.rs | 18 +++ .../analytics_granularity_type.rs | 24 ++++ .../analytics_metric_query_input_type.rs | 21 ++++ ...tics_metric_query_procedure_result_type.rs | 19 ++++ .../src/module_bindings/mod.rs | 11 ++ .../query_analytics_metric_procedure.rs | 59 ++++++++++ .../crates/spacetime-client/src/runtime.rs | 25 +++++ .../spacetime-module/src/runtime/profile.rs | 60 ++++++++++ 19 files changed, 787 insertions(+), 7 deletions(-) create mode 100644 server-rs/crates/module-runtime/tests/analytics_granularity.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/analytics_bucket_metric_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/analytics_granularity_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/analytics_metric_query_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/analytics_metric_query_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/query_analytics_metric_procedure.rs diff --git a/docs/technical/ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md b/docs/technical/ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md index e3f62918..8e8119c3 100644 --- a/docs/technical/ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md +++ b/docs/technical/ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md @@ -281,6 +281,85 @@ SpacetimeClient::seed_analytics_date_dimensions 3. 如需要由 `api-server` 触发 seed/ensure,再补 `spacetime-client` facade。 4. 进入 Step 7/8/9:事件写入链路、聚合查询 API、前端 contracts。 +## Step 7/8/9 后续接入记录(2026-05-04) + +本次继续推进此前暂缓的 Step 7/8/9 中“按日期维度聚合查询 API / contracts / client facade”部分。 + +### 已新增能力 + +1. `module-runtime` 新增 analytics metric 聚合领域类型与纯函数: + - `AnalyticsGranularity = day | week | month | quarter | year` + - `AnalyticsMetricQueryInput` + - `AnalyticsBucketMetric` + - `AnalyticsMetricQueryResponse` + - `aggregate_runtime_tracking_daily_stats(...)` + +2. `spacetime-module` 新增 `query_analytics_metric` procedure,直接聚合 tracking daily stat,输出按 bucket 排序的统计结果。 + +3. `spacetime-client` 新增 facade: + +```rust +SpacetimeClient::query_analytics_metric(event_key, scope_kind, scope_id, granularity) +``` + +4. `api-server` 新增登录态接口: + +```http +GET /api/profile/analytics/metric?eventKey=...&scopeKind=user&scopeId=...&granularity=day +``` + +请求参数: + +| 参数 | 说明 | +| --- | --- | +| `eventKey` | 埋点事件 key,必填 | +| `scopeKind` | `site | work | module | user` | +| `scopeId` | 对应范围 ID,必填 | +| `granularity` | `day | week | month | quarter | year` | + +响应 data: + +```ts +type AnalyticsMetricQueryResponse = { + buckets: Array<{ + bucketKey: string; + bucketStartDateKey: number; + bucketEndDateKey: number; + value: number; + }>; +}; +``` + +5. shared contracts / 前端 shared contracts 已新增 analytics query 类型: + - `AnalyticsMetricQueryRequest` + - `AnalyticsMetricQueryResponse` + - `AnalyticsBucketMetricResponse` / `AnalyticsBucketMetric` + - `AnalyticsGranularity` + +### 本次验证 + +从 `server-rs/` 执行通过: + +```bash +cargo test -p module-runtime --test analytics_granularity +cargo check -p spacetime-module +cargo check -p spacetime-client +cargo check -p api-server +``` + +验证结果: + +- `analytics_granularity` 测试通过:3 passed。 +- `spacetime-module` 编译通过,仅存在既有 dead_code warnings。 +- `spacetime-client` 编译通过。 +- `api-server` 编译通过,仅存在既有 prompt dead_code warnings。 + +### 注意事项 + +当前环境未检测到 `spacetime` / `spacetimedb` CLI,因此 analytics metric 相关 `module_bindings` 是按现有生成物结构手动补齐的临时生成物。后续有 CLI 的开发机应优先通过项目脚本重新生成 bindings,并复核手写生成物是否可被正式生成输出覆盖。 + +--- + ## 阶段结论 当前阶段已经完成“个人任务埋点范围收紧”和“日期维表 module 侧能力”的核心落地,并已生成 SpacetimeDB Rust client bindings。 diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 3b81ae06..aeb548ec 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -189,6 +189,7 @@ export type RedeemProfileRewardCodeResponse = { export type ProfileTaskCycle = 'daily'; export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user'; +export type AnalyticsGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year'; export type ProfileTaskStatus = | 'incomplete' | 'claimable' @@ -247,6 +248,24 @@ export type ProfileTaskConfigAdminListResponse = { entries: ProfileTaskConfigAdminResponse[]; }; +export type AnalyticsMetricQueryRequest = { + eventKey: string; + scopeKind: TrackingScopeKind; + scopeId: string; + granularity: AnalyticsGranularity; +}; + +export type AnalyticsBucketMetric = { + bucketKey: string; + bucketStartDateKey: number; + bucketEndDateKey: number; + value: number; +}; + +export type AnalyticsMetricQueryResponse = { + buckets: AnalyticsBucketMetric[]; +}; + export type AdminUpsertProfileTaskConfigRequest = { taskId: string; title: string; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 17cd9fe3..7bb27d64 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -109,10 +109,10 @@ use crate::{ admin_list_profile_invite_codes, admin_list_profile_redeem_codes, admin_list_profile_task_configs, admin_upsert_profile_invite_code, admin_upsert_profile_redeem_code, admin_upsert_profile_task_config, - claim_profile_task_reward, create_profile_recharge_order, get_profile_dashboard, - get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, - get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code, - redeem_profile_reward_code, + claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric, + get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center, + get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger, + redeem_profile_referral_invite_code, redeem_profile_reward_code, }, runtime_save::{ delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, @@ -1074,6 +1074,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/profile/analytics/metric", + get(get_profile_analytics_metric).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/profile/tasks", get(get_profile_task_center).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index a73b37e1..110e009f 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1,11 +1,11 @@ use axum::{ Json, - extract::{Extension, Path, State}, + extract::{Extension, Path, Query, State}, http::StatusCode, response::Response, }; use module_runtime::{ - PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord, + AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, @@ -15,10 +15,12 @@ use module_runtime::{ RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, }; use serde_json::{Value, json}; +use serde::Deserialize; use shared_contracts::runtime::{ AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest, - AdminUpsertProfileTaskConfigRequest, ClaimProfileTaskRewardResponse, + AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse, + AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse, CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE, @@ -31,6 +33,8 @@ use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, + ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER, + ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, @@ -278,6 +282,51 @@ pub async fn redeem_profile_reward_code( )) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalyticsMetricQueryParams { + pub event_key: String, + pub scope_kind: String, + pub scope_id: String, + pub granularity: String, +} + +pub async fn get_profile_analytics_metric( + State(state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + Query(query): Query, +) -> Result, Response> { + let scope_kind = parse_tracking_scope_kind(&query.scope_kind).map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + let granularity = parse_analytics_granularity(&query.granularity).map_err(|error| { + runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), + ) + })?; + + let record = state + .spacetime_client() + .query_analytics_metric(query.event_key, scope_kind, query.scope_id, granularity) + .await + .map_err(|error| { + runtime_profile_error_response( + &request_context, + map_runtime_profile_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + build_analytics_metric_query_response(record), + )) +} + pub async fn get_profile_task_center( State(state): State, Extension(request_context): Extension, @@ -811,6 +860,23 @@ fn build_profile_task_center_response( } } +fn build_analytics_metric_query_response( + record: module_runtime::AnalyticsMetricQueryResponse, +) -> AnalyticsMetricQueryResponse { + AnalyticsMetricQueryResponse { + buckets: record + .buckets + .into_iter() + .map(|bucket| AnalyticsBucketMetricResponse { + bucket_key: bucket.bucket_key, + bucket_start_date_key: bucket.bucket_start_date_key, + bucket_end_date_key: bucket.bucket_end_date_key, + value: bucket.value, + }) + .collect(), + } +} + fn build_profile_task_item_response( record: RuntimeProfileTaskItemRecord, ) -> ProfileTaskItemResponse { @@ -935,6 +1001,17 @@ fn parse_tracking_scope_kind(raw: &str) -> Result Result { + match raw.trim().to_ascii_lowercase().as_str() { + ANALYTICS_GRANULARITY_DAY => Ok(AnalyticsGranularity::Day), + ANALYTICS_GRANULARITY_WEEK => Ok(AnalyticsGranularity::Week), + ANALYTICS_GRANULARITY_MONTH => Ok(AnalyticsGranularity::Month), + ANALYTICS_GRANULARITY_QUARTER => Ok(AnalyticsGranularity::Quarter), + ANALYTICS_GRANULARITY_YEAR => Ok(AnalyticsGranularity::Year), + _ => Err("统计粒度无效".to_string()), + } +} + fn format_profile_task_cycle(cycle: RuntimeProfileTaskCycle) -> &'static str { match cycle { RuntimeProfileTaskCycle::Daily => PROFILE_TASK_CYCLE_DAILY, diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 7a9b0661..6985d778 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -4,6 +4,7 @@ use serde_json::Value; use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339}; +use std::collections::BTreeMap; use crate::domain::*; use crate::errors::RuntimeProfileFieldError; @@ -502,6 +503,81 @@ pub fn build_runtime_tracking_daily_stat_id( ) } +pub fn aggregate_runtime_tracking_daily_stats( + stats: Vec, + event_key: &str, + scope_kind: RuntimeTrackingScopeKind, + scope_id: &str, + granularity: AnalyticsGranularity, +) -> Vec { + let mut buckets: BTreeMap<(String, i64, i64), u64> = BTreeMap::new(); + let event_key = event_key.trim(); + let scope_id = scope_id.trim(); + + for stat in stats { + if stat.event_key.trim() != event_key + || stat.scope_kind != scope_kind + || stat.scope_id.trim() != scope_id + { + continue; + } + + let dimension = build_analytics_date_dimension_from_date_key(stat.day_key); + let (bucket_key, bucket_start_date_key, bucket_end_date_key) = + analytics_bucket_for_dimension(&dimension, granularity); + *buckets + .entry((bucket_key, bucket_start_date_key, bucket_end_date_key)) + .or_insert(0) += u64::from(stat.count); + } + + buckets + .into_iter() + .map( + |((bucket_key, bucket_start_date_key, bucket_end_date_key), value)| { + AnalyticsBucketMetric { + bucket_key, + bucket_start_date_key, + bucket_end_date_key, + value, + } + }, + ) + .collect() +} + +fn analytics_bucket_for_dimension( + dimension: &AnalyticsDateDimensionSnapshot, + granularity: AnalyticsGranularity, +) -> (String, i64, i64) { + match granularity { + AnalyticsGranularity::Day => ( + dimension.calendar_date.clone(), + dimension.date_key, + dimension.date_key, + ), + AnalyticsGranularity::Week => ( + dimension.iso_week_key.to_string(), + dimension.week_start_date_key, + dimension.week_end_date_key, + ), + AnalyticsGranularity::Month => ( + dimension.month_key.to_string(), + dimension.month_start_date_key, + dimension.month_end_date_key, + ), + AnalyticsGranularity::Quarter => ( + dimension.quarter_key.to_string(), + dimension.quarter_start_date_key, + dimension.quarter_end_date_key, + ), + AnalyticsGranularity::Year => ( + dimension.year_key.to_string(), + dimension.year_start_date_key, + dimension.year_end_date_key, + ), + } +} + pub fn build_runtime_profile_task_config_record( snapshot: RuntimeProfileTaskConfigSnapshot, ) -> RuntimeProfileTaskConfigRecord { diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index a147f3c3..f570c67b 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -116,6 +116,24 @@ pub fn build_runtime_profile_task_center_get_input( Ok(RuntimeProfileTaskCenterGetInput { user_id }) } +pub fn build_analytics_metric_query_input( + event_key: String, + scope_kind: RuntimeTrackingScopeKind, + scope_id: String, + granularity: AnalyticsGranularity, +) -> Result { + let event_key = normalize_required_string(event_key) + .ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?; + let scope_id = normalize_required_string(scope_id) + .ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?; + Ok(AnalyticsMetricQueryInput { + event_key, + scope_kind, + scope_id, + granularity, + }) +} + pub fn build_runtime_profile_task_claim_input( user_id: String, task_id: String, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 527b6e2e..882b659c 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -58,6 +58,78 @@ pub struct AnalyticsDateDimensionSnapshot { pub year_end_date_key: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AnalyticsGranularity { + Day, + Week, + Month, + Quarter, + Year, +} + +impl AnalyticsGranularity { + pub fn as_str(&self) -> &'static str { + match self { + Self::Day => "day", + Self::Week => "week", + Self::Month => "month", + Self::Quarter => "quarter", + Self::Year => "year", + } + } + + pub fn from_client_str(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "day" => Some(Self::Day), + "week" => Some(Self::Week), + "month" => Some(Self::Month), + "quarter" => Some(Self::Quarter), + "year" => Some(Self::Year), + _ => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeAnalyticsDailyStatSnapshot { + pub event_key: String, + pub scope_kind: RuntimeTrackingScopeKind, + pub scope_id: String, + pub day_key: i64, + pub count: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AnalyticsBucketMetric { + pub bucket_key: String, + pub bucket_start_date_key: i64, + pub bucket_end_date_key: i64, + pub value: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AnalyticsMetricQueryRequest { + pub event_key: String, + pub scope_kind: RuntimeTrackingScopeKind, + pub scope_id: String, + pub granularity: AnalyticsGranularity, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AnalyticsMetricQueryResponse { + pub buckets: Vec, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AnalyticsMetricQueryProcedureResult { + pub ok: bool, + pub buckets: Vec, + pub error_message: Option, +} + /// 运行时平台主题。 /// /// 当前只冻结 light/dark 两种主题,避免各层散落字符串字面量。 @@ -552,6 +624,15 @@ pub struct RuntimeProfileTaskCenterGetInput { pub user_id: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnalyticsMetricQueryInput { + pub event_key: String, + pub scope_kind: RuntimeTrackingScopeKind, + pub scope_id: String, + pub granularity: AnalyticsGranularity, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileTaskClaimInput { diff --git a/server-rs/crates/module-runtime/tests/analytics_granularity.rs b/server-rs/crates/module-runtime/tests/analytics_granularity.rs new file mode 100644 index 00000000..0543f64a --- /dev/null +++ b/server-rs/crates/module-runtime/tests/analytics_granularity.rs @@ -0,0 +1,105 @@ +use module_runtime::{ + AnalyticsGranularity, RuntimeAnalyticsDailyStatSnapshot, RuntimeTrackingScopeKind, + aggregate_runtime_tracking_daily_stats, +}; + +fn stat(event_key: &str, scope_id: &str, day_key: i64, count: u32) -> RuntimeAnalyticsDailyStatSnapshot { + RuntimeAnalyticsDailyStatSnapshot { + event_key: event_key.to_string(), + scope_kind: RuntimeTrackingScopeKind::User, + scope_id: scope_id.to_string(), + day_key, + count, + } +} + +#[test] +fn aggregates_daily_stats_by_iso_week_bucket() { + let buckets = aggregate_runtime_tracking_daily_stats( + vec![ + stat("daily_login", "user-1", 20_517, 1), // 2026-03-05 + stat("daily_login", "user-1", 20_518, 2), // 2026-03-06 + stat("daily_login", "user-1", 20_524, 3), // 2026-03-12 + stat("daily_login", "user-2", 20_517, 9), + ], + "daily_login", + RuntimeTrackingScopeKind::User, + "user-1", + AnalyticsGranularity::Week, + ); + + assert_eq!(buckets.len(), 2); + assert_eq!(buckets[0].bucket_key, "202610"); + assert_eq!(buckets[0].bucket_start_date_key, 20_514); // 2026-03-02 + assert_eq!(buckets[0].bucket_end_date_key, 20_520); // 2026-03-08 + assert_eq!(buckets[0].value, 3); + assert_eq!(buckets[1].bucket_key, "202611"); + assert_eq!(buckets[1].bucket_start_date_key, 20_521); // 2026-03-09 + assert_eq!(buckets[1].bucket_end_date_key, 20_527); // 2026-03-15 + assert_eq!(buckets[1].value, 3); +} + +#[test] +fn aggregates_daily_stats_by_month_quarter_and_year_bucket() { + let stats = vec![ + stat("play", "user-1", 20_545, 2), // 2026-04-02 + stat("play", "user-1", 20_573, 3), // 2026-04-30 + stat("play", "user-1", 20_574, 5), // 2026-05-01 + stat("play", "user-1", 20_818, 7), // 2026-12-31 + ]; + + let month = aggregate_runtime_tracking_daily_stats( + stats.clone(), + "play", + RuntimeTrackingScopeKind::User, + "user-1", + AnalyticsGranularity::Month, + ); + assert_eq!(month.iter().map(|bucket| (&bucket.bucket_key, bucket.value)).collect::>(), vec![(&"202604".to_string(), 5), (&"202605".to_string(), 5), (&"202612".to_string(), 7)]); + assert_eq!(month[0].bucket_start_date_key, 20_544); + assert_eq!(month[0].bucket_end_date_key, 20_573); + + let quarter = aggregate_runtime_tracking_daily_stats( + stats.clone(), + "play", + RuntimeTrackingScopeKind::User, + "user-1", + AnalyticsGranularity::Quarter, + ); + assert_eq!(quarter.iter().map(|bucket| (&bucket.bucket_key, bucket.value)).collect::>(), vec![(&"20262".to_string(), 10), (&"20264".to_string(), 7)]); + + let year = aggregate_runtime_tracking_daily_stats( + stats, + "play", + RuntimeTrackingScopeKind::User, + "user-1", + AnalyticsGranularity::Year, + ); + assert_eq!(year.len(), 1); + assert_eq!(year[0].bucket_key, "2026"); + assert_eq!(year[0].value, 17); +} + +#[test] +fn day_granularity_keeps_each_day_bucket_and_filters_scope() { + let buckets = aggregate_runtime_tracking_daily_stats( + vec![ + stat("daily_login", "user-1", 20_517, 1), + stat("daily_login", "user-1", 20_518, 2), + stat("daily_login", "user-2", 20_517, 9), + stat("other", "user-1", 20_517, 9), + ], + "daily_login", + RuntimeTrackingScopeKind::User, + "user-1", + AnalyticsGranularity::Day, + ); + + assert_eq!(buckets.len(), 2); + assert_eq!(buckets[0].bucket_key, "2026-03-05"); + assert_eq!(buckets[0].bucket_start_date_key, 20_517); + assert_eq!(buckets[0].bucket_end_date_key, 20_517); + assert_eq!(buckets[0].value, 1); + assert_eq!(buckets[1].bucket_key, "2026-03-06"); + assert_eq!(buckets[1].value, 2); +} diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 99d60881..67ebe613 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -25,6 +25,11 @@ pub const TRACKING_SCOPE_KIND_SITE: &str = "site"; pub const TRACKING_SCOPE_KIND_WORK: &str = "work"; pub const TRACKING_SCOPE_KIND_MODULE: &str = "module"; pub const TRACKING_SCOPE_KIND_USER: &str = "user"; +pub const ANALYTICS_GRANULARITY_DAY: &str = "day"; +pub const ANALYTICS_GRANULARITY_WEEK: &str = "week"; +pub const ANALYTICS_GRANULARITY_MONTH: &str = "month"; +pub const ANALYTICS_GRANULARITY_QUARTER: &str = "quarter"; +pub const ANALYTICS_GRANULARITY_YEAR: &str = "year"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; @@ -367,6 +372,30 @@ pub struct ProfileTaskConfigAdminListResponse { pub entries: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AnalyticsMetricQueryRequest { + pub event_key: String, + pub scope_kind: String, + pub scope_id: String, + pub granularity: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AnalyticsBucketMetricResponse { + pub bucket_key: String, + pub bucket_start_date_key: i64, + pub bucket_end_date_key: i64, + pub value: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AnalyticsMetricQueryResponse { + pub buckets: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AdminUpsertProfileTaskConfigRequest { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index fa03057a..394c9c93 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -136,6 +136,7 @@ use module_puzzle::{ use module_runtime::{ RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord, + AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, @@ -167,6 +168,7 @@ use module_runtime::{ build_runtime_profile_wallet_adjustment_input, build_runtime_profile_wallet_ledger_entry_record, build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input, + build_analytics_metric_query_input, build_runtime_referral_invite_center_record, build_runtime_referral_redeem_input, build_runtime_referral_redeem_record, build_runtime_setting_get_input, build_runtime_setting_record, build_runtime_setting_upsert_input, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 933e709c..280dd450 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -181,6 +181,17 @@ impl From for RuntimeProfileTa } } +impl From for AnalyticsMetricQueryInput { + fn from(input: module_runtime::AnalyticsMetricQueryInput) -> Self { + Self { + event_key: input.event_key, + scope_kind: map_runtime_tracking_scope_kind(input.scope_kind), + scope_id: input.scope_id, + granularity: map_analytics_granularity(input.granularity), + } + } +} + impl From for RuntimeProfileTaskClaimInput { fn from(input: module_runtime::RuntimeProfileTaskClaimInput) -> Self { Self { @@ -899,6 +910,22 @@ pub(crate) fn map_runtime_profile_task_center_procedure_result( )) } +pub(crate) fn map_analytics_metric_query_procedure_result( + result: AnalyticsMetricQueryProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(DomainAnalyticsMetricQueryResponse { + buckets: result + .buckets + .into_iter() + .map(map_analytics_bucket_metric) + .collect(), + }) +} + pub(crate) fn map_runtime_profile_task_claim_procedure_result( result: RuntimeProfileTaskClaimProcedureResult, ) -> Result { @@ -1751,6 +1778,17 @@ pub(crate) fn map_runtime_profile_dashboard_snapshot( } } +pub(crate) fn map_analytics_bucket_metric( + bucket: AnalyticsBucketMetric, +) -> module_runtime::AnalyticsBucketMetric { + module_runtime::AnalyticsBucketMetric { + bucket_key: bucket.bucket_key, + bucket_start_date_key: bucket.bucket_start_date_key, + bucket_end_date_key: bucket.bucket_end_date_key, + value: bucket.value, + } +} + pub(crate) fn map_runtime_profile_wallet_ledger_entry_snapshot( snapshot: RuntimeProfileWalletLedgerEntrySnapshot, ) -> module_runtime::RuntimeProfileWalletLedgerEntrySnapshot { @@ -4012,6 +4050,18 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( } } +pub(crate) fn map_analytics_granularity( + granularity: module_runtime::AnalyticsGranularity, +) -> AnalyticsGranularity { + match granularity { + module_runtime::AnalyticsGranularity::Day => AnalyticsGranularity::Day, + module_runtime::AnalyticsGranularity::Week => AnalyticsGranularity::Week, + module_runtime::AnalyticsGranularity::Month => AnalyticsGranularity::Month, + module_runtime::AnalyticsGranularity::Quarter => AnalyticsGranularity::Quarter, + module_runtime::AnalyticsGranularity::Year => AnalyticsGranularity::Year, + } +} + pub(crate) fn map_runtime_tracking_scope_kind( value: DomainRuntimeTrackingScopeKind, ) -> crate::module_bindings::RuntimeTrackingScopeKind { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/analytics_bucket_metric_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/analytics_bucket_metric_type.rs new file mode 100644 index 00000000..e3d6708d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/analytics_bucket_metric_type.rs @@ -0,0 +1,18 @@ +// 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 AnalyticsBucketMetric { + pub bucket_key: String, + pub bucket_start_date_key: i64, + pub bucket_end_date_key: i64, + pub value: u64, +} + +impl __sdk::InModule for AnalyticsBucketMetric { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/analytics_granularity_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/analytics_granularity_type.rs new file mode 100644 index 00000000..fe252aee --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/analytics_granularity_type.rs @@ -0,0 +1,24 @@ +// 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)] +#[derive(Copy, Eq, Hash)] +pub enum AnalyticsGranularity { + Day, + + Week, + + Month, + + Quarter, + + Year, +} + +impl __sdk::InModule for AnalyticsGranularity { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/analytics_metric_query_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/analytics_metric_query_input_type.rs new file mode 100644 index 00000000..9bc48446 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/analytics_metric_query_input_type.rs @@ -0,0 +1,21 @@ +// 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}; + +use super::analytics_granularity_type::AnalyticsGranularity; +use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AnalyticsMetricQueryInput { + pub event_key: String, + pub scope_kind: RuntimeTrackingScopeKind, + pub scope_id: String, + pub granularity: AnalyticsGranularity, +} + +impl __sdk::InModule for AnalyticsMetricQueryInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/analytics_metric_query_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/analytics_metric_query_procedure_result_type.rs new file mode 100644 index 00000000..7784536a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/analytics_metric_query_procedure_result_type.rs @@ -0,0 +1,19 @@ +// 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}; + +use super::analytics_bucket_metric_type::AnalyticsBucketMetric; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AnalyticsMetricQueryProcedureResult { + pub ok: bool, + pub buckets: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for AnalyticsMetricQueryProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index c7715108..e7457a3e 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -690,6 +690,12 @@ pub mod user_account_type; pub mod user_browse_history_table; pub mod user_browse_history_type; +pub mod analytics_bucket_metric_type; +pub mod analytics_granularity_type; +pub mod analytics_metric_query_input_type; +pub mod analytics_metric_query_procedure_result_type; +pub mod query_analytics_metric_procedure; + pub use accept_quest_reducer::accept_quest; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; @@ -736,6 +742,10 @@ pub use analytics_date_dimension_ensure_input_type::AnalyticsDateDimensionEnsure pub use analytics_date_dimension_seed_input_type::AnalyticsDateDimensionSeedInput; pub use analytics_date_dimension_table::*; pub use analytics_date_dimension_type::AnalyticsDateDimension; +pub use analytics_bucket_metric_type::AnalyticsBucketMetric; +pub use analytics_granularity_type::AnalyticsGranularity; +pub use analytics_metric_query_input_type::AnalyticsMetricQueryInput; +pub use analytics_metric_query_procedure_result_type::AnalyticsMetricQueryProcedureResult; pub use append_ai_text_chunk_and_return_procedure::append_ai_text_chunk_and_return; pub use apply_chapter_progression_ledger_entry_and_return_procedure::apply_chapter_progression_ledger_entry_and_return; pub use apply_chapter_progression_ledger_entry_reducer::apply_chapter_progression_ledger_entry; @@ -978,6 +988,7 @@ pub use get_profile_play_stats_procedure::get_profile_play_stats; pub use get_profile_recharge_center_procedure::get_profile_recharge_center; pub use get_profile_referral_invite_center_procedure::get_profile_referral_invite_center; pub use get_profile_task_center_procedure::get_profile_task_center; +pub use query_analytics_metric_procedure::query_analytics_metric; pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session; pub use get_puzzle_gallery_detail_procedure::get_puzzle_gallery_detail; pub use get_puzzle_run_procedure::get_puzzle_run; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/query_analytics_metric_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/query_analytics_metric_procedure.rs new file mode 100644 index 00000000..adc25bfa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/query_analytics_metric_procedure.rs @@ -0,0 +1,59 @@ +// 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}; + +use super::analytics_metric_query_input_type::AnalyticsMetricQueryInput; +use super::analytics_metric_query_procedure_result_type::AnalyticsMetricQueryProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct QueryAnalyticsMetricArgs { + pub input: AnalyticsMetricQueryInput, +} + +impl __sdk::InModule for QueryAnalyticsMetricArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `query_analytics_metric`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait query_analytics_metric { + fn query_analytics_metric(&self, input: AnalyticsMetricQueryInput) { + self.query_analytics_metric_then(input, |_, _| {}); + } + + fn query_analytics_metric_then( + &self, + input: AnalyticsMetricQueryInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl query_analytics_metric for super::RemoteProcedures { + fn query_analytics_metric_then( + &self, + input: AnalyticsMetricQueryInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AnalyticsMetricQueryProcedureResult>( + "query_analytics_metric", + QueryAnalyticsMetricArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 6ac37466..ab41b7b0 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -348,6 +348,31 @@ impl SpacetimeClient { .await } + pub async fn query_analytics_metric( + &self, + event_key: String, + scope_kind: DomainRuntimeTrackingScopeKind, + scope_id: String, + granularity: module_runtime::AnalyticsGranularity, + ) -> Result { + let procedure_input = + build_analytics_metric_query_input(event_key, scope_kind, scope_id, granularity) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .query_analytics_metric_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_analytics_metric_query_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn admin_list_profile_task_configs( &self, admin_user_id: String, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 9884a193..89a24541 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -471,6 +471,27 @@ pub fn list_profile_wallet_ledger( } } + +// analytics metric 查询直接聚合 tracking_daily_stat,避免 API 层订阅全量表后自行汇总。 +#[spacetimedb::procedure] +pub fn query_analytics_metric( + ctx: &mut ProcedureContext, + input: AnalyticsMetricQueryInput, +) -> AnalyticsMetricQueryProcedureResult { + match ctx.try_with_tx(|tx| query_analytics_metric_buckets(tx, input.clone())) { + Ok(buckets) => AnalyticsMetricQueryProcedureResult { + ok: true, + buckets, + error_message: None, + }, + Err(message) => AnalyticsMetricQueryProcedureResult { + ok: false, + buckets: Vec::new(), + error_message: Some(message), + }, + } +} + // 任务中心读取会顺手记录当日登录埋点,确保“每日登录”只依赖后端事实。 #[spacetimedb::procedure] pub fn get_profile_task_center( @@ -2726,6 +2747,45 @@ fn build_profile_task_center_snapshot( }) } + +fn query_analytics_metric_buckets( + ctx: &ReducerContext, + input: AnalyticsMetricQueryInput, +) -> Result, String> { + let validated_input = build_analytics_metric_query_input( + input.event_key, + input.scope_kind, + input.scope_id, + input.granularity, + ) + .map_err(|error| error.to_string())?; + let stats = ctx + .db + .tracking_daily_stat() + .iter() + .filter(|row| { + row.event_key.trim() == validated_input.event_key + && row.scope_kind == validated_input.scope_kind + && row.scope_id.trim() == validated_input.scope_id + }) + .map(|row| RuntimeAnalyticsDailyStatSnapshot { + event_key: row.event_key, + scope_kind: row.scope_kind, + scope_id: row.scope_id, + day_key: row.day_key, + count: row.count, + }) + .collect::>(); + + Ok(aggregate_runtime_tracking_daily_stats( + stats, + &validated_input.event_key, + validated_input.scope_kind, + &validated_input.scope_id, + validated_input.granularity, + )) +} + fn refresh_profile_task_progress( ctx: &ReducerContext, user_id: &str,