use axum::http::{Method, StatusCode}; #[cfg(not(test))] 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_external_generation_run_after_success( state: &AppState, provider: &str, operation: &str, request_label: &str, request_payload: Value, started_at_micros: i64, success: bool, failure_reason: Option, provider_request_id: Option, result_payload: Option, ) { let completed_at_micros = current_utc_micros(); let duration_ms = completed_at_micros.saturating_sub(started_at_micros).max(0) / 1_000; let mut draft = TrackingEventDraft::new("external_generation_run", "external-generation"); draft.scope_kind = RuntimeTrackingScopeKind::Module; draft.scope_id = provider.to_string(); draft.metadata = json!({ "runId": format!("external-generation-{}", Uuid::new_v4()), "provider": provider, "operation": operation, "requestLabel": request_label.trim(), "requestPayload": request_payload, "status": if success { "succeeded" } else { "failed" }, "success": success, "failureReason": failure_reason, "providerRequestId": provider_request_id, "resultPayload": result_payload, "startedAtMicros": started_at_micros, "completedAtMicros": completed_at_micros, "durationMs": duration_ms, }); record_tracking_event_after_success(state, &external_generation_request_context(), draft).await; } fn current_utc_micros() -> i64 { (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64 } fn external_generation_request_context() -> RequestContext { RequestContext::new( format!("external-generation-{}", Uuid::new_v4()), "external generation run".to_string(), std::time::Duration::ZERO, false, ) } 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_route_tracking_event_via_outbox_after_success(state, request_context, draft).await; } fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option { use RuntimeTrackingScopeKind::{Site, User}; if is_route_tracking_excluded(path) { 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", )), #[cfg(any())] ("GET", "/api/profile/save-archives") => Some(route_spec( "save_archive_list_view", "profile", User, "anonymous", )), #[cfg(any())] ("GET", "/api/profile/save-archives/{id}") => Some(route_spec( "save_archive_detail_view", "profile", User, "anonymous", )), #[cfg(any())] ("GET", "/api/profile/browse-history") => Some(route_spec( "browse_history_view", "profile", User, "anonymous", )), #[cfg(any())] ("POST", "/api/profile/browse-history") => Some(route_spec( "browse_history_record", "profile", User, "anonymous", )), #[cfg(any())] ("DELETE", "/api/profile/browse-history") => Some(route_spec( "browse_history_clear", "profile", User, "anonymous", )), #[cfg(any())] ("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/editor/audios/background-music/prompts/completions") => Some(route_spec( "editor_background_music_prompt_completion", "editor", User, "anonymous", )), ("POST", "/api/editor/audios/sound-effects/prompts/optimizations") => Some(route_spec( "editor_sound_effect_prompt_optimization", "editor", User, "anonymous", )), ("POST", "/api/editor/audios/background-music/prompts/simplifications") => { Some(route_spec( "editor_background_music_prompt_simplification", "editor", 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", )), #[cfg(any())] ("GET", "/api/runtime/save/snapshot") => Some(route_spec( "runtime_snapshot_view", "runtime", User, "anonymous", )), #[cfg(any())] ("PUT", "/api/runtime/save/snapshot") => Some(route_spec( "runtime_snapshot_save", "runtime", User, "anonymous", )), #[cfg(any())] ("DELETE", "/api/runtime/save/snapshot") => Some(route_spec( "runtime_snapshot_delete", "runtime", User, "anonymous", )), #[cfg(any())] _ if route.starts_with("/api/runtime/puzzle/") => Some(route_spec( "puzzle_route_success", "puzzle", user_scope_for(method), "anonymous", )), #[cfg(any())] _ 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", )) } #[cfg(any())] _ if route.starts_with("/api/runtime/custom-world") => Some(route_spec( "custom_world_route_success", "custom-world", user_scope_for(method), "anonymous", )), #[cfg(any())] _ if route.starts_with("/api/runtime/creative-agent") => Some(route_spec( "creative_agent_route_success", "creative-agent", user_scope_for(method), "anonymous", )), _ => None, } } fn is_route_tracking_excluded(path: &str) -> bool { path.starts_with("/admin/") } 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, } } #[cfg(any())] 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 { if is_known_static_route_segment(segment) { return false; } 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") } fn is_known_static_route_segment(segment: &str) -> bool { matches!( segment, "ai" | "analytics" | "api" | "asr" | "assets" | "auth" | "background-music" | "bidirection" | "bind-phone" | "browse-history" | "cancel" | "character-animation" | "character-visual" | "character-workflow-cache" | "chat" | "chunks" | "claim" | "complete" | "completions" | "config" | "dashboard" | "fail" | "feedback" | "generate" | "history" | "import-video" | "invite-center" | "llm" | "login" | "optimizations" | "login-options" | "logout" | "logout-all" | "me" | "metric" | "orders" | "phone" | "play-stats" | "profile" | "publish" | "recharge" | "recharge-center" | "redeem" | "redeem-code" | "redeem-codes" | "references" | "referrals" | "refresh" | "revoke" | "runtime" | "save" | "save-archives" | "send-code" | "sessions" | "settings" | "simplifications" | "sound-effects" | "snapshot" | "speech" | "sse" | "stages" | "start" | "stream" | "sts-upload-credentials" | "tasks" | "tts" | "volcengine" | "wallet-ledger" | "wechat" ) } #[cfg(not(test))] 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, ) { record_tracking_event_input_after_success( state, request_context, build_tracking_event_input(draft), ) .await; } async fn record_route_tracking_event_via_outbox_after_success( state: &AppState, request_context: &RequestContext, draft: TrackingEventDraft, ) { let event = build_tracking_event_input(draft); let event_key = event.event_key.clone(); let scope_kind = event.scope_kind; let scope_id = event.scope_id.clone(); if let Some(outbox) = state.tracking_outbox() { match outbox.enqueue(event.clone()).await { Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Enqueued) => { tracing::debug!( request_id = request_context.request_id(), operation = request_context.operation(), event_key = %event_key, scope_kind = %scope_kind.as_str(), scope_id = %scope_id, "后端 route 埋点已写入本机 outbox" ); return; } Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Dropped { reason }) => { 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, reason, "后端 route 埋点因 outbox 保护阈值被丢弃,主业务流程继续" ); return; } 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, "后端 route 埋点写入 outbox 失败,回退同步直写 SpacetimeDB" ); } } } record_tracking_event_input_after_success(state, request_context, event).await; } pub(crate) fn build_tracking_event_input( draft: TrackingEventDraft, ) -> module_runtime::RuntimeTrackingEventInput { let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let event_id = build_tracking_event_id(&draft, occurred_at_micros); module_runtime::RuntimeTrackingEventInput { event_id, event_key: draft.event_key.to_string(), scope_kind: draft.scope_kind, scope_id: draft.scope_id, user_id: draft.user_id, owner_user_id: draft.owner_user_id, profile_id: draft.profile_id, module_key: draft.module_key.map(str::to_string), metadata_json: draft.metadata.to_string(), occurred_at_micros: occurred_at_micros as i64, } } async fn record_tracking_event_input_after_success( state: &AppState, request_context: &RequestContext, event: module_runtime::RuntimeTrackingEventInput, ) { let event_key = event.event_key.clone(); let log_scope_kind = event.scope_kind; let scope_id = event.scope_id.clone(); let module_runtime::RuntimeTrackingEventInput { event_id, event_key: procedure_event_key, scope_kind: procedure_scope_kind, scope_id: procedure_scope_id, user_id, owner_user_id, profile_id, module_key, metadata_json, occurred_at_micros, } = event; match state .spacetime_client() .record_tracking_event( event_id, procedure_event_key, procedure_scope_kind, procedure_scope_id, user_id, owner_user_id, profile_id, module_key, metadata_json, occurred_at_micros, ) .await { Ok(()) => tracing::info!( request_id = request_context.request_id(), operation = request_context.operation(), event_key = %event_key, scope_kind = %log_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 = %log_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) } #[cfg(test)] mod tests { use axum::http::Method; use super::{is_route_tracking_excluded, normalize_route_path, resolve_route_tracking_spec}; #[test] fn route_normalization_preserves_static_segments_and_replaces_ids() { assert_eq!( normalize_route_path("/api/runtime/settings"), "/api/runtime/settings" ); assert_eq!( normalize_route_path("/api/profile/dashboard"), "/api/profile/dashboard" ); assert_eq!( normalize_route_path("/api/auth/sessions/session-01/revoke"), "/api/auth/sessions/{id}/revoke" ); } #[test] fn active_runtime_settings_routes_keep_tracking_specs() { for (method, event_key) in [ (Method::GET, "runtime_settings_view"), (Method::PUT, "runtime_settings_update"), ] { let spec = resolve_route_tracking_spec(&method, "/api/runtime/settings") .expect("active runtime settings route should be tracked"); assert_eq!(spec.event_key, event_key); assert_eq!(spec.module_key, "runtime"); } } #[test] fn editor_audio_prompt_assist_routes_keep_explicit_user_tracking_specs() { for (path, event_key) in [ ( "/api/editor/audios/sound-effects/prompts/optimizations", "editor_sound_effect_prompt_optimization", ), ( "/api/editor/audios/background-music/prompts/completions", "editor_background_music_prompt_completion", ), ( "/api/editor/audios/background-music/prompts/simplifications", "editor_background_music_prompt_simplification", ), ] { let spec = resolve_route_tracking_spec(&Method::POST, path) .expect("editor audio prompt assist route should be tracked"); assert_eq!(spec.event_key, event_key); assert_eq!(spec.module_key, "editor"); assert_eq!( spec.scope_kind, module_runtime::RuntimeTrackingScopeKind::User ); } } #[test] fn retired_play_paths_are_not_route_tracking_exclusions() { for path in [ "/api/runtime/big-fish/runs/run-01", "/api/runtime/visual-novel/runs/run-01", "/api/runtime/story/sessions/session-01", "/api/runtime/combat/state", "/api/runtime/rpg/state", "/api/runtime/chat/npc/turn/stream", ] { assert!(!is_route_tracking_excluded(path), "{path}"); } } #[test] fn admin_routes_remain_excluded_from_user_route_tracking() { assert!(is_route_tracking_excluded("/admin/api/overview")); } }