From 2a23ba6570a27a32cbdb88a48edcd8df0a9c1355 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 2 Sep 2026 08:19:28 +0000 Subject: [PATCH] =?UTF-8?q?=E9=98=B6=E6=AE=B52=E6=8E=A5=E5=85=A5tracking?= =?UTF-8?q?=E4=B8=BB=E4=BD=93=E5=BD=92=E5=B1=9E=E4=B8=8EAGC=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入 AuthenticatedAccessToken 与 ExternalApiPrincipal 的 tracking 主体归属。 让 External API Key User scope 使用 owner_user_id,并保持原有账号态语义。 在既有 route metadata 中追加 client=agc,保留原有字段和资产嵌套信息。 增加主体归属、scope 和 metadata 合并定向测试。 --- server-rs/crates/api-server/src/app.rs | 3 + server-rs/crates/api-server/src/tracking.rs | 190 ++++++++++++++++++-- 2 files changed, 177 insertions(+), 16 deletions(-) diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index df0506f49..7fcd33959 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -18,6 +18,7 @@ use crate::{ auth::AuthenticatedAccessToken, backpressure::limit_concurrent_requests, error_middleware::normalize_error_response, + external_api_auth::ExternalApiPrincipal, http_error::AppError, modules, request_context::{RequestContext, attach_request_context, resolve_request_id}, @@ -247,6 +248,7 @@ async fn record_api_tracking_after_success( .extensions() .get::() .cloned(); + let external_principal = response.extensions().get::().cloned(); record_route_tracking_event_after_success( &state, &request_context, @@ -254,6 +256,7 @@ async fn record_api_tracking_after_success( &path, response.status(), authenticated.as_ref(), + external_principal.as_ref(), client_marker, ) .await; diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index f60c0d541..391686666 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -6,7 +6,10 @@ use serde_json::{Value, json}; use time::OffsetDateTime; use uuid::Uuid; -use crate::{auth::AuthenticatedAccessToken, request_context::RequestContext, state::AppState}; +use crate::{ + auth::AuthenticatedAccessToken, external_api_auth::ExternalApiPrincipal, + request_context::RequestContext, state::AppState, +}; const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; const AGC_CLIENT_MARKER_VALUE: &str = "agc"; @@ -38,6 +41,12 @@ pub struct TrackingEventDraft { pub(crate) client_marker: Option, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct TrackingIdentity { + user_id: Option, + owner_user_id: Option, +} + impl TrackingEventDraft { pub fn new(event_key: &'static str, module_key: &'static str) -> Self { Self { @@ -128,6 +137,7 @@ pub async fn record_route_tracking_event_after_success( path: &str, status: StatusCode, authenticated: Option<&AuthenticatedAccessToken>, + external_principal: Option<&ExternalApiPrincipal>, client_marker: Option, ) { if !status.is_success() { @@ -137,27 +147,59 @@ pub async fn record_route_tracking_event_after_success( 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 identity = resolve_tracking_identity(authenticated, external_principal); + let scope_id = resolve_tracking_scope_id(&spec, &identity); 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.user_id = identity.user_id; + draft.owner_user_id = identity.owner_user_id; draft.client_marker = client_marker; - 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(); - } + draft.metadata = build_route_tracking_metadata( + &spec, + request_context, + method, + path, + status, + draft.client_marker, + ); record_route_tracking_event_via_outbox_after_success(state, request_context, draft).await; } +fn resolve_tracking_identity( + authenticated: Option<&AuthenticatedAccessToken>, + external_principal: Option<&ExternalApiPrincipal>, +) -> TrackingIdentity { + if let Some(principal) = external_principal { + return TrackingIdentity { + owner_user_id: Some(principal.owner_user_id().to_string()), + ..TrackingIdentity::default() + }; + } + + let Some(authenticated) = authenticated else { + return TrackingIdentity::default(); + }; + let user_id = authenticated.claims().user_id().to_string(); + TrackingIdentity { + user_id: Some(user_id.clone()), + owner_user_id: Some(user_id), + } +} + +fn resolve_tracking_scope_id(spec: &RouteTrackingSpec, identity: &TrackingIdentity) -> String { + match spec.scope_kind { + RuntimeTrackingScopeKind::User => identity + .owner_user_id + .clone() + .or_else(|| identity.user_id.clone()) + .unwrap_or_else(|| spec.scope_id.to_string()), + RuntimeTrackingScopeKind::Site => spec.scope_id.to_string(), + _ => spec.scope_id.to_string(), + } +} + fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option { use RuntimeTrackingScopeKind::{Site, User}; @@ -523,6 +565,7 @@ fn build_route_tracking_metadata( method: &Method, path: &str, status: StatusCode, + client_marker: Option, ) -> Value { let mut metadata = json!({ "route": path, @@ -536,6 +579,10 @@ fn build_route_tracking_metadata( metadata["assetOperation"] = json!(spec.event_key); } + if matches!(client_marker, Some(TrackingClientMarker::Agc)) { + metadata["client"] = json!(AGC_CLIENT_MARKER_VALUE); + } + metadata } @@ -856,13 +903,47 @@ fn runtime_profile_beijing_day_key(occurred_at_micros: i64) -> i64 { #[cfg(test)] mod tests { + use std::time::Duration; + use axum::http::{HeaderMap, HeaderValue, Method}; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, + }; + use time::OffsetDateTime; + + use crate::{ + auth::AuthenticatedAccessToken, external_api_auth::ExternalApiPrincipal, + request_context::RequestContext, + }; use super::{ - TrackingClientMarker, is_route_tracking_excluded, normalize_route_path, - resolve_route_tracking_spec, resolve_tracking_client_marker, + TrackingClientMarker, build_route_tracking_metadata, is_route_tracking_excluded, + normalize_route_path, resolve_route_tracking_spec, resolve_tracking_client_marker, + resolve_tracking_identity, resolve_tracking_scope_id, route_spec, }; + fn build_test_authenticated(user_id: &str) -> AuthenticatedAccessToken { + let config = JwtConfig::new("test-issuer".to_string(), "test-secret".to_string(), 3600) + .expect("test JWT config should build"); + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: user_id.to_string(), + session_id: "test-session".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: false, + binding_status: BindingStatus::Active, + display_name: Some("测试用户".to_string()), + }, + &config, + OffsetDateTime::now_utc(), + ) + .expect("test claims should build"); + + AuthenticatedAccessToken::new(claims) + } + #[test] fn tracking_client_marker_accepts_only_trimmed_lowercase_agc() { let cases = [ @@ -893,6 +974,83 @@ mod tests { assert_eq!(resolve_tracking_client_marker(&invalid_headers), None); } + #[test] + fn tracking_identity_keeps_authenticated_user_as_owner() { + let authenticated = build_test_authenticated("user-225"); + let identity = resolve_tracking_identity(Some(&authenticated), None); + + assert_eq!(identity.user_id.as_deref(), Some("user-225")); + assert_eq!(identity.owner_user_id.as_deref(), Some("user-225")); + + let spec = route_spec( + "profile_dashboard_view", + "profile", + module_runtime::RuntimeTrackingScopeKind::User, + "anonymous", + ); + assert_eq!(resolve_tracking_scope_id(&spec, &identity), "user-225"); + } + + #[test] + fn tracking_identity_uses_external_api_owner_and_does_not_forge_user() { + let authenticated = build_test_authenticated("login-user"); + let principal = ExternalApiPrincipal::for_test("owner-user", &[]); + let identity = resolve_tracking_identity(Some(&authenticated), Some(&principal)); + + assert_eq!(identity.user_id, None); + assert_eq!(identity.owner_user_id.as_deref(), Some("owner-user")); + + let spec = route_spec( + "external_editor_view", + "editor", + module_runtime::RuntimeTrackingScopeKind::User, + "anonymous", + ); + assert_eq!(resolve_tracking_scope_id(&spec, &identity), "owner-user"); + } + + #[test] + fn route_tracking_metadata_adds_agc_marker_without_dropping_existing_fields() { + let spec = route_spec( + "asset_upload_ticket_create", + "asset", + module_runtime::RuntimeTrackingScopeKind::User, + "anonymous", + ); + let request_context = RequestContext::new( + "request-225".to_string(), + "POST /api/assets/upload".to_string(), + Duration::ZERO, + false, + ); + + let marked = build_route_tracking_metadata( + &spec, + &request_context, + &Method::POST, + "/api/assets/upload", + axum::http::StatusCode::ACCEPTED, + Some(TrackingClientMarker::Agc), + ); + assert_eq!(marked["client"], "agc"); + assert_eq!(marked["route"], "/api/assets/upload"); + assert_eq!(marked["method"], "POST"); + assert_eq!(marked["status"], 202); + assert_eq!(marked["operation"], "POST /api/assets/upload"); + assert_eq!(marked["asset"]["operation"], "asset_upload_ticket_create"); + assert_eq!(marked["assetOperation"], "asset_upload_ticket_create"); + + let unmarked = build_route_tracking_metadata( + &spec, + &request_context, + &Method::POST, + "/api/assets/upload", + axum::http::StatusCode::ACCEPTED, + None, + ); + assert!(unmarked.get("client").is_none()); + } + #[test] fn route_normalization_preserves_static_segments_and_replaces_ids() { assert_eq!(