阶段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:
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user