阶段2接入tracking主体归属与AGC元数据

接入 AuthenticatedAccessToken 与 ExternalApiPrincipal 的 tracking 主体归属。

让 External API Key User scope 使用 owner_user_id,并保持原有账号态语义。

在既有 route metadata 中追加 client=agc,保留原有字段和资产嵌套信息。

增加主体归属、scope 和 metadata 合并定向测试。
This commit is contained in:
2026-09-02 08:19:28 +00:00
parent 2296f79fdc
commit 2a23ba6570
2 changed files with 177 additions and 16 deletions
+3
View File
@@ -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::<AuthenticatedAccessToken>()
.cloned();
let external_principal = response.extensions().get::<ExternalApiPrincipal>().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;
+174 -16
View File
@@ -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<TrackingClientMarker>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct TrackingIdentity {
user_id: Option<String>,
owner_user_id: Option<String>,
}
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<TrackingClientMarker>,
) {
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<RouteTrackingSpec> {
use RuntimeTrackingScopeKind::{Site, User};
@@ -523,6 +565,7 @@ fn build_route_tracking_metadata(
method: &Method,
path: &str,
status: StatusCode,
client_marker: Option<TrackingClientMarker>,
) -> 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!(