Files
Genarrative/server-rs/crates/api-server/src/tracking.rs
T
suzmii c12d744d91
Project CI / Frontend tests (pull_request) Successful in 3m16s
Project CI / Repository checks (pull_request) Successful in 2m37s
Project CI / Backend tests (pull_request) Successful in 7m14s
Project CI / Native shell tests (pull_request) Failing after 21m2s
合并master并保留双侧最新决策记录
合入主站 AGC 请求头归属与项目命名链路改动。
保留本分支 Direct 过程卡决策记录。
保留主站登录归属、workspace 边界与 Native shell 决策记录。
2026-09-03 16:04:04 +08:00

2205 lines
73 KiB
Rust

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<TrackingClientMarker> {
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<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub module_key: Option<&'static str>,
pub metadata: Value,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct TrackingIdentity {
user_id: Option<String>,
owner_user_id: Option<String>,
}
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<String>,
provider_request_id: Option<String>,
result_payload: Option<Value>,
) {
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<TrackingClientMarker>,
) {
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<TrackingClientMarker>,
) -> 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<RouteTrackingSpec> {
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<TrackingClientMarker>,
) -> 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::<serde_json::Value>(&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::<serde_json::Value>(&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"));
}
}