feat: add work-level play tracking
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-09 19:56:59 +08:00
parent 32a1530ab1
commit 3ad1075227
24 changed files with 1452 additions and 105 deletions

View File

@@ -4,6 +4,7 @@ use axum::{
extract::{DefaultBodyLimit, Extension},
http::Request,
middleware,
response::Response,
routing::{delete, get, post},
};
use tower_http::{
@@ -26,8 +27,8 @@ use crate::{
create_sts_upload_credentials, get_asset_history, get_asset_read_url,
},
auth::{
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
require_bearer_auth,
AuthenticatedAccessToken, attach_refresh_session_token, inspect_auth_claims,
inspect_refresh_session_cookie, require_bearer_auth,
},
auth_me::auth_me,
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
@@ -105,7 +106,7 @@ use crate::{
update_puzzle_run_pause, use_puzzle_runtime_prop,
},
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
request_context::{RequestContext, attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
runtime_browse_history::{
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
@@ -149,6 +150,7 @@ use crate::{
begin_story_runtime_session, begin_story_session, continue_story,
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
},
tracking::record_route_tracking_event_after_success,
vector_engine_audio_generation::{
create_visual_novel_background_music_task, create_visual_novel_sound_effect_task,
publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset,
@@ -499,16 +501,31 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/assets/direct-upload-tickets",
post(create_direct_upload_ticket),
post(create_direct_upload_ticket).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/sts-upload-credentials",
post(create_sts_upload_credentials),
post(create_sts_upload_credentials).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/objects/confirm",
post(confirm_asset_object).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/assets/objects/confirm", post(confirm_asset_object))
.route(
"/api/assets/objects/bind",
post(bind_asset_object_to_entity),
post(bind_asset_object_to_entity).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/assets/character-visual/generate",
@@ -1479,6 +1496,11 @@ pub fn build_router(state: AppState) -> Router {
.layer(middleware::from_fn(normalize_error_response))
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
.layer(middleware::from_fn(propagate_request_id_header))
// 用户行为埋点放在错误归一化外侧,只观察最终成功响应,不阻断主链路。
.layer(middleware::from_fn_with_state(
state.clone(),
record_api_tracking_after_success,
))
// 当前阶段先统一挂接 HTTP tracing后续 request_id、响应头与错误中间件继续在这里扩展。
.layer(
TraceLayer::new_for_http()
@@ -1541,6 +1563,31 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state)
}
async fn record_api_tracking_after_success(
axum::extract::State(state): axum::extract::State<AppState>,
Extension(request_context): Extension<RequestContext>,
request: Request<Body>,
next: middleware::Next,
) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
let response = next.run(request).await;
let authenticated = response
.extensions()
.get::<AuthenticatedAccessToken>()
.cloned();
record_route_tracking_event_after_success(
&state,
&request_context,
&method,
&path,
response.status(),
authenticated.as_ref(),
)
.await;
response
}
fn creative_agent_router(state: AppState) -> Router<AppState> {
Router::new()
.route(

View File

@@ -23,8 +23,13 @@ use shared_contracts::assets::{
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
platform_errors::map_oss_error, request_context::RequestContext, state::AppState,
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
tracking::{TrackingEventDraft, record_tracking_event_after_success},
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
@@ -41,6 +46,7 @@ const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<CreateDirectUploadTicketRequest>,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
@@ -75,12 +81,33 @@ pub async fn create_direct_upload_ticket(
"message": error.to_string(),
}))
})?;
let upload = DirectUploadTicketPayload::from(signed);
record_asset_tracking_event(
&state,
&request_context,
&authenticated,
"asset_upload_ticket_create",
json!({
"asset": {
"operation": "asset_upload_ticket_create",
"operationFamily": "upload_ticket",
"objectKey": upload.object_key.clone(),
"legacyPublicPath": upload.legacy_public_path.clone(),
"bucket": upload.bucket.clone(),
"contentType": upload.content_type.clone(),
"access": upload.access,
"keyPrefix": upload.key_prefix.clone(),
"maxSizeBytes": upload.max_size_bytes,
"successActionStatus": upload.success_action_status,
}
}),
)
.await;
Ok(json_success_body(
Some(&request_context),
CreateDirectUploadTicketResponse {
upload: DirectUploadTicketPayload::from(signed),
},
CreateDirectUploadTicketResponse { upload },
))
}
@@ -190,6 +217,7 @@ pub async fn create_sts_upload_credentials(
pub async fn confirm_asset_object(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ConfirmAssetObjectRequest>,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
@@ -209,33 +237,60 @@ pub async fn confirm_asset_object(
.await
.map_err(map_confirm_asset_object_error)?;
let asset_object = AssetObjectPayload {
asset_object_id: result.asset_object_id,
bucket: result.bucket,
object_key: result.object_key,
access_policy: result.access_policy.as_str().to_string(),
content_type: result.content_type,
content_length: result.content_length,
content_hash: result.content_hash,
version: result.version,
source_job_id: result.source_job_id,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
entity_id: result.entity_id,
asset_kind: result.asset_kind,
created_at: result.created_at,
updated_at: result.updated_at,
};
record_asset_tracking_event(
&state,
&request_context,
&authenticated,
"asset_upload_confirm",
json!({
"asset": {
"operation": "asset_upload_confirm",
"operationFamily": "object_confirm",
"assetObjectId": asset_object.asset_object_id,
"assetKind": asset_object.asset_kind,
"objectKey": asset_object.object_key,
"bucket": asset_object.bucket,
"accessPolicy": asset_object.access_policy,
"contentType": asset_object.content_type,
"contentLength": asset_object.content_length,
"version": asset_object.version,
"sourceJobId": asset_object.source_job_id,
"ownerUserId": asset_object.owner_user_id,
"profileId": asset_object.profile_id,
"entityId": asset_object.entity_id,
}
}),
)
.await;
Ok(json_success_body(
Some(&request_context),
ConfirmAssetObjectResponse {
asset_object: AssetObjectPayload {
asset_object_id: result.asset_object_id,
bucket: result.bucket,
object_key: result.object_key,
access_policy: result.access_policy.as_str().to_string(),
content_type: result.content_type,
content_length: result.content_length,
content_hash: result.content_hash,
version: result.version,
source_job_id: result.source_job_id,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
entity_id: result.entity_id,
asset_kind: result.asset_kind,
created_at: result.created_at,
updated_at: result.updated_at,
},
},
ConfirmAssetObjectResponse { asset_object },
))
}
pub async fn bind_asset_object_to_entity(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<BindAssetObjectRequest>,
) -> Result<Json<Value>, AppError> {
let now_micros = current_utc_micros();
@@ -258,25 +313,60 @@ pub async fn bind_asset_object_to_entity(
.await
.map_err(map_confirm_asset_object_error)?;
let asset_binding = AssetBindingPayload {
binding_id: result.binding_id,
asset_object_id: result.asset_object_id,
entity_kind: result.entity_kind,
entity_id: result.entity_id,
slot: result.slot,
asset_kind: result.asset_kind,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
created_at: result.created_at,
updated_at: result.updated_at,
};
record_asset_tracking_event(
&state,
&request_context,
&authenticated,
"asset_bind",
json!({
"asset": {
"operation": "asset_bind",
"operationFamily": "object_bind",
"bindingId": asset_binding.binding_id,
"assetObjectId": asset_binding.asset_object_id,
"assetKind": asset_binding.asset_kind,
"entityKind": asset_binding.entity_kind,
"entityId": asset_binding.entity_id,
"slot": asset_binding.slot,
"ownerUserId": asset_binding.owner_user_id,
"profileId": asset_binding.profile_id,
}
}),
)
.await;
Ok(json_success_body(
Some(&request_context),
BindAssetObjectResponse {
asset_binding: AssetBindingPayload {
binding_id: result.binding_id,
asset_object_id: result.asset_object_id,
entity_kind: result.entity_kind,
entity_id: result.entity_id,
slot: result.slot,
asset_kind: result.asset_kind,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
created_at: result.created_at,
updated_at: result.updated_at,
},
},
BindAssetObjectResponse { asset_binding },
))
}
async fn record_asset_tracking_event(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
event_key: &'static str,
metadata: Value,
) {
let user_id = authenticated.claims().user_id().to_string();
let mut draft = TrackingEventDraft::user(event_key, "asset", user_id.as_str());
draft.metadata = metadata;
record_tracking_event_after_success(state, request_context, draft).await;
}
fn resolve_object_key_from_query(query: &GetReadUrlQuery) -> Option<String> {
if let Some(object_key) = query
.object_key

View File

@@ -63,9 +63,13 @@ pub async fn require_bearer_auth(
&& let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers())
{
request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims.clone()));
let mut response = next.run(request).await;
response
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims));
return Ok(next.run(request).await);
return Ok(response);
}
let bearer_token = extract_bearer_token(request.headers())?;
@@ -114,10 +118,15 @@ pub async fn require_bearer_auth(
}
request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims.clone()));
let mut response = next.run(request).await;
response
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims));
Ok(next.run(request).await)
Ok(response)
}
pub async fn inspect_auth_claims(

View File

@@ -10,7 +10,10 @@ use platform_auth::{
use time::OffsetDateTime;
use crate::session_client::SessionClientContext;
use crate::{http_error::AppError, state::AppState};
use crate::{
http_error::AppError, request_context::RequestContext, state::AppState,
tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path,
};
#[derive(Debug, Clone)]
pub struct SignedAuthSession {
@@ -29,38 +32,24 @@ pub fn create_password_auth_session(
#[cfg(not(test))]
pub async fn record_daily_login_tracking_event_after_auth_success(
state: &AppState,
request_context: &crate::request_context::RequestContext,
request_context: &RequestContext,
user_id: &str,
login_method: AuthLoginMethod,
) {
// 登录埋点是运营数据,不应反向阻断已经成功的认证会话签发。
match state
.spacetime_client()
.record_daily_login_tracking_event(user_id.to_string())
.await
{
Ok(()) => tracing::info!(
request_id = request_context.request_id(),
operation = request_context.operation(),
user_id = %user_id,
login_method = %login_method.as_str(),
"登录成功每日登录埋点已记录"
),
Err(error) => tracing::warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
user_id = %user_id,
login_method = %login_method.as_str(),
error = %error,
"登录成功每日登录埋点记录失败,登录流程继续"
),
}
// 登录埋点是运营数据,不应反向阻断已经成功的认证会话签发;每日登录也走统一埋点 helper/procedure
record_daily_login_tracking_event_via_unified_path(
state,
request_context,
user_id,
login_method,
)
.await;
}
#[cfg(test)]
pub async fn record_daily_login_tracking_event_after_auth_success(
_state: &AppState,
_request_context: &crate::request_context::RequestContext,
_request_context: &RequestContext,
_user_id: &str,
_login_method: AuthLoginMethod,
) {

View File

@@ -65,6 +65,7 @@ use crate::{
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
pub async fn create_big_fish_session(
@@ -235,7 +236,7 @@ pub async fn record_big_fish_play(
let items = state
.spacetime_client()
.record_big_fish_play(BigFishPlayReportRecordInput {
session_id,
session_id: session_id.clone(),
user_id: authenticated.claims().user_id().to_string(),
elapsed_ms: payload.elapsed_ms.unwrap_or(0),
reported_at_micros: current_utc_micros(),
@@ -245,6 +246,19 @@ pub async fn record_big_fish_play(
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
record_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
"big-fish",
session_id.clone(),
&authenticated,
"/api/runtime/big-fish/sessions/{session_id}/play",
)
.run_id(session_id.clone()),
)
.await;
Ok(json_success_body(
Some(&request_context),
BigFishWorksResponse {

View File

@@ -74,6 +74,7 @@ use crate::{
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3;
@@ -827,7 +828,7 @@ pub async fn record_custom_world_gallery_play(
State(state): State<AppState>,
Path((owner_user_id, profile_id)): Path<(String, String)>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() {
return Err(custom_world_error_response(
@@ -842,8 +843,8 @@ pub async fn record_custom_world_gallery_play(
let mutation = state
.spacetime_client()
.record_custom_world_profile_play(CustomWorldProfilePlayReportRecordInput {
owner_user_id,
profile_id,
owner_user_id: owner_user_id.clone(),
profile_id: profile_id.clone(),
played_at_micros: current_utc_micros(),
})
.await
@@ -851,6 +852,20 @@ pub async fn record_custom_world_gallery_play(
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
record_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
"custom-world",
profile_id.clone(),
&authenticated,
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/play",
)
.owner_user_id(owner_user_id.clone())
.profile_id(profile_id.clone()),
)
.await;
Ok(json_success_body(
Some(&request_context),
CustomWorldGalleryDetailResponse {

View File

@@ -69,12 +69,14 @@ mod square_hole_agent_turn;
mod state;
mod story_battles;
mod story_sessions;
mod tracking;
mod vector_engine_audio_generation;
mod visual_novel;
mod volcengine_speech;
mod wechat_auth;
mod wechat_provider;
mod work_author;
mod work_play_tracking;
use shared_logging::init_tracing;
use std::{collections::HashSet, env, fs, io, panic, thread};

View File

@@ -48,8 +48,12 @@ use spacetime_client::{
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
request_context::RequestContext,
state::AppState,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent";
@@ -574,7 +578,7 @@ pub async fn start_match3d_run(
.start_match3d_run(Match3DRunStartRecordInput {
run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
profile_id,
profile_id: profile_id.clone(),
started_at_ms: current_utc_ms(),
})
.await
@@ -586,6 +590,22 @@ pub async fn start_match3d_run(
)
})?;
record_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
"match3d",
profile_id.clone(),
&authenticated,
"/api/runtime/match3d/...",
)
.profile_id(profile_id.clone())
.extra(json!({
"runId": run.run_id,
})),
)
.await;
Ok(json_success_body(
Some(&request_context),
Match3DRunResponse {

View File

@@ -100,6 +100,7 @@ use crate::{
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
@@ -1539,8 +1540,8 @@ pub async fn start_puzzle_run(
.start_puzzle_run(PuzzleRunStartRecordInput {
run_id: build_prefixed_uuid_id("puzzle-run-"),
owner_user_id: authenticated.claims().user_id().to_string(),
profile_id: payload.profile_id,
level_id: payload.level_id,
profile_id: payload.profile_id.clone(),
level_id: payload.level_id.clone(),
started_at_micros: current_utc_micros(),
})
.await
@@ -1552,6 +1553,23 @@ pub async fn start_puzzle_run(
)
})?;
record_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
"puzzle",
payload.profile_id.clone(),
&authenticated,
"/api/runtime/puzzle/...",
)
.profile_id(payload.profile_id.clone())
.extra(json!({
"levelId": payload.level_id,
"runId": run.run_id,
})),
)
.await;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {

View File

@@ -76,6 +76,7 @@ use crate::{
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
},
state::AppState,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const SQUARE_HOLE_AGENT_PROVIDER: &str = "square-hole-agent";
@@ -747,7 +748,7 @@ pub async fn start_square_hole_run(
.start_square_hole_run(SquareHoleRunStartRecordInput {
run_id: build_prefixed_uuid_id(SQUARE_HOLE_RUN_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
profile_id,
profile_id: profile_id.clone(),
started_at_ms: current_utc_ms(),
})
.await
@@ -759,6 +760,22 @@ pub async fn start_square_hole_run(
)
})?;
record_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
"square-hole",
profile_id.clone(),
&authenticated,
"/api/runtime/square-hole/...",
)
.profile_id(profile_id.clone())
.extra(json!({
"runId": run.run_id,
})),
)
.await;
Ok(json_success_body(
Some(&request_context),
SquareHoleRunResponse {

View File

@@ -0,0 +1,588 @@
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/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)
}

View File

@@ -29,9 +29,14 @@ use spacetime_client::{
use time::OffsetDateTime;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
prompt::visual_novel as vn_prompt, request_context::RequestContext, state::AppState,
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
prompt::visual_novel as vn_prompt,
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const VISUAL_NOVEL_PROVIDER: &str = "visual-novel";
@@ -445,7 +450,7 @@ pub async fn start_visual_novel_run(
.start_visual_novel_run(VisualNovelRunStartRecordInput {
run_id: build_prefixed_uuid_id(domain::VISUAL_NOVEL_RUN_ID_PREFIX),
owner_user_id: authenticated.claims().user_id().to_string(),
profile_id,
profile_id: profile_id.clone(),
mode: run_mode_to_wire(&payload.mode).to_string(),
snapshot_json: None,
started_at_micros: current_utc_micros(),
@@ -455,6 +460,23 @@ pub async fn start_visual_novel_run(
visual_novel_error_response(&request_context, map_spacetime_error(error))
})?;
record_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
"visual-novel",
profile_id.clone(),
&authenticated,
"/api/runtime/visual-novel/...",
)
.profile_id(profile_id.clone())
.extra(json!({
"mode": run_mode_to_wire(&payload.mode),
"runId": run.run_id,
})),
)
.await;
Ok(json_success_body(
Some(&request_context),
contract::VisualNovelRunResponse {

View File

@@ -0,0 +1,111 @@
use module_runtime::RuntimeTrackingScopeKind;
use serde_json::{Value, json};
use crate::{
auth::AuthenticatedAccessToken,
request_context::RequestContext,
state::AppState,
tracking::{TrackingEventDraft, record_tracking_event_after_success},
};
pub(crate) const WORK_PLAY_START_EVENT_KEY: &str = "work_play_start";
pub(crate) struct WorkPlayTrackingDraft {
pub play_type: &'static str,
pub work_id: String,
pub user_id: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub run_id: Option<String>,
pub source_route: &'static str,
pub extra: Value,
}
impl WorkPlayTrackingDraft {
pub(crate) fn new(
play_type: &'static str,
work_id: impl Into<String>,
authenticated: &AuthenticatedAccessToken,
source_route: &'static str,
) -> Self {
let user_id = authenticated.claims().user_id().to_string();
Self {
play_type,
work_id: work_id.into(),
user_id,
owner_user_id: None,
profile_id: None,
run_id: None,
source_route,
extra: json!({}),
}
}
pub(crate) fn owner_user_id(mut self, owner_user_id: impl Into<String>) -> Self {
self.owner_user_id = Some(owner_user_id.into());
self
}
pub(crate) fn profile_id(mut self, profile_id: impl Into<String>) -> Self {
self.profile_id = Some(profile_id.into());
self
}
pub(crate) fn run_id(mut self, run_id: impl Into<String>) -> Self {
self.run_id = Some(run_id.into());
self
}
pub(crate) fn extra(mut self, extra: Value) -> Self {
self.extra = extra;
self
}
}
/// 作品级正式游玩埋点scope 固定为 workscope_id 固定为稳定作品 ID。
/// 中文注释:该埋点用于“某作品被多少用户玩过”等分析,写入失败不阻断 runtime 主流程。
pub(crate) async fn record_work_play_start_after_success(
state: &AppState,
request_context: &RequestContext,
draft: WorkPlayTrackingDraft,
) {
let mut metadata = json!({
"operation": WORK_PLAY_START_EVENT_KEY,
"playType": draft.play_type,
"workId": draft.work_id,
"sourceRoute": draft.source_route,
});
metadata["userId"] = json!(draft.user_id);
if let Some(owner_user_id) = draft.owner_user_id.as_deref() {
metadata["ownerUserId"] = json!(owner_user_id);
}
if let Some(profile_id) = draft.profile_id.as_deref() {
metadata["profileId"] = json!(profile_id);
}
if let Some(run_id) = draft.run_id.as_deref() {
metadata["runId"] = json!(run_id);
}
if !draft.extra.is_null() {
metadata["extra"] = draft.extra;
}
let mut tracking = TrackingEventDraft::new(WORK_PLAY_START_EVENT_KEY, draft.play_type);
tracking.scope_kind = RuntimeTrackingScopeKind::Work;
tracking.scope_id = draft.work_id;
tracking.user_id = Some(draft.user_id);
tracking.owner_user_id = draft.owner_user_id;
tracking.profile_id = draft.profile_id;
tracking.metadata = metadata;
record_tracking_event_after_success(state, request_context, tracking).await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn work_play_event_key_is_stable() {
assert_eq!(WORK_PLAY_START_EVENT_KEY, "work_play_start");
}
}