use module_runtime::RuntimeTrackingScopeKind; use serde_json::{Value, json}; use crate::{ auth::{AuthenticatedAccessToken, RuntimePrincipal}, request_context::RequestContext, state::{AppState, PuzzleApiState}, 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: Option, pub owner_user_id: Option, pub profile_id: Option, pub run_id: Option, pub source_route: &'static str, pub extra: Value, } impl WorkPlayTrackingDraft { pub(crate) fn new( play_type: &'static str, work_id: impl Into, authenticated: &AuthenticatedAccessToken, source_route: &'static str, ) -> Self { Self::with_user_id( play_type, work_id, Some(authenticated.claims().user_id().to_string()), source_route, ) } pub(crate) fn runtime_principal( play_type: &'static str, work_id: impl Into, principal: &RuntimePrincipal, source_route: &'static str, ) -> Self { match principal { RuntimePrincipal::User(authenticated) => { Self::new(play_type, work_id, authenticated, source_route) } RuntimePrincipal::Guest(claims) => Self::with_user_id( play_type, work_id, Some(claims.subject().to_string()), source_route, ) .extra(json!({ "principalKind": "guest", "guestSubject": claims.subject(), "guestScope": claims.scope(), })), } } fn with_user_id( play_type: &'static str, work_id: impl Into, user_id: Option, source_route: &'static str, ) -> Self { 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) -> Self { self.owner_user_id = Some(owner_user_id.into()); self } pub(crate) fn profile_id(mut self, profile_id: impl Into) -> Self { self.profile_id = Some(profile_id.into()); self } pub(crate) fn run_id(mut self, run_id: impl Into) -> 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, ) { record_work_play_start_input_after_success(state, request_context, draft).await; } pub(crate) async fn record_puzzle_work_play_start_after_success( state: &PuzzleApiState, request_context: &RequestContext, draft: WorkPlayTrackingDraft, ) { record_work_play_start_input_after_success(state.root_state(), request_context, draft).await; } async fn record_work_play_start_input_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, }); if let Some(user_id) = draft.user_id.as_deref() { metadata["userId"] = json!(user_id); } else { metadata["userKind"] = json!("anonymous"); } 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 = 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"); } }