9734f78474
新增登录态音效提示词一键优化 BFF 与严格 JSON 候选门禁 新增 Worker 内部英文化服务、Unicode Script 校验和业务重试边界 补齐 mock LLM、路由埋点、回归测试及 T2 共享实施记录
918 lines
30 KiB
Rust
918 lines
30 KiB
Rust
use axum::http::{Method, StatusCode};
|
|
#[cfg(not(test))]
|
|
use module_auth::AuthLoginMethod;
|
|
use module_runtime::RuntimeTrackingScopeKind;
|
|
use serde_json::{Value, json};
|
|
use time::OffsetDateTime;
|
|
use uuid::Uuid;
|
|
|
|
use crate::{auth::AuthenticatedAccessToken, request_context::RequestContext, state::AppState};
|
|
|
|
/// 后端用户行为埋点入口统一走这里:写入失败只记录日志,不反向阻断主业务。
|
|
#[derive(Clone, Debug)]
|
|
pub struct TrackingEventDraft {
|
|
pub event_key: &'static str,
|
|
pub scope_kind: RuntimeTrackingScopeKind,
|
|
pub scope_id: String,
|
|
pub user_id: Option<String>,
|
|
pub owner_user_id: Option<String>,
|
|
pub profile_id: Option<String>,
|
|
pub module_key: Option<&'static str>,
|
|
pub metadata: Value,
|
|
}
|
|
|
|
impl TrackingEventDraft {
|
|
pub fn new(event_key: &'static str, module_key: &'static str) -> Self {
|
|
Self {
|
|
event_key,
|
|
scope_kind: RuntimeTrackingScopeKind::Site,
|
|
scope_id: "site".to_string(),
|
|
user_id: None,
|
|
owner_user_id: None,
|
|
profile_id: None,
|
|
module_key: Some(module_key),
|
|
metadata: json!({}),
|
|
}
|
|
}
|
|
|
|
pub fn user(event_key: &'static str, module_key: &'static str, user_id: &str) -> Self {
|
|
let normalized_user_id = user_id.trim().to_string();
|
|
let mut draft = Self::new(event_key, module_key);
|
|
draft.scope_kind = RuntimeTrackingScopeKind::User;
|
|
draft.scope_id = normalized_user_id.clone();
|
|
draft.user_id = Some(normalized_user_id.clone());
|
|
draft.owner_user_id = Some(normalized_user_id);
|
|
draft
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct RouteTrackingSpec {
|
|
event_key: &'static str,
|
|
module_key: &'static str,
|
|
scope_kind: RuntimeTrackingScopeKind,
|
|
scope_id: &'static str,
|
|
}
|
|
|
|
pub async fn record_external_generation_run_after_success(
|
|
state: &AppState,
|
|
provider: &str,
|
|
operation: &str,
|
|
request_label: &str,
|
|
request_payload: Value,
|
|
started_at_micros: i64,
|
|
success: bool,
|
|
failure_reason: Option<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>,
|
|
) {
|
|
if !status.is_success() {
|
|
return;
|
|
}
|
|
let Some(spec) = resolve_route_tracking_spec(method, path) else {
|
|
return;
|
|
};
|
|
|
|
let user_id = authenticated.map(|auth| auth.claims().user_id().to_string());
|
|
let scope_id = match spec.scope_kind {
|
|
RuntimeTrackingScopeKind::User => {
|
|
user_id.clone().unwrap_or_else(|| spec.scope_id.to_string())
|
|
}
|
|
RuntimeTrackingScopeKind::Site => spec.scope_id.to_string(),
|
|
_ => spec.scope_id.to_string(),
|
|
};
|
|
let mut draft = TrackingEventDraft::new(spec.event_key, spec.module_key);
|
|
draft.scope_kind = spec.scope_kind;
|
|
draft.scope_id = scope_id;
|
|
draft.user_id = user_id;
|
|
draft.metadata = build_route_tracking_metadata(&spec, request_context, method, path, status);
|
|
if draft.user_id.is_some() {
|
|
draft.owner_user_id = draft.user_id.clone();
|
|
}
|
|
|
|
record_route_tracking_event_via_outbox_after_success(state, request_context, draft).await;
|
|
}
|
|
|
|
fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option<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",
|
|
)),
|
|
("GET", "/api/auth/me") => Some(route_spec("auth_me_view", "auth", User, "anonymous")),
|
|
("GET", "/api/auth/sessions") => {
|
|
Some(route_spec("auth_sessions_view", "auth", User, "anonymous"))
|
|
}
|
|
("POST", "/api/auth/sessions/{id}/revoke") => {
|
|
Some(route_spec("auth_revoke_session", "auth", User, "anonymous"))
|
|
}
|
|
("POST", "/api/auth/refresh") => {
|
|
Some(route_spec("auth_refresh_success", "auth", Site, "site"))
|
|
}
|
|
("POST", "/api/auth/logout") => Some(route_spec("auth_logout", "auth", User, "anonymous")),
|
|
("POST", "/api/auth/logout-all") => {
|
|
Some(route_spec("auth_logout_all", "auth", User, "anonymous"))
|
|
}
|
|
("POST", "/api/auth/wechat/bind-phone") => Some(route_spec(
|
|
"auth_wechat_bind_phone_success",
|
|
"auth",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("PATCH", "/api/profile/me") => Some(route_spec(
|
|
"profile_identity_update",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("GET", "/api/profile/dashboard") => Some(route_spec(
|
|
"profile_dashboard_view",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("GET", "/api/profile/wallet-ledger") => Some(route_spec(
|
|
"wallet_ledger_view",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("GET", "/api/profile/recharge-center") => Some(route_spec(
|
|
"recharge_center_view",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/profile/recharge/orders") => Some(route_spec(
|
|
"recharge_order_create",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/profile/feedback") => {
|
|
Some(route_spec("feedback_submit", "profile", User, "anonymous"))
|
|
}
|
|
("GET", "/api/profile/referrals/invite-center") => Some(route_spec(
|
|
"invite_center_view",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/profile/referrals/redeem-code") => Some(route_spec(
|
|
"referral_invite_code_redeem",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/profile/redeem-codes/redeem") => Some(route_spec(
|
|
"redeem_code_submit",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("GET", "/api/profile/tasks") => {
|
|
Some(route_spec("task_center_view", "profile", User, "anonymous"))
|
|
}
|
|
("POST", "/api/profile/tasks/{id}/claim") => Some(route_spec(
|
|
"task_reward_claim",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
("GET", "/api/profile/save-archives") => Some(route_spec(
|
|
"save_archive_list_view",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
("GET", "/api/profile/save-archives/{id}") => Some(route_spec(
|
|
"save_archive_detail_view",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
("GET", "/api/profile/browse-history") => Some(route_spec(
|
|
"browse_history_view",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
("POST", "/api/profile/browse-history") => Some(route_spec(
|
|
"browse_history_record",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
("DELETE", "/api/profile/browse-history") => Some(route_spec(
|
|
"browse_history_clear",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
("GET", "/api/profile/play-stats") => {
|
|
Some(route_spec("play_stats_view", "profile", User, "anonymous"))
|
|
}
|
|
("GET", "/api/profile/analytics/metric") => Some(route_spec(
|
|
"profile_analytics_metric_view",
|
|
"profile",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/ai/tasks") => Some(route_spec("ai_task_create", "ai", User, "anonymous")),
|
|
("POST", "/api/ai/tasks/{id}/start") => {
|
|
Some(route_spec("ai_task_start", "ai", User, "anonymous"))
|
|
}
|
|
("POST", "/api/ai/tasks/{id}/stages/{id}/start") => {
|
|
Some(route_spec("ai_task_stage_start", "ai", User, "anonymous"))
|
|
}
|
|
("POST", "/api/ai/tasks/{id}/chunks") => {
|
|
Some(route_spec("ai_task_chunk_append", "ai", User, "anonymous"))
|
|
}
|
|
("POST", "/api/ai/tasks/{id}/stages/{id}/complete") => Some(route_spec(
|
|
"ai_task_stage_complete",
|
|
"ai",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/ai/tasks/{id}/references") => Some(route_spec(
|
|
"ai_task_reference_attach",
|
|
"ai",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/ai/tasks/{id}/complete") => {
|
|
Some(route_spec("ai_task_complete", "ai", User, "anonymous"))
|
|
}
|
|
("POST", "/api/ai/tasks/{id}/fail") => {
|
|
Some(route_spec("ai_task_fail", "ai", User, "anonymous"))
|
|
}
|
|
("POST", "/api/ai/tasks/{id}/cancel") => {
|
|
Some(route_spec("ai_task_cancel", "ai", User, "anonymous"))
|
|
}
|
|
("POST", "/api/assets/sts-upload-credentials") => Some(route_spec(
|
|
"asset_sts_credentials_create",
|
|
"asset",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/assets/character-visual/generate") => Some(route_spec(
|
|
"asset_character_visual_generate",
|
|
"asset",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/assets/character-visual/publish") => Some(route_spec(
|
|
"asset_character_visual_publish",
|
|
"asset",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/assets/character-animation/generate") => Some(route_spec(
|
|
"asset_character_animation_generate",
|
|
"asset",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/assets/character-animation/publish") => Some(route_spec(
|
|
"asset_character_animation_publish",
|
|
"asset",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/assets/character-animation/import-video") => Some(route_spec(
|
|
"asset_character_animation_import",
|
|
"asset",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/assets/character-workflow-cache") => Some(route_spec(
|
|
"asset_character_workflow_cache_save",
|
|
"asset",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("GET", "/api/assets/history") => {
|
|
Some(route_spec("asset_history_view", "asset", User, "anonymous"))
|
|
}
|
|
("POST", "/api/editor/audios/background-music/prompts/completions") => Some(route_spec(
|
|
"editor_background_music_prompt_completion",
|
|
"editor",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/editor/audios/sound-effects/prompts/optimizations") => Some(route_spec(
|
|
"editor_sound_effect_prompt_optimization",
|
|
"editor",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/editor/audios/background-music/prompts/simplifications") => {
|
|
Some(route_spec(
|
|
"editor_background_music_prompt_simplification",
|
|
"editor",
|
|
User,
|
|
"anonymous",
|
|
))
|
|
}
|
|
("POST", "/api/llm/chat/completions") => {
|
|
Some(route_spec("llm_request", "llm", User, "anonymous"))
|
|
}
|
|
("GET", "/api/speech/volcengine/config") => Some(route_spec(
|
|
"speech_config_view",
|
|
"speech",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("GET", "/api/speech/volcengine/asr/stream") => {
|
|
Some(route_spec("asr_stream_start", "speech", User, "anonymous"))
|
|
}
|
|
("GET", "/api/speech/volcengine/tts/bidirection") => Some(route_spec(
|
|
"tts_bidirection_start",
|
|
"speech",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("POST", "/api/speech/volcengine/tts/sse") => {
|
|
Some(route_spec("tts_sse_start", "speech", User, "anonymous"))
|
|
}
|
|
("GET", "/api/runtime/settings") => Some(route_spec(
|
|
"runtime_settings_view",
|
|
"runtime",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
("PUT", "/api/runtime/settings") => Some(route_spec(
|
|
"runtime_settings_update",
|
|
"runtime",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
("GET", "/api/runtime/save/snapshot") => Some(route_spec(
|
|
"runtime_snapshot_view",
|
|
"runtime",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
("PUT", "/api/runtime/save/snapshot") => Some(route_spec(
|
|
"runtime_snapshot_save",
|
|
"runtime",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
("DELETE", "/api/runtime/save/snapshot") => Some(route_spec(
|
|
"runtime_snapshot_delete",
|
|
"runtime",
|
|
User,
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
_ if route.starts_with("/api/runtime/puzzle/") => Some(route_spec(
|
|
"puzzle_route_success",
|
|
"puzzle",
|
|
user_scope_for(method),
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
_ if route.starts_with("/api/creation/square-hole/")
|
|
|| route.starts_with("/api/runtime/square-hole/") =>
|
|
{
|
|
Some(route_spec(
|
|
"square_hole_route_success",
|
|
"square-hole",
|
|
user_scope_for(method),
|
|
"anonymous",
|
|
))
|
|
}
|
|
#[cfg(any())]
|
|
_ if route.starts_with("/api/runtime/custom-world") => Some(route_spec(
|
|
"custom_world_route_success",
|
|
"custom-world",
|
|
user_scope_for(method),
|
|
"anonymous",
|
|
)),
|
|
#[cfg(any())]
|
|
_ if route.starts_with("/api/runtime/creative-agent") => Some(route_spec(
|
|
"creative_agent_route_success",
|
|
"creative-agent",
|
|
user_scope_for(method),
|
|
"anonymous",
|
|
)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn is_route_tracking_excluded(path: &str) -> bool {
|
|
path.starts_with("/admin/")
|
|
}
|
|
|
|
fn route_spec(
|
|
event_key: &'static str,
|
|
module_key: &'static str,
|
|
scope_kind: RuntimeTrackingScopeKind,
|
|
scope_id: &'static str,
|
|
) -> RouteTrackingSpec {
|
|
RouteTrackingSpec {
|
|
event_key,
|
|
module_key,
|
|
scope_kind,
|
|
scope_id,
|
|
}
|
|
}
|
|
|
|
#[cfg(any())]
|
|
fn user_scope_for(method: &Method) -> RuntimeTrackingScopeKind {
|
|
if matches!(*method, Method::GET) {
|
|
RuntimeTrackingScopeKind::Site
|
|
} else {
|
|
RuntimeTrackingScopeKind::User
|
|
}
|
|
}
|
|
|
|
fn build_route_tracking_metadata(
|
|
spec: &RouteTrackingSpec,
|
|
request_context: &RequestContext,
|
|
method: &Method,
|
|
path: &str,
|
|
status: StatusCode,
|
|
) -> Value {
|
|
let mut metadata = json!({
|
|
"route": path,
|
|
"method": method.as_str(),
|
|
"status": status.as_u16(),
|
|
"operation": request_context.operation(),
|
|
});
|
|
|
|
if spec.module_key == "asset" {
|
|
metadata["asset"] = build_asset_route_metadata(spec.event_key, path);
|
|
metadata["assetOperation"] = json!(spec.event_key);
|
|
}
|
|
|
|
metadata
|
|
}
|
|
|
|
fn build_asset_route_metadata(event_key: &str, path: &str) -> Value {
|
|
json!({
|
|
"operation": event_key,
|
|
"operationFamily": resolve_asset_operation_family(event_key),
|
|
"route": path,
|
|
})
|
|
}
|
|
|
|
fn resolve_asset_operation_family(event_key: &str) -> &'static str {
|
|
match event_key {
|
|
"asset_upload_ticket_create" => "upload_ticket",
|
|
"asset_sts_credentials_create" => "sts_credentials",
|
|
"asset_upload_confirm" => "object_confirm",
|
|
"asset_bind" => "object_bind",
|
|
"asset_character_visual_generate" => "character_visual_generate",
|
|
"asset_character_visual_publish" => "character_visual_publish",
|
|
"asset_character_animation_generate" => "character_animation_generate",
|
|
"asset_character_animation_publish" => "character_animation_publish",
|
|
"asset_character_animation_import" => "character_animation_import",
|
|
"asset_character_workflow_cache_save" => "character_workflow_cache_save",
|
|
"asset_history_view" => "history_view",
|
|
_ => "asset_operation",
|
|
}
|
|
}
|
|
|
|
fn normalize_route_path(path: &str) -> String {
|
|
let mut normalized = String::new();
|
|
for segment in path.trim_end_matches('/').split('/') {
|
|
if segment.is_empty() {
|
|
continue;
|
|
}
|
|
normalized.push('/');
|
|
normalized.push_str(if is_dynamic_path_segment(segment) {
|
|
"{id}"
|
|
} else {
|
|
segment
|
|
});
|
|
}
|
|
if normalized.is_empty() {
|
|
"/".to_string()
|
|
} else {
|
|
normalized
|
|
}
|
|
}
|
|
|
|
fn is_dynamic_path_segment(segment: &str) -> bool {
|
|
if is_known_static_route_segment(segment) {
|
|
return false;
|
|
}
|
|
|
|
let lower = segment.to_ascii_lowercase();
|
|
segment.len() >= 8
|
|
|| segment.chars().any(|ch| ch.is_ascii_digit())
|
|
|| lower.starts_with("world")
|
|
|| lower.starts_with("task")
|
|
|| lower.starts_with("profile")
|
|
|| lower.starts_with("session")
|
|
}
|
|
|
|
fn is_known_static_route_segment(segment: &str) -> bool {
|
|
matches!(
|
|
segment,
|
|
"ai" | "analytics"
|
|
| "api"
|
|
| "asr"
|
|
| "assets"
|
|
| "auth"
|
|
| "background-music"
|
|
| "bidirection"
|
|
| "bind-phone"
|
|
| "browse-history"
|
|
| "cancel"
|
|
| "character-animation"
|
|
| "character-visual"
|
|
| "character-workflow-cache"
|
|
| "chat"
|
|
| "chunks"
|
|
| "claim"
|
|
| "complete"
|
|
| "completions"
|
|
| "config"
|
|
| "dashboard"
|
|
| "fail"
|
|
| "feedback"
|
|
| "generate"
|
|
| "history"
|
|
| "import-video"
|
|
| "invite-center"
|
|
| "llm"
|
|
| "login"
|
|
| "optimizations"
|
|
| "login-options"
|
|
| "logout"
|
|
| "logout-all"
|
|
| "me"
|
|
| "metric"
|
|
| "orders"
|
|
| "phone"
|
|
| "play-stats"
|
|
| "profile"
|
|
| "publish"
|
|
| "recharge"
|
|
| "recharge-center"
|
|
| "redeem"
|
|
| "redeem-code"
|
|
| "redeem-codes"
|
|
| "references"
|
|
| "referrals"
|
|
| "refresh"
|
|
| "revoke"
|
|
| "runtime"
|
|
| "save"
|
|
| "save-archives"
|
|
| "send-code"
|
|
| "sessions"
|
|
| "settings"
|
|
| "simplifications"
|
|
| "sound-effects"
|
|
| "snapshot"
|
|
| "speech"
|
|
| "sse"
|
|
| "stages"
|
|
| "start"
|
|
| "stream"
|
|
| "sts-upload-credentials"
|
|
| "tasks"
|
|
| "tts"
|
|
| "volcengine"
|
|
| "wallet-ledger"
|
|
| "wechat"
|
|
)
|
|
}
|
|
|
|
#[cfg(not(test))]
|
|
pub async fn record_daily_login_tracking_event_after_success(
|
|
state: &AppState,
|
|
request_context: &RequestContext,
|
|
user_id: &str,
|
|
login_method: AuthLoginMethod,
|
|
) {
|
|
let mut draft = TrackingEventDraft::user("daily_login", "profile", user_id);
|
|
draft.metadata = json!({
|
|
"operation": request_context.operation(),
|
|
"loginMethod": login_method.as_str(),
|
|
});
|
|
|
|
record_tracking_event_after_success(state, request_context, draft).await;
|
|
}
|
|
|
|
pub async fn record_tracking_event_after_success(
|
|
state: &AppState,
|
|
request_context: &RequestContext,
|
|
draft: TrackingEventDraft,
|
|
) {
|
|
record_tracking_event_input_after_success(
|
|
state,
|
|
request_context,
|
|
build_tracking_event_input(draft),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn record_route_tracking_event_via_outbox_after_success(
|
|
state: &AppState,
|
|
request_context: &RequestContext,
|
|
draft: TrackingEventDraft,
|
|
) {
|
|
let event = build_tracking_event_input(draft);
|
|
let event_key = event.event_key.clone();
|
|
let scope_kind = event.scope_kind;
|
|
let scope_id = event.scope_id.clone();
|
|
|
|
if let Some(outbox) = state.tracking_outbox() {
|
|
match outbox.enqueue(event.clone()).await {
|
|
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Enqueued) => {
|
|
tracing::debug!(
|
|
request_id = request_context.request_id(),
|
|
operation = request_context.operation(),
|
|
event_key = %event_key,
|
|
scope_kind = %scope_kind.as_str(),
|
|
scope_id = %scope_id,
|
|
"后端 route 埋点已写入本机 outbox"
|
|
);
|
|
return;
|
|
}
|
|
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Dropped { reason }) => {
|
|
tracing::warn!(
|
|
request_id = request_context.request_id(),
|
|
operation = request_context.operation(),
|
|
event_key = %event_key,
|
|
scope_kind = %scope_kind.as_str(),
|
|
scope_id = %scope_id,
|
|
reason,
|
|
"后端 route 埋点因 outbox 保护阈值被丢弃,主业务流程继续"
|
|
);
|
|
return;
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
request_id = request_context.request_id(),
|
|
operation = request_context.operation(),
|
|
event_key = %event_key,
|
|
scope_kind = %scope_kind.as_str(),
|
|
scope_id = %scope_id,
|
|
error = %error,
|
|
"后端 route 埋点写入 outbox 失败,回退同步直写 SpacetimeDB"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
record_tracking_event_input_after_success(state, request_context, event).await;
|
|
}
|
|
|
|
pub(crate) fn build_tracking_event_input(
|
|
draft: TrackingEventDraft,
|
|
) -> module_runtime::RuntimeTrackingEventInput {
|
|
let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
|
let event_id = build_tracking_event_id(&draft, occurred_at_micros);
|
|
|
|
module_runtime::RuntimeTrackingEventInput {
|
|
event_id,
|
|
event_key: draft.event_key.to_string(),
|
|
scope_kind: draft.scope_kind,
|
|
scope_id: draft.scope_id,
|
|
user_id: draft.user_id,
|
|
owner_user_id: draft.owner_user_id,
|
|
profile_id: draft.profile_id,
|
|
module_key: draft.module_key.map(str::to_string),
|
|
metadata_json: draft.metadata.to_string(),
|
|
occurred_at_micros: occurred_at_micros as i64,
|
|
}
|
|
}
|
|
|
|
async fn record_tracking_event_input_after_success(
|
|
state: &AppState,
|
|
request_context: &RequestContext,
|
|
event: module_runtime::RuntimeTrackingEventInput,
|
|
) {
|
|
let event_key = event.event_key.clone();
|
|
let log_scope_kind = event.scope_kind;
|
|
let scope_id = event.scope_id.clone();
|
|
|
|
let module_runtime::RuntimeTrackingEventInput {
|
|
event_id,
|
|
event_key: procedure_event_key,
|
|
scope_kind: procedure_scope_kind,
|
|
scope_id: procedure_scope_id,
|
|
user_id,
|
|
owner_user_id,
|
|
profile_id,
|
|
module_key,
|
|
metadata_json,
|
|
occurred_at_micros,
|
|
} = event;
|
|
|
|
match state
|
|
.spacetime_client()
|
|
.record_tracking_event(
|
|
event_id,
|
|
procedure_event_key,
|
|
procedure_scope_kind,
|
|
procedure_scope_id,
|
|
user_id,
|
|
owner_user_id,
|
|
profile_id,
|
|
module_key,
|
|
metadata_json,
|
|
occurred_at_micros,
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => tracing::info!(
|
|
request_id = request_context.request_id(),
|
|
operation = request_context.operation(),
|
|
event_key = %event_key,
|
|
scope_kind = %log_scope_kind.as_str(),
|
|
scope_id = %scope_id,
|
|
"后端埋点已记录"
|
|
),
|
|
Err(error) => tracing::warn!(
|
|
request_id = request_context.request_id(),
|
|
operation = request_context.operation(),
|
|
event_key = %event_key,
|
|
scope_kind = %log_scope_kind.as_str(),
|
|
scope_id = %scope_id,
|
|
error = %error,
|
|
"后端埋点记录失败,主业务流程继续"
|
|
),
|
|
}
|
|
}
|
|
|
|
fn build_tracking_event_id(draft: &TrackingEventDraft, occurred_at_micros: i128) -> String {
|
|
if draft.event_key == "daily_login"
|
|
&& draft.scope_kind == RuntimeTrackingScopeKind::User
|
|
&& !draft.scope_id.trim().is_empty()
|
|
{
|
|
let day_key = runtime_profile_beijing_day_key(occurred_at_micros as i64);
|
|
return format!("daily-login:{}:{}", draft.scope_id.trim(), day_key);
|
|
}
|
|
|
|
format!(
|
|
"api:{}:{}:{}",
|
|
draft.event_key,
|
|
occurred_at_micros,
|
|
Uuid::new_v4()
|
|
)
|
|
}
|
|
|
|
fn runtime_profile_beijing_day_key(occurred_at_micros: i64) -> i64 {
|
|
const PROFILE_TASK_BEIJING_OFFSET_MICROS: i64 = 28_800_000_000;
|
|
const PROFILE_RUNTIME_DAY_MICROS: i64 = 86_400_000_000;
|
|
(occurred_at_micros + PROFILE_TASK_BEIJING_OFFSET_MICROS).div_euclid(PROFILE_RUNTIME_DAY_MICROS)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use axum::http::Method;
|
|
|
|
use super::{is_route_tracking_excluded, normalize_route_path, resolve_route_tracking_spec};
|
|
|
|
#[test]
|
|
fn route_normalization_preserves_static_segments_and_replaces_ids() {
|
|
assert_eq!(
|
|
normalize_route_path("/api/runtime/settings"),
|
|
"/api/runtime/settings"
|
|
);
|
|
assert_eq!(
|
|
normalize_route_path("/api/profile/dashboard"),
|
|
"/api/profile/dashboard"
|
|
);
|
|
assert_eq!(
|
|
normalize_route_path("/api/auth/sessions/session-01/revoke"),
|
|
"/api/auth/sessions/{id}/revoke"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_runtime_settings_routes_keep_tracking_specs() {
|
|
for (method, event_key) in [
|
|
(Method::GET, "runtime_settings_view"),
|
|
(Method::PUT, "runtime_settings_update"),
|
|
] {
|
|
let spec = resolve_route_tracking_spec(&method, "/api/runtime/settings")
|
|
.expect("active runtime settings route should be tracked");
|
|
assert_eq!(spec.event_key, event_key);
|
|
assert_eq!(spec.module_key, "runtime");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn editor_audio_prompt_assist_routes_keep_explicit_user_tracking_specs() {
|
|
for (path, event_key) in [
|
|
(
|
|
"/api/editor/audios/sound-effects/prompts/optimizations",
|
|
"editor_sound_effect_prompt_optimization",
|
|
),
|
|
(
|
|
"/api/editor/audios/background-music/prompts/completions",
|
|
"editor_background_music_prompt_completion",
|
|
),
|
|
(
|
|
"/api/editor/audios/background-music/prompts/simplifications",
|
|
"editor_background_music_prompt_simplification",
|
|
),
|
|
] {
|
|
let spec = resolve_route_tracking_spec(&Method::POST, path)
|
|
.expect("editor audio prompt assist route should be tracked");
|
|
assert_eq!(spec.event_key, event_key);
|
|
assert_eq!(spec.module_key, "editor");
|
|
assert_eq!(
|
|
spec.scope_kind,
|
|
module_runtime::RuntimeTrackingScopeKind::User
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn retired_play_paths_are_not_route_tracking_exclusions() {
|
|
for path in [
|
|
"/api/runtime/big-fish/runs/run-01",
|
|
"/api/runtime/visual-novel/runs/run-01",
|
|
"/api/runtime/story/sessions/session-01",
|
|
"/api/runtime/combat/state",
|
|
"/api/runtime/rpg/state",
|
|
"/api/runtime/chat/npc/turn/stream",
|
|
] {
|
|
assert!(!is_route_tracking_excluded(path), "{path}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn admin_routes_remain_excluded_from_user_route_tracking() {
|
|
assert!(is_route_tracking_excluded("/admin/api/overview"));
|
|
}
|
|
}
|