阶段3补齐主站路由埋点覆盖

补齐账号态和 External v1 路由 tracking 规范

复用资产详细事件并传递 AGC 标记,避免重复记录

修正 External API Key 主体归属和动态路由归一化
This commit is contained in:
2026-09-02 09:25:48 +00:00
parent 2a23ba6570
commit 01159a2698
5 changed files with 890 additions and 17 deletions
+4
View File
@@ -889,6 +889,8 @@ pub async fn admin_create_editor_showcase_campaign_image_upload_ticket(
&state,
&request_context,
admin.session().subject.as_str(),
Some(admin.session().subject.as_str()),
None,
CreateDirectUploadTicketRequest {
legacy_prefix: "generated-character-drafts".to_string(),
path_segments: vec![
@@ -944,6 +946,8 @@ pub async fn admin_confirm_editor_showcase_campaign_image_upload(
&state,
&request_context,
admin.session().subject.as_str(),
Some(admin.session().subject.as_str()),
None,
confirm_request,
)
.await
+4 -1
View File
@@ -237,12 +237,15 @@ async fn spacetime_unavailable_handler(
async fn record_api_tracking_after_success(
axum::extract::State(state): axum::extract::State<AppState>,
Extension(request_context): Extension<RequestContext>,
request: Request<Body>,
mut request: Request<Body>,
next: middleware::Next,
) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
let client_marker = resolve_tracking_client_marker(request.headers());
if let Some(client_marker) = client_marker {
request.extensions_mut().insert(client_marker);
}
let response = next.run(request).await;
let authenticated = response
.extensions()
+149 -4
View File
@@ -32,7 +32,7 @@ use crate::{
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
tracking::{TrackingEventDraft, record_tracking_event_after_success},
tracking::{TrackingClientMarker, TrackingEventDraft, record_tracking_event_after_success},
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
@@ -75,12 +75,15 @@ pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
client_marker: Option<Extension<TrackingClientMarker>>,
Json(payload): Json<CreateDirectUploadTicketRequest>,
) -> Result<Json<Value>, AppError> {
create_direct_upload_ticket_for_owner(
&state,
&request_context,
authenticated.claims().user_id(),
Some(authenticated.claims().user_id()),
client_marker.map(|Extension(marker)| marker),
payload,
)
.await
@@ -90,6 +93,8 @@ pub(crate) async fn create_direct_upload_ticket_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
tracking_user_id: Option<&str>,
client_marker: Option<TrackingClientMarker>,
payload: CreateDirectUploadTicketRequest,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
@@ -140,8 +145,10 @@ pub(crate) async fn create_direct_upload_ticket_for_owner(
record_asset_tracking_event(
state,
request_context,
tracking_user_id,
owner_user_id,
"asset_upload_ticket_create",
client_marker,
json!({
"asset": {
"operation": "asset_upload_ticket_create",
@@ -454,12 +461,15 @@ pub async fn confirm_asset_object(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
client_marker: Option<Extension<TrackingClientMarker>>,
Json(payload): Json<ConfirmAssetObjectRequest>,
) -> Result<Json<Value>, AppError> {
confirm_asset_object_for_owner(
&state,
&request_context,
authenticated.claims().user_id(),
Some(authenticated.claims().user_id()),
client_marker.map(|Extension(marker)| marker),
payload,
)
.await
@@ -469,6 +479,8 @@ pub(crate) async fn confirm_asset_object_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
tracking_user_id: Option<&str>,
client_marker: Option<TrackingClientMarker>,
payload: ConfirmAssetObjectRequest,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
@@ -509,8 +521,10 @@ pub(crate) async fn confirm_asset_object_for_owner(
record_asset_tracking_event(
state,
request_context,
tracking_user_id,
owner_user_id,
"asset_upload_confirm",
client_marker,
json!({
"asset": {
"operation": "asset_upload_confirm",
@@ -542,6 +556,7 @@ pub async fn bind_asset_object_to_entity(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
client_marker: Option<Extension<TrackingClientMarker>>,
Json(payload): Json<BindAssetObjectRequest>,
) -> Result<Json<Value>, AppError> {
let now_micros = current_utc_micros();
@@ -580,8 +595,10 @@ pub async fn bind_asset_object_to_entity(
record_asset_tracking_event(
&state,
&request_context,
Some(authenticated.claims().user_id()),
authenticated.claims().user_id(),
"asset_bind",
client_marker.map(|Extension(marker)| marker),
json!({
"asset": {
"operation": "asset_bind",
@@ -608,15 +625,84 @@ pub async fn bind_asset_object_to_entity(
async fn record_asset_tracking_event(
state: &AppState,
request_context: &RequestContext,
user_id: &str,
tracking_user_id: Option<&str>,
owner_user_id: &str,
event_key: &'static str,
client_marker: Option<TrackingClientMarker>,
metadata: Value,
) {
let mut draft = TrackingEventDraft::user(event_key, "asset", user_id);
draft.metadata = metadata;
let draft = build_asset_tracking_event_draft(
request_context,
tracking_user_id,
owner_user_id,
event_key,
client_marker,
metadata,
);
record_tracking_event_after_success(state, request_context, draft).await;
}
fn build_asset_tracking_event_draft(
request_context: &RequestContext,
tracking_user_id: Option<&str>,
owner_user_id: &str,
event_key: &'static str,
client_marker: Option<TrackingClientMarker>,
metadata: Value,
) -> TrackingEventDraft {
let mut draft = TrackingEventDraft::new(event_key, "asset");
draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User;
draft.scope_id = owner_user_id.trim().to_string();
draft.user_id = tracking_user_id.map(|user_id| user_id.trim().to_string());
draft.owner_user_id = Some(owner_user_id.trim().to_string());
draft.client_marker = client_marker;
let metadata = apply_asset_tracking_client_marker(metadata, client_marker);
draft.metadata = if client_marker.is_some() {
apply_asset_tracking_route_metadata(metadata, request_context)
} else {
metadata
};
draft
}
fn apply_asset_tracking_client_marker(
mut metadata: Value,
client_marker: Option<TrackingClientMarker>,
) -> Value {
if matches!(client_marker, Some(TrackingClientMarker::Agc))
&& let Some(object) = metadata.as_object_mut()
{
object.insert("client".to_string(), json!("agc"));
}
metadata
}
fn apply_asset_tracking_route_metadata(
mut metadata: Value,
request_context: &RequestContext,
) -> Value {
let Some(object) = metadata.as_object_mut() else {
return metadata;
};
let Some((method, request_uri)) = request_context.operation().split_once(' ') else {
return metadata;
};
let route = request_uri.split('?').next().unwrap_or(request_uri);
object
.entry("route".to_string())
.or_insert_with(|| json!(route));
object
.entry("method".to_string())
.or_insert_with(|| json!(method));
object
.entry("status".to_string())
.or_insert_with(|| json!(200));
object
.entry("operation".to_string())
.or_insert_with(|| json!(request_context.operation()));
metadata
}
fn resolve_asset_read_target(query: &GetReadUrlQuery) -> Result<AssetReadTarget, AppError> {
if let Some(object_key) = query
.object_key
@@ -1023,10 +1109,69 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::tracking::TrackingClientMarker;
use crate::{app::build_router, config::AppConfig, state::AppState};
type HmacSha256 = Hmac<Sha256>;
#[test]
fn asset_tracking_metadata_receives_only_valid_agc_marker() {
let request_context = crate::request_context::RequestContext::new(
"request-225-asset".to_string(),
"POST /api/external/v1/assets/objects/confirm?x=1".to_string(),
std::time::Duration::ZERO,
false,
);
let marked = super::apply_asset_tracking_client_marker(
json!({"asset": {"operation": "asset_upload_confirm"}}),
Some(TrackingClientMarker::Agc),
);
assert_eq!(marked["client"], "agc");
assert_eq!(marked["asset"]["operation"], "asset_upload_confirm");
let marked = super::apply_asset_tracking_route_metadata(marked, &request_context);
assert_eq!(marked["route"], "/api/external/v1/assets/objects/confirm");
assert_eq!(marked["method"], "POST");
assert_eq!(marked["status"], 200);
assert_eq!(
marked["operation"],
"POST /api/external/v1/assets/objects/confirm?x=1"
);
let unmarked = super::apply_asset_tracking_client_marker(
json!({"asset": {"operation": "asset_upload_confirm"}}),
None,
);
assert!(unmarked.get("client").is_none());
}
#[test]
fn external_asset_tracking_keeps_owner_without_forging_user() {
let request_context = crate::request_context::RequestContext::new(
"request-225-owner".to_string(),
"POST /api/external/v1/assets/objects/confirm".to_string(),
std::time::Duration::ZERO,
false,
);
let draft = super::build_asset_tracking_event_draft(
&request_context,
None,
"owner-225",
"asset_upload_confirm",
Some(TrackingClientMarker::Agc),
json!({"asset": {"operation": "asset_upload_confirm"}}),
);
assert_eq!(draft.user_id, None);
assert_eq!(draft.owner_user_id.as_deref(), Some("owner-225"));
assert_eq!(
draft.scope_kind,
module_runtime::RuntimeTrackingScopeKind::User
);
assert_eq!(draft.scope_id, "owner-225");
assert_eq!(draft.metadata["client"], "agc");
}
fn asset_object_record(
access_policy: module_assets::AssetObjectAccessPolicy,
owner_user_id: Option<&str>,
@@ -17,6 +17,7 @@ use crate::{
http_error::AppError,
request_context::RequestContext,
state::AppState,
tracking::TrackingClientMarker,
};
const SCOPE_EDITOR_ASSET: &str = "editor:asset";
@@ -26,6 +27,7 @@ pub async fn create_external_direct_upload_ticket(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
client_marker: Option<Extension<TrackingClientMarker>>,
Json(payload): Json<CreateDirectUploadTicketRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
@@ -33,6 +35,8 @@ pub async fn create_external_direct_upload_ticket(
&state,
&request_context,
principal.owner_user_id(),
None,
client_marker.map(|Extension(marker)| marker),
payload,
)
.await
@@ -42,12 +46,20 @@ pub async fn confirm_external_asset_object(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
client_marker: Option<Extension<TrackingClientMarker>>,
Json(mut payload): Json<ConfirmAssetObjectRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
payload.owner_user_id = Some(principal.owner_user_id().to_string());
confirm_asset_object_for_owner(&state, &request_context, principal.owner_user_id(), payload)
.await
confirm_asset_object_for_owner(
&state,
&request_context,
principal.owner_user_id(),
None,
client_marker.map(|Extension(marker)| marker),
payload,
)
.await
}
pub async fn get_external_asset_read_url(
File diff suppressed because it is too large Load Diff