Files
Genarrative/server-rs/crates/api-server/src/work_play_tracking.rs
kdletters c1dcf074bb feat: unify recommend anonymous runtime guest auth
- Route recommended runtime launches through shared runtime guest token handling
- Extend recommend-page anonymous play beyond jump-hop
- Add regression coverage for runtime guest launch clients
- Update docs to reflect the full anonymous-play matrix
2026-05-25 14:03:38 +08:00

169 lines
5.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 {
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<String>,
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<String>,
user_id: Option<String>,
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<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,
) {
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");
}
}