use axum::http::{HeaderMap, 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, external_api_auth::ExternalApiPrincipal, request_context::RequestContext, state::AppState, }; const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; const AGC_CLIENT_MARKER_VALUE: &str = "agc"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum TrackingClientMarker { Agc, } /// 登录 handler 在认证成功后向外层 tracking middleware 传递的可信用户主体。 /// /// 该类型只存在于当前 HTTP 响应的 extensions 中,不保存 token,也不跨请求复用。 #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct TrackingLoginSubject { user_id: String, } impl TrackingLoginSubject { pub(crate) fn new(user_id: &str) -> Self { Self { user_id: user_id.trim().to_string(), } } fn user_id(&self) -> &str { &self.user_id } } pub(crate) fn resolve_tracking_client_marker(headers: &HeaderMap) -> Option { headers .get(AGC_CLIENT_MARKER_HEADER) .and_then(|value| value.to_str().ok()) .filter(|value| value.trim() == AGC_CLIENT_MARKER_VALUE) .map(|_| TrackingClientMarker::Agc) } /// 后端用户行为埋点入口统一走这里:写入失败只记录日志,不反向阻断主业务。 #[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, } #[derive(Clone, Debug, Default, Eq, PartialEq)] struct TrackingIdentity { user_id: Option, owner_user_id: Option, } 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, handled_by_existing_event: bool, requires_agc_marker: bool, } 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>, external_principal: Option<&ExternalApiPrincipal>, login_subject: Option<&TrackingLoginSubject>, client_marker: Option, ) { let Some(spec) = resolve_route_tracking_spec(method, path) else { return; }; if !should_record_route_tracking(status, &spec, client_marker) { return; } let identity = resolve_tracking_identity(authenticated, external_principal, login_subject); let scope_id = resolve_tracking_scope_id(&spec, &identity); 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 = identity.user_id; draft.owner_user_id = identity.owner_user_id; draft.metadata = build_route_tracking_metadata(&spec, request_context, method, path, status, client_marker); record_route_tracking_event_via_outbox_after_success(state, request_context, draft).await; } fn should_record_route_tracking( status: StatusCode, spec: &RouteTrackingSpec, client_marker: Option, ) -> bool { status.is_success() && !spec.handled_by_existing_event && (!spec.requires_agc_marker || matches!(client_marker, Some(TrackingClientMarker::Agc))) } fn resolve_tracking_identity( authenticated: Option<&AuthenticatedAccessToken>, external_principal: Option<&ExternalApiPrincipal>, login_subject: Option<&TrackingLoginSubject>, ) -> TrackingIdentity { if let Some(principal) = external_principal { return TrackingIdentity { owner_user_id: Some(principal.owner_user_id().to_string()), ..TrackingIdentity::default() }; } if let Some(authenticated) = authenticated { let user_id = authenticated.claims().user_id().to_string(); return TrackingIdentity { user_id: Some(user_id.clone()), owner_user_id: Some(user_id), }; } if let Some(subject) = login_subject { let user_id = subject.user_id().trim(); if !user_id.is_empty() { return TrackingIdentity { user_id: Some(user_id.to_string()), owner_user_id: Some(user_id.to_string()), }; } } TrackingIdentity::default() } fn resolve_tracking_scope_id(spec: &RouteTrackingSpec, identity: &TrackingIdentity) -> String { match spec.scope_kind { RuntimeTrackingScopeKind::User => identity .owner_user_id .clone() .or_else(|| identity.user_id.clone()) .unwrap_or_else(|| spec.scope_id.to_string()), RuntimeTrackingScopeKind::Site => spec.scope_id.to_string(), _ => spec.scope_id.to_string(), } } 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", )), ("POST", "/api/auth/entry") => Some(agc_route_spec( "auth_password_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", )), ("POST", "/api/profile/api-keys") => Some(agc_route_spec( "profile_api_key_create", "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/recharge/orders/{id}/wechat/confirm") => Some(agc_route_spec( "recharge_order_wechat_confirm", "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/direct-upload-tickets") => { Some(manual_asset_route_spec("asset_upload_ticket_create")) } ("POST", "/api/assets/objects/confirm") => { Some(manual_asset_route_spec("asset_upload_confirm")) } ("GET", "/api/assets/read-url") => Some(agc_route_spec( "asset_read_url_view", "asset", User, "anonymous", )), ("GET", "/api/assets/read-bytes") => Some(agc_route_spec( "asset_read_bytes_view", "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", )) } ("GET", "/api/editor/projects") => Some(agc_route_spec( "editor_projects_view", "editor", User, "anonymous", )), ("POST", "/api/editor/projects") => Some(agc_route_spec( "editor_project_create", "editor", User, "anonymous", )), ("GET", "/api/editor/projects/{id}") => Some(agc_route_spec( "editor_project_view", "editor", User, "anonymous", )), ("POST", "/api/editor/projects/{id}/resources") => Some(agc_route_spec( "editor_project_resource_create", "editor", User, "anonymous", )), ("GET", "/api/editor/assets/library") => Some(agc_route_spec( "editor_asset_library_view", "editor", User, "anonymous", )), ("POST", "/api/editor/assets/folders") => Some(agc_route_spec( "editor_asset_folder_create", "editor", User, "anonymous", )), ("POST", "/api/editor/images/generations") => Some(agc_route_spec( "editor_image_generation_submit", "editor", User, "anonymous", )), ("POST", "/api/editor/images/edits") => Some(agc_route_spec( "editor_image_edit_submit", "editor", User, "anonymous", )), ("POST", "/api/editor/images/background-removals") => Some(agc_route_spec( "editor_image_background_removal_submit", "editor", User, "anonymous", )), ("POST", "/api/editor/icon-spritesheets/generations") => Some(agc_route_spec( "editor_icon_spritesheet_generation_submit", "editor", User, "anonymous", )), ("POST", "/api/editor/character-animations/generations") => Some(agc_route_spec( "editor_character_animation_generation_submit", "editor", User, "anonymous", )), ("POST", "/api/editor/videos/generations") => Some(agc_route_spec( "editor_video_generation_submit", "editor", User, "anonymous", )), ("POST", "/api/editor/audios/sound-effects/generations") => Some(agc_route_spec( "editor_sound_effect_generation_submit", "editor", User, "anonymous", )), ("POST", "/api/editor/audios/background-music/generations") => Some(agc_route_spec( "editor_background_music_generation_submit", "editor", User, "anonymous", )), ("POST", "/api/llm/chat/completions") => { Some(route_spec("llm_request", "llm", User, "anonymous")) } ("POST", "/api/llm/responses") => 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/external-generation/jobs/{id}") => Some(agc_route_spec( "external_generation_job_view", "runtime", User, "anonymous", )), ("POST", "/api/external/v1/assets/direct-upload-tickets") => { Some(manual_asset_route_spec("asset_upload_ticket_create")) } ("POST", "/api/external/v1/assets/objects/confirm") => { Some(manual_asset_route_spec("asset_upload_confirm")) } ("GET", "/api/external/v1/assets/read-url") => Some(agc_route_spec( "asset_read_url_view", "asset", User, "anonymous", )), ("GET", "/api/external/v1/editor/projects") => Some(agc_route_spec( "editor_projects_view", "editor", User, "anonymous", )), ("POST", "/api/external/v1/editor/projects") => Some(agc_route_spec( "editor_project_create", "editor", User, "anonymous", )), ("GET", "/api/external/v1/editor/projects/{id}") => Some(agc_route_spec( "editor_project_view", "editor", User, "anonymous", )), ("POST", "/api/external/v1/editor/projects/{id}/resources") => Some(agc_route_spec( "editor_project_resource_create", "editor", User, "anonymous", )), ("GET", "/api/external/v1/editor/assets/library") => Some(agc_route_spec( "editor_asset_library_view", "editor", User, "anonymous", )), ("POST", "/api/external/v1/editor/assets/folders") => Some(agc_route_spec( "editor_asset_folder_create", "editor", User, "anonymous", )), ("POST", "/api/external/v1/editor/images/generations") => Some(agc_route_spec( "editor_image_generation_submit", "editor", User, "anonymous", )), ("POST", "/api/external/v1/editor/images/edits") => Some(agc_route_spec( "editor_image_edit_submit", "editor", User, "anonymous", )), ("POST", "/api/external/v1/editor/images/background-removals") => Some(agc_route_spec( "editor_image_background_removal_submit", "editor", User, "anonymous", )), ("POST", "/api/external/v1/editor/icon-spritesheets/generations") => Some(agc_route_spec( "editor_icon_spritesheet_generation_submit", "editor", User, "anonymous", )), ("POST", "/api/external/v1/editor/character-animations/generations") => { Some(agc_route_spec( "editor_character_animation_generation_submit", "editor", User, "anonymous", )) } ("POST", "/api/external/v1/editor/videos/generations") => Some(agc_route_spec( "editor_video_generation_submit", "editor", User, "anonymous", )), ("POST", "/api/external/v1/editor/audios/sound-effects/generations") => { Some(agc_route_spec( "editor_sound_effect_generation_submit", "editor", User, "anonymous", )) } ("POST", "/api/external/v1/editor/audios/background-music/generations") => { Some(agc_route_spec( "editor_background_music_generation_submit", "editor", User, "anonymous", )) } ("GET", "/api/external/v1/generations/{id}") => Some(agc_route_spec( "external_generation_job_view", "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, handled_by_existing_event: false, requires_agc_marker: false, } } fn agc_route_spec( event_key: &'static str, module_key: &'static str, scope_kind: RuntimeTrackingScopeKind, scope_id: &'static str, ) -> RouteTrackingSpec { RouteTrackingSpec { requires_agc_marker: true, ..route_spec(event_key, module_key, scope_kind, scope_id) } } fn manual_asset_route_spec(event_key: &'static str) -> RouteTrackingSpec { RouteTrackingSpec { handled_by_existing_event: true, ..route_spec( event_key, "asset", RuntimeTrackingScopeKind::User, "anonymous", ) } } #[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, client_marker: Option, ) -> 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); } if matches!(client_marker, Some(TrackingClientMarker::Agc)) { metadata["client"] = json!(AGC_CLIENT_MARKER_VALUE); } 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" | "api-keys" | "asr" | "assets" | "auth" | "audios" | "background-music" | "bidirection" | "bind-phone" | "background-removals" | "browse-history" | "cancel" | "character-animation" | "character-animations" | "character-visual" | "character-workflow-cache" | "chat" | "chunks" | "claim" | "complete" | "completions" | "config" | "dashboard" | "direct-upload-tickets" | "edits" | "editor" | "external" | "external-generation" | "fail" | "feedback" | "folders" | "generate" | "generations" | "history" | "import-video" | "icon-spritesheets" | "images" | "invite-center" | "jobs" | "library" | "llm" | "login" | "optimizations" | "login-options" | "logout" | "logout-all" | "me" | "metric" | "objects" | "orders" | "phone" | "play-stats" | "profile" | "projects" | "publish" | "recharge" | "recharge-center" | "redeem" | "redeem-code" | "redeem-codes" | "read-bytes" | "read-url" | "references" | "referrals" | "refresh" | "revoke" | "resources" | "runtime" | "save" | "save-archives" | "send-code" | "sessions" | "settings" | "simplifications" | "sound-effects" | "snapshot" | "speech" | "sse" | "stages" | "start" | "stream" | "sts-upload-credentials" | "tasks" | "tts" | "volcengine" | "v1" | "videos" | "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 std::time::Duration; use axum::http::{HeaderMap, HeaderValue, Method}; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, }; use time::OffsetDateTime; use crate::{ auth::AuthenticatedAccessToken, external_api_auth::ExternalApiPrincipal, request_context::RequestContext, }; use super::{ TrackingClientMarker, TrackingEventDraft, TrackingLoginSubject, build_route_tracking_metadata, build_tracking_event_input, is_route_tracking_excluded, normalize_route_path, resolve_route_tracking_spec, resolve_tracking_client_marker, resolve_tracking_identity, resolve_tracking_scope_id, route_spec, should_record_route_tracking, }; fn build_test_authenticated(user_id: &str) -> AuthenticatedAccessToken { let config = JwtConfig::new("test-issuer".to_string(), "test-secret".to_string(), 3600) .expect("test JWT config should build"); let claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: user_id.to_string(), session_id: "test-session".to_string(), provider: AuthProvider::Password, roles: vec!["user".to_string()], token_version: 1, phone_verified: false, binding_status: BindingStatus::Active, display_name: Some("测试用户".to_string()), }, &config, OffsetDateTime::now_utc(), ) .expect("test claims should build"); AuthenticatedAccessToken::new(claims) } fn assert_route_spec( method: Method, path: &str, event_key: &str, module_key: &str, scope_kind: module_runtime::RuntimeTrackingScopeKind, handled_by_existing_event: bool, ) { let spec = resolve_route_tracking_spec(&method, path) .unwrap_or_else(|| panic!("missing route tracking spec: {method} {path}")); assert_eq!(spec.event_key, event_key, "{method} {path}"); assert_eq!(spec.module_key, module_key, "{method} {path}"); assert_eq!(spec.scope_kind, scope_kind, "{method} {path}"); assert_eq!( spec.handled_by_existing_event, handled_by_existing_event, "{method} {path}" ); } #[test] fn tracking_client_marker_accepts_only_trimmed_lowercase_agc() { let cases = [ (None, None), (Some(""), None), (Some("AGC"), None), (Some("other"), None), (Some(" agc "), Some(TrackingClientMarker::Agc)), (Some("agc"), Some(TrackingClientMarker::Agc)), ]; for (value, expected) in cases { let mut headers = HeaderMap::new(); if let Some(value) = value { headers.insert( "X-GENARRATIVE-CLIENT", HeaderValue::from_str(value).expect("marker fixture should be valid"), ); } assert_eq!(resolve_tracking_client_marker(&headers), expected); } let mut invalid_headers = HeaderMap::new(); invalid_headers.insert( "x-genarrative-client", HeaderValue::from_bytes(&[0xff]).expect("invalid text fixture should be accepted"), ); assert_eq!(resolve_tracking_client_marker(&invalid_headers), None); } #[test] fn tracking_identity_keeps_authenticated_user_as_owner() { let authenticated = build_test_authenticated("user-225"); let identity = resolve_tracking_identity(Some(&authenticated), None, None); assert_eq!(identity.user_id.as_deref(), Some("user-225")); assert_eq!(identity.owner_user_id.as_deref(), Some("user-225")); let spec = route_spec( "profile_dashboard_view", "profile", module_runtime::RuntimeTrackingScopeKind::User, "anonymous", ); assert_eq!(resolve_tracking_scope_id(&spec, &identity), "user-225"); } #[test] fn user_tracking_draft_sets_user_scope_and_subject_fields() { let draft = TrackingEventDraft::user("daily_login", "profile", " user-225 "); assert_eq!( draft.scope_kind, module_runtime::RuntimeTrackingScopeKind::User ); assert_eq!(draft.scope_id, "user-225"); assert_eq!(draft.user_id.as_deref(), Some("user-225")); assert_eq!(draft.owner_user_id.as_deref(), Some("user-225")); assert_eq!(draft.module_key, Some("profile")); assert_eq!(draft.event_key, "daily_login"); } #[test] fn tracking_identity_uses_external_api_owner_and_does_not_forge_user() { let authenticated = build_test_authenticated("login-user"); let principal = ExternalApiPrincipal::for_test("owner-user", &[]); let identity = resolve_tracking_identity(Some(&authenticated), Some(&principal), None); assert_eq!(identity.user_id, None); assert_eq!(identity.owner_user_id.as_deref(), Some("owner-user")); let spec = route_spec( "external_editor_view", "editor", module_runtime::RuntimeTrackingScopeKind::User, "anonymous", ); assert_eq!(resolve_tracking_scope_id(&spec, &identity), "owner-user"); } #[test] fn tracking_identity_uses_verified_login_subject_when_no_session_identity_exists() { let subject = TrackingLoginSubject::new(" user-225 "); let identity = resolve_tracking_identity(None, None, Some(&subject)); assert_eq!(identity.user_id.as_deref(), Some("user-225")); assert_eq!(identity.owner_user_id.as_deref(), Some("user-225")); } #[test] fn route_tracking_metadata_adds_agc_marker_without_dropping_existing_fields() { let spec = route_spec( "asset_upload_ticket_create", "asset", module_runtime::RuntimeTrackingScopeKind::User, "anonymous", ); let request_context = RequestContext::new( "request-225".to_string(), "POST /api/assets/upload".to_string(), Duration::ZERO, false, ); let marked = build_route_tracking_metadata( &spec, &request_context, &Method::POST, "/api/assets/upload", axum::http::StatusCode::ACCEPTED, Some(TrackingClientMarker::Agc), ); assert_eq!(marked["client"], "agc"); assert_eq!(marked["route"], "/api/assets/upload"); assert_eq!(marked["method"], "POST"); assert_eq!(marked["status"], 202); assert_eq!(marked["operation"], "POST /api/assets/upload"); assert_eq!(marked["asset"]["operation"], "asset_upload_ticket_create"); assert_eq!(marked["assetOperation"], "asset_upload_ticket_create"); let unmarked = build_route_tracking_metadata( &spec, &request_context, &Method::POST, "/api/assets/upload", axum::http::StatusCode::ACCEPTED, None, ); assert!(unmarked.get("client").is_none()); } #[test] fn tracking_event_input_preserves_agc_metadata_and_subject_fields() { let cases = [ (Some("user-225"), Some("user-225"), "user-225", "account"), (None, Some("owner-225"), "owner-225", "external-key"), ]; for (user_id, owner_user_id, scope_id, label) in cases { let mut draft = TrackingEventDraft::new("editor_image_generation_submit", "editor"); draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; draft.scope_id = scope_id.to_string(); draft.user_id = user_id.map(str::to_string); draft.owner_user_id = owner_user_id.map(str::to_string); draft.metadata = serde_json::json!({ "route": "/api/editor/images/generations", "method": "POST", "status": 202, "operation": "generateExternalEditorImage", "client": "agc", }); let input = build_tracking_event_input(draft); let metadata = serde_json::from_str::(&input.metadata_json) .unwrap_or_else(|error| panic!("{label} metadata should remain JSON: {error}")); assert!( metadata.is_object(), "{label} metadata must remain an object" ); assert_eq!(metadata["client"], "agc", "{label}"); assert_eq!( metadata["route"], "/api/editor/images/generations", "{label}" ); assert_eq!(metadata["status"], 202, "{label}"); assert_eq!(input.user_id.as_deref(), user_id); assert_eq!(input.owner_user_id.as_deref(), owner_user_id); assert_eq!(input.scope_id, scope_id); assert_eq!(input.event_key, "editor_image_generation_submit"); assert_eq!(input.module_key.as_deref(), Some("editor")); } } #[test] fn tracking_event_input_without_marker_keeps_metadata_object_without_client() { let mut draft = TrackingEventDraft::new("editor_projects_view", "editor"); draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; draft.scope_id = "user-225".to_string(); draft.user_id = Some("user-225".to_string()); draft.owner_user_id = Some("user-225".to_string()); draft.metadata = serde_json::json!({ "route": "/api/editor/projects", "method": "GET", "status": 200, "operation": "listEditorProjects", }); let input = build_tracking_event_input(draft); let metadata = serde_json::from_str::(&input.metadata_json) .expect("unmarked metadata should remain valid JSON"); assert!(metadata.is_object()); assert!(metadata.get("client").is_none()); assert_eq!(metadata["route"], "/api/editor/projects"); assert_eq!(metadata["status"], 200); } #[test] fn successful_statuses_record_only_explicit_business_routes() { let business_spec = route_spec( "editor_projects_view", "editor", module_runtime::RuntimeTrackingScopeKind::User, "anonymous", ); let handled_spec = super::manual_asset_route_spec("asset_upload_confirm"); for status in [ axum::http::StatusCode::OK, axum::http::StatusCode::CREATED, axum::http::StatusCode::ACCEPTED, axum::http::StatusCode::NO_CONTENT, ] { assert!( should_record_route_tracking(status, &business_spec, None), "{status} should record a successful business route" ); } assert!(!should_record_route_tracking( axum::http::StatusCode::OK, &handled_spec, Some(TrackingClientMarker::Agc), )); } #[test] fn client_marker_does_not_change_failure_status_or_excluded_route_semantics() { let business_spec = resolve_route_tracking_spec( &Method::POST, "/api/external/v1/editor/images/generations", ) .expect("External v1 business route should resolve"); assert!( !should_record_route_tracking(axum::http::StatusCode::ACCEPTED, &business_spec, None,), "an AGC-only route must not record an unmarked request" ); assert!(should_record_route_tracking( axum::http::StatusCode::ACCEPTED, &business_spec, Some(TrackingClientMarker::Agc), )); let existing_spec = resolve_route_tracking_spec(&Method::GET, "/api/auth/me") .expect("existing auth route should resolve"); assert!(should_record_route_tracking( axum::http::StatusCode::OK, &existing_spec, None, )); for status in [ axum::http::StatusCode::BAD_REQUEST, axum::http::StatusCode::UNAUTHORIZED, axum::http::StatusCode::FORBIDDEN, axum::http::StatusCode::NOT_FOUND, axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::http::StatusCode::BAD_GATEWAY, ] { assert!( !should_record_route_tracking( status, &business_spec, Some(TrackingClientMarker::Agc), ), "{status} must not create a successful route event" ); } assert!( resolve_route_tracking_spec(&Method::POST, "/api/external/v1/mcp").is_none(), "AGC marker must not turn MCP into a business route" ); } #[test] fn route_metadata_contains_no_authentication_or_secret_fields() { let spec = route_spec( "editor_projects_view", "editor", module_runtime::RuntimeTrackingScopeKind::User, "anonymous", ); let request_context = RequestContext::new( "request-225-safe-metadata".to_string(), "GET /api/editor/projects".to_string(), Duration::ZERO, false, ); let metadata = build_route_tracking_metadata( &spec, &request_context, &Method::GET, "/api/editor/projects", axum::http::StatusCode::OK, Some(TrackingClientMarker::Agc), ); let object = metadata .as_object() .expect("route metadata should be an object"); for forbidden_key in [ "authorization", "accessToken", "token", "apiKey", "cookie", "signature", "signedUrl", "requestBody", ] { assert!( !object.contains_key(forbidden_key), "route metadata must not contain {forbidden_key}" ); } assert_eq!(metadata["client"], "agc"); } #[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 agc_account_routes_have_explicit_tracking_specs() { use module_runtime::RuntimeTrackingScopeKind::User; for (method, path, event_key, module_key, handled_by_existing_event) in [ (Method::GET, "/api/auth/me", "auth_me_view", "auth", false), ( Method::POST, "/api/auth/entry", "auth_password_login_success", "auth", false, ), ( Method::POST, "/api/auth/phone/login", "auth_phone_login_success", "auth", false, ), ( Method::POST, "/api/auth/logout", "auth_logout", "auth", false, ), ( Method::GET, "/api/editor/assets/library", "editor_asset_library_view", "editor", false, ), ( Method::GET, "/api/assets/read-url", "asset_read_url_view", "asset", false, ), ( Method::GET, "/api/assets/read-bytes", "asset_read_bytes_view", "asset", false, ), ( Method::GET, "/api/profile/dashboard", "profile_dashboard_view", "profile", false, ), ( Method::GET, "/api/profile/recharge-center", "recharge_center_view", "profile", false, ), ( Method::POST, "/api/profile/recharge/orders", "recharge_order_create", "profile", false, ), ( Method::POST, "/api/profile/recharge/orders/order-225/wechat/confirm", "recharge_order_wechat_confirm", "profile", false, ), ( Method::GET, "/api/profile/wallet-ledger", "wallet_ledger_view", "profile", false, ), ( Method::POST, "/api/profile/api-keys", "profile_api_key_create", "profile", false, ), ( Method::POST, "/api/assets/direct-upload-tickets", "asset_upload_ticket_create", "asset", true, ), ( Method::POST, "/api/assets/objects/confirm", "asset_upload_confirm", "asset", true, ), ( Method::GET, "/api/editor/projects", "editor_projects_view", "editor", false, ), ( Method::POST, "/api/editor/projects", "editor_project_create", "editor", false, ), ( Method::GET, "/api/editor/projects/project-225", "editor_project_view", "editor", false, ), ( Method::POST, "/api/editor/projects/project-225/resources", "editor_project_resource_create", "editor", false, ), ( Method::POST, "/api/editor/assets/folders", "editor_asset_folder_create", "editor", false, ), ( Method::POST, "/api/editor/images/generations", "editor_image_generation_submit", "editor", false, ), ( Method::POST, "/api/editor/images/edits", "editor_image_edit_submit", "editor", false, ), ( Method::POST, "/api/editor/images/background-removals", "editor_image_background_removal_submit", "editor", false, ), ( Method::POST, "/api/editor/icon-spritesheets/generations", "editor_icon_spritesheet_generation_submit", "editor", false, ), ( Method::POST, "/api/editor/character-animations/generations", "editor_character_animation_generation_submit", "editor", false, ), ( Method::POST, "/api/editor/videos/generations", "editor_video_generation_submit", "editor", false, ), ( Method::POST, "/api/editor/audios/sound-effects/generations", "editor_sound_effect_generation_submit", "editor", false, ), ( Method::POST, "/api/editor/audios/background-music/generations", "editor_background_music_generation_submit", "editor", false, ), ( Method::GET, "/api/runtime/external-generation/jobs/job-225", "external_generation_job_view", "runtime", false, ), ] { assert_route_spec( method, path, event_key, module_key, User, handled_by_existing_event, ); } assert_route_spec( Method::POST, "/api/auth/refresh", "auth_refresh_success", "auth", module_runtime::RuntimeTrackingScopeKind::Site, false, ); assert_route_spec( Method::POST, "/api/auth/phone/send-code", "auth_phone_code_send", "auth", module_runtime::RuntimeTrackingScopeKind::Site, false, ); } #[test] fn newly_added_agc_routes_require_the_agc_marker() { for (method, path) in [ (Method::POST, "/api/auth/entry"), (Method::POST, "/api/profile/api-keys"), ( Method::POST, "/api/profile/recharge/orders/order-225/wechat/confirm", ), (Method::GET, "/api/assets/read-url"), (Method::GET, "/api/assets/read-bytes"), (Method::GET, "/api/editor/projects"), (Method::POST, "/api/editor/projects"), (Method::GET, "/api/editor/projects/project-225"), (Method::POST, "/api/editor/projects/project-225/resources"), (Method::GET, "/api/editor/assets/library"), (Method::POST, "/api/editor/assets/folders"), (Method::POST, "/api/editor/images/generations"), (Method::POST, "/api/editor/images/edits"), (Method::POST, "/api/editor/images/background-removals"), (Method::POST, "/api/editor/icon-spritesheets/generations"), (Method::POST, "/api/editor/character-animations/generations"), (Method::POST, "/api/editor/videos/generations"), (Method::POST, "/api/editor/audios/sound-effects/generations"), ( Method::POST, "/api/editor/audios/background-music/generations", ), (Method::GET, "/api/runtime/external-generation/jobs/job-225"), (Method::GET, "/api/external/v1/assets/read-url"), (Method::GET, "/api/external/v1/editor/projects"), (Method::POST, "/api/external/v1/editor/projects"), (Method::GET, "/api/external/v1/editor/projects/project-225"), ( Method::POST, "/api/external/v1/editor/projects/project-225/resources", ), (Method::GET, "/api/external/v1/editor/assets/library"), (Method::POST, "/api/external/v1/editor/assets/folders"), (Method::POST, "/api/external/v1/editor/images/generations"), (Method::POST, "/api/external/v1/editor/images/edits"), ( Method::POST, "/api/external/v1/editor/images/background-removals", ), ( Method::POST, "/api/external/v1/editor/icon-spritesheets/generations", ), ( Method::POST, "/api/external/v1/editor/character-animations/generations", ), (Method::POST, "/api/external/v1/editor/videos/generations"), ( Method::POST, "/api/external/v1/editor/audios/sound-effects/generations", ), ( Method::POST, "/api/external/v1/editor/audios/background-music/generations", ), (Method::GET, "/api/external/v1/generations/task-225"), ] { let spec = resolve_route_tracking_spec(&method, path) .unwrap_or_else(|| panic!("missing AGC route tracking spec: {method} {path}")); assert!( spec.requires_agc_marker, "{method} {path} must require the AGC marker" ); } for (method, path) in [ (Method::GET, "/api/auth/me"), (Method::POST, "/api/auth/phone/login"), (Method::POST, "/api/auth/logout"), (Method::GET, "/api/profile/dashboard"), (Method::POST, "/api/auth/refresh"), (Method::POST, "/api/auth/phone/send-code"), (Method::POST, "/api/assets/direct-upload-tickets"), (Method::POST, "/api/assets/objects/confirm"), ( Method::POST, "/api/external/v1/assets/direct-upload-tickets", ), (Method::POST, "/api/external/v1/assets/objects/confirm"), ] { let spec = resolve_route_tracking_spec(&method, path) .unwrap_or_else(|| panic!("missing existing route tracking spec: {method} {path}")); assert!( !spec.requires_agc_marker, "{method} {path} must preserve its existing tracking policy" ); } } #[test] fn agc_external_routes_keep_external_paths_and_specs() { use module_runtime::RuntimeTrackingScopeKind::User; for (method, path, event_key, module_key, handled_by_existing_event) in [ ( Method::POST, "/api/external/v1/assets/direct-upload-tickets", "asset_upload_ticket_create", "asset", true, ), ( Method::POST, "/api/external/v1/assets/objects/confirm", "asset_upload_confirm", "asset", true, ), ( Method::GET, "/api/external/v1/assets/read-url", "asset_read_url_view", "asset", false, ), ( Method::GET, "/api/external/v1/editor/projects", "editor_projects_view", "editor", false, ), ( Method::POST, "/api/external/v1/editor/projects", "editor_project_create", "editor", false, ), ( Method::GET, "/api/external/v1/editor/projects/project-225", "editor_project_view", "editor", false, ), ( Method::POST, "/api/external/v1/editor/projects/project-225/resources", "editor_project_resource_create", "editor", false, ), ( Method::GET, "/api/external/v1/editor/assets/library", "editor_asset_library_view", "editor", false, ), ( Method::POST, "/api/external/v1/editor/assets/folders", "editor_asset_folder_create", "editor", false, ), ( Method::POST, "/api/external/v1/editor/images/generations", "editor_image_generation_submit", "editor", false, ), ( Method::POST, "/api/external/v1/editor/images/edits", "editor_image_edit_submit", "editor", false, ), ( Method::POST, "/api/external/v1/editor/images/background-removals", "editor_image_background_removal_submit", "editor", false, ), ( Method::POST, "/api/external/v1/editor/icon-spritesheets/generations", "editor_icon_spritesheet_generation_submit", "editor", false, ), ( Method::POST, "/api/external/v1/editor/character-animations/generations", "editor_character_animation_generation_submit", "editor", false, ), ( Method::POST, "/api/external/v1/editor/videos/generations", "editor_video_generation_submit", "editor", false, ), ( Method::POST, "/api/external/v1/editor/audios/sound-effects/generations", "editor_sound_effect_generation_submit", "editor", false, ), ( Method::POST, "/api/external/v1/editor/audios/background-music/generations", "editor_background_music_generation_submit", "editor", false, ), ( Method::GET, "/api/external/v1/generations/task-225", "external_generation_job_view", "runtime", false, ), ] { assert_route_spec( method, path, event_key, module_key, User, handled_by_existing_event, ); } let spec = resolve_route_tracking_spec( &Method::GET, "/api/external/v1/editor/projects/project-225", ) .expect("external project route should resolve"); let request_context = RequestContext::new( "request-225-route".to_string(), "GET /api/external/v1/editor/projects/project-225".to_string(), Duration::ZERO, false, ); let metadata = build_route_tracking_metadata( &spec, &request_context, &Method::GET, "/api/external/v1/editor/projects/project-225", axum::http::StatusCode::OK, Some(TrackingClientMarker::Agc), ); assert_eq!( metadata["route"], "/api/external/v1/editor/projects/project-225" ); assert_eq!(metadata["client"], "agc"); } #[test] fn external_discovery_and_mcp_routes_are_not_business_tracking_specs() { for (method, path) in [ (Method::GET, "/api/external/v1/openapi.json"), (Method::GET, "/api/external/v1/agent-integration.json"), (Method::GET, "/api/external/v1/skill/SKILL.md"), (Method::GET, "/api/external/v1/skill.zip"), (Method::POST, "/api/external/v1/mcp"), ] { assert!( resolve_route_tracking_spec(&method, path).is_none(), "{method} {path} must stay outside business tracking" ); } } #[test] fn agc_dynamic_route_segments_remain_normalized() { assert_eq!( normalize_route_path("/api/external/v1/editor/projects/project-225/resources"), "/api/external/v1/editor/projects/{id}/resources" ); assert_eq!( normalize_route_path("/api/external/v1/generations/task-225"), "/api/external/v1/generations/{id}" ); assert_eq!( normalize_route_path("/api/runtime/external-generation/jobs/job-225"), "/api/runtime/external-generation/jobs/{id}" ); } #[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")); } }