use axum::http::{Method, StatusCode}; use module_auth::AuthLoginMethod; use module_runtime::RuntimeTrackingScopeKind; use serde_json::{Value, json}; use time::OffsetDateTime; use uuid::Uuid; use crate::{auth::AuthenticatedAccessToken, request_context::RequestContext, state::AppState}; /// 后端用户行为埋点入口统一走这里:写入失败只记录日志,不反向阻断主业务。 #[derive(Clone, Debug)] pub struct TrackingEventDraft { pub event_key: &'static str, pub scope_kind: RuntimeTrackingScopeKind, pub scope_id: String, pub user_id: Option, pub owner_user_id: Option, pub profile_id: Option, pub module_key: Option<&'static str>, pub metadata: Value, } impl TrackingEventDraft { pub fn new(event_key: &'static str, module_key: &'static str) -> Self { Self { event_key, scope_kind: RuntimeTrackingScopeKind::Site, scope_id: "site".to_string(), user_id: None, owner_user_id: None, profile_id: None, module_key: Some(module_key), metadata: json!({}), } } pub fn user(event_key: &'static str, module_key: &'static str, user_id: &str) -> Self { let normalized_user_id = user_id.trim().to_string(); let mut draft = Self::new(event_key, module_key); draft.scope_kind = RuntimeTrackingScopeKind::User; draft.scope_id = normalized_user_id.clone(); draft.user_id = Some(normalized_user_id.clone()); draft.owner_user_id = Some(normalized_user_id); draft } } #[derive(Clone, Debug)] struct RouteTrackingSpec { event_key: &'static str, module_key: &'static str, scope_kind: RuntimeTrackingScopeKind, scope_id: &'static str, } pub async fn record_route_tracking_event_after_success( state: &AppState, request_context: &RequestContext, method: &Method, path: &str, status: StatusCode, authenticated: Option<&AuthenticatedAccessToken>, ) { if !status.is_success() { return; } let Some(spec) = resolve_route_tracking_spec(method, path) else { return; }; let user_id = authenticated.map(|auth| auth.claims().user_id().to_string()); let scope_id = match spec.scope_kind { RuntimeTrackingScopeKind::User => { user_id.clone().unwrap_or_else(|| spec.scope_id.to_string()) } RuntimeTrackingScopeKind::Site => spec.scope_id.to_string(), _ => spec.scope_id.to_string(), }; let mut draft = TrackingEventDraft::new(spec.event_key, spec.module_key); draft.scope_kind = spec.scope_kind; draft.scope_id = scope_id; draft.user_id = user_id; draft.metadata = build_route_tracking_metadata(&spec, request_context, method, path, status); if draft.user_id.is_some() { draft.owner_user_id = draft.user_id.clone(); } record_tracking_event_after_success(state, request_context, draft).await; } fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option { use RuntimeTrackingScopeKind::{Site, User}; // 后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 明确排除,不走通用用户行为埋点。 if path.starts_with("/admin/") || path.contains("/big-fish") || path.contains("/visual-novel") || path.contains("/story") || path.contains("/combat") || path.contains("/rpg") || path.starts_with("/api/runtime/chat/") { return None; } let route = normalize_route_path(path); match (method.as_str(), route.as_str()) { ("GET", "/api/auth/login-options") => { Some(route_spec("auth_login_options_view", "auth", Site, "site")) } ("POST", "/api/auth/phone/send-code") => { Some(route_spec("auth_phone_code_send", "auth", Site, "site")) } ("POST", "/api/auth/phone/login") => Some(route_spec( "auth_phone_login_success", "auth", User, "anonymous", )), ("GET", "/api/auth/me") => Some(route_spec("auth_me_view", "auth", User, "anonymous")), ("GET", "/api/auth/sessions") => { Some(route_spec("auth_sessions_view", "auth", User, "anonymous")) } ("POST", "/api/auth/sessions/{id}/revoke") => { Some(route_spec("auth_revoke_session", "auth", User, "anonymous")) } ("POST", "/api/auth/refresh") => { Some(route_spec("auth_refresh_success", "auth", Site, "site")) } ("POST", "/api/auth/logout") => Some(route_spec("auth_logout", "auth", User, "anonymous")), ("POST", "/api/auth/logout-all") => { Some(route_spec("auth_logout_all", "auth", User, "anonymous")) } ("POST", "/api/auth/wechat/bind-phone") => Some(route_spec( "auth_wechat_bind_phone_success", "auth", User, "anonymous", )), ("PATCH", "/api/profile/me") => Some(route_spec( "profile_identity_update", "profile", User, "anonymous", )), ("GET", "/api/profile/dashboard") => Some(route_spec( "profile_dashboard_view", "profile", User, "anonymous", )), ("GET", "/api/profile/wallet-ledger") => Some(route_spec( "wallet_ledger_view", "profile", User, "anonymous", )), ("GET", "/api/profile/recharge-center") => Some(route_spec( "recharge_center_view", "profile", User, "anonymous", )), ("POST", "/api/profile/recharge/orders") => Some(route_spec( "recharge_order_create", "profile", User, "anonymous", )), ("POST", "/api/profile/feedback") => { Some(route_spec("feedback_submit", "profile", User, "anonymous")) } ("GET", "/api/profile/referrals/invite-center") => Some(route_spec( "invite_center_view", "profile", User, "anonymous", )), ("POST", "/api/profile/referrals/redeem-code") => Some(route_spec( "referral_invite_code_redeem", "profile", User, "anonymous", )), ("POST", "/api/profile/redeem-codes/redeem") => Some(route_spec( "redeem_code_submit", "profile", User, "anonymous", )), ("GET", "/api/profile/tasks") => { Some(route_spec("task_center_view", "profile", User, "anonymous")) } ("POST", "/api/profile/tasks/{id}/claim") => Some(route_spec( "task_reward_claim", "profile", User, "anonymous", )), ("GET", "/api/profile/save-archives") => Some(route_spec( "save_archive_list_view", "profile", User, "anonymous", )), ("GET", "/api/profile/save-archives/{id}") => Some(route_spec( "save_archive_detail_view", "profile", User, "anonymous", )), ("GET", "/api/profile/browse-history") => Some(route_spec( "browse_history_view", "profile", User, "anonymous", )), ("POST", "/api/profile/browse-history") => Some(route_spec( "browse_history_record", "profile", User, "anonymous", )), ("DELETE", "/api/profile/browse-history") => Some(route_spec( "browse_history_clear", "profile", User, "anonymous", )), ("GET", "/api/profile/play-stats") => { Some(route_spec("play_stats_view", "profile", User, "anonymous")) } ("GET", "/api/profile/analytics/metric") => Some(route_spec( "profile_analytics_metric_view", "profile", User, "anonymous", )), ("POST", "/api/ai/tasks") => Some(route_spec("ai_task_create", "ai", User, "anonymous")), ("POST", "/api/ai/tasks/{id}/start") => { Some(route_spec("ai_task_start", "ai", User, "anonymous")) } ("POST", "/api/ai/tasks/{id}/stages/{id}/start") => { Some(route_spec("ai_task_stage_start", "ai", User, "anonymous")) } ("POST", "/api/ai/tasks/{id}/chunks") => { Some(route_spec("ai_task_chunk_append", "ai", User, "anonymous")) } ("POST", "/api/ai/tasks/{id}/stages/{id}/complete") => Some(route_spec( "ai_task_stage_complete", "ai", User, "anonymous", )), ("POST", "/api/ai/tasks/{id}/references") => Some(route_spec( "ai_task_reference_attach", "ai", User, "anonymous", )), ("POST", "/api/ai/tasks/{id}/complete") => { Some(route_spec("ai_task_complete", "ai", User, "anonymous")) } ("POST", "/api/ai/tasks/{id}/fail") => { Some(route_spec("ai_task_fail", "ai", User, "anonymous")) } ("POST", "/api/ai/tasks/{id}/cancel") => { Some(route_spec("ai_task_cancel", "ai", User, "anonymous")) } ("POST", "/api/assets/sts-upload-credentials") => Some(route_spec( "asset_sts_credentials_create", "asset", User, "anonymous", )), ("POST", "/api/assets/character-visual/generate") => Some(route_spec( "asset_character_visual_generate", "asset", User, "anonymous", )), ("POST", "/api/assets/character-visual/publish") => Some(route_spec( "asset_character_visual_publish", "asset", User, "anonymous", )), ("POST", "/api/assets/character-animation/generate") => Some(route_spec( "asset_character_animation_generate", "asset", User, "anonymous", )), ("POST", "/api/assets/character-animation/publish") => Some(route_spec( "asset_character_animation_publish", "asset", User, "anonymous", )), ("POST", "/api/assets/character-animation/import-video") => Some(route_spec( "asset_character_animation_import", "asset", User, "anonymous", )), ("POST", "/api/assets/character-workflow-cache") => Some(route_spec( "asset_character_workflow_cache_save", "asset", User, "anonymous", )), ("GET", "/api/assets/history") => { Some(route_spec("asset_history_view", "asset", User, "anonymous")) } ("POST", "/api/llm/chat/completions") => { Some(route_spec("llm_request", "llm", User, "anonymous")) } ("GET", "/api/speech/volcengine/config") => Some(route_spec( "speech_config_view", "speech", User, "anonymous", )), ("GET", "/api/speech/volcengine/asr/stream") => { Some(route_spec("asr_stream_start", "speech", User, "anonymous")) } ("GET", "/api/speech/volcengine/tts/bidirection") => Some(route_spec( "tts_bidirection_start", "speech", User, "anonymous", )), ("POST", "/api/speech/volcengine/tts/sse") => { Some(route_spec("tts_sse_start", "speech", User, "anonymous")) } ("GET", "/api/runtime/settings") => Some(route_spec( "runtime_settings_view", "runtime", User, "anonymous", )), ("PUT", "/api/runtime/settings") => Some(route_spec( "runtime_settings_update", "runtime", User, "anonymous", )), ("GET", "/api/runtime/save/snapshot") => Some(route_spec( "runtime_snapshot_view", "runtime", User, "anonymous", )), ("PUT", "/api/runtime/save/snapshot") => Some(route_spec( "runtime_snapshot_save", "runtime", User, "anonymous", )), ("DELETE", "/api/runtime/save/snapshot") => Some(route_spec( "runtime_snapshot_delete", "runtime", User, "anonymous", )), _ if route.starts_with("/api/runtime/puzzle/") => Some(route_spec( "puzzle_route_success", "puzzle", user_scope_for(method), "anonymous", )), _ if route.starts_with("/api/creation/match3d/") || route.starts_with("/api/runtime/match3d/") => { Some(route_spec( "match3d_route_success", "match3d", user_scope_for(method), "anonymous", )) } _ if route.starts_with("/api/creation/square-hole/") || route.starts_with("/api/runtime/square-hole/") => { Some(route_spec( "square_hole_route_success", "square-hole", user_scope_for(method), "anonymous", )) } _ if route.starts_with("/api/runtime/custom-world") => Some(route_spec( "custom_world_route_success", "custom-world", user_scope_for(method), "anonymous", )), _ if route.starts_with("/api/runtime/creative-agent") => Some(route_spec( "creative_agent_route_success", "creative-agent", user_scope_for(method), "anonymous", )), _ => None, } } fn route_spec( event_key: &'static str, module_key: &'static str, scope_kind: RuntimeTrackingScopeKind, scope_id: &'static str, ) -> RouteTrackingSpec { RouteTrackingSpec { event_key, module_key, scope_kind, scope_id, } } fn user_scope_for(method: &Method) -> RuntimeTrackingScopeKind { if matches!(*method, Method::GET) { RuntimeTrackingScopeKind::Site } else { RuntimeTrackingScopeKind::User } } fn build_route_tracking_metadata( spec: &RouteTrackingSpec, request_context: &RequestContext, method: &Method, path: &str, status: StatusCode, ) -> Value { let mut metadata = json!({ "route": path, "method": method.as_str(), "status": status.as_u16(), "operation": request_context.operation(), }); if spec.module_key == "asset" { metadata["asset"] = build_asset_route_metadata(spec.event_key, path); metadata["assetOperation"] = json!(spec.event_key); } metadata } fn build_asset_route_metadata(event_key: &str, path: &str) -> Value { json!({ "operation": event_key, "operationFamily": resolve_asset_operation_family(event_key), "route": path, }) } fn resolve_asset_operation_family(event_key: &str) -> &'static str { match event_key { "asset_upload_ticket_create" => "upload_ticket", "asset_sts_credentials_create" => "sts_credentials", "asset_upload_confirm" => "object_confirm", "asset_bind" => "object_bind", "asset_character_visual_generate" => "character_visual_generate", "asset_character_visual_publish" => "character_visual_publish", "asset_character_animation_generate" => "character_animation_generate", "asset_character_animation_publish" => "character_animation_publish", "asset_character_animation_import" => "character_animation_import", "asset_character_workflow_cache_save" => "character_workflow_cache_save", "asset_history_view" => "history_view", _ => "asset_operation", } } fn normalize_route_path(path: &str) -> String { let mut normalized = String::new(); for segment in path.trim_end_matches('/').split('/') { if segment.is_empty() { continue; } normalized.push('/'); normalized.push_str(if is_dynamic_path_segment(segment) { "{id}" } else { segment }); } if normalized.is_empty() { "/".to_string() } else { normalized } } fn is_dynamic_path_segment(segment: &str) -> bool { let lower = segment.to_ascii_lowercase(); segment.len() >= 8 || segment.chars().any(|ch| ch.is_ascii_digit()) || lower.starts_with("world") || lower.starts_with("task") || lower.starts_with("profile") || lower.starts_with("session") } pub async fn record_daily_login_tracking_event_after_success( state: &AppState, request_context: &RequestContext, user_id: &str, login_method: AuthLoginMethod, ) { let mut draft = TrackingEventDraft::user("daily_login", "profile", user_id); draft.metadata = json!({ "operation": request_context.operation(), "loginMethod": login_method.as_str(), }); record_tracking_event_after_success(state, request_context, draft).await; } pub async fn record_tracking_event_after_success( state: &AppState, request_context: &RequestContext, draft: TrackingEventDraft, ) { let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let event_id = build_tracking_event_id(&draft, occurred_at_micros); let event_key = draft.event_key.to_string(); let scope_kind = draft.scope_kind; let scope_id = draft.scope_id; let metadata_json = draft.metadata.to_string(); match state .spacetime_client() .record_tracking_event( event_id, event_key.clone(), scope_kind, scope_id.clone(), draft.user_id, draft.owner_user_id, draft.profile_id, draft.module_key.map(str::to_string), metadata_json, occurred_at_micros as i64, ) .await { Ok(()) => tracing::info!( request_id = request_context.request_id(), operation = request_context.operation(), event_key = %event_key, scope_kind = %scope_kind.as_str(), scope_id = %scope_id, "后端埋点已记录" ), Err(error) => tracing::warn!( request_id = request_context.request_id(), operation = request_context.operation(), event_key = %event_key, scope_kind = %scope_kind.as_str(), scope_id = %scope_id, error = %error, "后端埋点记录失败,主业务流程继续" ), } } fn build_tracking_event_id(draft: &TrackingEventDraft, occurred_at_micros: i128) -> String { if draft.event_key == "daily_login" && draft.scope_kind == RuntimeTrackingScopeKind::User && !draft.scope_id.trim().is_empty() { let day_key = runtime_profile_beijing_day_key(occurred_at_micros as i64); return format!("daily-login:{}:{}", draft.scope_id.trim(), day_key); } format!( "api:{}:{}:{}", draft.event_key, occurred_at_micros, Uuid::new_v4() ) } fn runtime_profile_beijing_day_key(occurred_at_micros: i64) -> i64 { const PROFILE_TASK_BEIJING_OFFSET_MICROS: i64 = 28_800_000_000; const PROFILE_RUNTIME_DAY_MICROS: i64 = 86_400_000_000; (occurred_at_micros + PROFILE_TASK_BEIJING_OFFSET_MICROS).div_euclid(PROFILE_RUNTIME_DAY_MICROS) }