Files
Genarrative/server-rs/crates/api-server/src/tracking.rs

592 lines
19 KiB
Rust

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