5bc5328c06
Project CI / AI game creator shell Rust crates (push) Successful in 1m18s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m55s
Project CI / Backend tests (push) Successful in 3m42s
Project CI / Frontend tests (push) Successful in 1m44s
Project CI / Native shell tests (push) Successful in 5m37s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m24s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m30s
Project CI / Repository checks (push) Successful in 2m0s
Project CI / AI game creator shell web tests (push) Successful in 1m31s
删除独立埋点 origin 环境变量及配置字段,按 dev/release 渠道校验官方站点。 本地开发、测试及容器的 dev 渠道允许规范回环地址,兼容可变端口。 同步环境变量示例、技术方案和运维说明,补充渠道隔离与身份校验测试。 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/496
309 lines
11 KiB
Rust
309 lines
11 KiB
Rust
//! 客户端埋点只在数据库提交后确认;不使用普通路由 tracking outbox。
|
|
use axum::{
|
|
Json, Router,
|
|
extract::{DefaultBodyLimit, Extension, Query, State},
|
|
http::StatusCode,
|
|
middleware,
|
|
routing::{get, post},
|
|
};
|
|
use serde_json::Value;
|
|
use shared_contracts::{admin::AdminAgcTrackingEventListQuery, agc_analytics::AgcAnalyticsBatch};
|
|
use spacetime_client::SpacetimeClientError;
|
|
|
|
use crate::{
|
|
admin::{AuthenticatedAdmin, require_admin_auth},
|
|
api_response::json_success_body,
|
|
auth::{AuthenticatedAccessToken, require_bearer_auth},
|
|
config::AppConfig,
|
|
http_error::AppError,
|
|
request_context::RequestContext,
|
|
state::AppState,
|
|
};
|
|
|
|
const MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024;
|
|
|
|
pub fn router(state: AppState) -> Router<AppState> {
|
|
Router::new()
|
|
.route(
|
|
"/api/agc/analytics/batches",
|
|
post(upload_batch)
|
|
.route_layer(middleware::from_fn_with_state(
|
|
state.clone(),
|
|
require_bearer_auth,
|
|
))
|
|
.layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)),
|
|
)
|
|
.route(
|
|
"/admin/api/agc/tracking-events",
|
|
get(list_events).route_layer(middleware::from_fn_with_state(state, require_admin_auth)),
|
|
)
|
|
}
|
|
|
|
async fn upload_batch(
|
|
State(state): State<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Extension(auth): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<Value>, axum::extract::rejection::JsonRejection>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let Json(raw) = payload.map_err(|error| {
|
|
let status = if error.status() == StatusCode::PAYLOAD_TOO_LARGE {
|
|
StatusCode::PAYLOAD_TOO_LARGE
|
|
} else {
|
|
StatusCode::BAD_REQUEST
|
|
};
|
|
AppError::from_status(status).with_message("客户端埋点请求格式无效")
|
|
})?;
|
|
// serde 也能将位置数组解成 struct;上传合同只接受命名字段的 JSON 对象。
|
|
if !raw.is_object()
|
|
|| !raw
|
|
.get("events")
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|events| events.iter().all(Value::is_object))
|
|
{
|
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
|
.with_message("客户端埋点必须使用事件对象"));
|
|
}
|
|
let batch: AgcAnalyticsBatch = serde_json::from_value(raw).map_err(|_| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("客户端埋点字段不符合合同")
|
|
})?;
|
|
validate_subject_and_origin(&batch, auth.claims().user_id(), &state.config)?;
|
|
module_runtime::agc_analytics::validate_agc_analytics_batch(&batch).map_err(|error| {
|
|
let status = if error == "events_too_large" {
|
|
StatusCode::PAYLOAD_TOO_LARGE
|
|
} else {
|
|
StatusCode::BAD_REQUEST
|
|
};
|
|
AppError::from_status(status).with_message("客户端埋点批次格式不符合合同")
|
|
})?;
|
|
let acknowledgement = state
|
|
.spacetime_client()
|
|
.upload_agc_analytics_batch(batch)
|
|
.await
|
|
.map_err(map_database_error)?;
|
|
Ok(json_success_body(Some(&context), acknowledgement))
|
|
}
|
|
|
|
async fn list_events(
|
|
State(state): State<AppState>,
|
|
Extension(context): Extension<RequestContext>,
|
|
Extension(_admin): Extension<AuthenticatedAdmin>,
|
|
Query(query): Query<AdminAgcTrackingEventListQuery>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
module_runtime::agc_analytics::validate_agc_tracking_query(&query).map_err(|_| {
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("客户端埋点查询参数无效")
|
|
})?;
|
|
let payload = state
|
|
.spacetime_client()
|
|
.list_agc_tracking_events(query)
|
|
.await
|
|
.map_err(map_database_error)?;
|
|
Ok(json_success_body(Some(&context), payload))
|
|
}
|
|
|
|
fn validate_subject_and_origin(
|
|
batch: &AgcAnalyticsBatch,
|
|
user_id: &str,
|
|
config: &AppConfig,
|
|
) -> Result<(), AppError> {
|
|
// 复用部署渠道,不使用客户端 body 或 Host / Forwarded 头推断服务器所属平台。
|
|
let expected_origin = match config.client_download_channel.as_str() {
|
|
"dev" => "https://dev.genarrative.world",
|
|
"release" => "https://www.genarrative.world",
|
|
_ => {
|
|
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
|
.with_message("客户端埋点接收渠道无效"));
|
|
}
|
|
};
|
|
// 本地开发允许回环地址及可变端口,兼容端口漂移和容器映射;线上部署不启用此例外。
|
|
let local_origin = config.client_download_channel == "dev"
|
|
&& matches!(
|
|
config.environment.as_str(),
|
|
"development" | "test" | "container"
|
|
)
|
|
&& url::Url::parse(&batch.destination_origin).is_ok_and(|url| {
|
|
matches!(url.scheme(), "http" | "https")
|
|
&& url.host_str().is_some_and(|host| {
|
|
host == "localhost"
|
|
|| host
|
|
.trim_matches(['[', ']'])
|
|
.parse::<std::net::IpAddr>()
|
|
.is_ok_and(|ip| ip.is_loopback())
|
|
})
|
|
&& url.username().is_empty()
|
|
&& url.password().is_none()
|
|
&& url.origin().ascii_serialization() == batch.destination_origin
|
|
});
|
|
if batch.user_id != user_id
|
|
|| (batch.destination_origin != expected_origin && !local_origin)
|
|
|| batch
|
|
.events
|
|
.iter()
|
|
.any(|event| event.user_id.as_deref() != Some(user_id))
|
|
{
|
|
return Err(
|
|
AppError::from_status(StatusCode::FORBIDDEN).with_message("客户端埋点身份或平台不匹配")
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn map_database_error(error: SpacetimeClientError) -> AppError {
|
|
match error {
|
|
SpacetimeClientError::Procedure(message) if message == "agc_event_conflict" => {
|
|
AppError::from_status(StatusCode::CONFLICT).with_message("客户端埋点事件 ID 内容冲突")
|
|
}
|
|
SpacetimeClientError::Procedure(message)
|
|
if matches!(message.as_str(), "invalid_agc_query" | "invalid_agc_cursor") =>
|
|
{
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("客户端埋点查询参数无效")
|
|
}
|
|
// 不回显数据库或上传内容,超时/未知提交结果不产生确认。
|
|
_ => AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
|
.with_message("客户端埋点数据服务暂不可用"),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn batch() -> AgcAnalyticsBatch {
|
|
AgcAnalyticsBatch {
|
|
schema_version: 1,
|
|
batch_id: uuid::Uuid::new_v4().to_string(),
|
|
destination_origin: "https://dev.genarrative.world".into(),
|
|
user_id: "user-a".into(),
|
|
events: vec![],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn agc_analytics_subject_and_deployment_are_both_required() {
|
|
for (channel, origin, other_origin) in [
|
|
(
|
|
"dev",
|
|
"https://dev.genarrative.world",
|
|
"https://www.genarrative.world",
|
|
),
|
|
(
|
|
"release",
|
|
"https://www.genarrative.world",
|
|
"https://dev.genarrative.world",
|
|
),
|
|
] {
|
|
let config = AppConfig {
|
|
environment: "production".into(),
|
|
client_download_channel: channel.into(),
|
|
..AppConfig::default()
|
|
};
|
|
let mut batch = batch();
|
|
batch.destination_origin = origin.into();
|
|
assert!(validate_subject_and_origin(&batch, "user-a", &config).is_ok());
|
|
assert_eq!(
|
|
validate_subject_and_origin(&batch, "user-b", &config)
|
|
.unwrap_err()
|
|
.status_code(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
for rejected in [
|
|
other_origin,
|
|
"http://127.0.0.1:8082",
|
|
"https://custom.example",
|
|
] {
|
|
batch.destination_origin = rejected.into();
|
|
assert_eq!(
|
|
validate_subject_and_origin(&batch, "user-a", &config)
|
|
.unwrap_err()
|
|
.status_code(),
|
|
StatusCode::FORBIDDEN,
|
|
"{channel}: {rejected}"
|
|
);
|
|
}
|
|
}
|
|
for channel in ["", "qa-2026", "dev-win"] {
|
|
let config = AppConfig {
|
|
client_download_channel: channel.into(),
|
|
..AppConfig::default()
|
|
};
|
|
assert_eq!(
|
|
validate_subject_and_origin(&batch(), "user-a", &config)
|
|
.unwrap_err()
|
|
.status_code(),
|
|
StatusCode::SERVICE_UNAVAILABLE
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn agc_analytics_loopback_supports_local_ports_only_in_dev_environments() {
|
|
let mut batch = batch();
|
|
for environment in ["development", "test", "container", "production", "staging"] {
|
|
for channel in ["dev", "release"] {
|
|
let config = AppConfig {
|
|
environment: environment.into(),
|
|
client_download_channel: channel.into(),
|
|
..AppConfig::default()
|
|
};
|
|
let allowed =
|
|
channel == "dev" && matches!(environment, "development" | "test" | "container");
|
|
for origin in [
|
|
"http://127.0.0.1:8082",
|
|
"http://localhost:18080",
|
|
"http://[::1]:19001",
|
|
"https://localhost:8443",
|
|
] {
|
|
batch.destination_origin = origin.into();
|
|
assert_eq!(
|
|
validate_subject_and_origin(&batch, "user-a", &config).is_ok(),
|
|
allowed,
|
|
"{environment}/{channel}: {origin}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn agc_analytics_local_exception_rejects_noncanonical_and_nonloopback_origins() {
|
|
let config = AppConfig::default();
|
|
let mut batch = batch();
|
|
for origin in [
|
|
"http://192.168.1.2:8082",
|
|
"http://localhost.example:8082",
|
|
"http://0.0.0.0:8082",
|
|
"http://localhost:8082/",
|
|
"http://localhost:8082/api",
|
|
"http://localhost:8082?x=1",
|
|
"http://localhost:8082#fragment",
|
|
"http://user:password@localhost:8082",
|
|
"ftp://localhost:8082",
|
|
"https://dev.genarrative.world/",
|
|
"http://dev.genarrative.world",
|
|
"",
|
|
] {
|
|
batch.destination_origin = origin.into();
|
|
assert_eq!(
|
|
validate_subject_and_origin(&batch, "user-a", &config)
|
|
.unwrap_err()
|
|
.status_code(),
|
|
StatusCode::FORBIDDEN,
|
|
"{origin}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn agc_analytics_database_failure_never_becomes_acknowledgement() {
|
|
for (message, status) in [
|
|
("agc_event_conflict", StatusCode::CONFLICT),
|
|
("invalid_agc_cursor", StatusCode::BAD_REQUEST),
|
|
("unknown", StatusCode::SERVICE_UNAVAILABLE),
|
|
] {
|
|
assert_eq!(
|
|
map_database_error(SpacetimeClientError::Procedure(message.into())).status_code(),
|
|
status
|
|
);
|
|
}
|
|
}
|
|
}
|