This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
588
server-rs/crates/api-server/src/tracking.rs
Normal file
588
server-rs/crates/api-server/src/tracking.rs
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
111
server-rs/crates/api-server/src/work_play_tracking.rs
Normal file
111
server-rs/crates/api-server/src/work_play_tracking.rs
Normal 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 固定为 work,scope_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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user