Merge remote-tracking branch 'refs/remotes/origin/master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m17s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m25s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m34s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m37s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m23s
Project CI / Frontend tests (pull_request) Failing after 14m1s
Project CI / Repository checks (pull_request) Successful in 12m35s
Project CI / Native shell tests (pull_request) Successful in 17m2s
Project CI / Backend tests (pull_request) Successful in 17m18s
Project CI / AI game creator shell web tests (pull_request) Failing after 14m48s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m17s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m25s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m34s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m37s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m23s
Project CI / Frontend tests (pull_request) Failing after 14m1s
Project CI / Repository checks (pull_request) Successful in 12m35s
Project CI / Native shell tests (pull_request) Successful in 17m2s
Project CI / Backend tests (pull_request) Successful in 17m18s
Project CI / AI game creator shell web tests (pull_request) Failing after 14m48s
This commit is contained in:
Generated
+58
-33
@@ -52,17 +52,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -182,7 +171,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -193,7 +182,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1133,7 +1122,7 @@ dependencies = [
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"socket2",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1148,7 +1137,7 @@ dependencies = [
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1451,7 +1440,7 @@ version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
"document-features",
|
||||
"egui",
|
||||
@@ -1486,7 +1475,7 @@ version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"ahash",
|
||||
"bitflags 2.13.0",
|
||||
"emath",
|
||||
"epaint",
|
||||
@@ -1503,7 +1492,7 @@ version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
"document-features",
|
||||
"egui",
|
||||
@@ -1598,7 +1587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"ahash 0.8.12",
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
"ecolor",
|
||||
"emath",
|
||||
@@ -1628,7 +1617,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2062,9 +2051,6 @@ name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
dependencies = [
|
||||
"ahash 0.7.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -2094,6 +2080,11 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -3084,7 +3075,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3739,7 +3730,7 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527735ac204efb9fa3884bfd9224d016c5735fabe1d394ebed145b40e7545b99"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"ahash",
|
||||
"async-trait",
|
||||
"blake2",
|
||||
"bstr",
|
||||
@@ -3776,7 +3767,7 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a7ffe2f5acf9f94fd255cfd1438866bc9124f8f0c7d42562bd3f853df2094b7"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"ahash",
|
||||
"async-trait",
|
||||
"brotli",
|
||||
"bstr",
|
||||
@@ -3914,7 +3905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6705a26ad89d241a989a5395641931ba37076f5ab5fbd19ee92402414a43af32"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"hashbrown 0.12.3",
|
||||
"hashbrown 0.17.1",
|
||||
"parking_lot",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
@@ -4110,6 +4101,8 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4856,7 +4849,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5247,6 +5240,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5420,7 +5414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5529,7 +5523,7 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c4e9d07e21ad665c45736871e5005d8457ab3d2cdd7806e351aa5e1c8c2ac32"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"ahash",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
"hashbrown 0.16.1",
|
||||
@@ -5815,7 +5809,16 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6284,6 +6287,28 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"ts-rs-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.25.1"
|
||||
@@ -6901,7 +6926,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7134,7 +7159,7 @@ version = "0.30.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"ahash",
|
||||
"android-activity",
|
||||
"atomic-waker",
|
||||
"bitflags 2.13.0",
|
||||
|
||||
@@ -105,6 +105,7 @@ serde_json = "1"
|
||||
serde_urlencoded = "0.7"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
ts-rs = "12.0.1"
|
||||
socket2 = "0.6"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["mp3"] }
|
||||
spacetimedb = "=2.8.3"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
@@ -9,8 +11,8 @@ use axum::{
|
||||
};
|
||||
use serde_json::json;
|
||||
use tower_http::{
|
||||
classify::ServerErrorsFailureClass,
|
||||
trace::{DefaultOnRequest, TraceLayer},
|
||||
classify::{ServerErrorsAsFailures, ServerErrorsFailureClass, SharedClassifier},
|
||||
trace::{DefaultOnBodyChunk, DefaultOnEos, DefaultOnRequest, TraceLayer},
|
||||
};
|
||||
use tracing::{Level, Span, error, info_span};
|
||||
|
||||
@@ -85,66 +87,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
state.clone(),
|
||||
record_http_observability,
|
||||
))
|
||||
// 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(|request: &Request<Body>| {
|
||||
let request_id =
|
||||
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
|
||||
let route = crate::telemetry::observability_route(request.uri().path());
|
||||
let scheme = crate::telemetry::resolve_request_scheme(request.headers());
|
||||
let span_name = format!("{} {}", request.method(), route);
|
||||
|
||||
info_span!(
|
||||
"http.request",
|
||||
otel.kind = "server",
|
||||
otel.name = %span_name,
|
||||
otel.status_code = tracing::field::Empty,
|
||||
http.response.status_code = tracing::field::Empty,
|
||||
method = %request.method(),
|
||||
http.request.method = %request.method(),
|
||||
http.route = %route,
|
||||
url.scheme = %scheme,
|
||||
url.path = %request.uri().path(),
|
||||
request_id = %request_id,
|
||||
status = tracing::field::Empty,
|
||||
latency_ms = tracing::field::Empty,
|
||||
)
|
||||
})
|
||||
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
||||
.on_response(
|
||||
|response: &axum::response::Response,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
let status = response.status().as_u16();
|
||||
span.record("status", status);
|
||||
span.record("http.response.status_code", status);
|
||||
span.record(
|
||||
"otel.status_code",
|
||||
if response.status().is_server_error() {
|
||||
"ERROR"
|
||||
} else {
|
||||
"OK"
|
||||
},
|
||||
);
|
||||
span.record("latency_ms", latency_ms);
|
||||
},
|
||||
)
|
||||
.on_failure(
|
||||
|failure: ServerErrorsFailureClass,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
error!(
|
||||
parent: span,
|
||||
latency_ms,
|
||||
failure = %failure,
|
||||
"http request failed"
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.layer(http_trace_layer())
|
||||
// request_id 中间件先进入请求链,确保后续 tracing、错误处理和响应头层都能复用同一份请求标识。
|
||||
.layer(middleware::from_fn(attach_request_context))
|
||||
.with_state(state)
|
||||
@@ -159,68 +102,72 @@ pub fn build_spacetime_unavailable_router(message: String) -> Router {
|
||||
// 依赖不可用模式不挂业务 state,统一返回 503,并继续保留 request_id / API 版本 / 耗时响应头。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
.layer(middleware::from_fn(propagate_request_id_header))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(|request: &Request<Body>| {
|
||||
let request_id =
|
||||
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
|
||||
let route = crate::telemetry::observability_route(request.uri().path());
|
||||
let scheme = crate::telemetry::resolve_request_scheme(request.headers());
|
||||
let span_name = format!("{} {}", request.method(), route);
|
||||
|
||||
info_span!(
|
||||
"http.request",
|
||||
otel.kind = "server",
|
||||
otel.name = %span_name,
|
||||
otel.status_code = tracing::field::Empty,
|
||||
http.response.status_code = tracing::field::Empty,
|
||||
method = %request.method(),
|
||||
http.request.method = %request.method(),
|
||||
http.route = %route,
|
||||
url.scheme = %scheme,
|
||||
url.path = %request.uri().path(),
|
||||
request_id = %request_id,
|
||||
status = tracing::field::Empty,
|
||||
latency_ms = tracing::field::Empty,
|
||||
)
|
||||
})
|
||||
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
||||
.on_response(
|
||||
|response: &axum::response::Response,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
let status = response.status().as_u16();
|
||||
span.record("status", status);
|
||||
span.record("http.response.status_code", status);
|
||||
span.record(
|
||||
"otel.status_code",
|
||||
if response.status().is_server_error() {
|
||||
"ERROR"
|
||||
} else {
|
||||
"OK"
|
||||
},
|
||||
);
|
||||
span.record("latency_ms", latency_ms);
|
||||
},
|
||||
)
|
||||
.on_failure(
|
||||
|failure: ServerErrorsFailureClass,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
error!(
|
||||
parent: span,
|
||||
latency_ms,
|
||||
failure = %failure,
|
||||
"http request failed"
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.layer(http_trace_layer())
|
||||
.layer(middleware::from_fn(attach_request_context))
|
||||
}
|
||||
|
||||
type HttpTraceLayer = TraceLayer<
|
||||
SharedClassifier<ServerErrorsAsFailures>,
|
||||
fn(&Request<Body>) -> Span,
|
||||
DefaultOnRequest,
|
||||
fn(&Response, Duration, &Span),
|
||||
DefaultOnBodyChunk,
|
||||
DefaultOnEos,
|
||||
fn(ServerErrorsFailureClass, Duration, &Span),
|
||||
>;
|
||||
|
||||
fn http_trace_layer() -> HttpTraceLayer {
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(make_http_request_span as fn(&Request<Body>) -> Span)
|
||||
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
||||
.on_response(record_http_response_span as fn(&Response, Duration, &Span))
|
||||
.on_failure(record_http_failure as fn(ServerErrorsFailureClass, Duration, &Span))
|
||||
}
|
||||
|
||||
fn make_http_request_span(request: &Request<Body>) -> Span {
|
||||
let request_id = resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
|
||||
let route = crate::telemetry::observability_route(request);
|
||||
let scheme = crate::telemetry::resolve_request_scheme(request.headers());
|
||||
let span_name = format!("{} {}", request.method(), route);
|
||||
|
||||
info_span!(
|
||||
"http.request",
|
||||
otel.kind = "server",
|
||||
otel.name = %span_name,
|
||||
otel.status_code = tracing::field::Empty,
|
||||
http.response.status_code = tracing::field::Empty,
|
||||
method = %request.method(),
|
||||
http.request.method = %request.method(),
|
||||
http.route = %route,
|
||||
url.scheme = %scheme,
|
||||
url.path = %request.uri().path(),
|
||||
request_id = %request_id,
|
||||
status = tracing::field::Empty,
|
||||
latency_ms = tracing::field::Empty,
|
||||
)
|
||||
}
|
||||
|
||||
fn record_http_response_span(response: &Response, latency: Duration, span: &Span) {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
let status = response.status().as_u16();
|
||||
span.record("status", status);
|
||||
span.record("http.response.status_code", status);
|
||||
span.record(
|
||||
"otel.status_code",
|
||||
if response.status().is_server_error() {
|
||||
"ERROR"
|
||||
} else {
|
||||
"OK"
|
||||
},
|
||||
);
|
||||
span.record("latency_ms", latency_ms);
|
||||
}
|
||||
|
||||
fn record_http_failure(failure: ServerErrorsFailureClass, latency: Duration, span: &Span) {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
error!(parent: span, latency_ms, failure = %failure, "http request failed");
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SpacetimeUnavailableState {
|
||||
message: std::sync::Arc<str>,
|
||||
@@ -308,6 +255,201 @@ mod tests {
|
||||
const TEST_PASSWORD: &str = "secret123";
|
||||
const INTERNAL_TEST_SECRET: &str = "test-internal-secret";
|
||||
|
||||
mod http_tracing {
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use tracing::{
|
||||
Metadata, Subscriber,
|
||||
field::{Field, Visit},
|
||||
instrument::WithSubscriber,
|
||||
span::{Attributes, Id, Record},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::state::{BackpressureState, HttpRequestPermitPoolKind};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct HttpSpanCapture(Arc<Mutex<Vec<BTreeMap<String, String>>>>);
|
||||
|
||||
struct SpanFields<'a>(&'a mut BTreeMap<String, String>);
|
||||
|
||||
impl Visit for SpanFields<'_> {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
self.0
|
||||
.insert(field.name().to_string(), format!("{value:?}"));
|
||||
}
|
||||
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
self.0.insert(field.name().to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl Subscriber for HttpSpanCapture {
|
||||
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
|
||||
metadata.is_span() && metadata.name() == "http.request"
|
||||
}
|
||||
|
||||
fn new_span(&self, attributes: &Attributes<'_>) -> Id {
|
||||
let mut spans = self.0.lock().expect("span capture should lock");
|
||||
let mut fields = BTreeMap::new();
|
||||
attributes.record(&mut SpanFields(&mut fields));
|
||||
spans.push(fields);
|
||||
Id::from_u64(spans.len() as u64)
|
||||
}
|
||||
|
||||
fn record(&self, span: &Id, values: &Record<'_>) {
|
||||
let mut spans = self.0.lock().expect("span capture should lock");
|
||||
values.record(&mut SpanFields(&mut spans[span.into_u64() as usize - 1]));
|
||||
}
|
||||
|
||||
fn record_follows_from(&self, _: &Id, _: &Id) {}
|
||||
fn event(&self, _: &tracing::Event<'_>) {}
|
||||
fn enter(&self, _: &Id) {}
|
||||
fn exit(&self, _: &Id) {}
|
||||
}
|
||||
|
||||
async fn assert_rejection_observed(
|
||||
app: Router,
|
||||
request: Request<Body>,
|
||||
expected_status: StatusCode,
|
||||
expected_route: &str,
|
||||
expected_code: &str,
|
||||
) -> (Value, String) {
|
||||
let expected_request_id = request.headers().get("x-request-id").cloned();
|
||||
let path = request.uri().path().to_string();
|
||||
let capture = HttpSpanCapture::default();
|
||||
let response = app
|
||||
.oneshot(request)
|
||||
.with_subscriber(capture.clone())
|
||||
.await
|
||||
.expect("rejected request should complete");
|
||||
|
||||
assert_eq!(response.status(), expected_status);
|
||||
let request_id = response.headers()["x-request-id"]
|
||||
.to_str()
|
||||
.expect("request id should be a header string")
|
||||
.to_string();
|
||||
assert!(!request_id.is_empty());
|
||||
assert_ne!(request_id, "unknown");
|
||||
if let Some(expected_request_id) = expected_request_id {
|
||||
assert_eq!(response.headers()["x-request-id"], expected_request_id);
|
||||
}
|
||||
for name in ["x-api-version", "x-route-version"] {
|
||||
assert_eq!(response.headers()[name], shared_contracts::api::API_VERSION);
|
||||
}
|
||||
assert!(
|
||||
response.headers()["x-response-time-ms"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.parse::<u64>()
|
||||
.is_ok()
|
||||
);
|
||||
if expected_status == StatusCode::TOO_MANY_REQUESTS {
|
||||
assert_eq!(response.headers()["retry-after"], "1");
|
||||
}
|
||||
let payload = read_json_response(response).await;
|
||||
assert_eq!(payload["error"]["code"], expected_code);
|
||||
|
||||
let spans = capture.0.lock().expect("span capture should lock");
|
||||
assert_eq!(
|
||||
spans.len(),
|
||||
1,
|
||||
"each rejected request should have one HTTP span"
|
||||
);
|
||||
let fields = &spans[0];
|
||||
assert_eq!(fields["request_id"], request_id);
|
||||
assert_eq!(fields["http.route"], expected_route);
|
||||
assert_eq!(fields["otel.name"], format!("GET {expected_route}"));
|
||||
assert_eq!(fields["url.path"], path);
|
||||
assert_eq!(fields["http.request.method"], "GET");
|
||||
assert_eq!(fields["status"], expected_status.as_u16().to_string());
|
||||
assert_eq!(
|
||||
fields["http.response.status_code"],
|
||||
expected_status.as_u16().to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
fields["otel.status_code"],
|
||||
if expected_status.is_server_error() {
|
||||
"ERROR"
|
||||
} else {
|
||||
"OK"
|
||||
}
|
||||
);
|
||||
assert!(fields["latency_ms"].parse::<u64>().is_ok());
|
||||
(payload, request_id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_rejections_share_the_matched_route_template() {
|
||||
let app =
|
||||
build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
for project_id in ["project-one", "project-two"] {
|
||||
assert_rejection_observed(
|
||||
app.clone(),
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/editor/projects/{project_id}/agent-conversations"
|
||||
))
|
||||
.header("x-request-id", format!("req-trace-{project_id}"))
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"/api/editor/projects/{project_id}/agent-conversations",
|
||||
"UNAUTHORIZED",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backpressure_rejection_keeps_generated_context_and_headers() {
|
||||
let config = AppConfig {
|
||||
max_concurrent_requests: Some(1),
|
||||
..AppConfig::default()
|
||||
};
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let (_, pool) = BackpressureState::from_ref(&state)
|
||||
.request_permit_pool(HttpRequestPermitPoolKind::Default)
|
||||
.expect("default request pool should exist");
|
||||
let _held_permit = pool
|
||||
.try_acquire_owned()
|
||||
.expect("pool should have one permit");
|
||||
|
||||
let (payload, request_id) = assert_rejection_observed(
|
||||
build_router(state),
|
||||
Request::builder()
|
||||
.uri("/api/editor/projects/project-one/agent-conversations")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"/api/editor/projects/{project_id}/agent-conversations",
|
||||
"TOO_MANY_REQUESTS",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(payload["meta"]["requestId"], request_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_router_rejection_keeps_generated_context_and_headers() {
|
||||
let (payload, request_id) = assert_rejection_observed(
|
||||
build_spacetime_unavailable_router("test unavailable".to_string()),
|
||||
Request::builder()
|
||||
.uri("/api/auth/login-options")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"/api/*",
|
||||
"SERVICE_UNAVAILABLE",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(payload["meta"]["requestId"], request_id);
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_phone_user_with_password(
|
||||
state: &AppState,
|
||||
phone_number: &str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,364 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
request_context::attach_request_context,
|
||||
state::project_metadata::{EditorProjectMediaRepair, EditorProjectRepository},
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
http::Request,
|
||||
middleware,
|
||||
routing::{get, patch},
|
||||
};
|
||||
use futures_util::future::BoxFuture;
|
||||
use platform_auth::{AccessTokenClaims, AuthProvider, BindingStatus};
|
||||
use spacetime_client::EditorProjectLayoutSaveV2AckRecord;
|
||||
use std::sync::Mutex;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingProjects {
|
||||
calls: Mutex<Vec<(&'static str, Value)>>,
|
||||
error: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl RecordingProjects {
|
||||
fn finish<T: Send + 'static>(
|
||||
&self,
|
||||
operation: &'static str,
|
||||
input: Value,
|
||||
result: T,
|
||||
) -> BoxFuture<'_, Result<T, SpacetimeClientError>> {
|
||||
self.calls.lock().unwrap().push((operation, input));
|
||||
Box::pin(async move {
|
||||
match self.error {
|
||||
Some(message) => Err(SpacetimeClientError::Procedure(message.to_string())),
|
||||
None => Ok(result),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn project_record(project_id: &str, owner: &str, title: &str) -> EditorProjectRecord {
|
||||
let viewport = EditorCanvasViewportRecord {
|
||||
x: 1.0,
|
||||
y: 2.0,
|
||||
scale: 1.0,
|
||||
};
|
||||
EditorProjectRecord {
|
||||
project_id: project_id.to_string(),
|
||||
owner_user_id: owner.to_string(),
|
||||
title: title.to_string(),
|
||||
canvas: EditorCanvasRecord {
|
||||
canvas_id: "canvas-fixture".to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
title: title.to_string(),
|
||||
viewport: viewport.clone(),
|
||||
layers: json!([]),
|
||||
revision: 7,
|
||||
layout_storage_version: 2,
|
||||
background_color: None,
|
||||
created_at: "0.000000Z".to_string(),
|
||||
updated_at: "0.000000Z".to_string(),
|
||||
},
|
||||
viewport,
|
||||
layers: json!([]),
|
||||
resources: vec![],
|
||||
created_at: "0.000000Z".to_string(),
|
||||
updated_at: "0.000000Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorProjectRepository for RecordingProjects {
|
||||
fn get_recent_editor_project(
|
||||
&self,
|
||||
owner_user_id: String,
|
||||
) -> BoxFuture<'_, Result<Option<EditorProjectRecord>, SpacetimeClientError>> {
|
||||
let record = project_record("project-fixture", &owner_user_id, "原名称");
|
||||
self.finish("recent", json!({"owner": owner_user_id}), Some(record))
|
||||
}
|
||||
|
||||
fn list_editor_projects(
|
||||
&self,
|
||||
owner_user_id: String,
|
||||
) -> BoxFuture<'_, Result<Vec<EditorProjectRecord>, SpacetimeClientError>> {
|
||||
let record = project_record("project-fixture", &owner_user_id, "原名称");
|
||||
self.finish("list", json!({"owner": owner_user_id}), vec![record])
|
||||
}
|
||||
|
||||
fn get_editor_project(
|
||||
&self,
|
||||
input: EditorProjectGetRecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectRecord, SpacetimeClientError>> {
|
||||
let record = project_record(&input.project_id, &input.owner_user_id, "原名称");
|
||||
self.finish(
|
||||
"get",
|
||||
json!({"owner": input.owner_user_id, "project": input.project_id}),
|
||||
record,
|
||||
)
|
||||
}
|
||||
|
||||
fn create_editor_project(
|
||||
&self,
|
||||
input: EditorProjectCreateRecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectRecord, SpacetimeClientError>> {
|
||||
let record = project_record(&input.project_id, &input.owner_user_id, &input.title);
|
||||
self.finish("create", json!({"owner": input.owner_user_id, "project": input.project_id, "title": input.title, "at": input.now_micros}), record)
|
||||
}
|
||||
|
||||
fn rename_editor_project(
|
||||
&self,
|
||||
input: EditorProjectRenameRecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectRecord, SpacetimeClientError>> {
|
||||
let record = project_record(&input.project_id, &input.owner_user_id, &input.title);
|
||||
self.finish("rename", json!({"owner": input.owner_user_id, "project": input.project_id, "title": input.title, "at": input.updated_at_micros}), record)
|
||||
}
|
||||
|
||||
fn delete_editor_project(
|
||||
&self,
|
||||
input: EditorProjectDeleteRecordInput,
|
||||
) -> BoxFuture<'_, Result<String, SpacetimeClientError>> {
|
||||
self.finish(
|
||||
"delete",
|
||||
json!({"owner": input.owner_user_id, "project": input.project_id}),
|
||||
input.project_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn save_editor_project_layout_v2_ack(
|
||||
&self,
|
||||
input: EditorProjectLayoutSaveV2RecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectLayoutSaveV2AckRecord, SpacetimeClientError>> {
|
||||
let ack = EditorProjectLayoutSaveV2AckRecord {
|
||||
project_id: input.project_id.clone(),
|
||||
canvas_id: "canvas-fixture".to_string(),
|
||||
revision: input.expected_revision + 1,
|
||||
updated_at: "1.000000Z".to_string(),
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
};
|
||||
self.finish("layout", json!({"owner": input.owner_user_id, "project": input.project_id, "revision": input.expected_revision, "layers": input.layers_json, "viewport": input.viewport}), ack)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingMediaRepair {
|
||||
calls: Mutex<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
impl EditorProjectMediaRepair for RecordingMediaRepair {
|
||||
fn repair_project(
|
||||
&self,
|
||||
mut project: EditorProjectRecord,
|
||||
) -> BoxFuture<'_, EditorProjectRecord> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((project.project_id.clone(), project.owner_user_id.clone()));
|
||||
Box::pin(async move {
|
||||
project.updated_at = "media-repaired".to_string();
|
||||
project
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn authenticated_owner() -> AuthenticatedAccessToken {
|
||||
AuthenticatedAccessToken::new(AccessTokenClaims {
|
||||
iss: "fixture".to_string(),
|
||||
sub: "owner-from-token".to_string(),
|
||||
sid: "fixture-session".to_string(),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
ver: 1,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: None,
|
||||
device: None,
|
||||
iat: 1,
|
||||
exp: u64::MAX,
|
||||
})
|
||||
}
|
||||
|
||||
fn metadata_router(projects: Arc<RecordingProjects>, media: Arc<RecordingMediaRepair>) -> Router {
|
||||
// 直接以窄状态组装实际 handler,不构造 AppConfig、连接数据库或初始化其它平台。
|
||||
Router::new()
|
||||
.route("/projects/recent", get(load_recent_editor_project))
|
||||
.route(
|
||||
"/projects",
|
||||
get(list_editor_projects).post(create_editor_project),
|
||||
)
|
||||
.route(
|
||||
"/projects/{project_id}",
|
||||
get(get_editor_project)
|
||||
.patch(save_editor_project_layout)
|
||||
.delete(delete_editor_project),
|
||||
)
|
||||
.route(
|
||||
"/projects/{project_id}/metadata",
|
||||
patch(rename_editor_project),
|
||||
)
|
||||
.layer(Extension(authenticated_owner()))
|
||||
.layer(middleware::from_fn(attach_request_context))
|
||||
.with_state(EditorProjectState::new(projects, media))
|
||||
}
|
||||
|
||||
async fn send(router: &Router, method: &str, path: &str, body: Value) -> (StatusCode, Value) {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header("content-type", "application/json")
|
||||
.header("idempotency-key", "metadata-fixture")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), 65_536).await.unwrap();
|
||||
(status, serde_json::from_slice(&body).unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_project_reads_preserve_owner_and_use_media_repair_results() {
|
||||
let projects = Arc::new(RecordingProjects::default());
|
||||
let media = Arc::new(RecordingMediaRepair::default());
|
||||
let router = metadata_router(projects.clone(), media.clone());
|
||||
for (path, result_path) in [
|
||||
("/projects/recent", "/project"),
|
||||
("/projects", "/projects/0"),
|
||||
("/projects/project-fixture", "/project"),
|
||||
] {
|
||||
let (status, body) = send(&router, "GET", path, Value::Null).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let result = body.pointer(result_path).unwrap();
|
||||
assert_eq!(result["projectId"], "project-fixture");
|
||||
assert_eq!(result["updatedAt"], "media-repaired");
|
||||
}
|
||||
let calls = projects.calls.lock().unwrap();
|
||||
assert_eq!(
|
||||
calls.iter().map(|call| call.0).collect::<Vec<_>>(),
|
||||
["recent", "list", "get"]
|
||||
);
|
||||
assert!(
|
||||
calls
|
||||
.iter()
|
||||
.all(|call| call.1["owner"] == "owner-from-token")
|
||||
);
|
||||
assert_eq!(
|
||||
*media.calls.lock().unwrap(),
|
||||
vec![
|
||||
(
|
||||
"project-fixture".to_string(),
|
||||
"owner-from-token".to_string()
|
||||
);
|
||||
3
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_project_writes_preserve_owner_idempotency_and_revision() {
|
||||
let projects = Arc::new(RecordingProjects::default());
|
||||
let media = Arc::new(RecordingMediaRepair::default());
|
||||
let router = metadata_router(projects.clone(), media.clone());
|
||||
let (status, created) = send(
|
||||
&router,
|
||||
"POST",
|
||||
"/projects",
|
||||
json!({"title": " 测试项目 ", "ownerUserId": "untrusted-owner"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let project_id = editor_idempotent_create_id(
|
||||
EDITOR_PROJECT_ID_PREFIX,
|
||||
"owner-from-token",
|
||||
"internal-editor-project",
|
||||
"metadata-fixture",
|
||||
);
|
||||
assert_eq!(created["project"]["projectId"], project_id);
|
||||
assert_eq!(created["project"]["title"], "测试项目");
|
||||
|
||||
let (status, renamed) = send(
|
||||
&router,
|
||||
"PATCH",
|
||||
"/projects/project-fixture/metadata",
|
||||
json!({"title": "新名称"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(renamed["project"]["title"], "新名称");
|
||||
|
||||
let (status, saved) = send(
|
||||
&router,
|
||||
"PATCH",
|
||||
"/projects/project-fixture",
|
||||
json!({"viewport": {"x": 10, "y": 20, "scale": 2}, "layers": [], "expectedRevision": 7}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(saved["revision"], 8);
|
||||
assert_eq!(saved["projectId"], "project-fixture");
|
||||
|
||||
let (status, deleted) = send(&router, "DELETE", "/projects/project-fixture", Value::Null).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(deleted["deletedProjectId"], "project-fixture");
|
||||
let calls = projects.calls.lock().unwrap();
|
||||
assert_eq!(
|
||||
calls.iter().map(|call| call.0).collect::<Vec<_>>(),
|
||||
["create", "rename", "layout", "delete"]
|
||||
);
|
||||
assert!(
|
||||
calls
|
||||
.iter()
|
||||
.all(|call| call.1["owner"] == "owner-from-token")
|
||||
);
|
||||
assert_eq!(calls[0].1["project"], project_id);
|
||||
assert_eq!(calls[2].1["revision"], 7);
|
||||
assert_eq!(calls[2].1["layers"], "[]");
|
||||
assert_eq!(
|
||||
calls[2].1["viewport"],
|
||||
json!({"x": 10.0, "y": 20.0, "scale": 2.0})
|
||||
);
|
||||
assert!(media.calls.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_project_handlers_propagate_facade_errors_without_media_side_effects() {
|
||||
for (message, expected_status) in [
|
||||
("项目不存在", StatusCode::NOT_FOUND),
|
||||
("无权访问该项目", StatusCode::FORBIDDEN),
|
||||
("项目版本冲突", StatusCode::CONFLICT),
|
||||
] {
|
||||
let projects = Arc::new(RecordingProjects {
|
||||
error: Some(message),
|
||||
..Default::default()
|
||||
});
|
||||
let media = Arc::new(RecordingMediaRepair::default());
|
||||
let router = metadata_router(projects.clone(), media.clone());
|
||||
for (method, path, body) in [
|
||||
("GET", "/projects/recent", Value::Null),
|
||||
("GET", "/projects", Value::Null),
|
||||
("GET", "/projects/project-fixture", Value::Null),
|
||||
("POST", "/projects", json!({"title": "测试"})),
|
||||
(
|
||||
"PATCH",
|
||||
"/projects/project-fixture/metadata",
|
||||
json!({"title": "测试"}),
|
||||
),
|
||||
(
|
||||
"PATCH",
|
||||
"/projects/project-fixture",
|
||||
json!({"viewport": {"x": 0, "y": 0, "scale": 1}, "layers": [], "expectedRevision": 7}),
|
||||
),
|
||||
("DELETE", "/projects/project-fixture", Value::Null),
|
||||
] {
|
||||
let (status, body) = send(&router, method, path, body).await;
|
||||
assert_eq!(status, expected_status, "{method} {path}");
|
||||
assert!(body.to_string().contains(message));
|
||||
}
|
||||
assert_eq!(projects.calls.lock().unwrap().len(), 7);
|
||||
assert!(media.calls.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
external_api_keys::{hash_external_api_key, map_external_api_key_error},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
state::ExternalApiAuthState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -50,7 +50,7 @@ impl ExternalApiPrincipal {
|
||||
}
|
||||
|
||||
pub async fn require_external_api_key(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<ExternalApiAuthState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -61,7 +61,7 @@ pub async fn require_external_api_key(
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let raw_key = extract_external_api_bearer(request.headers())?;
|
||||
let key = state
|
||||
.spacetime_client()
|
||||
.authenticator()
|
||||
.authenticate_external_api_key(ExternalApiKeyAuthenticateRecordInput {
|
||||
key_hash: hash_external_api_key(raw_key.as_str()),
|
||||
used_at_micros: current_utc_micros(),
|
||||
@@ -88,7 +88,7 @@ pub async fn require_external_api_key(
|
||||
}
|
||||
|
||||
pub async fn require_external_mcp_api_key(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<ExternalApiAuthState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -171,6 +171,184 @@ fn extract_external_api_bearer(headers: &HeaderMap) -> Result<String, AppError>
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::external_api_auth::ExternalApiKeyAuthenticator;
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
extract::Extension,
|
||||
middleware,
|
||||
routing::get,
|
||||
};
|
||||
use futures_util::future::BoxFuture;
|
||||
use spacetime_client::{ExternalApiKeyRecord, SpacetimeClientError};
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct RecordingAuthenticator {
|
||||
requests: Mutex<Vec<ExternalApiKeyAuthenticateRecordInput>>,
|
||||
error: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ExternalApiKeyAuthenticator for RecordingAuthenticator {
|
||||
fn authenticate_external_api_key(
|
||||
&self,
|
||||
input: ExternalApiKeyAuthenticateRecordInput,
|
||||
) -> BoxFuture<'_, Result<ExternalApiKeyRecord, SpacetimeClientError>> {
|
||||
self.requests.lock().unwrap().push(input);
|
||||
Box::pin(async move {
|
||||
if let Some(error) = self.error {
|
||||
return Err(SpacetimeClientError::Procedure(error.to_string()));
|
||||
}
|
||||
Ok(ExternalApiKeyRecord {
|
||||
key_id: "key-from-store".to_string(),
|
||||
owner_user_id: "owner-from-store".to_string(),
|
||||
name: "测试密钥".to_string(),
|
||||
key_prefix: "tnr_sk_fixture".to_string(),
|
||||
scopes: vec!["editor:project".to_string()],
|
||||
created_at: "0.000000Z".to_string(),
|
||||
last_used_at: None,
|
||||
revoked_at: None,
|
||||
updated_at: "0.000000Z".to_string(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_test_router(
|
||||
dependency: Arc<RecordingAuthenticator>,
|
||||
entered: Arc<AtomicUsize>,
|
||||
mcp: bool,
|
||||
) -> Router {
|
||||
let state = ExternalApiAuthState::new(dependency);
|
||||
let router = Router::new().route("/protected", get(move |Extension(principal): Extension<ExternalApiPrincipal>| {
|
||||
let entered = entered.clone();
|
||||
async move {
|
||||
entered.fetch_add(1, Ordering::Relaxed);
|
||||
axum::Json(json!({"owner": principal.owner_user_id(), "projectScope": principal.has_scope("editor:project")}))
|
||||
}
|
||||
}));
|
||||
if mcp {
|
||||
router.layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
require_external_mcp_api_key,
|
||||
))
|
||||
} else {
|
||||
router.layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
require_external_api_key,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_external_auth_forwards_store_identity_and_only_hashes_credentials() {
|
||||
let dependency = Arc::new(RecordingAuthenticator {
|
||||
requests: Mutex::default(),
|
||||
error: None,
|
||||
});
|
||||
let entered = Arc::new(AtomicUsize::new(0));
|
||||
let response = auth_test_router(dependency.clone(), entered.clone(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/protected")
|
||||
.header(AUTHORIZATION, "Bearer tnr_sk_fixture_secret")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let principal = response.extensions().get::<ExternalApiPrincipal>().unwrap();
|
||||
assert_eq!(principal.owner_user_id(), "owner-from-store");
|
||||
assert_eq!(principal.key_id(), "key-from-store");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 1024).await.unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({"owner": "owner-from-store", "projectScope": true})
|
||||
);
|
||||
assert_eq!(entered.load(Ordering::Relaxed), 1);
|
||||
let requests = dependency.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].key_hash,
|
||||
hash_external_api_key("tnr_sk_fixture_secret")
|
||||
);
|
||||
assert!(requests[0].used_at_micros > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_external_auth_preserves_failure_mapping_and_mcp_guide() {
|
||||
for (message, expected_status) in [
|
||||
("API Key 不存在", StatusCode::UNAUTHORIZED),
|
||||
("无权使用此密钥", StatusCode::FORBIDDEN),
|
||||
("校验请求失败", StatusCode::BAD_REQUEST),
|
||||
] {
|
||||
for mcp in [false, true] {
|
||||
let dependency = Arc::new(RecordingAuthenticator {
|
||||
requests: Mutex::default(),
|
||||
error: Some(message),
|
||||
});
|
||||
let entered = Arc::new(AtomicUsize::new(0));
|
||||
let response = auth_test_router(dependency.clone(), entered.clone(), mcp)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/protected")
|
||||
.header(AUTHORIZATION, "Bearer invalid-fixture-key")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), expected_status);
|
||||
assert!(
|
||||
response
|
||||
.extensions()
|
||||
.get::<ExternalApiPrincipal>()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(entered.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(dependency.requests.lock().unwrap().len(), 1);
|
||||
let body = String::from_utf8(
|
||||
to_bytes(response.into_body(), 16_384)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
if mcp && expected_status == StatusCode::UNAUTHORIZED {
|
||||
assert!(body.contains("MCP_AUTHENTICATION_REQUIRED"));
|
||||
assert!(!body.contains(message));
|
||||
} else {
|
||||
assert!(body.contains(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn narrow_external_auth_rejects_missing_credentials_before_store_access() {
|
||||
let dependency = Arc::new(RecordingAuthenticator {
|
||||
requests: Mutex::default(),
|
||||
error: None,
|
||||
});
|
||||
let entered = Arc::new(AtomicUsize::new(0));
|
||||
let response = auth_test_router(dependency.clone(), entered.clone(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/protected")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert!(dependency.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(entered.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_authentication_guide_replaces_sensitive_key_diagnostics() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,12 @@ use crate::work_author::{
|
||||
ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID,
|
||||
};
|
||||
|
||||
pub(crate) mod external_api_auth;
|
||||
pub(crate) mod project_metadata;
|
||||
pub use external_api_auth::ExternalApiAuthState;
|
||||
pub(crate) use project_metadata::EditorMediaStorageState;
|
||||
pub use project_metadata::EditorProjectState;
|
||||
|
||||
const ADMIN_ROLE: &str = "admin";
|
||||
const EDITOR_AGENT_LLM_MAX_RETRIES: u32 = 1;
|
||||
const EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS: u64 = 60_000;
|
||||
@@ -300,6 +306,8 @@ pub struct AppStateInner {
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
ai_task_service: AiTaskService,
|
||||
spacetime_client: SpacetimeClient,
|
||||
external_api_auth_state: ExternalApiAuthState,
|
||||
editor_project_state: EditorProjectState,
|
||||
#[cfg(any())]
|
||||
puzzle_gallery_cache: PuzzleGalleryCache,
|
||||
tracking_outbox: Option<Arc<TrackingOutbox>>,
|
||||
@@ -662,6 +670,14 @@ impl AppState {
|
||||
let character_animation_oss_io_limiter =
|
||||
Arc::new(Semaphore::new(CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY));
|
||||
let editor_oss_http_client = build_editor_oss_http_client()?;
|
||||
let editor_project_state = EditorProjectState::new(
|
||||
Arc::new(spacetime_client.clone()),
|
||||
Arc::new(EditorMediaStorageState::new(
|
||||
spacetime_client.clone(),
|
||||
oss_client.clone(),
|
||||
editor_oss_http_client.clone(),
|
||||
)),
|
||||
);
|
||||
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
|
||||
let (profile_recharge_order_updates, _) = broadcast::channel(128);
|
||||
// `ensure_orphan_work_owner_user` 只为公开作品作者回退提供进程内占位账号,
|
||||
@@ -709,6 +725,8 @@ impl AppState {
|
||||
wechat_client,
|
||||
wechat_pay_client,
|
||||
ai_task_service,
|
||||
external_api_auth_state: ExternalApiAuthState::new(Arc::new(spacetime_client.clone())),
|
||||
editor_project_state,
|
||||
spacetime_client,
|
||||
#[cfg(any())]
|
||||
puzzle_gallery_cache: PuzzleGalleryCache::new(),
|
||||
@@ -1603,6 +1621,14 @@ impl AppState {
|
||||
&self.spacetime_client
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_external_api_auth_state(mut self, state: ExternalApiAuthState) -> Self {
|
||||
Arc::get_mut(&mut self.0)
|
||||
.expect("configure external authentication before sharing AppState")
|
||||
.external_api_auth_state = state;
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(any())]
|
||||
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
|
||||
&self.puzzle_gallery_cache
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use futures_util::future::BoxFuture;
|
||||
use spacetime_client::{
|
||||
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord, SpacetimeClient,
|
||||
SpacetimeClientError,
|
||||
};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
pub(crate) trait ExternalApiKeyAuthenticator: Send + Sync {
|
||||
fn authenticate_external_api_key(
|
||||
&self,
|
||||
input: ExternalApiKeyAuthenticateRecordInput,
|
||||
) -> BoxFuture<'_, Result<ExternalApiKeyRecord, SpacetimeClientError>>;
|
||||
}
|
||||
|
||||
impl ExternalApiKeyAuthenticator for SpacetimeClient {
|
||||
fn authenticate_external_api_key(
|
||||
&self,
|
||||
input: ExternalApiKeyAuthenticateRecordInput,
|
||||
) -> BoxFuture<'_, Result<ExternalApiKeyRecord, SpacetimeClientError>> {
|
||||
Box::pin(SpacetimeClient::authenticate_external_api_key(self, input))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExternalApiAuthState {
|
||||
authenticator: Arc<dyn ExternalApiKeyAuthenticator>,
|
||||
}
|
||||
|
||||
impl ExternalApiAuthState {
|
||||
pub(crate) fn new(authenticator: Arc<dyn ExternalApiKeyAuthenticator>) -> Self {
|
||||
Self { authenticator }
|
||||
}
|
||||
|
||||
pub(crate) fn authenticator(&self) -> &dyn ExternalApiKeyAuthenticator {
|
||||
self.authenticator.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for ExternalApiAuthState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.external_api_auth_state.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use futures_util::future::BoxFuture;
|
||||
use platform_oss::OssClient;
|
||||
use spacetime_client::{
|
||||
EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
|
||||
EditorProjectLayoutSaveV2AckRecord, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord,
|
||||
EditorProjectRenameRecordInput, SpacetimeClient, SpacetimeClientError,
|
||||
};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
// 元数据入口只依赖现役 typed facade 的项目能力,不暴露其它领域的数据库操作。
|
||||
pub(crate) trait EditorProjectRepository: Send + Sync {
|
||||
fn get_recent_editor_project(
|
||||
&self,
|
||||
owner_user_id: String,
|
||||
) -> BoxFuture<'_, Result<Option<EditorProjectRecord>, SpacetimeClientError>>;
|
||||
|
||||
fn list_editor_projects(
|
||||
&self,
|
||||
owner_user_id: String,
|
||||
) -> BoxFuture<'_, Result<Vec<EditorProjectRecord>, SpacetimeClientError>>;
|
||||
|
||||
fn get_editor_project(
|
||||
&self,
|
||||
input: EditorProjectGetRecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectRecord, SpacetimeClientError>>;
|
||||
|
||||
fn create_editor_project(
|
||||
&self,
|
||||
input: EditorProjectCreateRecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectRecord, SpacetimeClientError>>;
|
||||
|
||||
fn rename_editor_project(
|
||||
&self,
|
||||
input: EditorProjectRenameRecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectRecord, SpacetimeClientError>>;
|
||||
|
||||
fn delete_editor_project(
|
||||
&self,
|
||||
input: EditorProjectDeleteRecordInput,
|
||||
) -> BoxFuture<'_, Result<String, SpacetimeClientError>>;
|
||||
|
||||
fn save_editor_project_layout_v2_ack(
|
||||
&self,
|
||||
input: EditorProjectLayoutSaveV2RecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectLayoutSaveV2AckRecord, SpacetimeClientError>>;
|
||||
}
|
||||
|
||||
impl EditorProjectRepository for SpacetimeClient {
|
||||
fn get_recent_editor_project(
|
||||
&self,
|
||||
owner_user_id: String,
|
||||
) -> BoxFuture<'_, Result<Option<EditorProjectRecord>, SpacetimeClientError>> {
|
||||
Box::pin(SpacetimeClient::get_recent_editor_project(
|
||||
self,
|
||||
owner_user_id,
|
||||
))
|
||||
}
|
||||
|
||||
fn list_editor_projects(
|
||||
&self,
|
||||
owner_user_id: String,
|
||||
) -> BoxFuture<'_, Result<Vec<EditorProjectRecord>, SpacetimeClientError>> {
|
||||
Box::pin(SpacetimeClient::list_editor_projects(self, owner_user_id))
|
||||
}
|
||||
|
||||
fn get_editor_project(
|
||||
&self,
|
||||
input: EditorProjectGetRecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectRecord, SpacetimeClientError>> {
|
||||
Box::pin(SpacetimeClient::get_editor_project(self, input))
|
||||
}
|
||||
|
||||
fn create_editor_project(
|
||||
&self,
|
||||
input: EditorProjectCreateRecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectRecord, SpacetimeClientError>> {
|
||||
Box::pin(SpacetimeClient::create_editor_project(self, input))
|
||||
}
|
||||
|
||||
fn rename_editor_project(
|
||||
&self,
|
||||
input: EditorProjectRenameRecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectRecord, SpacetimeClientError>> {
|
||||
Box::pin(SpacetimeClient::rename_editor_project(self, input))
|
||||
}
|
||||
|
||||
fn delete_editor_project(
|
||||
&self,
|
||||
input: EditorProjectDeleteRecordInput,
|
||||
) -> BoxFuture<'_, Result<String, SpacetimeClientError>> {
|
||||
Box::pin(SpacetimeClient::delete_editor_project(self, input))
|
||||
}
|
||||
|
||||
fn save_editor_project_layout_v2_ack(
|
||||
&self,
|
||||
input: EditorProjectLayoutSaveV2RecordInput,
|
||||
) -> BoxFuture<'_, Result<EditorProjectLayoutSaveV2AckRecord, SpacetimeClientError>> {
|
||||
Box::pin(SpacetimeClient::save_editor_project_layout_v2_ack(
|
||||
self, input,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait EditorProjectMediaRepair: Send + Sync {
|
||||
fn repair_project(&self, project: EditorProjectRecord) -> BoxFuture<'_, EditorProjectRecord>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EditorProjectState {
|
||||
projects: Arc<dyn EditorProjectRepository>,
|
||||
media_repair: Arc<dyn EditorProjectMediaRepair>,
|
||||
}
|
||||
|
||||
impl EditorProjectState {
|
||||
pub(crate) fn new(
|
||||
projects: Arc<dyn EditorProjectRepository>,
|
||||
media_repair: Arc<dyn EditorProjectMediaRepair>,
|
||||
) -> Self {
|
||||
Self {
|
||||
projects,
|
||||
media_repair,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn projects(&self) -> &dyn EditorProjectRepository {
|
||||
self.projects.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) async fn repair_project_media(
|
||||
&self,
|
||||
project: EditorProjectRecord,
|
||||
) -> EditorProjectRecord {
|
||||
self.media_repair.repair_project(project).await
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for EditorProjectState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.editor_project_state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// 读项目时的内联媒体修复与生成写入共用相同存储依赖,保持原有上传和登记顺序。
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct EditorMediaStorageState {
|
||||
spacetime_client: SpacetimeClient,
|
||||
oss_client: Option<OssClient>,
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl EditorMediaStorageState {
|
||||
pub(super) fn new(
|
||||
spacetime_client: SpacetimeClient,
|
||||
oss_client: Option<OssClient>,
|
||||
http_client: reqwest::Client,
|
||||
) -> Self {
|
||||
Self {
|
||||
spacetime_client,
|
||||
oss_client,
|
||||
http_client,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn spacetime_client(&self) -> &SpacetimeClient {
|
||||
&self.spacetime_client
|
||||
}
|
||||
|
||||
pub(crate) fn oss_client(&self) -> Option<&OssClient> {
|
||||
self.oss_client.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn editor_oss_http_client(&self) -> &reqwest::Client {
|
||||
&self.http_client
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for EditorMediaStorageState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
Self::new(
|
||||
state.spacetime_client().clone(),
|
||||
state.oss_client().cloned(),
|
||||
state.editor_oss_http_client().clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorProjectMediaRepair for EditorMediaStorageState {
|
||||
fn repair_project(&self, project: EditorProjectRecord) -> BoxFuture<'_, EditorProjectRecord> {
|
||||
Box::pin(crate::editor_project::repair_editor_project_record_inline_media(self, project))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
extract::{MatchedPath, State},
|
||||
http::{HeaderMap, Request, Response},
|
||||
middleware::Next,
|
||||
};
|
||||
@@ -28,28 +28,42 @@ pub async fn record_http_observability(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
observe_http_request(
|
||||
http_metrics(),
|
||||
state.config.slow_request_threshold_ms,
|
||||
request,
|
||||
next,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn observe_http_request(
|
||||
metrics: &HttpMetrics,
|
||||
slow_request_threshold_ms: u64,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
let method = request.method().as_str().to_string();
|
||||
let route = observability_route(request.uri().path());
|
||||
let route = observability_route(&request);
|
||||
let scheme = resolve_request_scheme(request.headers());
|
||||
let path = request.uri().path().to_string();
|
||||
let request_id = resolve_request_id(&request).unwrap_or_else(|| "unknown".to_string());
|
||||
let base_labels = http_base_labels(method.clone(), route.clone());
|
||||
let metrics = http_metrics();
|
||||
metrics.in_flight.add(1, &base_labels);
|
||||
let in_flight = RequestInFlightGuard::new(&metrics.in_flight, base_labels.clone());
|
||||
let started_at = std::time::Instant::now();
|
||||
|
||||
let response = next.run(request).await;
|
||||
let status = response.status().as_u16();
|
||||
let status_class = status_class(status);
|
||||
let latency_ms = started_at.elapsed().as_millis().min(u64::MAX as u128) as u64;
|
||||
let slow_request = latency_ms >= state.config.slow_request_threshold_ms;
|
||||
let slow_request = latency_ms >= slow_request_threshold_ms;
|
||||
let labels = http_response_labels(base_labels, status);
|
||||
metrics.requests.add(1, &labels);
|
||||
metrics
|
||||
.duration
|
||||
.record(started_at.elapsed().as_secs_f64(), &labels);
|
||||
metrics.in_flight.add(-1, &labels[..2]);
|
||||
drop(in_flight);
|
||||
|
||||
if slow_request {
|
||||
warn!(
|
||||
@@ -201,12 +215,32 @@ fn track_response_body_in_flight(response: Response<Body>) -> Response<Body> {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HttpMetrics {
|
||||
requests: Counter<u64>,
|
||||
in_flight: opentelemetry::metrics::UpDownCounter<i64>,
|
||||
duration: opentelemetry::metrics::Histogram<f64>,
|
||||
}
|
||||
|
||||
// 请求 Future 被取消或 panic unwind 时也必须释放计数;响应体存活由另一 guard 统计。
|
||||
struct RequestInFlightGuard<'a> {
|
||||
counter: &'a opentelemetry::metrics::UpDownCounter<i64>,
|
||||
labels: Vec<KeyValue>,
|
||||
}
|
||||
|
||||
impl<'a> RequestInFlightGuard<'a> {
|
||||
fn new(counter: &'a opentelemetry::metrics::UpDownCounter<i64>, labels: Vec<KeyValue>) -> Self {
|
||||
counter.add(1, &labels);
|
||||
Self { counter, labels }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RequestInFlightGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.counter.add(-1, &self.labels);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any())]
|
||||
struct PuzzleGalleryCacheMetrics {
|
||||
hits: Counter<u64>,
|
||||
@@ -467,7 +501,11 @@ fn status_class(status: u16) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn observability_route(path: &str) -> String {
|
||||
pub(crate) fn observability_route<B>(request: &Request<B>) -> String {
|
||||
if let Some(path) = request.extensions().get::<MatchedPath>() {
|
||||
return path.as_str().to_string();
|
||||
}
|
||||
let path = request.uri().path();
|
||||
if path.starts_with("/admin/api/") {
|
||||
"/admin/api/*".to_string()
|
||||
} else if path.starts_with("/api/") {
|
||||
@@ -490,22 +528,232 @@ pub(crate) fn resolve_request_scheme(headers: &HeaderMap) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, Bytes},
|
||||
http::{HeaderMap, HeaderValue, Request, StatusCode},
|
||||
middleware,
|
||||
routing::get,
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use opentelemetry::{
|
||||
KeyValue,
|
||||
metrics::{Counter, Histogram, SyncInstrument, UpDownCounter},
|
||||
};
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Notify;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{observability_route, resolve_request_scheme};
|
||||
use super::{HttpMetrics, observability_route, observe_http_request, resolve_request_scheme};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Measurements<T> {
|
||||
values: Mutex<Vec<(T, Vec<KeyValue>)>>,
|
||||
}
|
||||
|
||||
impl<T: Send> SyncInstrument<T> for Measurements<T> {
|
||||
fn measure(&self, value: T, attributes: &[KeyValue]) {
|
||||
self.values
|
||||
.lock()
|
||||
.expect("measurement lock")
|
||||
.push((value, attributes.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
fn observed_router(router: Router) -> (Router, Arc<Measurements<i64>>) {
|
||||
let in_flight = Arc::new(Measurements::default());
|
||||
let metrics = HttpMetrics {
|
||||
requests: Counter::new(Arc::new(Measurements::<u64>::default())),
|
||||
in_flight: UpDownCounter::new(in_flight.clone()),
|
||||
duration: Histogram::new(Arc::new(Measurements::<f64>::default())),
|
||||
};
|
||||
let router = router.layer(middleware::from_fn(move |request, next| {
|
||||
let metrics = metrics.clone();
|
||||
async move { observe_http_request(&metrics, u64::MAX, request, next).await }
|
||||
}));
|
||||
(router, in_flight)
|
||||
}
|
||||
|
||||
fn request(uri: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.expect("request")
|
||||
}
|
||||
|
||||
fn assert_request_finished(measurements: &Measurements<i64>) {
|
||||
let values = measurements.values.lock().expect("measurement lock");
|
||||
assert_eq!(
|
||||
values.iter().map(|(value, _)| *value).collect::<Vec<_>>(),
|
||||
vec![1, -1]
|
||||
);
|
||||
assert_eq!(
|
||||
values[0].1, values[1].1,
|
||||
"decrement must use the original labels"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_request_releases_in_flight_measurement() {
|
||||
let entered = Arc::new(Notify::new());
|
||||
let handler_entered = entered.clone();
|
||||
let (router, measurements) = observed_router(Router::new().route(
|
||||
"/api/pending/{id}",
|
||||
get(move || {
|
||||
let entered = handler_entered.clone();
|
||||
async move {
|
||||
entered.notify_one();
|
||||
std::future::pending::<StatusCode>().await
|
||||
}
|
||||
}),
|
||||
));
|
||||
let task = tokio::spawn(router.oneshot(request("/api/pending/123")));
|
||||
tokio::time::timeout(Duration::from_secs(5), entered.notified())
|
||||
.await
|
||||
.expect("handler entered");
|
||||
assert_eq!(
|
||||
measurements.values.lock().expect("measurement lock")[0].0,
|
||||
1
|
||||
);
|
||||
|
||||
task.abort();
|
||||
assert!(
|
||||
task.await
|
||||
.expect_err("request should be cancelled")
|
||||
.is_cancelled()
|
||||
);
|
||||
assert_request_finished(&measurements);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn panicking_request_releases_in_flight_measurement() {
|
||||
async fn panic_handler() -> StatusCode {
|
||||
panic!("handler panic for cancellation cleanup test");
|
||||
}
|
||||
let (router, measurements) =
|
||||
observed_router(Router::new().route("/panic", get(panic_handler)));
|
||||
let task = tokio::spawn(router.oneshot(request("/panic")));
|
||||
assert!(task.await.expect_err("handler should panic").is_panic());
|
||||
assert_request_finished(&measurements);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_release_in_flight_once_for_success_and_errors() {
|
||||
for status in [
|
||||
StatusCode::OK,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
] {
|
||||
let (router, measurements) = observed_router(
|
||||
Router::new().route("/response", get(move || async move { status })),
|
||||
);
|
||||
let response = router
|
||||
.oneshot(request("/response"))
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), status);
|
||||
assert_request_finished(&measurements);
|
||||
drop(response);
|
||||
assert_request_finished(&measurements);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_response_releases_request_before_body_completion() {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let (router, measurements) = observed_router(Router::new().route(
|
||||
"/events",
|
||||
get(|| async {
|
||||
let stream = futures_util::stream::iter([Ok::<_, Infallible>(Bytes::from_static(
|
||||
b"data: ready\n\n",
|
||||
))])
|
||||
.chain(futures_util::stream::pending());
|
||||
(
|
||||
[("content-type", "text/event-stream")],
|
||||
Body::from_stream(stream),
|
||||
)
|
||||
}),
|
||||
));
|
||||
let mut response = router
|
||||
.oneshot(request("/events"))
|
||||
.await
|
||||
.expect("streaming response");
|
||||
assert_request_finished(&measurements);
|
||||
let frame = response
|
||||
.body_mut()
|
||||
.frame()
|
||||
.await
|
||||
.expect("first frame")
|
||||
.expect("body frame");
|
||||
assert_eq!(frame.into_data().expect("data frame"), "data: ready\n\n");
|
||||
drop(response);
|
||||
assert_request_finished(&measurements);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matched_routes_share_templates_and_preserve_distinct_endpoints() {
|
||||
let routes = Router::new().nest(
|
||||
"/api",
|
||||
Router::new()
|
||||
.route("/projects/{project_id}", get(|| async {}))
|
||||
.route("/assets/{asset_id}", get(|| async {})),
|
||||
);
|
||||
let (router, measurements) = observed_router(routes);
|
||||
for (uri, template) in [
|
||||
(
|
||||
"/api/projects/project-1?cursor=private",
|
||||
"/api/projects/{project_id}",
|
||||
),
|
||||
("/api/projects/project-2", "/api/projects/{project_id}"),
|
||||
("/api/assets/asset-1", "/api/assets/{asset_id}"),
|
||||
("/api/missing/private-id?token=private", "/api/*"),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request(uri))
|
||||
.await
|
||||
.expect("response");
|
||||
drop(response);
|
||||
assert_request_finished(&measurements);
|
||||
let mut values = measurements.values.lock().expect("measurement lock");
|
||||
assert_eq!(
|
||||
values[0].1,
|
||||
vec![
|
||||
KeyValue::new("http.request.method", "GET"),
|
||||
KeyValue::new("http.route", template)
|
||||
]
|
||||
);
|
||||
values.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observability_route_keeps_metrics_labels_low_cardinality() {
|
||||
assert_eq!(
|
||||
observability_route("/api/editor/showcase/resources?cursor=abc"),
|
||||
observability_route(&request("/api/editor/showcase/resources?cursor=abc")),
|
||||
"/api/*"
|
||||
);
|
||||
assert_eq!(
|
||||
observability_route("/api/editor/projects/project-1"),
|
||||
observability_route(&request("/api/editor/projects/project-1")),
|
||||
"/api/*"
|
||||
);
|
||||
assert_eq!(observability_route("/api/runtime/settings"), "/api/*");
|
||||
assert_eq!(observability_route("/admin/api/debug/http"), "/admin/api/*");
|
||||
assert_eq!(
|
||||
observability_route(&request("/api/runtime/settings")),
|
||||
"/api/*"
|
||||
);
|
||||
assert_eq!(
|
||||
observability_route(&request("/admin/api/debug/http")),
|
||||
"/admin/api/*"
|
||||
);
|
||||
assert_eq!(
|
||||
observability_route(&request("/missing/private-id")),
|
||||
"other"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,6 +11,8 @@ reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "st
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "sync"] }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
@@ -18,6 +18,9 @@ use tokio::time::sleep;
|
||||
mod provider_adapter;
|
||||
pub mod router_billing;
|
||||
|
||||
#[cfg(test)]
|
||||
mod observability_tests;
|
||||
|
||||
pub use provider_adapter::{
|
||||
ANTHROPIC_PROVIDER_INSTANCE_ID, ANTHROPIC_PROVIDER_PROTOCOL_ID, AnthropicProviderAdapter,
|
||||
OPENAI_CHAT_PROVIDER_INSTANCE_ID, OPENAI_CHAT_PROVIDER_PROTOCOL_ID,
|
||||
@@ -1640,6 +1643,16 @@ impl LlmClient {
|
||||
self
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "llm.request",
|
||||
skip_all,
|
||||
fields(
|
||||
provider = self.config.provider().as_str(),
|
||||
operation = "run",
|
||||
api_kind = request.api_kind.as_str(),
|
||||
model = request.resolved_model(self.config.model()),
|
||||
)
|
||||
)]
|
||||
pub async fn run(&self, request: LlmRunRequest) -> Result<LlmRunResponse, LlmError> {
|
||||
request.validate()?;
|
||||
let resolved_model = request.resolved_model(self.config.model()).to_string();
|
||||
@@ -1687,6 +1700,16 @@ impl LlmClient {
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "llm.request",
|
||||
skip_all,
|
||||
fields(
|
||||
provider = self.config.provider().as_str(),
|
||||
operation = "stream_run",
|
||||
api_kind = request.api_kind.as_str(),
|
||||
model = request.resolved_model(self.config.model()),
|
||||
)
|
||||
)]
|
||||
pub async fn stream_run<F>(
|
||||
&self,
|
||||
request: LlmRunRequest,
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
sync::{Arc, Mutex, mpsc},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::{
|
||||
Instrument, Subscriber,
|
||||
field::{Field, Visit},
|
||||
instrument::WithSubscriber,
|
||||
span::{Attributes, Id, Record},
|
||||
};
|
||||
use tracing_subscriber::{Layer, layer::Context, prelude::*, registry::LookupSpan};
|
||||
|
||||
use super::{LlmClient, LlmConfig, LlmError, LlmProvider, LlmRunRequest};
|
||||
|
||||
const PRIVATE_INPUT: &str = "PRIVATE_MESSAGE_MUST_NOT_ENTER_SPAN";
|
||||
const PRIVATE_KEY: &str = "PRIVATE_API_KEY_MUST_NOT_ENTER_SPAN";
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct CapturedSpan {
|
||||
name: String,
|
||||
parent: Option<u64>,
|
||||
fields: BTreeMap<String, String>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Capture {
|
||||
spans: Arc<Mutex<BTreeMap<u64, CapturedSpan>>>,
|
||||
delta_parents: Arc<Mutex<Vec<u64>>>,
|
||||
}
|
||||
|
||||
struct Fields<'a>(&'a mut BTreeMap<String, String>);
|
||||
|
||||
impl Visit for Fields<'_> {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
self.0.insert(field.name().into(), format!("{value:?}"));
|
||||
}
|
||||
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
self.0.insert(field.name().into(), value.into());
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Subscriber + for<'a> LookupSpan<'a>> Layer<S> for Capture {
|
||||
fn on_new_span(&self, attributes: &Attributes<'_>, id: &Id, context: Context<'_, S>) {
|
||||
let span = context.span(id).expect("registered span");
|
||||
let mut captured = CapturedSpan {
|
||||
name: attributes.metadata().name().into(),
|
||||
parent: span.parent().map(|parent| parent.id().into_u64()),
|
||||
..CapturedSpan::default()
|
||||
};
|
||||
attributes.record(&mut Fields(&mut captured.fields));
|
||||
self.spans.lock().unwrap().insert(id.into_u64(), captured);
|
||||
}
|
||||
|
||||
fn on_record(&self, id: &Id, values: &Record<'_>, _: Context<'_, S>) {
|
||||
let mut spans = self.spans.lock().unwrap();
|
||||
values.record(&mut Fields(
|
||||
&mut spans.get_mut(&id.into_u64()).unwrap().fields,
|
||||
));
|
||||
}
|
||||
|
||||
fn on_close(&self, id: Id, _: Context<'_, S>) {
|
||||
self.spans
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut(&id.into_u64())
|
||||
.unwrap()
|
||||
.closed = true;
|
||||
}
|
||||
|
||||
fn on_event(&self, event: &tracing::Event<'_>, context: Context<'_, S>) {
|
||||
if event.metadata().target() == "llm_observability_test_delta" {
|
||||
let parent = context
|
||||
.event_span(event)
|
||||
.expect("delta should have a parent");
|
||||
self.delta_parents
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(parent.id().into_u64());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
fn provider_span(&self) -> (u64, CapturedSpan) {
|
||||
let spans = self.spans.lock().unwrap();
|
||||
let provider_spans = spans
|
||||
.iter()
|
||||
.filter(|(_, span)| span.name == "llm.request")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(provider_spans.len(), 1);
|
||||
let (id, span) = provider_spans[0];
|
||||
(*id, span.clone())
|
||||
}
|
||||
|
||||
fn assert_completed(&self, operation: &str) {
|
||||
let (_, span) = self.provider_span();
|
||||
assert!(
|
||||
span.closed,
|
||||
"provider span must close after completion or cancellation"
|
||||
);
|
||||
assert_eq!(
|
||||
span.fields.get("operation").map(String::as_str),
|
||||
Some(operation)
|
||||
);
|
||||
assert_eq!(
|
||||
span.fields.get("provider").map(String::as_str),
|
||||
Some("openai_compatible")
|
||||
);
|
||||
assert_eq!(
|
||||
span.fields.get("api_kind").map(String::as_str),
|
||||
Some("openai_chat")
|
||||
);
|
||||
assert_eq!(
|
||||
span.fields.get("model").map(String::as_str),
|
||||
Some("requested-model")
|
||||
);
|
||||
let spans = self.spans.lock().unwrap();
|
||||
assert_eq!(
|
||||
spans[&span.parent.expect("HTTP/application parent")].name,
|
||||
"test.request"
|
||||
);
|
||||
let captured = format!("{spans:?}");
|
||||
assert!(!captured.contains(PRIVATE_INPUT));
|
||||
assert!(!captured.contains(PRIVATE_KEY));
|
||||
assert!(!span.fields.contains_key("self"));
|
||||
assert!(!span.fields.contains_key("request"));
|
||||
}
|
||||
}
|
||||
|
||||
struct ProviderFixture {
|
||||
client: LlmClient,
|
||||
entered: oneshot::Receiver<()>,
|
||||
release: mpsc::Sender<()>,
|
||||
server: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
// 使用本地替代上游和通道控制响应时点,不依赖实际 Provider 或计时猜测。
|
||||
fn provider_fixture(status: &str, content_type: &str, body: &str) -> ProviderFixture {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let (entered, entered_rx) = oneshot::channel();
|
||||
let (release, release_rx) = mpsc::channel();
|
||||
let server = thread::spawn(move || {
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
let mut stream = loop {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => break stream,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"request did not arrive"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Err(error) => panic!("fixture accept: {error}"),
|
||||
}
|
||||
};
|
||||
stream.set_nonblocking(false).unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let mut chunk = [0; 4096];
|
||||
loop {
|
||||
let count = stream.read(&mut chunk).unwrap();
|
||||
assert_ne!(count, 0);
|
||||
bytes.extend_from_slice(&chunk[..count]);
|
||||
if let Some(headers_end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") {
|
||||
let headers = String::from_utf8_lossy(&bytes[..headers_end]);
|
||||
let length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if bytes.len() >= headers_end + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
entered.send(()).unwrap();
|
||||
release_rx.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
// 取消用例已关闭客户端连接,允许写回失败。
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
});
|
||||
let config = LlmConfig::new(
|
||||
LlmProvider::OpenAiCompatible,
|
||||
format!("http://{address}"),
|
||||
PRIVATE_KEY.into(),
|
||||
"default-model".into(),
|
||||
5_000,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
.unwrap();
|
||||
ProviderFixture {
|
||||
client: LlmClient::new(config).unwrap(),
|
||||
entered: entered_rx,
|
||||
release,
|
||||
server,
|
||||
}
|
||||
}
|
||||
|
||||
fn request() -> LlmRunRequest {
|
||||
LlmRunRequest::single_turn("system", PRIVATE_INPUT)
|
||||
.with_openai_chat()
|
||||
.with_model("requested-model")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_span_covers_awaited_execution_and_keeps_parent_without_arguments() {
|
||||
let fixture = provider_fixture(
|
||||
"200 OK",
|
||||
"application/json",
|
||||
r#"{"choices":[{"message":{"content":"completed"},"finish_reason":"stop"}]}"#,
|
||||
);
|
||||
let capture = Capture::default();
|
||||
let response =
|
||||
async {
|
||||
async {
|
||||
let mut future = Box::pin(fixture.client.run(request()));
|
||||
assert!(capture.spans.lock().unwrap().values().all(|span| span.name != "llm.request"));
|
||||
tokio::select! {
|
||||
response = &mut future => panic!("request returned before released: {response:?}"),
|
||||
entered = fixture.entered => entered.unwrap(),
|
||||
}
|
||||
assert!(!capture.provider_span().1.closed);
|
||||
// 挂起时不得把 Provider span 留在当前异步执行上下文。
|
||||
assert_eq!(tracing::Span::current().metadata().unwrap().name(), "test.request");
|
||||
fixture.release.send(()).unwrap();
|
||||
future.await.unwrap()
|
||||
}.instrument(tracing::info_span!("test.request")).await
|
||||
}
|
||||
.with_subscriber(tracing_subscriber::registry().with(capture.clone()))
|
||||
.await;
|
||||
fixture.server.join().unwrap();
|
||||
assert_eq!(response.text, "completed");
|
||||
capture.assert_completed("run");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_callbacks_inherit_provider_span_and_keep_result() {
|
||||
let fixture = provider_fixture(
|
||||
"200 OK",
|
||||
"text/event-stream",
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n",
|
||||
);
|
||||
let capture = Capture::default();
|
||||
let mut deltas = Vec::new();
|
||||
let response =
|
||||
async {
|
||||
async {
|
||||
let mut future = Box::pin(fixture.client.stream_run(request(), |delta| {
|
||||
tracing::info!(target: "llm_observability_test_delta", "delta received");
|
||||
deltas.push(delta.delta_text.clone());
|
||||
}));
|
||||
tokio::select! {
|
||||
response = &mut future => panic!("stream returned before released: {response:?}"),
|
||||
entered = fixture.entered => entered.unwrap(),
|
||||
}
|
||||
fixture.release.send(()).unwrap();
|
||||
future.await.unwrap()
|
||||
}.instrument(tracing::info_span!("test.request")).await
|
||||
}
|
||||
.with_subscriber(tracing_subscriber::registry().with(capture.clone()))
|
||||
.await;
|
||||
fixture.server.join().unwrap();
|
||||
assert_eq!(response.text, "hello");
|
||||
assert_eq!(deltas.concat(), "hello");
|
||||
capture.assert_completed("stream_run");
|
||||
let id = capture.provider_span().0;
|
||||
let parents = capture.delta_parents.lock().unwrap();
|
||||
assert!(!parents.is_empty());
|
||||
assert!(parents.iter().all(|parent| *parent == id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() {
|
||||
let fixture = provider_fixture(
|
||||
"401 Unauthorized",
|
||||
"application/json",
|
||||
r#"{"error":{"message":"upstream-rejected"}}"#,
|
||||
);
|
||||
let capture = Capture::default();
|
||||
let error = async {
|
||||
async {
|
||||
fixture.release.send(()).unwrap();
|
||||
fixture.client.run(request()).await.unwrap_err()
|
||||
}
|
||||
.instrument(tracing::info_span!("test.request"))
|
||||
.await
|
||||
}
|
||||
.with_subscriber(tracing_subscriber::registry().with(capture.clone()))
|
||||
.await;
|
||||
fixture.server.join().unwrap();
|
||||
assert!(
|
||||
matches!(error, LlmError::Upstream { status_code: 401, message } if message.contains("upstream-rejected"))
|
||||
);
|
||||
capture.assert_completed("run");
|
||||
|
||||
let fixture = provider_fixture("200 OK", "application/json", "{}");
|
||||
let capture = Capture::default();
|
||||
async {
|
||||
async {
|
||||
let mut future = Box::pin(fixture.client.run(request()));
|
||||
tokio::select! {
|
||||
response = &mut future => panic!("request returned before cancellation: {response:?}"),
|
||||
entered = fixture.entered => entered.unwrap(),
|
||||
}
|
||||
assert!(!capture.provider_span().1.closed);
|
||||
drop(future);
|
||||
fixture.release.send(()).unwrap();
|
||||
}.instrument(tracing::info_span!("test.request")).await
|
||||
}.with_subscriber(tracing_subscriber::registry().with(capture.clone())).await;
|
||||
fixture.server.join().unwrap();
|
||||
capture.assert_completed("run");
|
||||
}
|
||||
@@ -8,8 +8,13 @@ license.workspace = true
|
||||
# 默认给 api-server 等原生后端暴露资产上传 DTO;SpacetimeDB WASM 路径通过 workspace 依赖关闭默认 feature。
|
||||
default = ["oss-contracts"]
|
||||
oss-contracts = []
|
||||
# 导出 TS 绑定:生成物落在 `packages/shared/src/contracts/generated/`。
|
||||
# 解析边界留痕不用 feature 开关:`shared-contracts` 只暴露一个可注册的回调,
|
||||
# 由壳层(AGC)在启动时接到自己的 `app_log!` 上,见 `asset_kind.rs`。
|
||||
ts-bindings = ["dep:ts-rs"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
ts-rs = { workspace = true, optional = true }
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
mod asset_kind;
|
||||
pub use asset_kind::{
|
||||
GameCreationAppAssetKind, NonCanonicalAssetKindReporter, set_non_canonical_asset_kind_reporter,
|
||||
};
|
||||
|
||||
pub const GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION: &str = "game-creation-app.manifest.v1";
|
||||
pub const GAME_CREATION_AGENT_RUN_SCHEMA_VERSION: &str = "game-creator-agent-run.v1";
|
||||
pub const GAME_CREATION_AGENT_RUN_MAX_PASSES: u8 = 3;
|
||||
pub const GAME_CREATION_AGENT_TOOL_CALL_MAX: u16 = 128;
|
||||
pub const GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND: &str = "ui-design-doc";
|
||||
pub const GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE: &str = "application/json";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -491,7 +498,7 @@ pub struct GameCreationAppImageSequenceFrame {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameCreationAppAssetManifestEntry {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub kind: GameCreationAppAssetKind,
|
||||
pub media_type: String,
|
||||
pub local_path: String,
|
||||
pub source: GameCreationAppAssetSource,
|
||||
@@ -543,7 +550,9 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry {
|
||||
// (写侧整结构体覆盖落盘,替换值会真的写回文件)。派生只属于缺字段的读兼容,
|
||||
// 以及读显示口径 `game_creation_app_asset_effective_category`。
|
||||
let category = match wire.category.as_deref() {
|
||||
None => game_creation_app_asset_category_for_kind(&wire.kind),
|
||||
None => {
|
||||
game_creation_app_asset_category_for_raw_kind(&wire.kind, "manifest.asset.category")
|
||||
}
|
||||
Some(raw) => game_creation_app_asset_category_from_str(raw).ok_or_else(|| {
|
||||
<D::Error as serde::de::Error>::custom(format!(
|
||||
"未知的素材分类 category = {raw}:本客户端会整结构体重写 manifest,\
|
||||
@@ -553,7 +562,7 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry {
|
||||
};
|
||||
Ok(Self {
|
||||
id: wire.id,
|
||||
kind: wire.kind,
|
||||
kind: GameCreationAppAssetKind::parse_with_context(&wire.kind, "manifest.asset.kind"),
|
||||
media_type: wire.media_type,
|
||||
local_path: wire.local_path,
|
||||
source: wire.source,
|
||||
@@ -565,60 +574,6 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry {
|
||||
}
|
||||
}
|
||||
|
||||
pub const GAME_CREATION_APP_CANONICAL_ASSET_KINDS: [&str; 16] = [
|
||||
"image",
|
||||
"scene",
|
||||
"character",
|
||||
"character-animation",
|
||||
"icon",
|
||||
"icon-spritesheet",
|
||||
"icon-spec",
|
||||
"ui-design",
|
||||
"publication-material",
|
||||
"spec",
|
||||
"video",
|
||||
"sound-effect",
|
||||
"background-music",
|
||||
"audio",
|
||||
"document",
|
||||
"code",
|
||||
];
|
||||
|
||||
pub fn canonical_game_creation_app_asset_kind(value: &str) -> &'static str {
|
||||
// kind 词汇大小写不敏感:UI 设计资产的现役写入侧写的是**大写** `"UI"`
|
||||
// (`apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs` 的
|
||||
// `register_local_asset_at(..., "UI", ...)`,`workflow.rs` / `persistence.rs` 同),
|
||||
// 只按小写收口会让它落到 `image` 兜底、再经 `image -> unclassified` 永远停在
|
||||
// 「待归类」;且派生值本身就是 unclassified,读时自愈也救不回来。
|
||||
// TS 侧 `canonicalGameCreationAppAssetKind` 用同一口径,由
|
||||
// `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 交叉钉住。
|
||||
let normalized = value.trim().to_lowercase();
|
||||
match normalized.as_str() {
|
||||
"game-background" => "scene",
|
||||
"character-art" => "character",
|
||||
"ui-prototype" => "ui-design",
|
||||
// 画板导出等现役写入侧仍会写出这些非 canonical 值,必须在这里收口,
|
||||
// 否则会落到 image 兜底并被 image -> unclassified 误分到「待归类」。
|
||||
"ui" => "ui-design",
|
||||
"art-spritesheet" => "icon-spritesheet",
|
||||
"art-spritesheet-slice" => "icon",
|
||||
"illustration" | "game-art" => "image",
|
||||
"game-entry" | "game-script" | "game-style" => "code",
|
||||
"animation" => "character-animation",
|
||||
"asset" => "image",
|
||||
// 字体上传(`ttf / otf / woff / woff2`)登记的 manifest 资产 kind 就是 `font`,
|
||||
// 不收口同样只能落「待归类」。
|
||||
"font" => "document",
|
||||
canonical => match GAME_CREATION_APP_CANONICAL_ASSET_KINDS
|
||||
.iter()
|
||||
.find(|candidate| **candidate == canonical)
|
||||
{
|
||||
Some(value) => value,
|
||||
None => "image",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum GameCreationAppAssetCategory {
|
||||
@@ -639,33 +594,90 @@ pub const GAME_CREATION_APP_ASSET_CATEGORIES: [GameCreationAppAssetCategory; 6]
|
||||
GameCreationAppAssetCategory::Unclassified,
|
||||
];
|
||||
|
||||
/// canonical kind 到功能分类的默认映射,必须穷举 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS`。
|
||||
pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: [(&str, GameCreationAppAssetCategory); 16] = [
|
||||
("image", GameCreationAppAssetCategory::Unclassified),
|
||||
("scene", GameCreationAppAssetCategory::Scene),
|
||||
("character", GameCreationAppAssetCategory::Character),
|
||||
/// kind 到功能分类的默认映射,**按枚举变体穷举**:少一个变体编译不过,
|
||||
/// 与 `GameCreationAppAssetKind::ALL` 的一致性由测试 `asset_category_mapping_covers_every_kind`
|
||||
/// 兜住。这是 kind → 分类的唯一真源,仓库里不再有第二份 canonical 字符串列表。
|
||||
pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: &[(
|
||||
GameCreationAppAssetKind,
|
||||
GameCreationAppAssetCategory,
|
||||
)] = &[
|
||||
(
|
||||
"character-animation",
|
||||
GameCreationAppAssetCategory::Character,
|
||||
),
|
||||
("icon", GameCreationAppAssetCategory::UiInteraction),
|
||||
(
|
||||
"icon-spritesheet",
|
||||
GameCreationAppAssetCategory::UiInteraction,
|
||||
),
|
||||
("icon-spec", GameCreationAppAssetCategory::UiInteraction),
|
||||
("ui-design", GameCreationAppAssetCategory::UiInteraction),
|
||||
(
|
||||
"publication-material",
|
||||
GameCreationAppAssetKind::Image,
|
||||
GameCreationAppAssetCategory::Unclassified,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::Scene,
|
||||
GameCreationAppAssetCategory::Scene,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::Character,
|
||||
GameCreationAppAssetCategory::Character,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::CharacterAnimation,
|
||||
GameCreationAppAssetCategory::Character,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::Icon,
|
||||
GameCreationAppAssetCategory::UiInteraction,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
GameCreationAppAssetCategory::UiInteraction,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
GameCreationAppAssetCategory::UiInteraction,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::UiDesign,
|
||||
GameCreationAppAssetCategory::UiInteraction,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::UiDesignDoc,
|
||||
GameCreationAppAssetCategory::UiInteraction,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::PublicationMaterial,
|
||||
GameCreationAppAssetCategory::Unclassified,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::Spec,
|
||||
GameCreationAppAssetCategory::Document,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::Video,
|
||||
GameCreationAppAssetCategory::Unclassified,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::Audio,
|
||||
GameCreationAppAssetCategory::Audio,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::SoundEffect,
|
||||
GameCreationAppAssetCategory::Audio,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::BackgroundMusic,
|
||||
GameCreationAppAssetCategory::Audio,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::Font,
|
||||
GameCreationAppAssetCategory::Document,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::Document,
|
||||
GameCreationAppAssetCategory::Document,
|
||||
),
|
||||
(
|
||||
GameCreationAppAssetKind::Code,
|
||||
GameCreationAppAssetCategory::Unclassified,
|
||||
),
|
||||
// 认不出的 kind 一律落「待归类」。
|
||||
(
|
||||
GameCreationAppAssetKind::Unknown,
|
||||
GameCreationAppAssetCategory::Unclassified,
|
||||
),
|
||||
("spec", GameCreationAppAssetCategory::Document),
|
||||
("video", GameCreationAppAssetCategory::Unclassified),
|
||||
("sound-effect", GameCreationAppAssetCategory::Audio),
|
||||
("background-music", GameCreationAppAssetCategory::Audio),
|
||||
("audio", GameCreationAppAssetCategory::Audio),
|
||||
("document", GameCreationAppAssetCategory::Document),
|
||||
("code", GameCreationAppAssetCategory::Unclassified),
|
||||
];
|
||||
|
||||
pub fn game_creation_app_asset_category_from_str(
|
||||
@@ -682,32 +694,46 @@ pub fn game_creation_app_asset_category_from_str(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn game_creation_app_asset_category_for_kind(kind: &str) -> GameCreationAppAssetCategory {
|
||||
let canonical = canonical_game_creation_app_asset_kind(kind);
|
||||
pub fn game_creation_app_asset_category_for_kind(
|
||||
kind: GameCreationAppAssetKind,
|
||||
) -> GameCreationAppAssetCategory {
|
||||
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == canonical)
|
||||
.find(|(candidate, _)| *candidate == kind)
|
||||
.map(|(_, category)| *category)
|
||||
.unwrap_or(GameCreationAppAssetCategory::Unclassified)
|
||||
}
|
||||
|
||||
/// 解析外部 kind 后再按共享枚举派生分类;内部业务逻辑应传入已解析的枚举。
|
||||
pub fn game_creation_app_asset_category_for_raw_kind(
|
||||
raw_kind: &str,
|
||||
context: &'static str,
|
||||
) -> GameCreationAppAssetCategory {
|
||||
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::parse_with_context(
|
||||
raw_kind, context,
|
||||
))
|
||||
}
|
||||
|
||||
/// manifest 资产的**有效分类**(读显示口径)。
|
||||
///
|
||||
/// 与 TS 侧 `gameCreationAppAssetCategory` 是同一套口径的两份实现,由
|
||||
/// `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析
|
||||
/// `EFFECTIVE_CATEGORY_CONTRACT` 决策矩阵交叉钉住,两侧不许各写一份。
|
||||
/// 与 TS 侧 `gameCreationAppAssetCategory` 是同一套口径的两份实现,两侧不许各写一份:
|
||||
/// Rust 侧由本文件的 `EFFECTIVE_CATEGORY_CONTRACT` 决策矩阵用例钉住,TS 侧由
|
||||
/// `packages/shared/src/contracts/gameCreationApp.test.ts` 的同口径用例钉住。
|
||||
///
|
||||
/// 规则:落盘 `category` 是权威值;**唯一例外**是落盘值 `unclassified` 而该资产 `kind`
|
||||
/// 能派生出明确的非 `unclassified` 分类时采用派生值。这条例外用于自愈历史上被系统误写成
|
||||
/// `unclassified` 的存量(典型是 `kind:"ui"` / `kind:"UI"` 因 kind 不在 canonical 目录而
|
||||
/// 落到 `image -> unclassified`),不写迁移脚本且永久生效;`kind` 派生结果本身就是
|
||||
/// `unclassified` 的(`image / video / code / publication-material`)仍信任落盘值。
|
||||
/// `unclassified` 的存量(例如 UI 资产落盘 `unclassified` 而 kind 已是 `ui-design`),
|
||||
/// 不写迁移脚本且永久生效;`kind` 派生结果本身就是 `unclassified` 的
|
||||
/// (`image / video / code / publication-material`,以及认不出的 kind)仍信任落盘值。
|
||||
///
|
||||
/// kind 认不出时解析边界只收口成 `Unknown` 并留痕(见
|
||||
/// `GameCreationAppAssetKind::parse_with_context`)。
|
||||
///
|
||||
/// 注意分工:本函数只用于**读显示**(UI 栏目、Agent 投影)。回写 manifest 必须用落盘原值
|
||||
/// (`GameCreationAppAssetManifestEntry.category` 反序列化后就是落盘原值),否则「编辑标签」
|
||||
/// 面板会把自愈值写回去,把「只改标签」变成静默改分类。
|
||||
pub fn game_creation_app_asset_effective_category(
|
||||
kind: &str,
|
||||
kind: GameCreationAppAssetKind,
|
||||
persisted: GameCreationAppAssetCategory,
|
||||
) -> GameCreationAppAssetCategory {
|
||||
if persisted == GameCreationAppAssetCategory::Unclassified {
|
||||
@@ -2078,7 +2104,7 @@ mod tests {
|
||||
});
|
||||
manifest.assets.push(GameCreationAppAssetManifestEntry {
|
||||
id: "asset-player".to_string(),
|
||||
kind: "character".to_string(),
|
||||
kind: GameCreationAppAssetKind::Character,
|
||||
media_type: "image".to_string(),
|
||||
local_path: "assets/images/player.png".to_string(),
|
||||
image_sequence_frames: None,
|
||||
@@ -2168,109 +2194,98 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_category_mapping_covers_every_canonical_kind() {
|
||||
fn manifest_unknown_kind_is_preserved_as_unknown() {
|
||||
let entry = asset_entry_from_json(asset_entry_json("future-kind"));
|
||||
assert_eq!(entry.kind, GameCreationAppAssetKind::Unknown);
|
||||
let serialized = serde_json::to_value(entry).expect("unknown asset entry serializes");
|
||||
assert_eq!(serialized["kind"], "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_category_mapping_covers_every_kind() {
|
||||
assert_eq!(
|
||||
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND.len(),
|
||||
GAME_CREATION_APP_CANONICAL_ASSET_KINDS.len()
|
||||
GameCreationAppAssetKind::ALL.len(),
|
||||
"分类映射必须与 kind 词汇表逐项对齐"
|
||||
);
|
||||
for kind in GAME_CREATION_APP_CANONICAL_ASSET_KINDS {
|
||||
for kind in GameCreationAppAssetKind::ALL {
|
||||
let mapped = GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == kind)
|
||||
.unwrap_or_else(|| panic!("canonical kind 缺少功能分类映射:{kind}"));
|
||||
.find(|(candidate, _)| candidate == kind)
|
||||
.unwrap_or_else(|| panic!("kind 缺少功能分类映射:{}", kind.as_str()));
|
||||
assert_eq!(
|
||||
mapped.1,
|
||||
game_creation_app_asset_category_for_kind(kind),
|
||||
"canonical kind 映射不一致:{kind}"
|
||||
game_creation_app_asset_category_for_kind(*kind),
|
||||
"kind 映射不一致:{}",
|
||||
kind.as_str()
|
||||
);
|
||||
assert!(GAME_CREATION_APP_ASSET_CATEGORIES.contains(&mapped.1));
|
||||
}
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind("icon"),
|
||||
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Icon),
|
||||
GameCreationAppAssetCategory::UiInteraction
|
||||
);
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind("ui-design"),
|
||||
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::UiDesign),
|
||||
GameCreationAppAssetCategory::UiInteraction
|
||||
);
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind("character-animation"),
|
||||
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::CharacterAnimation),
|
||||
GameCreationAppAssetCategory::Character
|
||||
);
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind("scene"),
|
||||
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Scene),
|
||||
GameCreationAppAssetCategory::Scene
|
||||
);
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind("sound-effect"),
|
||||
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::SoundEffect),
|
||||
GameCreationAppAssetCategory::Audio
|
||||
);
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind("spec"),
|
||||
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Spec),
|
||||
GameCreationAppAssetCategory::Document
|
||||
);
|
||||
for kind in ["image", "video", "code", "publication-material"] {
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind(kind),
|
||||
game_creation_app_asset_category_for_raw_kind(kind, "test.category"),
|
||||
GameCreationAppAssetCategory::Unclassified,
|
||||
"{kind} 必须落待归类"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind("game-background"),
|
||||
GameCreationAppAssetCategory::Scene
|
||||
);
|
||||
// 现役写入侧写的是大写 `"UI"`(UI 设计 JSON 资产)与 `font`(字体上传)。
|
||||
// 旧断言钉的是「`"UI"` 落待归类」,那正是真机 8 条 UI 资产永远归不了类的成因:
|
||||
// 派生值本身就是 unclassified,读时自愈也救不回来,只能在别名表收口。
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind("UI"),
|
||||
GameCreationAppAssetCategory::UiInteraction
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_game_creation_app_asset_kind("UI"),
|
||||
"ui-design",
|
||||
"kind 别名表必须大小写不敏感"
|
||||
);
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind("font"),
|
||||
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Font),
|
||||
GameCreationAppAssetCategory::Document
|
||||
);
|
||||
// `Object.prototype` 上的键不是别名:Rust 侧是 `match` 字面量,本来就落 `image`;
|
||||
// TS 侧别名表是对象字面量,必须用 `Object.hasOwn` 挡住原型命中,两侧才一致。
|
||||
// 这两条与 TS `gameCreationApp.test.ts` / `assetKindCanonicalMapping.test.ts` 成对。
|
||||
// 严格口径:非 canonical 原值(含大小写变体与 `Object.prototype` 上的键)一律落
|
||||
// 「待归类」。
|
||||
for prototype_key in ["constructor", "__proto__", "toString", "valueOf"] {
|
||||
assert_eq!(
|
||||
canonical_game_creation_app_asset_kind(prototype_key),
|
||||
"image",
|
||||
"{prototype_key} 必须落 image 兜底"
|
||||
);
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind(prototype_key),
|
||||
game_creation_app_asset_category_for_raw_kind(prototype_key, "test.category"),
|
||||
GameCreationAppAssetCategory::Unclassified
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_kind("unknown-kind"),
|
||||
GameCreationAppAssetCategory::Unclassified
|
||||
);
|
||||
for raw in ["UI", "future-kind", "unknown-kind"] {
|
||||
assert_eq!(
|
||||
game_creation_app_asset_category_for_raw_kind(raw, "test.category"),
|
||||
GameCreationAppAssetCategory::Unclassified,
|
||||
"{raw} 是非 canonical 值,不得被归一成别的分类"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 有效分类(读显示)决策矩阵:`(kind, 落盘 category, 有效 category)`。
|
||||
///
|
||||
/// **这是「落盘值 / kind 派生 / 读时自愈」三个口径的唯一真源**:本表由下面的
|
||||
/// `asset_effective_category_follows_the_shared_contract_matrix` 逐条断言,
|
||||
/// 同时被 TS 侧
|
||||
/// `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析出来喂给
|
||||
/// `gameCreationAppAssetCategory` 对照。两侧各写一份矩阵就会重新分叉成
|
||||
/// 「UI 显示 ui-interaction、Agent 读 unclassified」的两套口径。
|
||||
/// `asset_effective_category_follows_the_shared_contract_matrix` 逐条断言。
|
||||
/// TS 侧 `gameCreationAppAssetCategory` 是同一口径的另一份实现,由
|
||||
/// `packages/shared/src/contracts/gameCreationApp.test.ts` 的同名矩阵保持同步。
|
||||
const EFFECTIVE_CATEGORY_CONTRACT: [(&str, &str, &str); 8] = [
|
||||
// 落盘值合法且明确 → 落盘值为准。
|
||||
("character", "character", "character"),
|
||||
("ui", "scene", "scene"),
|
||||
("ui-design", "scene", "scene"),
|
||||
("image", "audio", "audio"),
|
||||
// 落盘 unclassified 且 kind 能派生明确分类 → 自愈成派生值。
|
||||
("ui", "unclassified", "ui-interaction"),
|
||||
("ui-design", "unclassified", "ui-interaction"),
|
||||
("character", "unclassified", "character"),
|
||||
// kind 派生结果本身就是 unclassified → 信任落盘值。
|
||||
("image", "unclassified", "unclassified"),
|
||||
@@ -2286,7 +2301,10 @@ mod tests {
|
||||
let expected = game_creation_app_asset_category_from_str(expected)
|
||||
.unwrap_or_else(|| panic!("矩阵里的期望分类非法:{expected}"));
|
||||
assert_eq!(
|
||||
game_creation_app_asset_effective_category(kind, persisted),
|
||||
game_creation_app_asset_effective_category(
|
||||
GameCreationAppAssetKind::parse_with_context(kind, "test.effective-category"),
|
||||
persisted,
|
||||
),
|
||||
expected,
|
||||
"有效分类矩阵不一致:kind={kind} persisted={persisted:?}"
|
||||
);
|
||||
@@ -2294,7 +2312,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_manifest_entry_defaults_category_and_tags_for_legacy_payloads() {
|
||||
fn asset_manifest_entry_defaults_category_and_tags_for_payloads_missing_them() {
|
||||
let character = asset_entry_from_json(asset_entry_json("character"));
|
||||
assert_eq!(character.category, GameCreationAppAssetCategory::Character);
|
||||
assert!(character.tags.is_empty());
|
||||
@@ -2302,11 +2320,17 @@ mod tests {
|
||||
let icon = asset_entry_from_json(asset_entry_json("icon-spritesheet"));
|
||||
assert_eq!(icon.category, GameCreationAppAssetCategory::UiInteraction);
|
||||
|
||||
let legacy_alias = asset_entry_from_json(asset_entry_json("game-background"));
|
||||
assert_eq!(legacy_alias.category, GameCreationAppAssetCategory::Scene);
|
||||
|
||||
let unknown = asset_entry_from_json(asset_entry_json("unknown-kind"));
|
||||
assert_eq!(unknown.category, GameCreationAppAssetCategory::Unclassified);
|
||||
// 缺 `category` 时按 kind 派生;非 canonical 的 kind 一律落「待归类」,
|
||||
// 判定结果只出现在留痕日志里。
|
||||
for raw_kind in ["future-kind", "unknown-kind"] {
|
||||
let legacy = asset_entry_from_json(asset_entry_json(raw_kind));
|
||||
assert_eq!(legacy.kind, GameCreationAppAssetKind::Unknown);
|
||||
assert_eq!(
|
||||
legacy.category,
|
||||
GameCreationAppAssetCategory::Unclassified,
|
||||
"{raw_kind} 不得被归一成别的分类"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
||||
use std::fmt;
|
||||
use std::ops::Deref;
|
||||
use std::str::FromStr;
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// 「非 canonical 资源 kind」留痕回调:`(原始输入串, 调用上下文)`。
|
||||
///
|
||||
/// `shared-contracts` 是跨进程共用的 crate,拿不到壳层的日志实现(AGC 壳的 `app_log!`),
|
||||
/// 所以这里只留一个可注册的回调:解析边界认不出 canonical 值时把**原始输入串**交出去,
|
||||
/// 壳层注册后即可把 legacy 写入方定位到具体调用点。未注册时静默丢弃(例如后端进程)。
|
||||
pub type NonCanonicalAssetKindReporter = fn(raw_kind: &str, context: &str);
|
||||
|
||||
static NON_CANONICAL_ASSET_KIND_REPORTER: RwLock<Option<NonCanonicalAssetKindReporter>> =
|
||||
RwLock::new(None);
|
||||
|
||||
/// 注册留痕回调;重复注册以后注册的为准。
|
||||
pub fn set_non_canonical_asset_kind_reporter(reporter: NonCanonicalAssetKindReporter) {
|
||||
match NON_CANONICAL_ASSET_KIND_REPORTER.write() {
|
||||
Ok(mut slot) => *slot = Some(reporter),
|
||||
Err(poisoned) => *poisoned.into_inner() = Some(reporter),
|
||||
}
|
||||
}
|
||||
|
||||
fn report_non_canonical_asset_kind(raw_kind: &str, context: &str) {
|
||||
let reporter = match NON_CANONICAL_ASSET_KIND_REPORTER.read() {
|
||||
Ok(slot) => *slot,
|
||||
Err(poisoned) => *poisoned.into_inner(),
|
||||
};
|
||||
if let Some(reporter) = reporter {
|
||||
reporter(raw_kind, context);
|
||||
}
|
||||
}
|
||||
|
||||
/// GameCreationApp manifest 资源 kind 的**唯一**词汇表。
|
||||
///
|
||||
/// 枚举变体、线上值(kebab-case)、`as_str()`、`ALL`、严格解析与 ts-rs 绑定全部由下面这张
|
||||
/// 声明表派生,TS 侧消费生成的 union。
|
||||
///
|
||||
/// 口径:**只接受 canonical 值**。认不出的输入收口成 `Unknown`;留痕由
|
||||
/// [`Self::parse_with_context`] 交给已注册的 reporter。
|
||||
macro_rules! game_creation_app_asset_kinds {
|
||||
($($variant:ident => $wire:literal),+ $(,)?) => {
|
||||
/// GameCreationApp manifest 资源 kind 的**唯一**类型,也是 TS 侧 kind union 的唯一真源
|
||||
/// (经 ts-rs 生成,前端不再手写第二份列表)。
|
||||
///
|
||||
/// JSON 使用 kebab-case;`Unknown` 表示当前边界无法解析输入。
|
||||
/// 写入方可以保留这个未解析结果,但必须由调用者决定是否拒绝写入或交给后续归类。
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]
|
||||
#[cfg_attr(
|
||||
feature = "ts-bindings",
|
||||
ts(
|
||||
export,
|
||||
export_to = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../../packages/shared/src/contracts/generated/"
|
||||
)
|
||||
)
|
||||
)]
|
||||
pub enum GameCreationAppAssetKind {
|
||||
$(
|
||||
#[cfg_attr(feature = "ts-bindings", ts(rename = $wire))]
|
||||
$variant,
|
||||
)+
|
||||
}
|
||||
|
||||
impl GameCreationAppAssetKind {
|
||||
/// 全部变体(末尾是 `Unknown`),按声明顺序。
|
||||
pub const ALL: &'static [Self] = &[$(Self::$variant,)+];
|
||||
|
||||
/// `canvas.asset_generate` 当前允许提交的资源 kind 子集。
|
||||
///
|
||||
/// 工具输入与 manifest 持久化共用同一组 canonical 枚举值,不再维护第二份
|
||||
/// 字符串白名单。工具 schema、prompt 和执行前校验都应从这里派生。
|
||||
pub const CANVAS_ASSET_KINDS: &'static [Self] = &[
|
||||
Self::Image,
|
||||
Self::IconSpec,
|
||||
Self::UiDesign,
|
||||
Self::IconSpritesheet,
|
||||
];
|
||||
|
||||
/// 线上值:kind 字符串表只有这一份,就是上面那张声明表。
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
$(Self::$variant => $wire,)+
|
||||
}
|
||||
}
|
||||
|
||||
/// 资源编辑器和摘要视图共用的音频资源族。
|
||||
pub const fn is_audio(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Audio | Self::SoundEffect | Self::BackgroundMusic
|
||||
)
|
||||
}
|
||||
|
||||
/// 资源编辑器和摘要视图共用的视觉资源族。
|
||||
pub const fn is_visual(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Image
|
||||
| Self::Scene
|
||||
| Self::Character
|
||||
| Self::CharacterAnimation
|
||||
| Self::Icon
|
||||
| Self::IconSpritesheet
|
||||
| Self::IconSpec
|
||||
| Self::UiDesign
|
||||
)
|
||||
}
|
||||
|
||||
/// 可作为单张静态图片来源的资源族。
|
||||
pub const fn is_static_image(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Image
|
||||
| Self::Scene
|
||||
| Self::Character
|
||||
| Self::Icon
|
||||
| Self::IconSpritesheet
|
||||
| Self::IconSpec
|
||||
| Self::UiDesign
|
||||
| Self::PublicationMaterial
|
||||
| Self::Spec
|
||||
)
|
||||
}
|
||||
|
||||
/// 严格解析:只认 canonical 值,其余(含大小写、空白变体)一律 `Unknown`。
|
||||
///
|
||||
/// 私有实现细节,**不是**解析入口:外部一律走 [`Self::parse_with_context`],
|
||||
/// 保证"认不出"这件事只在一个地方被收口、被留痕。
|
||||
fn match_canonical(value: &str) -> Self {
|
||||
let mut index = 0;
|
||||
while index < Self::ALL.len() {
|
||||
let candidate = Self::ALL[index];
|
||||
if candidate.as_str() == value {
|
||||
return candidate;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
Self::Unknown
|
||||
}
|
||||
|
||||
/// **唯一**的 kind 解析入口:严格等值匹配,认不出 canonical 值时把原始串与上下文
|
||||
/// 交给已注册的 reporter(AGC 壳注册成 `app_log!`)。
|
||||
///
|
||||
/// 认不出就收口成 `Unknown`,而不是 `Err`:反序列化必须不可失败,
|
||||
/// 以便调用方决定后续处理。
|
||||
/// 字面 `"unknown"` 本身是合法输入,不算异常,不报。
|
||||
pub fn parse_with_context(value: &str, context: &'static str) -> Self {
|
||||
let parsed = Self::match_canonical(value);
|
||||
if parsed == Self::Unknown && value != Self::Unknown.as_str() {
|
||||
report_non_canonical_asset_kind(value, context);
|
||||
}
|
||||
parsed
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
game_creation_app_asset_kinds! {
|
||||
Image => "image",
|
||||
Scene => "scene",
|
||||
Character => "character",
|
||||
CharacterAnimation => "character-animation",
|
||||
Icon => "icon",
|
||||
IconSpritesheet => "icon-spritesheet",
|
||||
IconSpec => "icon-spec",
|
||||
UiDesign => "ui-design",
|
||||
UiDesignDoc => "ui-design-doc",
|
||||
PublicationMaterial => "publication-material",
|
||||
Spec => "spec",
|
||||
Video => "video",
|
||||
Audio => "audio",
|
||||
SoundEffect => "sound-effect",
|
||||
BackgroundMusic => "background-music",
|
||||
Font => "font",
|
||||
Document => "document",
|
||||
Code => "code",
|
||||
Unknown => "unknown",
|
||||
}
|
||||
|
||||
impl FromStr for GameCreationAppAssetKind {
|
||||
type Err = std::convert::Infallible;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self::parse_with_context(value, "from_str"))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GameCreationAppAssetKind {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for GameCreationAppAssetKind {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for GameCreationAppAssetKind {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<str> for GameCreationAppAssetKind {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.as_str() == other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&str> for GameCreationAppAssetKind {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.as_str() == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<String> for GameCreationAppAssetKind {
|
||||
fn eq(&self, other: &String) -> bool {
|
||||
self.as_str() == other
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for GameCreationAppAssetKind {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for GameCreationAppAssetKind {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct AssetKindVisitor;
|
||||
|
||||
impl<'de> de::Visitor<'de> for AssetKindVisitor {
|
||||
type Value = GameCreationAppAssetKind;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("a GameCreationApp asset kind string")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
Ok(GameCreationAppAssetKind::parse_with_context(value, "serde"))
|
||||
}
|
||||
|
||||
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
self.visit_str(&value)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(AssetKindVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{GameCreationAppAssetKind, set_non_canonical_asset_kind_reporter};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// 留痕回调是进程级单例,测试之间必须串行注册/观察。
|
||||
static REPORTER_LOCK: Mutex<()> = Mutex::new(());
|
||||
static REPORTS: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
|
||||
|
||||
fn report_into_slot(raw_kind: &str, context: &str) {
|
||||
if let Ok(mut reports) = REPORTS.lock() {
|
||||
reports.push((raw_kind.to_string(), context.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 串行持有 reporter 槽位,并把收集器清空后注册回调。
|
||||
fn record_reports() -> MutexGuard<'static, ()> {
|
||||
let guard = match REPORTER_LOCK.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
if let Ok(mut reports) = REPORTS.lock() {
|
||||
reports.clear();
|
||||
}
|
||||
set_non_canonical_asset_kind_reporter(report_into_slot);
|
||||
guard
|
||||
}
|
||||
|
||||
fn recorded_reports() -> Vec<(String, String)> {
|
||||
REPORTS.lock().expect("reports lock").clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_uses_kebab_case() {
|
||||
let values = [
|
||||
(GameCreationAppAssetKind::Image, "image"),
|
||||
(
|
||||
GameCreationAppAssetKind::CharacterAnimation,
|
||||
"character-animation",
|
||||
),
|
||||
(GameCreationAppAssetKind::UiDesignDoc, "ui-design-doc"),
|
||||
(GameCreationAppAssetKind::SoundEffect, "sound-effect"),
|
||||
(
|
||||
GameCreationAppAssetKind::BackgroundMusic,
|
||||
"background-music",
|
||||
),
|
||||
(GameCreationAppAssetKind::Font, "font"),
|
||||
(GameCreationAppAssetKind::Unknown, "unknown"),
|
||||
];
|
||||
|
||||
for (value, encoded) in values {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&value).unwrap(),
|
||||
format!("\"{encoded}\"")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<GameCreationAppAssetKind>(&format!("\"{encoded}\""))
|
||||
.unwrap(),
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_wire_values_are_closed_over_unknown() {
|
||||
let _guard = record_reports();
|
||||
for raw in ["future-kind", "not-a-kind", "IMAGE", " image "] {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<GameCreationAppAssetKind>(&format!("\"{raw}\"")).unwrap(),
|
||||
GameCreationAppAssetKind::Unknown
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
recorded_reports().len(),
|
||||
4,
|
||||
"每个非 canonical 的落盘值都应留痕"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_wire_strings_are_deserialized() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<GameCreationAppAssetKind>(r#""ui\u002Ddesign""#).unwrap(),
|
||||
GameCreationAppAssetKind::UiDesign
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_canonical_input_is_reported_with_raw_value_and_context() {
|
||||
let _guard = record_reports();
|
||||
assert_eq!(
|
||||
GameCreationAppAssetKind::parse_with_context("UI", "unit-test.context"),
|
||||
GameCreationAppAssetKind::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
GameCreationAppAssetKind::parse_with_context("unknown", "unit-test.context"),
|
||||
GameCreationAppAssetKind::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
recorded_reports().as_slice(),
|
||||
[("UI".to_string(), "unit-test.context".to_string())],
|
||||
"只有非 canonical 的原值才留痕,字面 unknown 不算异常"
|
||||
);
|
||||
}
|
||||
|
||||
/// 词汇表只有一张声明表:`ALL` 覆盖全部变体、线上值互不重复、往返一致;
|
||||
/// 严格口径下大小写与空白变体都不命中。
|
||||
#[test]
|
||||
fn declared_vocabulary_is_unique_and_strictly_parsed() {
|
||||
let _guard = record_reports();
|
||||
assert_eq!(GameCreationAppAssetKind::ALL.len(), 19);
|
||||
let mut wire_values = std::collections::BTreeSet::new();
|
||||
for kind in GameCreationAppAssetKind::ALL {
|
||||
assert!(
|
||||
wire_values.insert(kind.as_str()),
|
||||
"线上值重复:{}",
|
||||
kind.as_str()
|
||||
);
|
||||
assert_eq!(
|
||||
GameCreationAppAssetKind::parse_with_context(kind.as_str(), "unit-test.vocabulary"),
|
||||
*kind
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
GameCreationAppAssetKind::parse_with_context("unknown", "unit-test.vocabulary"),
|
||||
GameCreationAppAssetKind::Unknown
|
||||
);
|
||||
for raw in ["IMAGE", " image ", "Image", "future-kind"] {
|
||||
assert_eq!(
|
||||
GameCreationAppAssetKind::parse_with_context(raw, "unit-test.vocabulary"),
|
||||
GameCreationAppAssetKind::Unknown,
|
||||
"{raw} 不许命中 canonical 值"
|
||||
);
|
||||
}
|
||||
// 每个非 canonical 变体都要留痕。
|
||||
assert_eq!(recorded_reports().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_family_helpers_cover_the_shared_kind_sets() {
|
||||
let audio = GameCreationAppAssetKind::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|kind| kind.is_audio())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
audio,
|
||||
vec![
|
||||
GameCreationAppAssetKind::Audio,
|
||||
GameCreationAppAssetKind::SoundEffect,
|
||||
GameCreationAppAssetKind::BackgroundMusic,
|
||||
]
|
||||
);
|
||||
|
||||
let visual = GameCreationAppAssetKind::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|kind| kind.is_visual())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
visual,
|
||||
vec![
|
||||
GameCreationAppAssetKind::Image,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
GameCreationAppAssetKind::Character,
|
||||
GameCreationAppAssetKind::CharacterAnimation,
|
||||
GameCreationAppAssetKind::Icon,
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
GameCreationAppAssetKind::UiDesign,
|
||||
]
|
||||
);
|
||||
|
||||
let static_images = GameCreationAppAssetKind::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|kind| kind.is_static_image())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
static_images,
|
||||
vec![
|
||||
GameCreationAppAssetKind::Image,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
GameCreationAppAssetKind::Character,
|
||||
GameCreationAppAssetKind::Icon,
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
GameCreationAppAssetKind::UiDesign,
|
||||
GameCreationAppAssetKind::PublicationMaterial,
|
||||
GameCreationAppAssetKind::Spec,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user