merge: master into codex/bark-battle
This commit is contained in:
@@ -11,6 +11,7 @@ base64 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
||||
http-body-util = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||
webp = { workspace = true }
|
||||
module-ai = { workspace = true }
|
||||
@@ -43,18 +44,23 @@ sha2 = { workspace = true }
|
||||
shared-contracts = { workspace = true, features = ["oss-contracts"] }
|
||||
shared-kernel = { workspace = true }
|
||||
shared-logging = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
spacetime-client = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util"] }
|
||||
tokio-stream = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
time = { workspace = true, features = ["formatting"] }
|
||||
tower-http = { workspace = true, features = ["trace"] }
|
||||
tracing = { workspace = true }
|
||||
opentelemetry = { workspace = true }
|
||||
url = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
zip = { workspace = true, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
use axum::Json;
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
http::{HeaderValue, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
#[cfg(test)]
|
||||
@@ -32,6 +41,30 @@ where
|
||||
Json(serde_json::to_value(data).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn json_success_data_bytes_response(
|
||||
request_context: Option<&RequestContext>,
|
||||
data_json: Bytes,
|
||||
) -> Response {
|
||||
if let Some(context) = request_context
|
||||
&& context.wants_envelope()
|
||||
{
|
||||
let meta = serde_json::to_vec(&build_api_response_meta(Some(context)))
|
||||
.map(Bytes::from)
|
||||
.unwrap_or_else(|_| Bytes::from_static(b"null"));
|
||||
let chunks = [
|
||||
Bytes::from_static(b"{\"ok\":true,\"data\":"),
|
||||
data_json,
|
||||
Bytes::from_static(b",\"error\":null,\"meta\":"),
|
||||
meta,
|
||||
Bytes::from_static(b"}"),
|
||||
];
|
||||
let stream = stream::iter(chunks.into_iter().map(Ok::<Bytes, Infallible>));
|
||||
return json_body_response(Body::from_stream(stream));
|
||||
}
|
||||
|
||||
json_bytes_response(data_json)
|
||||
}
|
||||
|
||||
pub fn json_error_body(
|
||||
request_context: Option<&RequestContext>,
|
||||
error: &ApiErrorPayload,
|
||||
@@ -65,6 +98,19 @@ fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiRespo
|
||||
)
|
||||
}
|
||||
|
||||
fn json_bytes_response(bytes: Bytes) -> Response {
|
||||
json_body_response(Body::from(bytes))
|
||||
}
|
||||
|
||||
fn json_body_response(body: Body) -> Response {
|
||||
let mut response = body.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -106,6 +152,31 @@ mod tests {
|
||||
assert!(body.get("meta").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn success_response_streams_cached_data_inside_standard_envelope() {
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
let request_context = build_request_context(true);
|
||||
let response = json_success_data_bytes_response(
|
||||
Some(&request_context),
|
||||
Bytes::from_static(br#"{"items":[]}"#),
|
||||
);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value = serde_json::from_slice(&body).expect("body should be json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(payload["data"]["items"], Value::Array(Vec::new()));
|
||||
assert_eq!(
|
||||
payload["meta"]["requestId"],
|
||||
Value::String("req-test".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_body_returns_legacy_shape_without_envelope_header() {
|
||||
let request_context = build_request_context(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::Extension,
|
||||
extract::{Extension, FromRef},
|
||||
http::Request,
|
||||
middleware,
|
||||
response::Response,
|
||||
@@ -11,17 +11,19 @@ use tower_http::{
|
||||
classify::ServerErrorsFailureClass,
|
||||
trace::{DefaultOnRequest, TraceLayer},
|
||||
};
|
||||
use tracing::{Level, Span, error, info, info_span, warn};
|
||||
use tracing::{Level, Span, error, info_span};
|
||||
|
||||
use crate::{
|
||||
auth::{AuthenticatedAccessToken, require_bearer_auth},
|
||||
backpressure::limit_concurrent_requests,
|
||||
creation_entry_config::require_creation_entry_route_enabled,
|
||||
error_middleware::normalize_error_response,
|
||||
modules,
|
||||
request_context::{RequestContext, attach_request_context, resolve_request_id},
|
||||
response_headers::propagate_request_id_header,
|
||||
runtime_inventory::get_runtime_inventory_state,
|
||||
state::AppState,
|
||||
state::{AppState, BackpressureState},
|
||||
telemetry::record_http_observability,
|
||||
tracking::record_route_tracking_event_after_success,
|
||||
vector_engine_audio_generation::{
|
||||
create_background_music_task, create_sound_effect_task,
|
||||
@@ -42,8 +44,6 @@ use crate::{
|
||||
|
||||
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
|
||||
|
||||
Router::new()
|
||||
.merge(modules::admin::router(state.clone()))
|
||||
.merge(modules::health::router(state.clone()))
|
||||
@@ -77,6 +77,11 @@ pub fn build_router(state: AppState) -> Router {
|
||||
state.clone(),
|
||||
require_creation_entry_route_enabled,
|
||||
))
|
||||
// HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。
|
||||
.layer(middleware::from_fn_with_state(
|
||||
BackpressureState::from_ref(&state),
|
||||
limit_concurrent_requests,
|
||||
))
|
||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
|
||||
@@ -86,47 +91,55 @@ pub fn build_router(state: AppState) -> Router {
|
||||
state.clone(),
|
||||
record_api_tracking_after_success,
|
||||
))
|
||||
// HTTP 指标与请求完成日志放在 tracing span 内侧,日志事件可以继承当前 trace/span context。
|
||||
.layer(middleware::from_fn_with_state(
|
||||
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(),
|
||||
uri = %request.uri(),
|
||||
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(
|
||||
move |response: &axum::response::Response,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
|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();
|
||||
let slow_request = latency_ms >= slow_request_threshold_ms;
|
||||
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);
|
||||
if slow_request {
|
||||
warn!(
|
||||
parent: span,
|
||||
status,
|
||||
latency_ms,
|
||||
slow_request = true,
|
||||
"http request completed slowly"
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
parent: span,
|
||||
status,
|
||||
latency_ms,
|
||||
slow_request = false,
|
||||
"http request completed"
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.on_failure(
|
||||
|
||||
@@ -752,10 +752,14 @@ mod tests {
|
||||
};
|
||||
use hmac::{Hmac, Mac};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use reqwest::{Method, multipart};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_kernel::new_uuid_simple_string;
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
@@ -873,13 +877,17 @@ mod tests {
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let token =
|
||||
seed_authenticated_token(&state, "13800138120", "sess_assets_direct_upload").await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/assets/direct-upload-tickets")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-request-id", "req-oss-ticket")
|
||||
.header("x-genarrative-response-envelope", "1")
|
||||
@@ -1693,6 +1701,33 @@ mod tests {
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
async fn seed_authenticated_token(
|
||||
state: &AppState,
|
||||
phone_number: &str,
|
||||
session_seed: &str,
|
||||
) -> String {
|
||||
let user = state
|
||||
.seed_test_phone_user_with_password(phone_number, "secret123")
|
||||
.await;
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: user.id.clone(),
|
||||
session_id: state.seed_test_refresh_session_for_user(&user, session_seed),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: user.token_version,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some(user.display_name.clone()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
|
||||
fn build_object_url(
|
||||
config: &AppConfig,
|
||||
object_key: &str,
|
||||
|
||||
481
server-rs/crates/api-server/src/backpressure.rs
Normal file
481
server-rs/crates/api-server/src/backpressure.rs
Normal file
@@ -0,0 +1,481 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Request, State},
|
||||
http::{HeaderValue, StatusCode, header::RETRY_AFTER},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use tokio::sync::{OwnedSemaphorePermit, TryAcquireError};
|
||||
|
||||
use crate::{
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::{BackpressureState, HttpRequestPermitPool, HttpRequestPermitPoolKind},
|
||||
};
|
||||
|
||||
pub async fn limit_concurrent_requests(
|
||||
State(state): State<BackpressureState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if should_bypass_backpressure(&request) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
let requested_pool = classify_request_permit_pool(request.uri().path());
|
||||
let Some((permit_pool_kind, permit_pool)) = state.request_permit_pool(requested_pool) else {
|
||||
return next.run(request).await;
|
||||
};
|
||||
|
||||
match acquire_http_request_permit(permit_pool_kind, permit_pool) {
|
||||
Ok(permit) => hold_permit_until_response_body_dropped(next.run(request).await, permit),
|
||||
Err(_) => reject_overloaded_request(&request),
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_http_request_permit(
|
||||
permit_pool_kind: HttpRequestPermitPoolKind,
|
||||
permit_pool: Arc<HttpRequestPermitPool>,
|
||||
) -> Result<HttpRequestPermitGuard, TryAcquireError> {
|
||||
match permit_pool.clone().try_acquire_owned() {
|
||||
Ok(permit) => {
|
||||
crate::telemetry::update_http_request_permits_available(
|
||||
permit_pool_kind,
|
||||
permit_pool.available_permits(),
|
||||
);
|
||||
Ok(HttpRequestPermitGuard {
|
||||
permit_pool_kind,
|
||||
permit: Some(permit),
|
||||
permit_pool,
|
||||
})
|
||||
}
|
||||
Err(error) => {
|
||||
crate::telemetry::update_http_request_permits_available(
|
||||
permit_pool_kind,
|
||||
permit_pool.available_permits(),
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hold_permit_until_response_body_dropped(
|
||||
response: Response,
|
||||
permit: HttpRequestPermitGuard,
|
||||
) -> Response {
|
||||
response.map(|body| {
|
||||
Body::new(body.map_frame(move |frame| {
|
||||
let _permit_guard = &permit;
|
||||
frame
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
struct HttpRequestPermitGuard {
|
||||
permit_pool_kind: HttpRequestPermitPoolKind,
|
||||
permit: Option<OwnedSemaphorePermit>,
|
||||
permit_pool: Arc<HttpRequestPermitPool>,
|
||||
}
|
||||
|
||||
impl Drop for HttpRequestPermitGuard {
|
||||
fn drop(&mut self) {
|
||||
drop(self.permit.take());
|
||||
crate::telemetry::update_http_request_permits_available(
|
||||
self.permit_pool_kind,
|
||||
self.permit_pool.available_permits(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_overloaded_request(request: &Request<Body>) -> Response {
|
||||
let request_context = request.extensions().get::<RequestContext>().cloned();
|
||||
let mut response = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
|
||||
.with_message("服务繁忙,请稍后重试")
|
||||
.into_response_with_context(request_context.as_ref());
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(RETRY_AFTER, HeaderValue::from_static("1"));
|
||||
response
|
||||
}
|
||||
|
||||
fn should_bypass_backpressure(request: &Request<Body>) -> bool {
|
||||
request.uri().path() == "/healthz"
|
||||
}
|
||||
|
||||
fn classify_request_permit_pool(path: &str) -> HttpRequestPermitPoolKind {
|
||||
if is_gallery_list_path(path) {
|
||||
HttpRequestPermitPoolKind::Gallery
|
||||
} else if is_gallery_detail_path(path) {
|
||||
HttpRequestPermitPoolKind::Detail
|
||||
} else if path.starts_with("/admin/api/") {
|
||||
HttpRequestPermitPoolKind::Admin
|
||||
} else {
|
||||
HttpRequestPermitPoolKind::Default
|
||||
}
|
||||
}
|
||||
|
||||
fn is_gallery_list_path(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/api/runtime/puzzle/gallery" | "/api/runtime/custom-world-gallery"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_gallery_detail_path(path: &str) -> bool {
|
||||
let puzzle_prefix = "/api/runtime/puzzle/gallery/";
|
||||
if let Some(profile_id) = path.strip_prefix(puzzle_prefix) {
|
||||
return !profile_id.is_empty() && !profile_id.contains('/');
|
||||
}
|
||||
|
||||
let custom_world_prefix = "/api/runtime/custom-world-gallery/";
|
||||
if let Some(remainder) = path.strip_prefix(custom_world_prefix) {
|
||||
let mut segments = remainder.split('/');
|
||||
return matches!(
|
||||
(segments.next(), segments.next(), segments.next()),
|
||||
(Some(owner_user_id), Some(profile_id), None)
|
||||
if !owner_user_id.is_empty() && !profile_id.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::Extension,
|
||||
http::{Request, StatusCode, header::RETRY_AFTER},
|
||||
middleware,
|
||||
routing::get,
|
||||
};
|
||||
use tokio::sync::Notify;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use axum::extract::FromRef;
|
||||
|
||||
use crate::{
|
||||
config::AppConfig,
|
||||
state::{AppState, BackpressureState},
|
||||
};
|
||||
|
||||
use super::{classify_request_permit_pool, limit_concurrent_requests};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HeldRequestGate {
|
||||
entered: Arc<Notify>,
|
||||
release: Arc<Notify>,
|
||||
}
|
||||
|
||||
async fn held_request(Extension(gate): Extension<HeldRequestGate>) -> &'static str {
|
||||
gate.entered.notify_one();
|
||||
gate.release.notified().await;
|
||||
"ok"
|
||||
}
|
||||
|
||||
async fn fast_request() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
fn test_request(path: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.expect("test request should build")
|
||||
}
|
||||
|
||||
fn build_test_app(max_concurrent_requests: usize, gate: HeldRequestGate) -> Router {
|
||||
let mut config = AppConfig::default();
|
||||
config.max_concurrent_requests = Some(max_concurrent_requests);
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let backpressure_state = BackpressureState::from_ref(&state);
|
||||
|
||||
Router::new()
|
||||
.route("/held", get(held_request))
|
||||
.route("/fast", get(fast_request))
|
||||
.route("/healthz", get(fast_request))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
backpressure_state,
|
||||
limit_concurrent_requests,
|
||||
))
|
||||
.layer(Extension(gate))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn build_grouped_test_app(
|
||||
default_max_concurrent_requests: usize,
|
||||
gallery_max_concurrent_requests: usize,
|
||||
admin_max_concurrent_requests: usize,
|
||||
gate: HeldRequestGate,
|
||||
) -> Router {
|
||||
let mut config = AppConfig::default();
|
||||
config.max_concurrent_requests = Some(default_max_concurrent_requests);
|
||||
config.gallery_max_concurrent_requests = Some(gallery_max_concurrent_requests);
|
||||
config.admin_max_concurrent_requests = Some(admin_max_concurrent_requests);
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let backpressure_state = BackpressureState::from_ref(&state);
|
||||
|
||||
Router::new()
|
||||
.route("/held", get(held_request))
|
||||
.route("/api/runtime/puzzle/gallery", get(held_request))
|
||||
.route("/api/runtime/custom-world-gallery", get(held_request))
|
||||
.route("/api/runtime/puzzle/gallery/profile-1", get(held_request))
|
||||
.route(
|
||||
"/api/runtime/puzzle/gallery/profile-1/like",
|
||||
get(fast_request),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/custom-world-gallery/user-1/profile-1",
|
||||
get(held_request),
|
||||
)
|
||||
.route("/admin/api/overview", get(held_request))
|
||||
.route("/fast", get(fast_request))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
backpressure_state,
|
||||
limit_concurrent_requests,
|
||||
))
|
||||
.layer(Extension(gate))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_429_when_concurrency_permits_are_exhausted() {
|
||||
let gate = HeldRequestGate {
|
||||
entered: Arc::new(Notify::new()),
|
||||
release: Arc::new(Notify::new()),
|
||||
};
|
||||
let app = build_test_app(1, gate.clone());
|
||||
let entered = gate.entered.notified();
|
||||
|
||||
let held_response = tokio::spawn(app.clone().oneshot(test_request("/held")));
|
||||
entered.await;
|
||||
|
||||
let rejected_response = app
|
||||
.clone()
|
||||
.oneshot(test_request("/fast"))
|
||||
.await
|
||||
.expect("rejected request should complete");
|
||||
assert_eq!(rejected_response.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(
|
||||
rejected_response
|
||||
.headers()
|
||||
.get(RETRY_AFTER)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("1")
|
||||
);
|
||||
|
||||
gate.release.notify_one();
|
||||
let completed_response = held_response
|
||||
.await
|
||||
.expect("held request task should join")
|
||||
.expect("held request should complete");
|
||||
assert_eq!(completed_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_bypasses_concurrency_backpressure() {
|
||||
let gate = HeldRequestGate {
|
||||
entered: Arc::new(Notify::new()),
|
||||
release: Arc::new(Notify::new()),
|
||||
};
|
||||
let app = build_test_app(1, gate.clone());
|
||||
let entered = gate.entered.notified();
|
||||
|
||||
let held_response = tokio::spawn(app.clone().oneshot(test_request("/held")));
|
||||
entered.await;
|
||||
|
||||
let health_response = app
|
||||
.clone()
|
||||
.oneshot(test_request("/healthz"))
|
||||
.await
|
||||
.expect("healthz request should complete");
|
||||
assert_eq!(health_response.status(), StatusCode::OK);
|
||||
|
||||
gate.release.notify_one();
|
||||
let completed_response = held_response
|
||||
.await
|
||||
.expect("held request task should join")
|
||||
.expect("held request should complete");
|
||||
assert_eq!(completed_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permit_is_held_until_response_body_is_dropped() {
|
||||
let gate = HeldRequestGate {
|
||||
entered: Arc::new(Notify::new()),
|
||||
release: Arc::new(Notify::new()),
|
||||
};
|
||||
let app = build_test_app(1, gate);
|
||||
|
||||
let first_response = app
|
||||
.clone()
|
||||
.oneshot(test_request("/fast"))
|
||||
.await
|
||||
.expect("first request should complete");
|
||||
assert_eq!(first_response.status(), StatusCode::OK);
|
||||
|
||||
let rejected_response = app
|
||||
.clone()
|
||||
.oneshot(test_request("/fast"))
|
||||
.await
|
||||
.expect("second request should complete");
|
||||
assert_eq!(rejected_response.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
|
||||
drop(first_response);
|
||||
|
||||
let accepted_response = app
|
||||
.oneshot(test_request("/fast"))
|
||||
.await
|
||||
.expect("third request should complete");
|
||||
assert_eq!(accepted_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gallery_pool_rejects_gallery_without_blocking_default_routes() {
|
||||
let gate = HeldRequestGate {
|
||||
entered: Arc::new(Notify::new()),
|
||||
release: Arc::new(Notify::new()),
|
||||
};
|
||||
let app = build_grouped_test_app(2, 1, 1, gate.clone());
|
||||
let entered = gate.entered.notified();
|
||||
|
||||
let held_response = tokio::spawn(
|
||||
app.clone()
|
||||
.oneshot(test_request("/api/runtime/puzzle/gallery")),
|
||||
);
|
||||
entered.await;
|
||||
|
||||
let rejected_gallery_response = app
|
||||
.clone()
|
||||
.oneshot(test_request("/api/runtime/custom-world-gallery"))
|
||||
.await
|
||||
.expect("rejected gallery request should complete");
|
||||
assert_eq!(
|
||||
rejected_gallery_response.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
|
||||
let accepted_default_response = app
|
||||
.clone()
|
||||
.oneshot(test_request("/fast"))
|
||||
.await
|
||||
.expect("default request should complete");
|
||||
assert_eq!(accepted_default_response.status(), StatusCode::OK);
|
||||
|
||||
gate.release.notify_one();
|
||||
let completed_response = held_response
|
||||
.await
|
||||
.expect("held request task should join")
|
||||
.expect("held request should complete");
|
||||
assert_eq!(completed_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detail_pool_falls_back_to_default_when_unset() {
|
||||
let gate = HeldRequestGate {
|
||||
entered: Arc::new(Notify::new()),
|
||||
release: Arc::new(Notify::new()),
|
||||
};
|
||||
let mut config = AppConfig::default();
|
||||
config.max_concurrent_requests = Some(1);
|
||||
config.detail_max_concurrent_requests = None;
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let backpressure_state = BackpressureState::from_ref(&state);
|
||||
let app = Router::new()
|
||||
.route("/api/runtime/puzzle/gallery/profile-1", get(held_request))
|
||||
.route("/fast", get(fast_request))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
backpressure_state,
|
||||
limit_concurrent_requests,
|
||||
))
|
||||
.layer(Extension(gate.clone()))
|
||||
.with_state(state);
|
||||
let entered = gate.entered.notified();
|
||||
|
||||
let held_response = tokio::spawn(
|
||||
app.clone()
|
||||
.oneshot(test_request("/api/runtime/puzzle/gallery/profile-1")),
|
||||
);
|
||||
entered.await;
|
||||
|
||||
let rejected_default_response = app
|
||||
.clone()
|
||||
.oneshot(test_request("/fast"))
|
||||
.await
|
||||
.expect("default request should complete");
|
||||
assert_eq!(
|
||||
rejected_default_response.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
|
||||
gate.release.notify_one();
|
||||
let completed_response = held_response
|
||||
.await
|
||||
.expect("held request task should join")
|
||||
.expect("held request should complete");
|
||||
assert_eq!(completed_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_pool_is_isolated_from_default_routes() {
|
||||
let gate = HeldRequestGate {
|
||||
entered: Arc::new(Notify::new()),
|
||||
release: Arc::new(Notify::new()),
|
||||
};
|
||||
let app = build_grouped_test_app(2, 1, 1, gate.clone());
|
||||
let entered = gate.entered.notified();
|
||||
|
||||
let held_response = tokio::spawn(app.clone().oneshot(test_request("/admin/api/overview")));
|
||||
entered.await;
|
||||
|
||||
let rejected_admin_response = app
|
||||
.clone()
|
||||
.oneshot(test_request("/admin/api/overview"))
|
||||
.await
|
||||
.expect("rejected admin request should complete");
|
||||
assert_eq!(
|
||||
rejected_admin_response.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
|
||||
let accepted_default_response = app
|
||||
.clone()
|
||||
.oneshot(test_request("/fast"))
|
||||
.await
|
||||
.expect("default request should complete");
|
||||
assert_eq!(accepted_default_response.status(), StatusCode::OK);
|
||||
|
||||
gate.release.notify_one();
|
||||
let completed_response = held_response
|
||||
.await
|
||||
.expect("held request task should join")
|
||||
.expect("held request should complete");
|
||||
assert_eq!(completed_response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_only_exact_gallery_detail_paths_as_detail() {
|
||||
assert_eq!(
|
||||
classify_request_permit_pool("/api/runtime/puzzle/gallery/profile-1"),
|
||||
crate::state::HttpRequestPermitPoolKind::Detail
|
||||
);
|
||||
assert_eq!(
|
||||
classify_request_permit_pool("/api/runtime/puzzle/gallery/profile-1/like"),
|
||||
crate::state::HttpRequestPermitPoolKind::Default
|
||||
);
|
||||
assert_eq!(
|
||||
classify_request_permit_pool("/api/runtime/custom-world-gallery/user-1/profile-1"),
|
||||
crate::state::HttpRequestPermitPoolKind::Detail
|
||||
);
|
||||
assert_eq!(
|
||||
classify_request_permit_pool("/api/runtime/custom-world-gallery/user-1/profile-1/like"),
|
||||
crate::state::HttpRequestPermitPoolKind::Default
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,19 @@ pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000
|
||||
pub struct AppConfig {
|
||||
pub bind_host: String,
|
||||
pub bind_port: u16,
|
||||
pub listen_backlog: i32,
|
||||
pub worker_threads: Option<usize>,
|
||||
pub max_concurrent_requests: Option<usize>,
|
||||
pub gallery_max_concurrent_requests: Option<usize>,
|
||||
pub detail_max_concurrent_requests: Option<usize>,
|
||||
pub admin_max_concurrent_requests: Option<usize>,
|
||||
pub tracking_outbox_enabled: bool,
|
||||
pub tracking_outbox_dir: PathBuf,
|
||||
pub tracking_outbox_batch_size: usize,
|
||||
pub tracking_outbox_flush_interval: Duration,
|
||||
pub tracking_outbox_max_bytes: u64,
|
||||
pub log_filter: String,
|
||||
pub otel_enabled: bool,
|
||||
pub admin_username: Option<String>,
|
||||
pub admin_password: Option<String>,
|
||||
pub admin_token_ttl_seconds: u64,
|
||||
@@ -147,7 +159,19 @@ impl Default for AppConfig {
|
||||
Self {
|
||||
bind_host: "127.0.0.1".to_string(),
|
||||
bind_port: 3000,
|
||||
listen_backlog: 1024,
|
||||
worker_threads: None,
|
||||
max_concurrent_requests: None,
|
||||
gallery_max_concurrent_requests: None,
|
||||
detail_max_concurrent_requests: None,
|
||||
admin_max_concurrent_requests: None,
|
||||
tracking_outbox_enabled: true,
|
||||
tracking_outbox_dir: PathBuf::from("server-rs/.data/tracking-outbox"),
|
||||
tracking_outbox_batch_size: 500,
|
||||
tracking_outbox_flush_interval: Duration::from_millis(1_000),
|
||||
tracking_outbox_max_bytes: 256 * 1024 * 1024,
|
||||
log_filter: "info,tower_http=info".to_string(),
|
||||
otel_enabled: false,
|
||||
admin_username: None,
|
||||
admin_password: None,
|
||||
admin_token_ttl_seconds: 4 * 60 * 60,
|
||||
@@ -164,11 +188,11 @@ impl Default for AppConfig {
|
||||
dev_password_entry_auto_register_enabled: false,
|
||||
sms_auth_enabled: false,
|
||||
sms_auth_provider: "mock".to_string(),
|
||||
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
|
||||
sms_endpoint: "dysmsapi.aliyuncs.com".to_string(),
|
||||
sms_access_key_id: None,
|
||||
sms_access_key_secret: None,
|
||||
sms_sign_name: "速通互联验证码".to_string(),
|
||||
sms_template_code: "100001".to_string(),
|
||||
sms_sign_name: "北京亓盒网络科技".to_string(),
|
||||
sms_template_code: "SMS_506245486".to_string(),
|
||||
sms_template_param_key: "code".to_string(),
|
||||
sms_country_code: "86".to_string(),
|
||||
sms_scheme_name: None,
|
||||
@@ -301,6 +325,57 @@ impl AppConfig {
|
||||
{
|
||||
config.log_filter = log_filter;
|
||||
}
|
||||
if let Some(listen_backlog) =
|
||||
read_first_positive_i32_env(&["GENARRATIVE_API_LISTEN_BACKLOG"])
|
||||
{
|
||||
config.listen_backlog = listen_backlog;
|
||||
}
|
||||
if let Some(worker_threads) = read_first_usize_env(&["GENARRATIVE_API_WORKER_THREADS"]) {
|
||||
config.worker_threads = Some(worker_threads);
|
||||
}
|
||||
if let Some(max_concurrent_requests) =
|
||||
read_first_usize_env(&["GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"])
|
||||
{
|
||||
config.max_concurrent_requests = Some(max_concurrent_requests);
|
||||
}
|
||||
if let Some(max_concurrent_requests) =
|
||||
read_first_usize_env(&["GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS"])
|
||||
{
|
||||
config.gallery_max_concurrent_requests = Some(max_concurrent_requests);
|
||||
}
|
||||
if let Some(max_concurrent_requests) =
|
||||
read_first_usize_env(&["GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS"])
|
||||
{
|
||||
config.detail_max_concurrent_requests = Some(max_concurrent_requests);
|
||||
}
|
||||
if let Some(max_concurrent_requests) =
|
||||
read_first_usize_env(&["GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS"])
|
||||
{
|
||||
config.admin_max_concurrent_requests = Some(max_concurrent_requests);
|
||||
}
|
||||
if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_TRACKING_OUTBOX_ENABLED"]) {
|
||||
config.tracking_outbox_enabled = enabled;
|
||||
}
|
||||
if let Some(dir) = read_first_non_empty_env(&["GENARRATIVE_TRACKING_OUTBOX_DIR"]) {
|
||||
config.tracking_outbox_dir = PathBuf::from(dir);
|
||||
}
|
||||
if let Some(batch_size) = read_first_usize_env(&["GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"])
|
||||
{
|
||||
config.tracking_outbox_batch_size = batch_size;
|
||||
}
|
||||
if let Some(flush_interval_ms) =
|
||||
read_first_positive_u64_env(&["GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"])
|
||||
{
|
||||
config.tracking_outbox_flush_interval = Duration::from_millis(flush_interval_ms);
|
||||
}
|
||||
if let Some(max_bytes) =
|
||||
read_first_positive_u64_env(&["GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"])
|
||||
{
|
||||
config.tracking_outbox_max_bytes = max_bytes;
|
||||
}
|
||||
if let Some(otel_enabled) = read_first_bool_env(&["GENARRATIVE_OTEL_ENABLED"]) {
|
||||
config.otel_enabled = otel_enabled;
|
||||
}
|
||||
|
||||
config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]);
|
||||
config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]);
|
||||
@@ -881,6 +956,14 @@ fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_positive_i32_env(keys: &[&str]) -> Option<i32> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| parse_positive_i32(&value))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_positive_u64_env(keys: &[&str]) -> Option<u64> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
@@ -946,6 +1029,16 @@ fn parse_duration_seconds(raw: &str) -> Option<u64> {
|
||||
}
|
||||
|
||||
fn parse_bool(raw: &str) -> Option<bool> {
|
||||
let raw = raw.trim();
|
||||
let raw = raw
|
||||
.strip_prefix('"')
|
||||
.and_then(|value| value.strip_suffix('"'))
|
||||
.or_else(|| {
|
||||
raw.strip_prefix('\'')
|
||||
.and_then(|value| value.strip_suffix('\''))
|
||||
})
|
||||
.unwrap_or(raw);
|
||||
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
@@ -971,6 +1064,15 @@ fn parse_positive_u32(raw: &str) -> Option<u32> {
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn parse_positive_i32(raw: &str) -> Option<i32> {
|
||||
let value = raw.trim().parse::<i32>().ok()?;
|
||||
if value <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn parse_u32(raw: &str) -> Option<u32> {
|
||||
raw.trim().parse::<u32>().ok()
|
||||
}
|
||||
@@ -1012,7 +1114,9 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider};
|
||||
use super::{
|
||||
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, parse_bool,
|
||||
};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -1035,13 +1139,44 @@ mod tests {
|
||||
config.dashscope_base_url,
|
||||
"https://dashscope.aliyuncs.com/api/v1"
|
||||
);
|
||||
assert_eq!(config.sms_endpoint, "dypnsapi.aliyuncs.com");
|
||||
assert_eq!(config.sms_endpoint, "dysmsapi.aliyuncs.com");
|
||||
assert_eq!(config.sms_sign_name, "北京亓盒网络科技");
|
||||
assert_eq!(config.sms_template_code, "SMS_506245486");
|
||||
assert_eq!(config.sms_template_param_key, "code");
|
||||
assert_eq!(
|
||||
config.wechat_authorize_endpoint,
|
||||
"https://open.weixin.qq.com/connect/qrconnect"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bool_accepts_wrapped_quotes_from_shell_env() {
|
||||
assert_eq!(parse_bool("\"true\""), Some(true));
|
||||
assert_eq!(parse_bool("'true'"), Some(true));
|
||||
assert_eq!(parse_bool("\"false\""), Some(false));
|
||||
assert_eq!(parse_bool("'off'"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock should not poison");
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("SMS_AUTH_ENABLED");
|
||||
std::env::set_var("SMS_AUTH_ENABLED", "\"true\"");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert!(config.sms_auth_enabled);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("SMS_AUTH_ENABLED");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_non_public_models_and_urls() {
|
||||
let _guard = ENV_LOCK
|
||||
@@ -1151,6 +1286,79 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_api_runtime_performance_settings() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock should not poison");
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_API_LISTEN_BACKLOG");
|
||||
std::env::remove_var("GENARRATIVE_API_WORKER_THREADS");
|
||||
std::env::remove_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
|
||||
std::env::remove_var("GENARRATIVE_OTEL_ENABLED");
|
||||
std::env::set_var("GENARRATIVE_API_LISTEN_BACKLOG", "2048");
|
||||
std::env::set_var("GENARRATIVE_API_WORKER_THREADS", "6");
|
||||
std::env::set_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS", "128");
|
||||
std::env::set_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS", "64");
|
||||
std::env::set_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS", "32");
|
||||
std::env::set_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS", "16");
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false");
|
||||
std::env::set_var(
|
||||
"GENARRATIVE_TRACKING_OUTBOX_DIR",
|
||||
"/tmp/genarrative-tracking-outbox",
|
||||
);
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250");
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000");
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576");
|
||||
std::env::set_var("GENARRATIVE_OTEL_ENABLED", "true");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert_eq!(config.listen_backlog, 2048);
|
||||
assert_eq!(config.worker_threads, Some(6));
|
||||
assert_eq!(config.max_concurrent_requests, Some(128));
|
||||
assert_eq!(config.gallery_max_concurrent_requests, Some(64));
|
||||
assert_eq!(config.detail_max_concurrent_requests, Some(32));
|
||||
assert_eq!(config.admin_max_concurrent_requests, Some(16));
|
||||
assert!(!config.tracking_outbox_enabled);
|
||||
assert_eq!(
|
||||
config.tracking_outbox_dir,
|
||||
std::path::PathBuf::from("/tmp/genarrative-tracking-outbox")
|
||||
);
|
||||
assert_eq!(config.tracking_outbox_batch_size, 250);
|
||||
assert_eq!(
|
||||
config.tracking_outbox_flush_interval,
|
||||
std::time::Duration::from_millis(2_000)
|
||||
);
|
||||
assert_eq!(config.tracking_outbox_max_bytes, 1_048_576);
|
||||
assert!(config.otel_enabled);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_API_LISTEN_BACKLOG");
|
||||
std::env::remove_var("GENARRATIVE_API_WORKER_THREADS");
|
||||
std::env::remove_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
|
||||
std::env::remove_var("GENARRATIVE_OTEL_ENABLED");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_wechat_pay_settings() {
|
||||
let _guard = ENV_LOCK
|
||||
|
||||
@@ -13,6 +13,7 @@ mod auth_payload;
|
||||
mod auth_public_user;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod backpressure;
|
||||
mod bark_battle;
|
||||
mod big_fish;
|
||||
mod big_fish_agent_turn;
|
||||
@@ -54,10 +55,12 @@ mod password_entry;
|
||||
mod password_management;
|
||||
mod phone_auth;
|
||||
mod platform_errors;
|
||||
mod process_metrics;
|
||||
mod profile_identity;
|
||||
mod prompt;
|
||||
mod puzzle;
|
||||
mod puzzle_agent_turn;
|
||||
mod puzzle_gallery_cache;
|
||||
mod refresh_session;
|
||||
mod registration_reward;
|
||||
mod request_context;
|
||||
@@ -75,7 +78,9 @@ mod square_hole_agent_turn;
|
||||
mod state;
|
||||
mod story_battles;
|
||||
mod story_sessions;
|
||||
mod telemetry;
|
||||
mod tracking;
|
||||
mod tracking_outbox;
|
||||
mod vector_engine_audio_generation;
|
||||
mod visual_novel;
|
||||
mod volcengine_speech;
|
||||
@@ -85,8 +90,15 @@ mod wechat_provider;
|
||||
mod work_author;
|
||||
mod work_play_tracking;
|
||||
|
||||
use shared_logging::init_tracing;
|
||||
use std::{collections::HashSet, env, fs, io, panic, thread, time::Duration};
|
||||
use shared_logging::{OtelConfig, init_tracing};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
env, fs, io,
|
||||
net::{SocketAddr, TcpListener as StdTcpListener},
|
||||
panic, thread,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::runtime::Builder as TokioRuntimeBuilder;
|
||||
use tokio::time::timeout;
|
||||
@@ -103,12 +115,18 @@ fn main() -> Result<(), io::Error> {
|
||||
.name("api-server-bootstrap".to_string())
|
||||
.stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
||||
.spawn(|| {
|
||||
TokioRuntimeBuilder::new_multi_thread()
|
||||
load_local_env_files();
|
||||
let config = AppConfig::from_env();
|
||||
let mut runtime_builder = TokioRuntimeBuilder::new_multi_thread();
|
||||
runtime_builder
|
||||
.enable_all()
|
||||
.thread_name("api-server-worker")
|
||||
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
||||
.build()?
|
||||
.block_on(run_server())
|
||||
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES);
|
||||
if let Some(worker_threads) = config.worker_threads {
|
||||
runtime_builder.worker_threads(worker_threads);
|
||||
}
|
||||
|
||||
runtime_builder.build()?.block_on(run_server(config))
|
||||
})?;
|
||||
|
||||
match server_thread.join() {
|
||||
@@ -117,28 +135,55 @@ fn main() -> Result<(), io::Error> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_server() -> Result<(), io::Error> {
|
||||
// 运行本地开发与联调时,优先从仓库根目录加载本地变量。
|
||||
// 只尊重外层 shell 先注入的变量;后续本地文件需要能覆盖前序本地文件。
|
||||
load_local_env_files();
|
||||
|
||||
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
|
||||
let config = AppConfig::from_env();
|
||||
init_tracing(&config.log_filter)?;
|
||||
async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
init_tracing(
|
||||
&config.log_filter,
|
||||
OtelConfig {
|
||||
enabled: config.otel_enabled,
|
||||
},
|
||||
)?;
|
||||
process_metrics::register_process_metrics();
|
||||
telemetry::register_http_runtime_metrics();
|
||||
|
||||
let bind_address = config.bind_socket_addr();
|
||||
let listener = TcpListener::bind(bind_address).await?;
|
||||
let listen_backlog = config.listen_backlog;
|
||||
let worker_threads = config.worker_threads;
|
||||
let otel_enabled = config.otel_enabled;
|
||||
let listener = build_tcp_listener(bind_address, listen_backlog)?;
|
||||
|
||||
let state = restore_app_state_for_startup(config)
|
||||
.await
|
||||
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
|
||||
state.puzzle_gallery_cache().spawn_cleanup_task();
|
||||
if let Some(outbox) = state.tracking_outbox() {
|
||||
outbox.spawn_worker();
|
||||
}
|
||||
let router = build_router(state);
|
||||
|
||||
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");
|
||||
info!(
|
||||
%bind_address,
|
||||
listen_backlog,
|
||||
worker_threads = worker_threads.unwrap_or(0),
|
||||
otel_enabled,
|
||||
"api-server 已完成 tracing 初始化并开始监听"
|
||||
);
|
||||
|
||||
axum::serve(listener, router).await
|
||||
}
|
||||
|
||||
fn build_tcp_listener(
|
||||
bind_address: SocketAddr,
|
||||
listen_backlog: i32,
|
||||
) -> Result<TcpListener, io::Error> {
|
||||
let domain = Domain::for_address(bind_address);
|
||||
let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&bind_address.into())?;
|
||||
socket.listen(listen_backlog)?;
|
||||
TcpListener::from_std(StdTcpListener::from(socket))
|
||||
}
|
||||
|
||||
async fn restore_app_state_for_startup(
|
||||
config: AppConfig,
|
||||
) -> Result<AppState, state::AppStateInitError> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
881
server-rs/crates/api-server/src/match3d/draft.rs
Normal file
881
server-rs/crates/api-server/src/match3d/draft.rs
Normal file
@@ -0,0 +1,881 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn submit_and_finalize_match3d_message(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: String,
|
||||
payload: SendMatch3DAgentMessageRequest,
|
||||
) -> Result<Match3DAgentSessionRecord, Response> {
|
||||
ensure_non_empty(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
&session_id,
|
||||
"sessionId",
|
||||
)?;
|
||||
ensure_non_empty(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
&payload.client_message_id,
|
||||
"clientMessageId",
|
||||
)?;
|
||||
ensure_non_empty(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
&payload.text,
|
||||
"text",
|
||||
)?;
|
||||
|
||||
let submitted = state
|
||||
.spacetime_client()
|
||||
.submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
user_message_id: payload.client_message_id.clone(),
|
||||
user_message_text: payload.text.clone(),
|
||||
submitted_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let next_turn = submitted.current_turn.saturating_add(1);
|
||||
let next_config = build_config_from_message(&submitted, &payload);
|
||||
let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn);
|
||||
let progress_percent = resolve_progress_percent_for_turn(next_turn);
|
||||
let stage = if progress_percent >= 100 {
|
||||
"ReadyToCompile"
|
||||
} else {
|
||||
"Collecting"
|
||||
}
|
||||
.to_string();
|
||||
|
||||
state
|
||||
.spacetime_client()
|
||||
.finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput {
|
||||
session_id,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)),
|
||||
assistant_reply_text: Some(assistant_reply),
|
||||
config_json: serialize_match3d_config(&next_config),
|
||||
progress_percent,
|
||||
stage,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
error_message: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn load_match3d_agent_session_response_with_persisted_assets(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session: Match3DAgentSessionRecord,
|
||||
) -> Match3DAgentSessionSnapshotResponse {
|
||||
let Some(profile_id) = resolve_match3d_session_existing_profile_id(&session) else {
|
||||
return map_match3d_agent_session_response(session);
|
||||
};
|
||||
let assets =
|
||||
get_match3d_existing_generated_item_assets(state, owner_user_id, profile_id.as_str()).await;
|
||||
map_match3d_agent_session_response_with_assets(session, &assets)
|
||||
}
|
||||
|
||||
fn resolve_match3d_session_existing_profile_id(
|
||||
session: &Match3DAgentSessionRecord,
|
||||
) -> Option<String> {
|
||||
session
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.profile_id.trim())
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
.or_else(|| {
|
||||
session
|
||||
.published_profile_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
})
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(super) async fn compile_match3d_draft_for_session(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
session_id: String,
|
||||
game_name: Option<String>,
|
||||
summary: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
cover_image_src: Option<String>,
|
||||
generate_click_sound: Option<bool>,
|
||||
) -> Result<(Match3DAgentSessionRecord, Vec<Match3DGeneratedItemAsset>), Response> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let initial_session = state
|
||||
.spacetime_client()
|
||||
.get_match3d_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let mut config = resolve_config_or_default(initial_session.config.as_ref());
|
||||
if let Some(generate_click_sound) = generate_click_sound {
|
||||
config.generate_click_sound = generate_click_sound;
|
||||
}
|
||||
// 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session
|
||||
// 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。
|
||||
let has_complete_form_config = !config.theme_text.trim().is_empty()
|
||||
&& config.clear_count > 0
|
||||
&& (1..=10).contains(&config.difficulty);
|
||||
if !has_complete_form_config
|
||||
&& (initial_session.current_turn < 3 || initial_session.progress_percent < 100)
|
||||
{
|
||||
return Err(match3d_bad_request(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
"match3d 创作配置尚未确认完成",
|
||||
));
|
||||
}
|
||||
|
||||
let requested_game_name = normalize_optional_match3d_text(game_name);
|
||||
let requested_summary = normalize_optional_match3d_text(summary);
|
||||
let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty());
|
||||
let requested_cover_image_src = normalize_optional_match3d_text(cover_image_src);
|
||||
let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
|
||||
let profile_id = resolve_match3d_draft_profile_id(&initial_session);
|
||||
let initial_game_name = requested_game_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_work_metadata.game_name.clone());
|
||||
let initial_tags = requested_tags
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
|
||||
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
|
||||
execute_billable_match3d_draft_generation(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
billing_asset_id.as_str(),
|
||||
async {
|
||||
let mut session = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
profile_id.clone(),
|
||||
Some(initial_game_name),
|
||||
requested_summary.clone().or_else(|| Some(String::new())),
|
||||
Some(serde_json::to_string(&initial_tags).unwrap_or_default()),
|
||||
requested_cover_image_src.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if session.draft.is_none() {
|
||||
return Err(match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"),
|
||||
));
|
||||
}
|
||||
|
||||
let mut generated_work_metadata = generate_match3d_draft_plan(state, &config).await;
|
||||
let resolved_game_name = requested_game_name
|
||||
.unwrap_or_else(|| generated_work_metadata.metadata.game_name.clone());
|
||||
let resolved_summary = requested_summary
|
||||
.clone()
|
||||
.unwrap_or_else(|| generated_work_metadata.metadata.summary.clone());
|
||||
let resolved_tags = match requested_tags {
|
||||
Some(tags) => tags,
|
||||
None => {
|
||||
generate_match3d_work_tags_for_plan(
|
||||
state,
|
||||
resolved_game_name.as_str(),
|
||||
config.theme_text.as_str(),
|
||||
resolved_summary.as_str(),
|
||||
&generated_work_metadata.metadata.tags,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
generated_work_metadata.metadata.tags = resolved_tags.clone();
|
||||
session = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session_id,
|
||||
owner_user_id.clone(),
|
||||
profile_id.clone(),
|
||||
Some(resolved_game_name),
|
||||
Some(resolved_summary),
|
||||
Some(serde_json::to_string(&resolved_tags).unwrap_or_default()),
|
||||
requested_cover_image_src.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let existing_assets = get_match3d_existing_generated_item_assets(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
)
|
||||
.await;
|
||||
let generated_item_assets = generate_match3d_item_assets(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
owner_user_id.as_str(),
|
||||
session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
&config,
|
||||
generated_work_metadata.items,
|
||||
existing_assets,
|
||||
)
|
||||
.await?;
|
||||
let generated_item_assets = ensure_match3d_background_asset(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
owner_user_id.as_str(),
|
||||
session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
&config,
|
||||
generated_work_metadata.background_prompt.as_str(),
|
||||
generated_item_assets,
|
||||
)
|
||||
.await?;
|
||||
let existing_cover_image_src = get_match3d_existing_cover_image_src(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
)
|
||||
.await;
|
||||
let default_cover_image_src = requested_cover_image_src
|
||||
.clone()
|
||||
.or(existing_cover_image_src)
|
||||
.or_else(|| resolve_match3d_default_cover_image_src(&generated_item_assets));
|
||||
let next_session = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session.session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
profile_id,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
default_cover_image_src,
|
||||
None,
|
||||
serialize_match3d_generated_item_assets(&generated_item_assets),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((next_session, generated_item_assets))
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。
|
||||
async fn execute_billable_match3d_draft_generation<T, Fut>(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
operation: Fut,
|
||||
) -> Result<T, Response>
|
||||
where
|
||||
Fut: Future<Output = Result<T, Response>>,
|
||||
{
|
||||
let points_consumed = consume_match3d_draft_generation_points(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
match operation.await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(response) => {
|
||||
if points_consumed {
|
||||
refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id)
|
||||
.await;
|
||||
}
|
||||
Err(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn consume_match3d_draft_generation_points(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
) -> Result<bool, Response> {
|
||||
let ledger_id = format!(
|
||||
"asset_operation_consume:{}:match3d_draft_generation:{}",
|
||||
owner_user_id, billing_asset_id
|
||||
);
|
||||
match state
|
||||
.spacetime_client()
|
||||
.consume_profile_wallet_points(
|
||||
owner_user_id.to_string(),
|
||||
MATCH3D_DRAFT_GENERATION_POINTS_COST,
|
||||
ledger_id,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||||
tracing::warn!(
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
error = %error,
|
||||
"抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) => Err(match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_asset_operation_wallet_error(error),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn refund_match3d_draft_generation_points(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
) {
|
||||
let ledger_id = format!(
|
||||
"asset_operation_refund:{}:match3d_draft_generation:{}",
|
||||
owner_user_id, billing_asset_id
|
||||
);
|
||||
if let Err(error) = state
|
||||
.spacetime_client()
|
||||
.refund_profile_wallet_points(
|
||||
owner_user_id.to_string(),
|
||||
MATCH3D_DRAFT_GENERATION_POINTS_COST,
|
||||
ledger_id,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
error = %error,
|
||||
"抓大鹅草稿生成失败后的泥点退款失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String {
|
||||
session
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.profile_id.trim())
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
.or_else(|| {
|
||||
session
|
||||
.published_profile_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
})
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn upsert_match3d_draft_snapshot(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
profile_id: String,
|
||||
game_name: Option<String>,
|
||||
summary_text: Option<String>,
|
||||
tags_json: Option<String>,
|
||||
cover_image_src: Option<String>,
|
||||
cover_asset_id: Option<String>,
|
||||
generated_item_assets_json: Option<String>,
|
||||
) -> Result<Match3DAgentSessionRecord, Response> {
|
||||
state
|
||||
.spacetime_client()
|
||||
.compile_match3d_draft(Match3DCompileDraftRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
author_display_name: resolve_author_display_name(state, authenticated),
|
||||
game_name,
|
||||
summary_text,
|
||||
tags_json,
|
||||
cover_image_src,
|
||||
cover_asset_id,
|
||||
compiled_at_micros: current_utc_micros(),
|
||||
generated_item_assets_json,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
pub(super) fn build_config_from_create_request(
|
||||
payload: &CreateMatch3DAgentSessionRequest,
|
||||
) -> Match3DConfigJson {
|
||||
Match3DConfigJson {
|
||||
theme_text: payload
|
||||
.theme_text
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(MATCH3D_DEFAULT_THEME)
|
||||
.to_string(),
|
||||
reference_image_src: payload.reference_image_src.clone(),
|
||||
clear_count: payload.clear_count.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT),
|
||||
difficulty: payload
|
||||
.difficulty
|
||||
.unwrap_or(MATCH3D_DEFAULT_DIFFICULTY)
|
||||
.clamp(1, 10),
|
||||
asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()),
|
||||
asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()),
|
||||
asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()),
|
||||
generate_click_sound: payload.generate_click_sound.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_config_from_message(
|
||||
session: &Match3DAgentSessionRecord,
|
||||
payload: &SendMatch3DAgentMessageRequest,
|
||||
) -> Match3DConfigJson {
|
||||
let current = resolve_config_or_default(session.config.as_ref());
|
||||
let text = payload.text.trim();
|
||||
let reference_image_src = payload
|
||||
.reference_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or(current.reference_image_src);
|
||||
let quick_fill_requested =
|
||||
payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置");
|
||||
|
||||
let mut theme_text = current.theme_text;
|
||||
let mut clear_count = current.clear_count.max(1);
|
||||
let mut difficulty = current.difficulty.clamp(1, 10);
|
||||
let asset_style_id = current.asset_style_id;
|
||||
let asset_style_label = current.asset_style_label;
|
||||
let asset_style_prompt = current.asset_style_prompt;
|
||||
let generate_click_sound = current.generate_click_sound;
|
||||
|
||||
match session.current_turn {
|
||||
0 => {
|
||||
theme_text = if quick_fill_requested {
|
||||
MATCH3D_DEFAULT_THEME.to_string()
|
||||
} else {
|
||||
parse_theme_answer(text).unwrap_or(theme_text)
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
clear_count = if quick_fill_requested {
|
||||
clear_count
|
||||
} else {
|
||||
parse_number_after_keywords(text, &["消除", "次数", "clearCount"])
|
||||
.unwrap_or(clear_count)
|
||||
}
|
||||
.max(1);
|
||||
}
|
||||
_ => {
|
||||
difficulty = if quick_fill_requested {
|
||||
difficulty
|
||||
} else {
|
||||
parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty)
|
||||
}
|
||||
.clamp(1, 10);
|
||||
}
|
||||
}
|
||||
|
||||
Match3DConfigJson {
|
||||
theme_text,
|
||||
reference_image_src,
|
||||
clear_count,
|
||||
difficulty,
|
||||
asset_style_id,
|
||||
asset_style_label,
|
||||
asset_style_prompt,
|
||||
generate_click_sound,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson {
|
||||
config
|
||||
.map(|config| Match3DConfigJson {
|
||||
theme_text: config.theme_text.clone(),
|
||||
reference_image_src: config.reference_image_src.clone(),
|
||||
clear_count: config.clear_count.max(1),
|
||||
difficulty: config.difficulty.clamp(1, 10),
|
||||
asset_style_id: config.asset_style_id.clone(),
|
||||
asset_style_label: config.asset_style_label.clone(),
|
||||
asset_style_prompt: config.asset_style_prompt.clone(),
|
||||
generate_click_sound: config.generate_click_sound,
|
||||
})
|
||||
.unwrap_or_else(|| Match3DConfigJson {
|
||||
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
|
||||
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
generate_click_sound: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(super) fn serialize_match3d_config(config: &Match3DConfigJson) -> Option<String> {
|
||||
serde_json::to_string(config).ok()
|
||||
}
|
||||
|
||||
pub(super) fn build_seed_text(
|
||||
payload: &CreateMatch3DAgentSessionRequest,
|
||||
config: &Match3DConfigJson,
|
||||
) -> String {
|
||||
payload
|
||||
.seed_text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}题材,消除{}次,难度{}",
|
||||
config.theme_text, config.clear_count, config.difficulty
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String {
|
||||
format!(
|
||||
"已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}。",
|
||||
config.theme_text,
|
||||
config.clear_count,
|
||||
config.clear_count.saturating_mul(3),
|
||||
config.difficulty
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String {
|
||||
match current_turn {
|
||||
0 => MATCH3D_QUESTION_THEME.to_string(),
|
||||
1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(),
|
||||
2 => MATCH3D_QUESTION_DIFFICULTY.to_string(),
|
||||
_ => build_match3d_assistant_reply(config),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 {
|
||||
match current_turn {
|
||||
0 => 0,
|
||||
1 => 33,
|
||||
2 => 66,
|
||||
_ => 100,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_theme_answer(text: &str) -> Option<String> {
|
||||
for marker in ["题材", "主题"] {
|
||||
if let Some((_, value)) = text.split_once(marker) {
|
||||
let normalized = value
|
||||
.trim_matches(|ch: char| ch == ':' || ch == ':' || ch.is_whitespace())
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim_matches(['。', ',', ',', ';', ';'])
|
||||
.to_string();
|
||||
if !normalized.is_empty() {
|
||||
return Some(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
let trimmed = text.trim();
|
||||
if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit())
|
||||
{
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option<u32> {
|
||||
for keyword in keywords {
|
||||
if let Some(index) = text.find(keyword) {
|
||||
let suffix = &text[index + keyword.len()..];
|
||||
if let Some(value) = first_positive_integer(suffix) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
first_positive_integer(text)
|
||||
}
|
||||
|
||||
fn first_positive_integer(text: &str) -> Option<u32> {
|
||||
let mut digits = String::new();
|
||||
for ch in text.chars() {
|
||||
if ch.is_ascii_digit() {
|
||||
digits.push(ch);
|
||||
} else if !digits.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
digits.parse::<u32>().ok().filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
pub(super) fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||||
let mut result: Vec<String> = Vec::new();
|
||||
for tag in tags {
|
||||
let trimmed = normalize_match3d_tag(tag.as_str());
|
||||
if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) {
|
||||
result.push(trimmed);
|
||||
}
|
||||
if result.len() >= 6 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
async fn generate_match3d_draft_plan(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
) -> Match3DGeneratedDraftPlan {
|
||||
let Some(llm_client) = state
|
||||
.creative_agent_gpt5_client()
|
||||
.or_else(|| state.llm_client())
|
||||
else {
|
||||
return fallback_match3d_draft_plan(config);
|
||||
};
|
||||
let system_prompt = "你是抓大鹅游戏的草稿生成编辑,只返回 JSON。";
|
||||
let gameplay_item_count = resolve_match3d_gameplay_item_count(config);
|
||||
let generated_item_count = resolve_match3d_generated_item_count(config);
|
||||
let user_prompt = format!(
|
||||
"题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、summary、tags、backgroundPrompt、items。gameName 为 4 到 12 个中文字符,不要包含“作品”“游戏”;summary 为 18 到 48 个中文字符的作品描述,说明题材氛围和核心体验,不要写规则说明;tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字,后续会用同一作品信息再次生成作品标签;backgroundPrompt 是用于生成局内纯背景图的中文提示词,只描述竖屏移动端抓大鹅题材氛围、色彩和环境,不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品;当前玩法需要 {} 种物品,但素材图固定每 5 个物品一批,因此 items 必须向上补齐并正好返回 {} 项,每项包含 name、itemSize 和 soundPrompt。name 为 2 到 6 个汉字;itemSize 只能是“大”“中”“小”之一,按物品真实相对尺寸判断,例如西瓜/大箱子偏大,苹果/杯子偏中,糖果/钥匙偏小;soundPrompt 只作为历史字段保留,可返回空字符串。",
|
||||
config.theme_text, gameplay_item_count, generated_item_count
|
||||
);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) => parse_match3d_draft_plan(response.content.as_str(), config)
|
||||
.unwrap_or_else(|| fallback_match3d_draft_plan(config)),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = MATCH3D_AGENT_PROVIDER,
|
||||
theme_text = config.theme_text.as_str(),
|
||||
error = %error,
|
||||
"抓大鹅草稿生成计划失败,降级使用本地生成计划"
|
||||
);
|
||||
fallback_match3d_draft_plan(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_match3d_draft_plan(
|
||||
raw: &str,
|
||||
config: &Match3DConfigJson,
|
||||
) -> Option<Match3DGeneratedDraftPlan> {
|
||||
let raw = raw.trim();
|
||||
let json_text = if let Some(start) = raw.find('{')
|
||||
&& let Some(end) = raw.rfind('}')
|
||||
&& end > start
|
||||
{
|
||||
&raw[start..=end]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let value = serde_json::from_str::<Value>(json_text).ok()?;
|
||||
let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?);
|
||||
if game_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let tags = value
|
||||
.get("tags")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str)))
|
||||
.unwrap_or_default();
|
||||
let fallback = fallback_match3d_draft_plan(config);
|
||||
let summary = value
|
||||
.get("summary")
|
||||
.or_else(|| value.get("description"))
|
||||
.or_else(|| value.get("workSummary"))
|
||||
.or_else(|| value.get("work_summary"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_work_summary)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback.metadata.summary);
|
||||
let items = value
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let name =
|
||||
normalize_match3d_item_name(item.get("name").and_then(Value::as_str)?);
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let item_size = item
|
||||
.get("itemSize")
|
||||
.or_else(|| item.get("item_size"))
|
||||
.or_else(|| item.get("size"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_item_size)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| infer_match3d_item_size(&name));
|
||||
let sound_prompt = item
|
||||
.get("soundPrompt")
|
||||
.or_else(|| item.get("sound_prompt"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_audio_prompt)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| build_fallback_match3d_item_sound_prompt(config, &name));
|
||||
Some(Match3DGeneratedItemPlan {
|
||||
name,
|
||||
item_size,
|
||||
sound_prompt,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let background_prompt = value
|
||||
.get("backgroundPrompt")
|
||||
.or_else(|| value.get("background_prompt"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_background_prompt)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback.background_prompt);
|
||||
|
||||
Some(Match3DGeneratedDraftPlan {
|
||||
metadata: Match3DGeneratedWorkMetadata {
|
||||
game_name,
|
||||
summary,
|
||||
tags: normalize_match3d_tag_candidates(tags),
|
||||
},
|
||||
items: normalize_match3d_item_plan(config, items),
|
||||
background_prompt,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn parse_match3d_work_metadata(raw: &str) -> Option<Match3DGeneratedWorkMetadata> {
|
||||
let config = Match3DConfigJson {
|
||||
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
|
||||
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
generate_click_sound: false,
|
||||
};
|
||||
parse_match3d_draft_plan(raw, &config).map(|plan| plan.metadata)
|
||||
}
|
||||
|
||||
fn normalize_match3d_game_name(raw: &str) -> String {
|
||||
raw.trim()
|
||||
.trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、'])
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(16)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_match3d_work_summary(raw: &str) -> String {
|
||||
raw.trim()
|
||||
.trim_matches(['"', '\'', '“', '”'])
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(80)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(super) fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata {
|
||||
let theme = theme_text.trim();
|
||||
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
|
||||
Match3DGeneratedWorkMetadata {
|
||||
game_name: format!("{normalized_theme}抓大鹅"),
|
||||
summary: normalize_match3d_work_summary(
|
||||
format!("{normalized_theme}主题的轻量抓取消除作品,适合快速体验。").as_str(),
|
||||
),
|
||||
tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "2D素材"]),
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDraftPlan {
|
||||
let metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
|
||||
let items = fallback_match3d_item_names(config.theme_text.as_str())
|
||||
.into_iter()
|
||||
.take(resolve_match3d_generated_item_count(config))
|
||||
.map(|name| Match3DGeneratedItemPlan {
|
||||
item_size: infer_match3d_item_size(&name),
|
||||
sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name),
|
||||
name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Match3DGeneratedDraftPlan {
|
||||
background_prompt: build_fallback_match3d_background_prompt(config),
|
||||
metadata,
|
||||
items,
|
||||
}
|
||||
}
|
||||
1406
server-rs/crates/api-server/src/match3d/handlers.rs
Normal file
1406
server-rs/crates/api-server/src/match3d/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
2631
server-rs/crates/api-server/src/match3d/item_assets.rs
Normal file
2631
server-rs/crates/api-server/src/match3d/item_assets.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,10 @@ pub(super) fn map_match3d_agent_session_response_with_assets(
|
||||
) -> Match3DAgentSessionSnapshotResponse {
|
||||
let mut response = map_match3d_agent_session_response(session);
|
||||
if let Some(draft) = response.draft.as_mut() {
|
||||
if generated_item_assets.is_empty() {
|
||||
return response;
|
||||
}
|
||||
|
||||
draft.generated_item_assets = generated_item_assets
|
||||
.iter()
|
||||
.cloned()
|
||||
@@ -129,7 +133,15 @@ pub(super) fn map_match3d_config_response(
|
||||
pub(super) fn map_match3d_draft_response(
|
||||
draft: Match3DResultDraftRecord,
|
||||
) -> Match3DResultDraftResponse {
|
||||
Match3DResultDraftResponse {
|
||||
// 中文注释:session draft 自身也可能携带生成素材快照,不能只依赖 work detail 回读补齐 UI 背景和容器图。
|
||||
let generated_item_assets = parse_match3d_generated_item_assets(
|
||||
draft.generated_item_assets_json.as_deref(),
|
||||
)
|
||||
.into_iter()
|
||||
.map(Match3DGeneratedItemAsset::from)
|
||||
.collect::<Vec<_>>();
|
||||
let background_asset = find_match3d_generated_background_asset(&generated_item_assets);
|
||||
let mut response = Match3DResultDraftResponse {
|
||||
profile_id: draft.profile_id,
|
||||
game_name: draft.game_name,
|
||||
theme_text: draft.theme_text,
|
||||
@@ -147,8 +159,24 @@ pub(super) fn map_match3d_draft_response(
|
||||
background_image_src: None,
|
||||
background_image_object_key: None,
|
||||
generated_background_asset: None,
|
||||
generated_item_assets: Vec::new(),
|
||||
generated_item_assets: generated_item_assets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(map_match3d_generated_item_asset_for_agent)
|
||||
.collect(),
|
||||
};
|
||||
|
||||
if response
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
{
|
||||
response.cover_image_src = resolve_match3d_default_cover_image_src(&generated_item_assets);
|
||||
}
|
||||
apply_match3d_background_asset_to_agent_draft(&mut response, background_asset);
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_generated_item_asset_for_agent(
|
||||
@@ -365,6 +393,45 @@ pub(super) fn build_match3d_work_profile_record_with_assets(
|
||||
item
|
||||
}
|
||||
|
||||
fn match3d_text_present(value: Option<&String>) -> bool {
|
||||
value.is_some_and(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool {
|
||||
match3d_text_present(asset.image_src.as_ref())
|
||||
|| match3d_text_present(asset.image_object_key.as_ref())
|
||||
|| asset.image_views.iter().any(|view| {
|
||||
match3d_text_present(view.image_src.as_ref())
|
||||
|| match3d_text_present(view.image_object_key.as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool {
|
||||
match3d_text_present(asset.image_src.as_ref())
|
||||
|| match3d_text_present(asset.image_object_key.as_ref())
|
||||
|| match3d_text_present(asset.container_image_src.as_ref())
|
||||
|| match3d_text_present(asset.container_image_object_key.as_ref())
|
||||
}
|
||||
|
||||
fn resolve_match3d_work_generation_status(
|
||||
item: &Match3DWorkProfileRecord,
|
||||
assets: &[Match3DGeneratedItemAssetJson],
|
||||
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
||||
) -> Option<String> {
|
||||
if item.publication_status.eq_ignore_ascii_case("published") {
|
||||
return Some("ready".to_string());
|
||||
}
|
||||
|
||||
if assets.is_empty()
|
||||
|| !assets.iter().any(match3d_item_asset_has_image)
|
||||
|| !background_asset.is_some_and(match3d_background_asset_has_image)
|
||||
{
|
||||
return Some("generating".to_string());
|
||||
}
|
||||
|
||||
Some("ready".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_message_response(
|
||||
message: Match3DAgentMessageRecord,
|
||||
) -> Match3DAgentMessageResponse {
|
||||
@@ -383,6 +450,11 @@ pub(super) fn map_match3d_work_summary_response(
|
||||
let generated_item_asset_json =
|
||||
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
|
||||
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
|
||||
let generation_status = resolve_match3d_work_generation_status(
|
||||
&item,
|
||||
&generated_item_asset_json,
|
||||
background_asset.as_ref(),
|
||||
);
|
||||
let generated_background_asset = background_asset
|
||||
.clone()
|
||||
.map(map_match3d_background_asset_for_work);
|
||||
@@ -408,6 +480,7 @@ pub(super) fn map_match3d_work_summary_response(
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status,
|
||||
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
|
||||
background_image_src: background_asset
|
||||
.as_ref()
|
||||
|
||||
37
server-rs/crates/api-server/src/match3d/runtime.rs
Normal file
37
server-rs/crates/api-server/src/match3d/runtime.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
pub(super) fn normalize_match3d_run_status(value: &str) -> &str {
|
||||
match value {
|
||||
"Running" => "running",
|
||||
"Won" => "won",
|
||||
"Failed" => "failed",
|
||||
"Stopped" => "stopped",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_match3d_item_state(value: &str) -> &str {
|
||||
match value {
|
||||
"InBoard" => "in_board",
|
||||
"InTray" => "in_tray",
|
||||
"Cleared" => "cleared",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_match3d_failure_reason(value: &str) -> &str {
|
||||
match value {
|
||||
"TimeUp" => "time_up",
|
||||
"TrayFull" => "tray_full",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_match3d_click_reject_reason(value: &str) -> &str {
|
||||
match value {
|
||||
"RejectedNotClickable" => "item_not_clickable",
|
||||
"RejectedAlreadyMoved" => "item_not_in_board",
|
||||
"RejectedTrayFull" => "tray_full",
|
||||
"VersionConflict" => "snapshot_version_mismatch",
|
||||
"RunFinished" => "run_not_active",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
1875
server-rs/crates/api-server/src/match3d/tests.rs
Normal file
1875
server-rs/crates/api-server/src/match3d/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
483
server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs
Normal file
483
server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs
Normal file
@@ -0,0 +1,483 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn generate_match3d_material_sheet(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
) -> Result<Match3DMaterialSheet, AppError> {
|
||||
let settings = require_match3d_vector_engine_gemini_image_settings(state)?;
|
||||
let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?;
|
||||
let prompt = build_match3d_material_sheet_prompt(config, item_names);
|
||||
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
|
||||
let generated = create_match3d_vector_engine_gemini_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
negative_prompt.as_str(),
|
||||
"抓大鹅素材图生成失败",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅素材图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DMaterialSheet {
|
||||
task_id: generated.task_id,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
fn require_match3d_vector_engine_gemini_image_settings(
|
||||
state: &AppState,
|
||||
) -> Result<Match3DVectorEngineGeminiImageSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.vector_engine_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.vector_engine_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_match3d_vector_engine_gemini_image_http_client(
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_match3d_vector_engine_gemini_image_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let request_body = build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt,
|
||||
negative_prompt,
|
||||
MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO,
|
||||
);
|
||||
let response = http_client
|
||||
.post(build_match3d_vector_engine_gemini_generate_content_url(
|
||||
settings,
|
||||
))
|
||||
.query(&[("key", settings.api_key.as_str())])
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}"
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
status,
|
||||
response_text.as_str(),
|
||||
failure_context,
|
||||
));
|
||||
}
|
||||
|
||||
let payload = parse_match3d_json_payload(
|
||||
response_text.as_str(),
|
||||
"解析抓大鹅 VectorEngine Gemini 图片生成响应失败",
|
||||
"vector-engine-gemini",
|
||||
)?;
|
||||
let image_urls = extract_match3d_image_urls(&payload);
|
||||
if !image_urls.is_empty() {
|
||||
return download_match3d_images_from_urls(
|
||||
http_client,
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
image_urls,
|
||||
1,
|
||||
"vector-engine-gemini",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let b64_images = extract_match3d_b64_images(&payload);
|
||||
if !b64_images.is_empty() {
|
||||
return Ok(match3d_images_from_base64(
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
b64_images,
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片",
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
aspect_ratio: &str,
|
||||
) -> Value {
|
||||
json!({
|
||||
"contents": [{
|
||||
"role": "user",
|
||||
"parts": [{
|
||||
"text": build_match3d_vector_engine_gemini_prompt(prompt, negative_prompt),
|
||||
}],
|
||||
}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["TEXT", "IMAGE"],
|
||||
"imageConfig": {
|
||||
"aspectRatio": aspect_ratio,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_vector_engine_gemini_generate_content_url(
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
) -> String {
|
||||
let base_url = settings.base_url.trim_end_matches("/v1");
|
||||
format!(
|
||||
"{}/v1beta/models/{}:generateContent",
|
||||
base_url, MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL
|
||||
)
|
||||
}
|
||||
|
||||
fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str) -> String {
|
||||
let prompt = prompt.trim();
|
||||
let negative_prompt = negative_prompt.trim();
|
||||
if negative_prompt.is_empty() {
|
||||
return prompt.to_string();
|
||||
}
|
||||
|
||||
format!("{prompt}\n避免:{negative_prompt}")
|
||||
}
|
||||
|
||||
async fn download_match3d_images_from_urls(
|
||||
http_client: &reqwest::Client,
|
||||
task_id: String,
|
||||
image_urls: Vec<String>,
|
||||
candidate_count: u32,
|
||||
provider: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
{
|
||||
images
|
||||
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
|
||||
}
|
||||
Ok(OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_match3d_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
provider: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("下载抓大鹅生成图片失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": "下载抓大鹅生成图片失败",
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
|
||||
Ok(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes: body.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_images_from_base64(
|
||||
task_id: String,
|
||||
b64_images: Vec<String>,
|
||||
candidate_count: u32,
|
||||
) -> OpenAiGeneratedImages {
|
||||
let images = b64_images
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
|
||||
.collect();
|
||||
OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
|
||||
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
|
||||
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
|
||||
Some(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_match3d_json_payload(
|
||||
raw_text: &str,
|
||||
failure_context: &str,
|
||||
provider: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("{failure_context}:{error}"),
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "url", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
|
||||
let mut deduped = Vec::new();
|
||||
for url in urls {
|
||||
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
|
||||
deduped.push(url);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
pub(super) fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
|
||||
let mut values = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
|
||||
collect_match3d_inline_image_data(payload, &mut values);
|
||||
values
|
||||
}
|
||||
|
||||
fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
collect_match3d_inline_image_data(entry, results);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for key in ["inlineData", "inline_data"] {
|
||||
if let Some(Value::Object(inline_data)) = object.get(key) {
|
||||
let mime_type = inline_data
|
||||
.get("mimeType")
|
||||
.or_else(|| inline_data.get("mime_type"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("image/png")
|
||||
.to_ascii_lowercase();
|
||||
if !mime_type.is_empty() && !mime_type.starts_with("image/") {
|
||||
continue;
|
||||
}
|
||||
if let Some(data) = inline_data
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
results.push(data.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for nested_value in object.values() {
|
||||
collect_match3d_inline_image_data(nested_value, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, target_key, &mut results);
|
||||
results.into_iter().next()
|
||||
}
|
||||
|
||||
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
collect_match3d_strings_by_key(entry, target_key, results);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for (key, nested_value) in object {
|
||||
if key == target_key {
|
||||
match nested_value {
|
||||
Value::String(text) => {
|
||||
let text = text.trim();
|
||||
if !text.is_empty() {
|
||||
results.push(text.to_string());
|
||||
}
|
||||
}
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
if let Some(text) = entry
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
results.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
collect_match3d_strings_by_key(nested_value, target_key, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
upstream_status: reqwest::StatusCode,
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> AppError {
|
||||
let message = parse_match3d_api_error_message(raw_text, fallback_message);
|
||||
let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800);
|
||||
tracing::warn!(
|
||||
provider = "vector-engine-gemini",
|
||||
upstream_status = upstream_status.as_u16(),
|
||||
message = %message,
|
||||
raw_excerpt = %raw_excerpt,
|
||||
"抓大鹅 VectorEngine Gemini 图片生成上游请求失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"upstreamStatus": upstream_status.as_u16(),
|
||||
"message": message,
|
||||
"rawExcerpt": raw_excerpt,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
let trimmed = raw_text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return fallback_message.to_string();
|
||||
}
|
||||
if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
|
||||
for key in ["message", "code"] {
|
||||
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
|
||||
return if key == "message" {
|
||||
value
|
||||
} else {
|
||||
format!("{fallback_message}({value})")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
|
||||
raw_text.chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or("image/png");
|
||||
match mime_type {
|
||||
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||||
mime_type.to_string()
|
||||
}
|
||||
_ => "image/png".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn match3d_mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
1254
server-rs/crates/api-server/src/match3d/works.rs
Normal file
1254
server-rs/crates/api-server/src/match3d/works.rs
Normal file
File diff suppressed because it is too large
Load Diff
493
server-rs/crates/api-server/src/process_metrics.rs
Normal file
493
server-rs/crates/api-server/src/process_metrics.rs
Normal file
@@ -0,0 +1,493 @@
|
||||
use std::{
|
||||
sync::{Mutex, OnceLock},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use opentelemetry::global;
|
||||
use tracing::warn;
|
||||
|
||||
// 进程指标只描述 api-server 自身,不携带请求、用户或作品维度,避免 OTLP 指标高基数膨胀。
|
||||
pub(crate) fn register_process_metrics() {
|
||||
static REGISTERED: OnceLock<()> = OnceLock::new();
|
||||
REGISTERED.get_or_init(register_process_metrics_once);
|
||||
}
|
||||
|
||||
fn register_process_metrics_once() {
|
||||
let meter = global::meter("genarrative-api");
|
||||
|
||||
meter
|
||||
.i64_observable_up_down_counter("process.memory.usage")
|
||||
.with_unit("By")
|
||||
.with_description("api-server process physical memory usage")
|
||||
.with_callback(|observer| {
|
||||
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||
return;
|
||||
};
|
||||
observer.observe(to_i64(snapshot.rss_bytes), &[]);
|
||||
})
|
||||
.build();
|
||||
|
||||
meter
|
||||
.i64_observable_up_down_counter("process.memory.virtual")
|
||||
.with_unit("By")
|
||||
.with_description("api-server committed virtual memory")
|
||||
.with_callback(|observer| {
|
||||
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||
return;
|
||||
};
|
||||
if let Some(virtual_bytes) = snapshot.virtual_bytes {
|
||||
observer.observe(to_i64(virtual_bytes), &[]);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
meter
|
||||
.i64_observable_up_down_counter("genarrative.process.memory.private")
|
||||
.with_unit("By")
|
||||
.with_description("api-server private memory for local diagnostics")
|
||||
.with_callback(|observer| {
|
||||
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||
return;
|
||||
};
|
||||
if let Some(private_bytes) = snapshot.private_bytes {
|
||||
observer.observe(to_i64(private_bytes), &[]);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
meter
|
||||
.f64_observable_counter("process.cpu.time")
|
||||
.with_unit("s")
|
||||
.with_description("api-server total user plus system CPU time")
|
||||
.with_callback(|observer| {
|
||||
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||
return;
|
||||
};
|
||||
if let Some(cpu_time_seconds) = snapshot.cpu_time_seconds {
|
||||
observer.observe(cpu_time_seconds, &[]);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
meter
|
||||
.f64_observable_gauge("genarrative.process.cpu.usage_percent")
|
||||
.with_unit("%")
|
||||
.with_description("api-server process CPU usage between metric collections")
|
||||
.with_callback(|observer| {
|
||||
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||
return;
|
||||
};
|
||||
if let Some(cpu_time_seconds) = snapshot.cpu_time_seconds {
|
||||
if let Some(usage_percent) =
|
||||
process_cpu_usage_percent(cpu_time_seconds, Instant::now())
|
||||
{
|
||||
observer.observe(usage_percent, &[]);
|
||||
}
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
meter
|
||||
.i64_observable_up_down_counter("process.thread.count")
|
||||
.with_unit("{thread}")
|
||||
.with_description("api-server process thread count")
|
||||
.with_callback(|observer| {
|
||||
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||
return;
|
||||
};
|
||||
observer.observe(to_i64(snapshot.thread_count), &[]);
|
||||
})
|
||||
.build();
|
||||
|
||||
meter
|
||||
.i64_observable_up_down_counter("process.windows.handle.count")
|
||||
.with_unit("{handle}")
|
||||
.with_description("api-server process handle count on Windows")
|
||||
.with_callback(|observer| {
|
||||
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||
return;
|
||||
};
|
||||
if let Some(handle_count) = snapshot.windows_handle_count {
|
||||
observer.observe(to_i64(handle_count), &[]);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
meter
|
||||
.i64_observable_up_down_counter("process.unix.file_descriptor.count")
|
||||
.with_unit("{file_descriptor}")
|
||||
.with_description("api-server process file descriptor count on Unix")
|
||||
.with_callback(|observer| {
|
||||
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||
return;
|
||||
};
|
||||
if let Some(fd_count) = snapshot.unix_fd_count {
|
||||
observer.observe(to_i64(fd_count), &[]);
|
||||
}
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
fn to_i64(value: u64) -> i64 {
|
||||
value.min(i64::MAX as u64) as i64
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct ProcessMetricsSnapshot {
|
||||
rss_bytes: u64,
|
||||
private_bytes: Option<u64>,
|
||||
virtual_bytes: Option<u64>,
|
||||
cpu_time_seconds: Option<f64>,
|
||||
thread_count: u64,
|
||||
windows_handle_count: Option<u64>,
|
||||
unix_fd_count: Option<u64>,
|
||||
}
|
||||
|
||||
impl ProcessMetricsSnapshot {
|
||||
fn collect() -> Option<Self> {
|
||||
collect_process_metrics()
|
||||
.inspect_err(|error| {
|
||||
warn!(%error, "采集 api-server 进程指标失败");
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct CpuUsageSample {
|
||||
cpu_time_seconds: f64,
|
||||
observed_at: Instant,
|
||||
}
|
||||
|
||||
fn process_cpu_usage_percent(cpu_time_seconds: f64, observed_at: Instant) -> Option<f64> {
|
||||
static LAST_SAMPLE: OnceLock<Mutex<Option<CpuUsageSample>>> = OnceLock::new();
|
||||
|
||||
let mut last_sample = LAST_SAMPLE.get_or_init(|| Mutex::new(None)).lock().ok()?;
|
||||
let previous = *last_sample;
|
||||
*last_sample = Some(CpuUsageSample {
|
||||
cpu_time_seconds,
|
||||
observed_at,
|
||||
});
|
||||
|
||||
let previous = previous?;
|
||||
let wall_delta_seconds = observed_at
|
||||
.checked_duration_since(previous.observed_at)?
|
||||
.as_secs_f64();
|
||||
cpu_usage_ratio_between_samples(
|
||||
previous.cpu_time_seconds,
|
||||
cpu_time_seconds,
|
||||
0.0,
|
||||
wall_delta_seconds,
|
||||
)
|
||||
.map(|ratio| ratio * 100.0)
|
||||
}
|
||||
|
||||
fn cpu_usage_ratio_between_samples(
|
||||
previous_cpu_seconds: f64,
|
||||
current_cpu_seconds: f64,
|
||||
previous_wall_seconds: f64,
|
||||
current_wall_seconds: f64,
|
||||
) -> Option<f64> {
|
||||
let cpu_delta_seconds = current_cpu_seconds - previous_cpu_seconds;
|
||||
let wall_delta_seconds = current_wall_seconds - previous_wall_seconds;
|
||||
if cpu_delta_seconds < 0.0 || wall_delta_seconds <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(cpu_delta_seconds / wall_delta_seconds)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
|
||||
use windows_sys::Win32::{
|
||||
System::{
|
||||
ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX},
|
||||
Threading::{GetCurrentProcess, GetCurrentProcessId, GetProcessHandleCount},
|
||||
},
|
||||
};
|
||||
|
||||
let handle = unsafe { GetCurrentProcess() };
|
||||
let mut counters = PROCESS_MEMORY_COUNTERS_EX {
|
||||
cb: std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let ok = unsafe {
|
||||
GetProcessMemoryInfo(
|
||||
handle,
|
||||
std::ptr::addr_of_mut!(counters).cast(),
|
||||
counters.cb,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return Err("GetProcessMemoryInfo returned false".to_string());
|
||||
}
|
||||
|
||||
let mut handle_count = 0_u32;
|
||||
let handle_count = if unsafe { GetProcessHandleCount(handle, &mut handle_count) } == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(u64::from(handle_count))
|
||||
};
|
||||
|
||||
let cpu_time_seconds = windows_process_cpu_time_seconds(handle);
|
||||
|
||||
Ok(ProcessMetricsSnapshot {
|
||||
rss_bytes: counters.WorkingSetSize as u64,
|
||||
private_bytes: Some(counters.PrivateUsage as u64),
|
||||
virtual_bytes: Some(counters.PrivateUsage as u64),
|
||||
cpu_time_seconds,
|
||||
thread_count: u64::from(unsafe { GetCurrentProcessId() }.thread_count()?),
|
||||
windows_handle_count: handle_count,
|
||||
unix_fd_count: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_process_cpu_time_seconds(handle: windows_sys::Win32::Foundation::HANDLE) -> Option<f64> {
|
||||
use windows_sys::Win32::{
|
||||
Foundation::FILETIME,
|
||||
System::Threading::GetProcessTimes,
|
||||
};
|
||||
|
||||
let mut creation_time = FILETIME::default();
|
||||
let mut exit_time = FILETIME::default();
|
||||
let mut kernel_time = FILETIME::default();
|
||||
let mut user_time = FILETIME::default();
|
||||
let ok = unsafe {
|
||||
GetProcessTimes(
|
||||
handle,
|
||||
&mut creation_time,
|
||||
&mut exit_time,
|
||||
&mut kernel_time,
|
||||
&mut user_time,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let total_100ns = filetime_100ns(kernel_time) + filetime_100ns(user_time);
|
||||
Some(total_100ns as f64 / 10_000_000.0)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn filetime_100ns(filetime: windows_sys::Win32::Foundation::FILETIME) -> u64 {
|
||||
((filetime.dwHighDateTime as u64) << 32) | u64::from(filetime.dwLowDateTime)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
trait WindowsProcessThreadCount {
|
||||
fn thread_count(self) -> Result<u32, String>;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl WindowsProcessThreadCount for u32 {
|
||||
fn thread_count(self) -> Result<u32, String> {
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{CloseHandle, INVALID_HANDLE_VALUE},
|
||||
System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next,
|
||||
TH32CS_SNAPPROCESS,
|
||||
},
|
||||
};
|
||||
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return Err("CreateToolhelp32Snapshot returned INVALID_HANDLE_VALUE".to_string());
|
||||
}
|
||||
|
||||
let mut entry = PROCESSENTRY32 {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut found = None;
|
||||
let mut ok = unsafe { Process32First(snapshot, &mut entry) };
|
||||
while ok != 0 {
|
||||
if entry.th32ProcessID == self {
|
||||
found = Some(entry.cntThreads);
|
||||
break;
|
||||
}
|
||||
ok = unsafe { Process32Next(snapshot, &mut entry) };
|
||||
}
|
||||
unsafe {
|
||||
CloseHandle(snapshot);
|
||||
}
|
||||
|
||||
found.ok_or_else(|| format!("process {self} not found in ToolHelp snapshot"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
|
||||
let status = std::fs::read_to_string("/proc/self/status")
|
||||
.map_err(|error| format!("read /proc/self/status failed: {error}"))?;
|
||||
let statm = std::fs::read_to_string("/proc/self/statm")
|
||||
.map_err(|error| format!("read /proc/self/statm failed: {error}"))?;
|
||||
let stat = std::fs::read_to_string("/proc/self/stat")
|
||||
.map_err(|error| format!("read /proc/self/stat failed: {error}"))?;
|
||||
let page_size = linux_page_size_bytes()?;
|
||||
|
||||
let rss_bytes = parse_status_kb(&status, "VmRSS:")
|
||||
.map(|value| value * 1024)
|
||||
.or_else(|| parse_statm_pages(&statm, 1).map(|value| value * page_size))
|
||||
.ok_or_else(|| "missing VmRSS/statm resident field".to_string())?;
|
||||
let virtual_bytes = parse_status_kb(&status, "VmSize:")
|
||||
.map(|value| value * 1024)
|
||||
.or_else(|| parse_statm_pages(&statm, 0).map(|value| value * page_size))
|
||||
.ok_or_else(|| "missing VmSize/statm size field".to_string())?;
|
||||
let private_bytes = parse_status_kb(&status, "VmData:").map(|value| value * 1024);
|
||||
let cpu_time_seconds = linux_cpu_time_seconds(&stat)?;
|
||||
let thread_count = parse_status_u64(&status, "Threads:")
|
||||
.ok_or_else(|| "missing Threads field".to_string())?;
|
||||
|
||||
Ok(ProcessMetricsSnapshot {
|
||||
rss_bytes,
|
||||
private_bytes,
|
||||
virtual_bytes: Some(virtual_bytes),
|
||||
cpu_time_seconds: Some(cpu_time_seconds),
|
||||
thread_count,
|
||||
windows_handle_count: None,
|
||||
unix_fd_count: linux_fd_count(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_cpu_time_seconds(stat: &str) -> Result<f64, String> {
|
||||
let cpu_ticks = parse_linux_proc_stat_cpu_ticks(stat)
|
||||
.ok_or_else(|| "missing /proc/self/stat utime/stime fields".to_string())?;
|
||||
let ticks_per_second = linux_clock_ticks_per_second()?;
|
||||
Ok(cpu_ticks as f64 / ticks_per_second as f64)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_clock_ticks_per_second() -> Result<u64, String> {
|
||||
static CLOCK_TICKS_PER_SECOND: OnceLock<Result<u64, String>> = OnceLock::new();
|
||||
|
||||
CLOCK_TICKS_PER_SECOND
|
||||
.get_or_init(|| {
|
||||
let output = std::process::Command::new("getconf")
|
||||
.arg("CLK_TCK")
|
||||
.output()
|
||||
.map_err(|error| format!("getconf CLK_TCK failed: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("getconf CLK_TCK exited with {}", output.status));
|
||||
}
|
||||
let text = String::from_utf8(output.stdout)
|
||||
.map_err(|error| format!("getconf CLK_TCK output is not utf8: {error}"))?;
|
||||
text.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|error| format!("parse CLK_TCK failed: {error}"))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_linux_proc_stat_cpu_ticks(stat: &str) -> Option<u64> {
|
||||
let fields_after_comm = stat.rsplit_once(") ")?.1;
|
||||
let mut fields = fields_after_comm.split_whitespace();
|
||||
let utime = fields.nth(11)?.parse::<u64>().ok()?;
|
||||
let stime = fields.next()?.parse::<u64>().ok()?;
|
||||
Some(utime + stime)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_page_size_bytes() -> Result<u64, String> {
|
||||
let output = std::process::Command::new("getconf")
|
||||
.arg("PAGESIZE")
|
||||
.output()
|
||||
.map_err(|error| format!("getconf PAGESIZE failed: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("getconf PAGESIZE exited with {}", output.status));
|
||||
}
|
||||
let text = String::from_utf8(output.stdout)
|
||||
.map_err(|error| format!("getconf PAGESIZE output is not utf8: {error}"))?;
|
||||
text.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|error| format!("parse PAGESIZE failed: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_fd_count() -> Option<u64> {
|
||||
let entries = std::fs::read_dir("/proc/self/fd").ok()?;
|
||||
Some(entries.filter_map(Result::ok).count() as u64)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_status_kb(status: &str, key: &str) -> Option<u64> {
|
||||
parse_status_u64(status, key)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_status_u64(status: &str, key: &str) -> Option<u64> {
|
||||
status.lines().find_map(|line| {
|
||||
let rest = line.strip_prefix(key)?.trim();
|
||||
rest.split_whitespace().next()?.parse::<u64>().ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_statm_pages(statm: &str, index: usize) -> Option<u64> {
|
||||
statm
|
||||
.split_whitespace()
|
||||
.nth(index)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
|
||||
Err("process metrics are only implemented for Windows and Linux".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::cpu_usage_ratio_between_samples;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::{
|
||||
parse_linux_proc_stat_cpu_ticks, parse_statm_pages, parse_status_kb, parse_status_u64,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn parses_linux_proc_status_memory_fields() {
|
||||
let status = "Name:\tapi-server\nVmSize:\t 123456 kB\nVmRSS:\t 7890 kB\nVmData:\t 3456 kB\nThreads:\t37\n";
|
||||
|
||||
assert_eq!(parse_status_kb(status, "VmRSS:"), Some(7890));
|
||||
assert_eq!(parse_status_kb(status, "VmSize:"), Some(123456));
|
||||
assert_eq!(parse_status_kb(status, "VmData:"), Some(3456));
|
||||
assert_eq!(parse_status_u64(status, "Threads:"), Some(37));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn parses_linux_statm_pages() {
|
||||
assert_eq!(parse_statm_pages("100 20 0 0 0 0 0", 0), Some(100));
|
||||
assert_eq!(parse_statm_pages("100 20 0 0 0 0 0", 1), Some(20));
|
||||
assert_eq!(parse_statm_pages("100 20", 7), None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn parses_linux_proc_stat_cpu_ticks_with_space_in_process_name() {
|
||||
let stat = "123 (api server) S 1 2 3 4 5 6 7 8 9 10 120 30 0 0 20 0 18 0 12345";
|
||||
|
||||
assert_eq!(parse_linux_proc_stat_cpu_ticks(stat), Some(150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_usage_ratio_uses_cpu_time_delta_over_wall_time() {
|
||||
assert_eq!(
|
||||
cpu_usage_ratio_between_samples(10.0, 12.5, 100.0, 101.0),
|
||||
Some(2.5)
|
||||
);
|
||||
assert_eq!(
|
||||
cpu_usage_ratio_between_samples(10.0, 9.0, 100.0, 101.0),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
cpu_usage_ratio_between_samples(10.0, 11.0, 100.0, 100.0),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1943
server-rs/crates/api-server/src/puzzle/draft.rs
Normal file
1943
server-rs/crates/api-server/src/puzzle/draft.rs
Normal file
File diff suppressed because it is too large
Load Diff
264
server-rs/crates/api-server/src/puzzle/generation.rs
Normal file
264
server-rs/crates/api-server/src/puzzle/generation.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
|
||||
if error.code() == "UPSTREAM_ERROR" {
|
||||
let body_text = error.body_text();
|
||||
return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图图片生成失败:{body_text}"),
|
||||
}));
|
||||
}
|
||||
|
||||
error
|
||||
}
|
||||
|
||||
pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
|
||||
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|
||||
|| is_puzzle_request_timeout_message(error.body_text().as_str())
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_image_candidates(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
reference_image_src: Option<&str>,
|
||||
use_reference_image_edit: bool,
|
||||
image_model: Option<&str>,
|
||||
candidate_count: u32,
|
||||
candidate_start_index: usize,
|
||||
) -> Result<Vec<GeneratedPuzzleImageCandidate>, AppError> {
|
||||
let total_started_at = Instant::now();
|
||||
let count = candidate_count.clamp(1, 1);
|
||||
let resolved_model = resolve_puzzle_image_model(image_model);
|
||||
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
|
||||
let has_reference_image = has_puzzle_reference_image(reference_image_src);
|
||||
let should_use_reference_image_edit =
|
||||
should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit);
|
||||
let actual_prompt = build_puzzle_vector_engine_generation_prompt(
|
||||
build_puzzle_image_prompt(level_name, prompt).as_str(),
|
||||
should_use_reference_image_edit,
|
||||
);
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
prompt_chars = prompt.chars().count(),
|
||||
actual_prompt_chars = actual_prompt.chars().count(),
|
||||
has_reference_image,
|
||||
use_reference_image_edit = should_use_reference_image_edit,
|
||||
"拼图图片生成请求已准备"
|
||||
);
|
||||
let reference_image_started_at = Instant::now();
|
||||
let reference_image = match reference_image_src
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|_| should_use_reference_image_edit)
|
||||
{
|
||||
Some(source) => {
|
||||
let resolved =
|
||||
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
reference_mime = %resolved.mime_type,
|
||||
reference_bytes = resolved.bytes_len,
|
||||
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||||
"拼图参考图解析完成"
|
||||
);
|
||||
Some(resolved)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if !should_use_reference_image_edit {
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
has_reference_image,
|
||||
use_reference_image_edit = should_use_reference_image_edit,
|
||||
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||||
"拼图参考图解析跳过"
|
||||
);
|
||||
}
|
||||
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。
|
||||
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
|
||||
let settings = require_puzzle_vector_engine_settings(state)?;
|
||||
let vector_engine_started_at = Instant::now();
|
||||
let generated = if should_use_reference_image_edit {
|
||||
let reference_image = reference_image.as_ref().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "AI 重绘需要提供参考图。",
|
||||
}))
|
||||
})?;
|
||||
let edit_result = create_puzzle_vector_engine_image_edit(
|
||||
&http_client,
|
||||
&settings,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
count,
|
||||
reference_image,
|
||||
)
|
||||
.await;
|
||||
match edit_result {
|
||||
Ok(generated) => Ok(generated),
|
||||
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
|
||||
tracing::warn!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
reference_mime = %reference_image.mime_type,
|
||||
reference_bytes = reference_image.bytes_len,
|
||||
error = %error,
|
||||
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
|
||||
);
|
||||
create_puzzle_vector_engine_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
resolved_model,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
count,
|
||||
Some(reference_image),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
} else {
|
||||
create_puzzle_vector_engine_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
resolved_model,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
count,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
generated_image_count = generated.images.len(),
|
||||
elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64,
|
||||
"拼图 VectorEngine 生图与下载完成"
|
||||
);
|
||||
let mut items = Vec::with_capacity(generated.images.len());
|
||||
|
||||
for (index, image) in generated.images.into_iter().enumerate() {
|
||||
let candidate_id = format!(
|
||||
"{session_id}-candidate-{}",
|
||||
candidate_start_index + index + 1
|
||||
);
|
||||
let downloaded_image = image.clone();
|
||||
let persist_started_at = Instant::now();
|
||||
let asset = persist_puzzle_generated_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
level_name,
|
||||
candidate_id.as_str(),
|
||||
generated.task_id.as_str(),
|
||||
image,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
candidate_id = %candidate_id,
|
||||
image_bytes = downloaded_image.bytes.len(),
|
||||
image_mime = %downloaded_image.mime_type,
|
||||
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
|
||||
"拼图生成图片已写入 OSS 与资产索引"
|
||||
);
|
||||
items.push(GeneratedPuzzleImageCandidate {
|
||||
record: PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id,
|
||||
image_src: asset.image_src,
|
||||
asset_id: asset.asset_id,
|
||||
prompt: prompt.to_string(),
|
||||
actual_prompt: Some(actual_prompt.clone()),
|
||||
source_type: resolved_model.candidate_source_type().to_string(),
|
||||
// 单图生成结果总是直接成为当前正式图。
|
||||
selected: index == 0,
|
||||
},
|
||||
downloaded_image,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
candidate_count = items.len(),
|
||||
has_reference_image,
|
||||
elapsed_ms = total_started_at.elapsed().as_millis() as u64,
|
||||
"拼图图片候选生成完成"
|
||||
);
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_ui_background_image(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
|
||||
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"),
|
||||
"9:16",
|
||||
1,
|
||||
&[],
|
||||
"拼图 UI 背景图生成失败",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "拼图 UI 背景图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
persist_puzzle_ui_background_image(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
level_name,
|
||||
generated.task_id.as_str(),
|
||||
image,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_puzzle_ui_background_request_prompt_for_test(
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
) -> String {
|
||||
build_puzzle_ui_background_generation_prompt(level_name, prompt)
|
||||
}
|
||||
2044
server-rs/crates/api-server/src/puzzle/handlers.rs
Normal file
2044
server-rs/crates/api-server/src/puzzle/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,7 @@ pub(super) fn map_puzzle_form_draft_response(
|
||||
pub(super) fn map_puzzle_draft_level_response(
|
||||
level: PuzzleDraftLevelRecord,
|
||||
) -> PuzzleDraftLevelResponse {
|
||||
let generation_status = resolve_puzzle_level_generation_status(&level);
|
||||
PuzzleDraftLevelResponse {
|
||||
level_id: level.level_id,
|
||||
level_name: level.level_name,
|
||||
@@ -115,7 +116,7 @@ pub(super) fn map_puzzle_draft_level_response(
|
||||
selected_candidate_id: level.selected_candidate_id,
|
||||
cover_image_src: level.cover_image_src,
|
||||
cover_asset_id: level.cover_asset_id,
|
||||
generation_status: level.generation_status,
|
||||
generation_status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,9 +279,120 @@ pub(super) fn map_puzzle_result_preview_finding_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option<String> {
|
||||
let has_viewable_result = item
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
|| item.levels.iter().any(has_puzzle_level_image);
|
||||
if has_viewable_result {
|
||||
return Some("ready".to_string());
|
||||
}
|
||||
|
||||
item.levels
|
||||
.iter()
|
||||
.map(resolve_puzzle_level_generation_status)
|
||||
.find(|status| status.as_str() == "generating")
|
||||
.or_else(|| {
|
||||
item.levels
|
||||
.iter()
|
||||
.map(resolve_puzzle_level_generation_status)
|
||||
.find(|status| status.as_str() == "ready")
|
||||
})
|
||||
.or_else(|| {
|
||||
item.levels
|
||||
.iter()
|
||||
.map(resolve_puzzle_level_generation_status)
|
||||
.find(|status| !status.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_puzzle_level_generation_status(level: &PuzzleDraftLevelRecord) -> String {
|
||||
if level.generation_status.trim() == "generating" && has_puzzle_level_image(level) {
|
||||
return "ready".to_string();
|
||||
}
|
||||
|
||||
level.generation_status.trim().to_string()
|
||||
}
|
||||
|
||||
fn has_puzzle_level_image(level: &PuzzleDraftLevelRecord) -> bool {
|
||||
let has_cover = level
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_selected_candidate = level
|
||||
.selected_candidate_id
|
||||
.as_deref()
|
||||
.and_then(|candidate_id| {
|
||||
level
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.candidate_id == candidate_id)
|
||||
})
|
||||
.map(|candidate| candidate.image_src.trim())
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let has_fallback_candidate = level
|
||||
.candidates
|
||||
.last()
|
||||
.map(|candidate| candidate.image_src.trim())
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
|
||||
has_cover || has_selected_candidate || has_fallback_candidate
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_work_summary_response(
|
||||
state: &AppState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let generation_status = resolve_puzzle_work_generation_status(&item);
|
||||
let author = resolve_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
Some(&item.author_display_name),
|
||||
None,
|
||||
);
|
||||
PuzzleWorkSummaryResponse {
|
||||
work_id: item.work_id,
|
||||
profile_id: item.profile_id,
|
||||
owner_user_id: item.owner_user_id,
|
||||
source_session_id: item.source_session_id,
|
||||
author_display_name: author.display_name,
|
||||
work_title: item.work_title,
|
||||
work_description: item.work_description,
|
||||
level_name: item.level_name,
|
||||
summary: item.summary,
|
||||
theme_tags: item.theme_tags,
|
||||
cover_image_src: item.cover_image_src,
|
||||
cover_asset_id: item.cover_asset_id,
|
||||
publication_status: item.publication_status,
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
play_count: item.play_count,
|
||||
remix_count: item.remix_count,
|
||||
like_count: item.like_count,
|
||||
recent_play_count_7d: item.recent_play_count_7d,
|
||||
point_incentive_total_half_points: item.point_incentive_total_half_points,
|
||||
point_incentive_claimed_points: item.point_incentive_claimed_points,
|
||||
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
|
||||
point_incentive_claimable_points: item
|
||||
.point_incentive_total_half_points
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status,
|
||||
levels: item
|
||||
.levels
|
||||
.iter()
|
||||
.map(|x| map_puzzle_draft_level_response(x.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_gallery_card_response(
|
||||
state: &AppState,
|
||||
item: PuzzleGalleryCardRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let author = resolve_work_author_by_user_id(
|
||||
state,
|
||||
@@ -316,6 +428,7 @@ pub(super) fn map_puzzle_work_summary_response(
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status: item.generation_status,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
898
server-rs/crates/api-server/src/puzzle/tests.rs
Normal file
898
server-rs/crates/api-server/src/puzzle/tests.rs
Normal file
@@ -0,0 +1,898 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn puzzle_generated_image_size_is_square_1_1() {
|
||||
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024");
|
||||
assert_eq!(PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, "1024x1024");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
PuzzleImageModel::Gemini31FlashPreview,
|
||||
"一只猫在雨夜灯牌下回头。",
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
4,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
|
||||
assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE);
|
||||
assert_eq!(body["n"], 1);
|
||||
assert!(body.get("official_fallback").is_none());
|
||||
assert!(body.get("image").is_none());
|
||||
assert!(
|
||||
body["prompt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("文字水印")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
let reference_image = PuzzleResolvedReferenceImage {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: cursor.get_ref().len(),
|
||||
bytes: cursor.into_inner(),
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
PuzzleImageModel::GptImage2,
|
||||
"参考图里的小猫做成拼图主图。",
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
1,
|
||||
Some(&reference_image),
|
||||
);
|
||||
|
||||
let images = body["image"]
|
||||
.as_array()
|
||||
.expect("fallback generation should include reference image array");
|
||||
assert_eq!(images.len(), 1);
|
||||
assert!(
|
||||
images[0]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("data:image/png;base64,")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
|
||||
let settings = PuzzleVectorEngineSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
puzzle_vector_engine_images_edit_url(&settings),
|
||||
"https://vector.example/v1/images/edits"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_edit_response_decodes_b64_image() {
|
||||
let images = puzzle_images_from_base64(
|
||||
"edit-1".to_string(),
|
||||
vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")],
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(images.images.len(), 1);
|
||||
assert_eq!(images.images[0].mime_type, "image/png");
|
||||
assert_eq!(images.images[0].extension, "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_prompt_strongly_uses_reference_image() {
|
||||
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true);
|
||||
|
||||
assert!(prompt.contains("参考图作为第一优先级"));
|
||||
assert!(prompt.contains("严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围"));
|
||||
assert!(prompt.contains("请生成雨夜猫街。"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() {
|
||||
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", false);
|
||||
|
||||
assert_eq!(prompt, "请生成雨夜猫街。");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_image_edit_requires_ai_redraw() {
|
||||
assert!(!should_use_puzzle_reference_image_edit(None, true));
|
||||
assert!(!should_use_puzzle_reference_image_edit(
|
||||
Some("data:image/png;base64,abcd"),
|
||||
false
|
||||
));
|
||||
assert!(should_use_puzzle_reference_image_edit(
|
||||
Some("data:image/png;base64,abcd"),
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_image_sources_are_deduped_and_limited() {
|
||||
let sources = collect_puzzle_reference_image_sources(
|
||||
Some("data:image/png;base64,a"),
|
||||
&[
|
||||
"data:image/png;base64,a".to_string(),
|
||||
"data:image/png;base64,b".to_string(),
|
||||
"data:image/png;base64,c".to_string(),
|
||||
"data:image/png;base64,d".to_string(),
|
||||
"data:image/png;base64,e".to_string(),
|
||||
"data:image/png;base64,f".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(sources.len(), 5);
|
||||
assert_eq!(sources[0], "data:image/png;base64,a");
|
||||
assert_eq!(sources[1], "data:image/png;base64,b");
|
||||
assert!(!sources.contains(&"data:image/png;base64,f".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_request_error(
|
||||
"创建拼图 VectorEngine 图片生成任务失败:operation timed out".to_string(),
|
||||
);
|
||||
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
);
|
||||
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
|
||||
let timeout_error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
);
|
||||
assert!(should_fallback_puzzle_reference_edit_to_generation(
|
||||
&timeout_error
|
||||
));
|
||||
|
||||
let auth_error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
r#"{"error":{"message":"invalid api key"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
);
|
||||
assert!(!should_fallback_puzzle_reference_edit_to_generation(
|
||||
&auth_error
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
|
||||
let error = match reqwest::Client::new().get("http://[::1").build() {
|
||||
Ok(_) => panic!("invalid url should fail request build"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let app_error = map_puzzle_vector_engine_reqwest_error(
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
"https://api.vectorengine.ai/v1/images/edits",
|
||||
error,
|
||||
);
|
||||
|
||||
let response = app_error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
|
||||
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||
"VECTOR_ENGINE_API_KEY 未配置".to_string(),
|
||||
));
|
||||
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() {
|
||||
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||
"APIMart 图片生成密钥未配置".to_string(),
|
||||
));
|
||||
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = response.into_body();
|
||||
let bytes = axum::body::to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.expect("body bytes should read");
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&bytes).expect("error response should be valid json");
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["provider"],
|
||||
Value::String(VECTOR_ENGINE_PROVIDER.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["error"]["details"]["message"],
|
||||
Value::String("VectorEngine 图片生成密钥未配置".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||
let levels_json = serde_json::to_string(&vec![json!({
|
||||
"level_id": "puzzle-level-1",
|
||||
"level_name": "雨夜猫街",
|
||||
"picture_description": "一只猫在雨夜灯牌下回头。",
|
||||
"candidates": [],
|
||||
"selected_candidate_id": null,
|
||||
"cover_image_src": null,
|
||||
"cover_asset_id": null,
|
||||
"generation_status": "idle",
|
||||
})])
|
||||
.expect("levels json");
|
||||
let payload = ExecutePuzzleAgentActionRequest {
|
||||
action: "generate_puzzle_images".to_string(),
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
should_auto_name_level: None,
|
||||
candidate_id: None,
|
||||
level_id: Some("puzzle-level-1".to_string()),
|
||||
work_title: Some("暖灯猫街作品".to_string()),
|
||||
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
|
||||
picture_description: None,
|
||||
level_name: None,
|
||||
summary: Some("当前关卡画面。".to_string()),
|
||||
theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]),
|
||||
levels_json: Some(levels_json.clone()),
|
||||
};
|
||||
|
||||
let session = build_puzzle_session_snapshot_from_action_payload(
|
||||
"puzzle-session-1",
|
||||
&payload,
|
||||
Some(levels_json.as_str()),
|
||||
1_713_686_401_234_567,
|
||||
)
|
||||
.expect("fallback session");
|
||||
|
||||
let draft = session.draft.expect("draft");
|
||||
assert_eq!(session.stage, "ready_to_publish");
|
||||
assert_eq!(draft.work_title, "暖灯猫街作品");
|
||||
assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]);
|
||||
assert_eq!(draft.levels[0].level_id, "puzzle-level-1");
|
||||
assert_eq!(
|
||||
draft.levels[0].picture_description,
|
||||
"一只猫在雨夜灯牌下回头。"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#),
|
||||
Some("雨夜猫街".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"),
|
||||
Some("暖灯猫街".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
|
||||
Some("雨夜猫街".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text(r#"{"levelNam"#),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() {
|
||||
let naming = parse_puzzle_level_naming_from_text(
|
||||
r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
|
||||
)
|
||||
.expect("naming should parse");
|
||||
|
||||
assert_eq!(naming.level_name, "雨夜猫街");
|
||||
assert_eq!(
|
||||
naming.work_description.as_deref(),
|
||||
Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图")
|
||||
);
|
||||
assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT);
|
||||
assert!(naming.work_tags.contains(&"雨夜".to_string()));
|
||||
assert!(naming.work_tags.contains(&"猫咪".to_string()));
|
||||
assert!(naming.work_tags.contains(&"灯牌".to_string()));
|
||||
assert_eq!(
|
||||
naming.ui_background_prompt.as_deref(),
|
||||
Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_naming_parser_filters_forbidden_ui_prompt_words() {
|
||||
let naming = parse_puzzle_level_naming_from_text(
|
||||
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街背景,中央不要出现拼图槽、棋盘、HUD、按钮、文字或水印,保留暖色灯光"}"#,
|
||||
)
|
||||
.expect("naming should parse");
|
||||
let prompt = naming
|
||||
.ui_background_prompt
|
||||
.as_deref()
|
||||
.expect("prompt should parse");
|
||||
|
||||
assert!(!prompt.contains("拼图槽"));
|
||||
assert!(!prompt.contains("棋盘"));
|
||||
assert!(!prompt.contains("HUD"));
|
||||
assert!(!prompt.contains("按钮"));
|
||||
assert!(!prompt.contains("文字"));
|
||||
assert!(!prompt.contains("水印"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
|
||||
assert_eq!(
|
||||
build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"),
|
||||
"雨夜猫街"
|
||||
);
|
||||
assert_eq!(
|
||||
build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"),
|
||||
"奇境初见"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_name_image_data_url_downsizes_generated_image() {
|
||||
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
let downloaded = PuzzleDownloadedImage {
|
||||
extension: "png".to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes: cursor.into_inner(),
|
||||
};
|
||||
|
||||
let data_url =
|
||||
build_puzzle_level_name_image_data_url(&downloaded).expect("data url should be generated");
|
||||
|
||||
assert!(data_url.starts_with("data:image/png;base64,"));
|
||||
assert!(data_url.len() > "data:image/png;base64,".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_uploaded_cover_can_reuse_resolved_history_image() {
|
||||
let resolved = PuzzleResolvedReferenceImage {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 8,
|
||||
bytes: b"pngbytes".to_vec(),
|
||||
};
|
||||
|
||||
let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved);
|
||||
|
||||
assert_eq!(downloaded.extension, "png");
|
||||
assert_eq!(downloaded.mime_type, "image/png");
|
||||
assert_eq!(downloaded.bytes, b"pngbytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_first_level_name_snapshot_defaults_work_title() {
|
||||
let levels_json = serde_json::to_string(&vec![json!({
|
||||
"level_id": "puzzle-level-1",
|
||||
"level_name": "猫画面",
|
||||
"picture_description": "一只猫在雨夜灯牌下回头。",
|
||||
"candidates": [],
|
||||
"selected_candidate_id": null,
|
||||
"cover_image_src": null,
|
||||
"cover_asset_id": null,
|
||||
"generation_status": "idle",
|
||||
})])
|
||||
.expect("levels json");
|
||||
let payload = ExecutePuzzleAgentActionRequest {
|
||||
action: "generate_puzzle_images".to_string(),
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
should_auto_name_level: None,
|
||||
candidate_id: None,
|
||||
level_id: Some("puzzle-level-1".to_string()),
|
||||
work_title: Some("猫画面".to_string()),
|
||||
work_description: None,
|
||||
picture_description: None,
|
||||
level_name: None,
|
||||
summary: None,
|
||||
theme_tags: Some(vec![]),
|
||||
levels_json: Some(levels_json.clone()),
|
||||
};
|
||||
let session = build_puzzle_session_snapshot_from_action_payload(
|
||||
"puzzle-session-1",
|
||||
&payload,
|
||||
Some(levels_json.as_str()),
|
||||
1_713_686_401_234_567,
|
||||
)
|
||||
.expect("fallback session");
|
||||
|
||||
let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
session,
|
||||
"puzzle-level-1",
|
||||
"雨夜猫街",
|
||||
"猫画面",
|
||||
1_713_686_401_234_568,
|
||||
);
|
||||
let draft = renamed.draft.expect("draft");
|
||||
assert_eq!(draft.level_name, "雨夜猫街");
|
||||
assert_eq!(draft.work_title, "雨夜猫街");
|
||||
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() {
|
||||
let mut session = PuzzleAgentSessionRecord {
|
||||
session_id: "puzzle-session-1".to_string(),
|
||||
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
|
||||
current_turn: 1,
|
||||
progress_percent: 94,
|
||||
stage: "ready_to_publish".to_string(),
|
||||
anchor_pack: test_puzzle_anchor_pack_record(),
|
||||
draft: Some(test_puzzle_draft_record()),
|
||||
messages: Vec::new(),
|
||||
last_assistant_reply: None,
|
||||
published_profile_id: None,
|
||||
suggested_actions: Vec::new(),
|
||||
result_preview: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
{
|
||||
let draft = session.draft.as_mut().expect("draft");
|
||||
draft.work_title = "猫画面".to_string();
|
||||
draft.work_description = String::new();
|
||||
draft.summary = String::new();
|
||||
draft.theme_tags = Vec::new();
|
||||
}
|
||||
let metadata = PuzzleLevelNaming {
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()),
|
||||
work_tags: vec![
|
||||
"插画".to_string(),
|
||||
"灯牌".to_string(),
|
||||
"街角".to_string(),
|
||||
"猫咪".to_string(),
|
||||
"暖色".to_string(),
|
||||
"雨夜".to_string(),
|
||||
],
|
||||
ui_background_prompt: None,
|
||||
};
|
||||
|
||||
let session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
session,
|
||||
&metadata,
|
||||
"猫画面",
|
||||
1_713_686_401_234_568,
|
||||
);
|
||||
|
||||
let draft = session.draft.expect("draft");
|
||||
assert_eq!(draft.work_title, "雨夜猫街");
|
||||
assert_eq!(
|
||||
draft.work_description,
|
||||
"在湿润灯牌与猫影之间完成一套雨夜街角拼图"
|
||||
);
|
||||
assert_eq!(draft.summary, draft.work_description);
|
||||
assert_eq!(draft.theme_tags, metadata.work_tags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
|
||||
let level = PuzzleDraftLevelResponse {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
background_music: Some(CreationAudioAsset {
|
||||
task_id: "suno-task-1".to_string(),
|
||||
provider: "vector-engine-suno".to_string(),
|
||||
asset_object_id: Some("assetobj_1".to_string()),
|
||||
asset_kind: Some("puzzle_background_music".to_string()),
|
||||
audio_src: "/generated-puzzle-assets/audio.mp3".to_string(),
|
||||
prompt: Some("轻快拼图音乐".to_string()),
|
||||
title: Some("雨夜猫街背景音乐".to_string()),
|
||||
updated_at: Some("2026-05-11T00:00:00Z".to_string()),
|
||||
}),
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
generation_status: "ready".to_string(),
|
||||
};
|
||||
let request_context = RequestContext::new(
|
||||
"test-request".to_string(),
|
||||
"PUT /api/runtime/puzzle/works/test".to_string(),
|
||||
Duration::ZERO,
|
||||
false,
|
||||
);
|
||||
|
||||
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
|
||||
.expect("levels should serialize");
|
||||
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
|
||||
assert_eq!(
|
||||
payload[0]["background_music"]["audio_src"],
|
||||
Value::String("/generated-puzzle-assets/audio.mp3".to_string())
|
||||
);
|
||||
assert!(payload[0]["background_music"].get("audioSrc").is_none());
|
||||
|
||||
let records = parse_puzzle_level_records_from_module_json(&levels_json)
|
||||
.expect("levels should map back into records");
|
||||
let music = records[0]
|
||||
.background_music
|
||||
.as_ref()
|
||||
.expect("background music should exist");
|
||||
assert_eq!(music.audio_src, "/generated-puzzle-assets/audio.mp3");
|
||||
assert_eq!(music.asset_kind.as_deref(), Some("puzzle_background_music"));
|
||||
|
||||
let response = map_puzzle_draft_level_response(records[0].clone());
|
||||
assert_eq!(
|
||||
response
|
||||
.background_music
|
||||
.as_ref()
|
||||
.map(|asset| asset.audio_src.as_str()),
|
||||
Some("/generated-puzzle-assets/audio.mp3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
|
||||
let level = PuzzleDraftLevelResponse {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()),
|
||||
ui_background_image_src: Some(
|
||||
"/generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||
),
|
||||
ui_background_image_object_key: Some(
|
||||
"generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||
),
|
||||
background_music: None,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
|
||||
cover_asset_id: Some("asset-1".to_string()),
|
||||
generation_status: "ready".to_string(),
|
||||
};
|
||||
let request_context = RequestContext::new(
|
||||
"test-request".to_string(),
|
||||
"PUT /api/runtime/puzzle/works/test".to_string(),
|
||||
Duration::ZERO,
|
||||
false,
|
||||
);
|
||||
|
||||
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
|
||||
.expect("levels should serialize");
|
||||
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
|
||||
assert_eq!(
|
||||
payload[0]["ui_background_prompt"],
|
||||
Value::String("雨夜猫街竖屏拼图UI背景".to_string())
|
||||
);
|
||||
assert!(payload[0].get("uiBackgroundPrompt").is_none());
|
||||
|
||||
let records = parse_puzzle_level_records_from_module_json(&levels_json)
|
||||
.expect("levels should map back into records");
|
||||
assert_eq!(
|
||||
records[0].ui_background_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/ui/background.png")
|
||||
);
|
||||
|
||||
let response = map_puzzle_draft_level_response(records[0].clone());
|
||||
assert_eq!(
|
||||
response.ui_background_image_object_key.as_deref(),
|
||||
Some("generated-puzzle-assets/session/ui/background.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
|
||||
let level = PuzzleDraftLevelRecord {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: vec![PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id: "candidate-1".to_string(),
|
||||
image_src: "/generated-puzzle-assets/session/candidate-1.png".to_string(),
|
||||
asset_id: "asset-1".to_string(),
|
||||
prompt: "雨夜猫街".to_string(),
|
||||
actual_prompt: None,
|
||||
source_type: "generated".to_string(),
|
||||
selected: true,
|
||||
}],
|
||||
selected_candidate_id: Some("candidate-1".to_string()),
|
||||
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
|
||||
cover_asset_id: Some("asset-1".to_string()),
|
||||
generation_status: "generating".to_string(),
|
||||
};
|
||||
|
||||
let response = map_puzzle_work_summary_response(
|
||||
&state,
|
||||
PuzzleWorkProfileRecord {
|
||||
work_id: "puzzle-work-1".to_string(),
|
||||
profile_id: "puzzle-profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_session_id: Some("puzzle-session-1".to_string()),
|
||||
author_display_name: "玩家".to_string(),
|
||||
work_title: "雨夜猫街".to_string(),
|
||||
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
summary: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
theme_tags: vec!["猫".to_string()],
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
publication_status: "draft".to_string(),
|
||||
updated_at: "2026-05-08T00:00:00.000Z".to_string(),
|
||||
published_at: None,
|
||||
play_count: 0,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
recent_play_count_7d: 0,
|
||||
point_incentive_total_half_points: 0,
|
||||
point_incentive_claimed_points: 0,
|
||||
publish_ready: false,
|
||||
anchor_pack: test_puzzle_anchor_pack_record(),
|
||||
levels: vec![level],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(response.levels.len(), 1);
|
||||
assert_eq!(response.generation_status.as_deref(), Some("ready"));
|
||||
assert_eq!(response.levels[0].generation_status, "ready");
|
||||
assert_eq!(
|
||||
response.levels[0].cover_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/cover.png")
|
||||
);
|
||||
assert_eq!(
|
||||
response.levels[0].candidates[0].image_src,
|
||||
"/generated-puzzle-assets/session/candidate-1.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
|
||||
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
|
||||
|
||||
assert!(prompt.contains("9:16"));
|
||||
assert!(prompt.contains("纯背景图"));
|
||||
assert!(prompt.contains("不得出现拼图槽"));
|
||||
assert!(prompt.contains("默认拼图槽"));
|
||||
assert!(prompt.contains("文字"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_initial_ui_background_prompt_prefers_ai_generated_prompt() {
|
||||
let mut draft = test_puzzle_draft_record();
|
||||
draft.work_title = "模板作品名".to_string();
|
||||
draft.work_description = "模板作品描述".to_string();
|
||||
let mut target_level = draft.levels[0].clone();
|
||||
target_level.level_name = "雨夜猫街".to_string();
|
||||
let ai_prompt = "雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次";
|
||||
target_level.ui_background_prompt = Some(ai_prompt.to_string());
|
||||
|
||||
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
|
||||
|
||||
assert_eq!(prompt, ai_prompt);
|
||||
assert!(!prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_initial_ui_background_prompt_falls_back_to_context_template() {
|
||||
let draft = test_puzzle_draft_record();
|
||||
let target_level = draft.levels[0].clone();
|
||||
|
||||
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
|
||||
|
||||
assert!(prompt.contains("雨夜猫街"));
|
||||
assert!(prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_ui_background_initial_attach_updates_first_level_fields() {
|
||||
let draft = test_puzzle_draft_record();
|
||||
let generated = GeneratedPuzzleUiBackgroundResponse {
|
||||
image_src: "/generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||
object_key: "generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||
};
|
||||
let mut levels = draft.levels.clone();
|
||||
|
||||
attach_puzzle_level_ui_background(
|
||||
&mut levels,
|
||||
"puzzle-level-1",
|
||||
"雨夜猫街移动端拼图UI背景".to_string(),
|
||||
generated,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
levels[0].ui_background_prompt.as_deref(),
|
||||
Some("雨夜猫街移动端拼图UI背景")
|
||||
);
|
||||
assert_eq!(
|
||||
levels[0].ui_background_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/ui/background.png")
|
||||
);
|
||||
assert_eq!(
|
||||
levels[0].ui_background_image_object_key.as_deref(),
|
||||
Some("generated-puzzle-assets/session/ui/background.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_initial_draft_assets_must_include_ui_background() {
|
||||
let mut draft = test_puzzle_draft_record();
|
||||
let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect_err("缺少自动生成资产时不能把草稿标记为完成");
|
||||
assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY);
|
||||
assert!(missing_all.body_text().contains("UI背景图"));
|
||||
|
||||
draft.levels[0].ui_background_image_src =
|
||||
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
|
||||
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||
.expect("UI 背景存在时即可完成自动草稿资源检查");
|
||||
}
|
||||
|
||||
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
|
||||
let item = PuzzleAnchorItemRecord {
|
||||
key: "visualSubject".to_string(),
|
||||
label: "画面".to_string(),
|
||||
value: "雨夜猫街".to_string(),
|
||||
status: "confirmed".to_string(),
|
||||
};
|
||||
|
||||
PuzzleAnchorPackRecord {
|
||||
theme_promise: item.clone(),
|
||||
visual_subject: item.clone(),
|
||||
visual_mood: item.clone(),
|
||||
composition_hooks: item.clone(),
|
||||
tags_and_forbidden: item,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_puzzle_draft_record() -> PuzzleResultDraftRecord {
|
||||
let anchor_pack = test_puzzle_anchor_pack_record();
|
||||
PuzzleResultDraftRecord {
|
||||
work_title: "雨夜猫街".to_string(),
|
||||
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
level_name: "猫画面".to_string(),
|
||||
summary: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
theme_tags: vec![],
|
||||
forbidden_directives: vec![],
|
||||
creator_intent: None,
|
||||
anchor_pack,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
generation_status: "idle".to_string(),
|
||||
levels: vec![PuzzleDraftLevelRecord {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: "猫画面".to_string(),
|
||||
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: vec![],
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
generation_status: "idle".to_string(),
|
||||
}],
|
||||
form_draft: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_primary_level_update_preserves_reference_for_regeneration() {
|
||||
let draft = test_puzzle_draft_record();
|
||||
let mut target_level = draft.levels[0].clone();
|
||||
target_level.level_name = "雨夜猫街".to_string();
|
||||
|
||||
let levels = build_puzzle_levels_with_primary_update(
|
||||
&draft,
|
||||
&target_level,
|
||||
Some("data:image/png;base64,abcd"),
|
||||
);
|
||||
|
||||
assert_eq!(levels[0].level_name, "雨夜猫街");
|
||||
assert_eq!(
|
||||
levels[0].picture_reference.as_deref(),
|
||||
Some("data:image/png;base64,abcd")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_generated_fallback_snapshot_preserves_picture_reference() {
|
||||
let anchor_pack = test_puzzle_anchor_pack_record();
|
||||
let session = PuzzleAgentSessionRecord {
|
||||
session_id: "puzzle-session-1".to_string(),
|
||||
seed_text: "雨夜猫街".to_string(),
|
||||
current_turn: 1,
|
||||
progress_percent: 0,
|
||||
stage: "draft_ready".to_string(),
|
||||
anchor_pack: anchor_pack.clone(),
|
||||
draft: Some(test_puzzle_draft_record()),
|
||||
messages: Vec::new(),
|
||||
last_assistant_reply: None,
|
||||
published_profile_id: None,
|
||||
suggested_actions: Vec::new(),
|
||||
result_preview: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
let candidate = PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id: "puzzle-session-1-candidate-1".to_string(),
|
||||
image_src: "/generated-puzzle-assets/puzzle-session-1/1/cover.png".to_string(),
|
||||
asset_id: "puzzle-cover-1".to_string(),
|
||||
prompt: "雨夜猫街".to_string(),
|
||||
actual_prompt: Some("雨夜猫街".to_string()),
|
||||
source_type: "generated:gpt-image-2".to_string(),
|
||||
selected: true,
|
||||
};
|
||||
|
||||
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
session,
|
||||
"puzzle-level-1",
|
||||
vec![candidate],
|
||||
Some("data:image/png;base64,abcd"),
|
||||
1_713_686_401_234_568,
|
||||
);
|
||||
|
||||
let draft = session.draft.expect("draft");
|
||||
assert_eq!(
|
||||
draft.levels[0].picture_reference.as_deref(),
|
||||
Some("data:image/png;base64,abcd")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
|
||||
let invalid_operation = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": "操作不合法",
|
||||
}));
|
||||
let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": "泥点余额不足",
|
||||
}));
|
||||
|
||||
assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true));
|
||||
assert!(!should_sync_puzzle_freeze_boundary(
|
||||
&invalid_operation,
|
||||
false
|
||||
));
|
||||
assert!(!should_sync_puzzle_freeze_boundary(&other_error, true));
|
||||
}
|
||||
1283
server-rs/crates/api-server/src/puzzle/vector_engine.rs
Normal file
1283
server-rs/crates/api-server/src/puzzle/vector_engine.rs
Normal file
File diff suppressed because it is too large
Load Diff
252
server-rs/crates/api-server/src/puzzle_gallery_cache.rs
Normal file
252
server-rs/crates/api-server/src/puzzle_gallery_cache.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use axum::response::Response;
|
||||
use bytes::Bytes;
|
||||
use shared_contracts::{
|
||||
puzzle_gallery::{PuzzleGalleryResponse, PuzzleGalleryWorkRefResponse},
|
||||
puzzle_works::PuzzleWorkSummaryResponse,
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, MutexGuard, OwnedMutexGuard, RwLock},
|
||||
time,
|
||||
};
|
||||
|
||||
use crate::{api_response::json_success_data_bytes_response, request_context::RequestContext};
|
||||
|
||||
const PUZZLE_GALLERY_PRIMARY_ITEM_COUNT: usize = 10;
|
||||
const PUZZLE_GALLERY_PREVIEW_REF_COUNT: usize = 10;
|
||||
const PUZZLE_GALLERY_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
const PUZZLE_GALLERY_CACHE_MAX_IDLE: Duration = Duration::from_secs(300);
|
||||
const PUZZLE_GALLERY_CACHE_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PuzzleGalleryCache {
|
||||
inner: Arc<RwLock<Option<PuzzleGalleryCacheEntry>>>,
|
||||
rebuild_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PuzzleGalleryCacheEntry {
|
||||
data_json: Bytes,
|
||||
built_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PuzzleGalleryCachedResponse {
|
||||
data_json: Bytes,
|
||||
}
|
||||
|
||||
impl PuzzleGalleryCachedResponse {
|
||||
pub fn data_json_len(&self) -> usize {
|
||||
self.data_json.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl PuzzleGalleryCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(None)),
|
||||
rebuild_lock: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn acquire_rebuild_guard(&self) -> MutexGuard<'_, ()> {
|
||||
self.rebuild_lock.lock().await
|
||||
}
|
||||
|
||||
pub async fn read_fresh_response(&self) -> Option<PuzzleGalleryCachedResponse> {
|
||||
let guard = self.inner.read().await;
|
||||
let entry = guard.as_ref()?;
|
||||
let now = Instant::now();
|
||||
if now.duration_since(entry.built_at) > PUZZLE_GALLERY_CACHE_TTL {
|
||||
return None;
|
||||
}
|
||||
Some(PuzzleGalleryCachedResponse {
|
||||
data_json: entry.data_json.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn read_stale_response(&self) -> Option<PuzzleGalleryCachedResponse> {
|
||||
let guard = self.inner.read().await;
|
||||
let entry = guard.as_ref()?;
|
||||
Some(PuzzleGalleryCachedResponse {
|
||||
data_json: entry.data_json.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn try_acquire_owned_rebuild_guard(&self) -> Option<OwnedMutexGuard<()>> {
|
||||
self.rebuild_lock.clone().try_lock_owned().ok()
|
||||
}
|
||||
|
||||
pub async fn store_response(
|
||||
&self,
|
||||
response: PuzzleGalleryResponse,
|
||||
) -> Result<PuzzleGalleryCachedResponse, serde_json::Error> {
|
||||
let now = Instant::now();
|
||||
let cached = PuzzleGalleryCachedResponse {
|
||||
data_json: Bytes::from(serde_json::to_vec(&response)?),
|
||||
};
|
||||
*self.inner.write().await = Some(PuzzleGalleryCacheEntry {
|
||||
data_json: cached.data_json.clone(),
|
||||
built_at: now,
|
||||
});
|
||||
Ok(cached)
|
||||
}
|
||||
|
||||
pub fn spawn_cleanup_task(&self) {
|
||||
let cache = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = time::interval(PUZZLE_GALLERY_CACHE_CLEANUP_INTERVAL);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
cache.cleanup_idle_entry().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn cleanup_idle_entry(&self) {
|
||||
let mut guard = self.inner.write().await;
|
||||
if let Some(entry) = guard.as_ref()
|
||||
&& Instant::now().duration_since(entry.built_at) > PUZZLE_GALLERY_CACHE_MAX_IDLE
|
||||
{
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_puzzle_gallery_window_response(
|
||||
items: Vec<PuzzleWorkSummaryResponse>,
|
||||
) -> PuzzleGalleryResponse {
|
||||
let total_count = items.len().min(u32::MAX as usize) as u32;
|
||||
let preview_refs = items
|
||||
.iter()
|
||||
.skip(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT)
|
||||
.take(PUZZLE_GALLERY_PREVIEW_REF_COUNT)
|
||||
.map(|item| PuzzleGalleryWorkRefResponse {
|
||||
work_id: item.work_id.clone(),
|
||||
profile_id: item.profile_id.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let next_cursor = items
|
||||
.get(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT + PUZZLE_GALLERY_PREVIEW_REF_COUNT)
|
||||
.map(|item| item.profile_id.clone());
|
||||
let has_more =
|
||||
items.len() > PUZZLE_GALLERY_PRIMARY_ITEM_COUNT + PUZZLE_GALLERY_PREVIEW_REF_COUNT;
|
||||
|
||||
PuzzleGalleryResponse {
|
||||
items: items
|
||||
.into_iter()
|
||||
.take(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT)
|
||||
.collect(),
|
||||
preview_refs,
|
||||
has_more,
|
||||
next_cursor,
|
||||
total_count,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn puzzle_gallery_cached_json(
|
||||
request_context: &RequestContext,
|
||||
response: PuzzleGalleryCachedResponse,
|
||||
) -> Response {
|
||||
json_success_data_bytes_response(Some(request_context), response.data_json)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build_summary(index: usize) -> PuzzleWorkSummaryResponse {
|
||||
PuzzleWorkSummaryResponse {
|
||||
work_id: format!("work-{index}"),
|
||||
profile_id: format!("profile-{index}"),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_session_id: None,
|
||||
author_display_name: "作者".to_string(),
|
||||
work_title: format!("作品 {index}"),
|
||||
work_description: "描述".to_string(),
|
||||
level_name: "第一关".to_string(),
|
||||
summary: "摘要".to_string(),
|
||||
theme_tags: Vec::new(),
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
publication_status: "published".to_string(),
|
||||
updated_at: "2026-05-01T00:00:00Z".to_string(),
|
||||
published_at: None,
|
||||
play_count: 0,
|
||||
remix_count: 0,
|
||||
like_count: 0,
|
||||
recent_play_count_7d: 0,
|
||||
point_incentive_total_half_points: 0,
|
||||
point_incentive_claimed_points: 0,
|
||||
point_incentive_total_points: 0.0,
|
||||
point_incentive_claimable_points: 0,
|
||||
publish_ready: true,
|
||||
generation_status: Some("ready".to_string()),
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_window_returns_primary_cards_preview_refs_and_cursor() {
|
||||
let response =
|
||||
build_puzzle_gallery_window_response((0..25).map(build_summary).collect::<Vec<_>>());
|
||||
|
||||
assert_eq!(response.total_count, 25);
|
||||
assert_eq!(response.items.len(), 10);
|
||||
assert_eq!(response.preview_refs.len(), 10);
|
||||
assert_eq!(response.items[0].profile_id, "profile-0");
|
||||
assert_eq!(response.items[9].profile_id, "profile-9");
|
||||
assert_eq!(response.preview_refs[0].profile_id, "profile-10");
|
||||
assert_eq!(response.preview_refs[9].profile_id, "profile-19");
|
||||
assert!(response.has_more);
|
||||
assert_eq!(response.next_cursor.as_deref(), Some("profile-20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_window_handles_short_gallery_without_more_cursor() {
|
||||
let response =
|
||||
build_puzzle_gallery_window_response((0..8).map(build_summary).collect::<Vec<_>>());
|
||||
|
||||
assert_eq!(response.total_count, 8);
|
||||
assert_eq!(response.items.len(), 8);
|
||||
assert!(response.preview_refs.is_empty());
|
||||
assert!(!response.has_more);
|
||||
assert_eq!(response.next_cursor, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_response_remains_readable_after_fresh_ttl() {
|
||||
let cache = PuzzleGalleryCache::new();
|
||||
let response =
|
||||
build_puzzle_gallery_window_response((0..8).map(build_summary).collect::<Vec<_>>());
|
||||
cache
|
||||
.store_response(response)
|
||||
.await
|
||||
.expect("cache response should serialize");
|
||||
|
||||
{
|
||||
let mut guard = cache.inner.write().await;
|
||||
let entry = guard.as_mut().expect("cache entry should exist");
|
||||
entry.built_at = Instant::now() - PUZZLE_GALLERY_CACHE_TTL - Duration::from_secs(1);
|
||||
}
|
||||
|
||||
assert!(cache.read_fresh_response().await.is_none());
|
||||
assert!(cache.read_stale_response().await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_owned_rebuild_guard_allows_only_one_refresher() {
|
||||
let cache = PuzzleGalleryCache::new();
|
||||
let first_guard = cache.try_acquire_owned_rebuild_guard();
|
||||
|
||||
assert!(first_guard.is_some());
|
||||
assert!(cache.try_acquire_owned_rebuild_guard().is_none());
|
||||
|
||||
drop(first_guard);
|
||||
assert!(cache.try_acquire_owned_rebuild_guard().is_some());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use std::{
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||
use module_auth::{
|
||||
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
|
||||
@@ -27,20 +28,126 @@ use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
|
||||
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
|
||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::puzzle_gallery_cache::PuzzleGalleryCache;
|
||||
use crate::tracking_outbox::TrackingOutbox;
|
||||
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
|
||||
use crate::wechat_provider::build_wechat_provider;
|
||||
|
||||
const ADMIN_ROLE: &str = "admin";
|
||||
|
||||
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
|
||||
pub type HttpRequestPermitPool = Semaphore;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum HttpRequestPermitPoolKind {
|
||||
Default,
|
||||
Gallery,
|
||||
Detail,
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl HttpRequestPermitPoolKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "default",
|
||||
Self::Gallery => "gallery",
|
||||
Self::Detail => "detail",
|
||||
Self::Admin => "admin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppState {
|
||||
pub struct HttpRequestPermitPools {
|
||||
default: Option<Arc<HttpRequestPermitPool>>,
|
||||
gallery: Option<Arc<HttpRequestPermitPool>>,
|
||||
detail: Option<Arc<HttpRequestPermitPool>>,
|
||||
admin: Option<Arc<HttpRequestPermitPool>>,
|
||||
}
|
||||
|
||||
impl HttpRequestPermitPools {
|
||||
fn from_config(config: &AppConfig) -> Self {
|
||||
Self {
|
||||
default: config
|
||||
.max_concurrent_requests
|
||||
.map(HttpRequestPermitPool::new)
|
||||
.map(Arc::new),
|
||||
gallery: config
|
||||
.gallery_max_concurrent_requests
|
||||
.map(HttpRequestPermitPool::new)
|
||||
.map(Arc::new),
|
||||
detail: config
|
||||
.detail_max_concurrent_requests
|
||||
.map(HttpRequestPermitPool::new)
|
||||
.map(Arc::new),
|
||||
admin: config
|
||||
.admin_max_concurrent_requests
|
||||
.map(HttpRequestPermitPool::new)
|
||||
.map(Arc::new),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pool(
|
||||
&self,
|
||||
kind: HttpRequestPermitPoolKind,
|
||||
) -> Option<(HttpRequestPermitPoolKind, Arc<HttpRequestPermitPool>)> {
|
||||
let selected = match kind {
|
||||
HttpRequestPermitPoolKind::Default => self.default.clone(),
|
||||
HttpRequestPermitPoolKind::Gallery => self.gallery.clone(),
|
||||
HttpRequestPermitPoolKind::Detail => self.detail.clone(),
|
||||
HttpRequestPermitPoolKind::Admin => self.admin.clone(),
|
||||
};
|
||||
selected.map(|pool| (kind, pool)).or_else(|| {
|
||||
self.default
|
||||
.clone()
|
||||
.map(|pool| (HttpRequestPermitPoolKind::Default, pool))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BackpressureState {
|
||||
permit_pools: HttpRequestPermitPools,
|
||||
}
|
||||
|
||||
impl BackpressureState {
|
||||
pub fn request_permit_pool(
|
||||
&self,
|
||||
kind: HttpRequestPermitPoolKind,
|
||||
) -> Option<(HttpRequestPermitPoolKind, Arc<HttpRequestPermitPool>)> {
|
||||
self.permit_pools.pool(kind)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppState(Arc<AppStateInner>);
|
||||
|
||||
impl std::ops::Deref for AppState {
|
||||
type Target = AppStateInner;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for BackpressureState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
Self {
|
||||
permit_pools: state.http_request_permit_pools(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Axum/Hyper 会在路由树和连接 service 上频繁 clone state;AppState 外层必须保持浅拷贝。
|
||||
#[derive(Debug)]
|
||||
pub struct AppStateInner {
|
||||
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
|
||||
#[allow(dead_code)]
|
||||
pub config: AppConfig,
|
||||
http_request_permit_pools: HttpRequestPermitPools,
|
||||
auth_jwt_config: JwtConfig,
|
||||
admin_runtime: Option<AdminRuntime>,
|
||||
refresh_cookie_config: RefreshCookieConfig,
|
||||
@@ -60,6 +167,8 @@ pub struct AppState {
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
ai_task_service: AiTaskService,
|
||||
spacetime_client: SpacetimeClient,
|
||||
puzzle_gallery_cache: PuzzleGalleryCache,
|
||||
tracking_outbox: Option<Arc<TrackingOutbox>>,
|
||||
llm_client: Option<LlmClient>,
|
||||
creative_agent_gpt5_client: Option<LlmClient>,
|
||||
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
|
||||
@@ -190,11 +299,14 @@ impl AppState {
|
||||
pool_size: config.spacetime_pool_size,
|
||||
procedure_timeout: config.spacetime_procedure_timeout,
|
||||
});
|
||||
let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone());
|
||||
let llm_client = build_llm_client(&config)?;
|
||||
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
|
||||
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
|
||||
|
||||
Ok(Self {
|
||||
Ok(Self(Arc::new(AppStateInner {
|
||||
config,
|
||||
http_request_permit_pools,
|
||||
auth_jwt_config,
|
||||
admin_runtime,
|
||||
refresh_cookie_config,
|
||||
@@ -214,13 +326,15 @@ impl AppState {
|
||||
wechat_pay_client,
|
||||
ai_task_service,
|
||||
spacetime_client,
|
||||
puzzle_gallery_cache: PuzzleGalleryCache::new(),
|
||||
tracking_outbox,
|
||||
llm_client,
|
||||
creative_agent_gpt5_client,
|
||||
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
|
||||
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
#[cfg(test)]
|
||||
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
|
||||
})
|
||||
})))
|
||||
}
|
||||
|
||||
pub fn auth_jwt_config(&self) -> &JwtConfig {
|
||||
@@ -235,6 +349,10 @@ impl AppState {
|
||||
&self.refresh_cookie_config
|
||||
}
|
||||
|
||||
pub fn http_request_permit_pools(&self) -> HttpRequestPermitPools {
|
||||
self.http_request_permit_pools.clone()
|
||||
}
|
||||
|
||||
pub async fn upsert_creation_entry_type_config(
|
||||
&self,
|
||||
input: module_runtime::CreationEntryTypeAdminUpsertInput,
|
||||
@@ -464,6 +582,14 @@ impl AppState {
|
||||
&self.spacetime_client
|
||||
}
|
||||
|
||||
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
|
||||
&self.puzzle_gallery_cache
|
||||
}
|
||||
|
||||
pub fn tracking_outbox(&self) -> Option<Arc<TrackingOutbox>> {
|
||||
self.tracking_outbox.clone()
|
||||
}
|
||||
|
||||
pub fn llm_client(&self) -> Option<&LlmClient> {
|
||||
self.llm_client.as_ref()
|
||||
}
|
||||
|
||||
512
server-rs/crates/api-server/src/telemetry.rs
Normal file
512
server-rs/crates/api-server/src/telemetry.rs
Normal file
@@ -0,0 +1,512 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{HeaderMap, Request, Response},
|
||||
middleware::Next,
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use opentelemetry::{KeyValue, global, metrics::Counter};
|
||||
use std::sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicI64, Ordering},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
request_context::resolve_request_id,
|
||||
state::{AppState, HttpRequestPermitPoolKind},
|
||||
};
|
||||
|
||||
static HTTP_RESPONSE_BODY_IN_FLIGHT: AtomicI64 = AtomicI64::new(0);
|
||||
static TRACKING_OUTBOX_PENDING_BYTES: AtomicI64 = AtomicI64::new(0);
|
||||
static TRACKING_OUTBOX_PENDING_FILES: AtomicI64 = AtomicI64::new(0);
|
||||
static HTTP_REQUEST_PERMITS_AVAILABLE: OnceLock<HttpRequestPermitsAvailableGauges> =
|
||||
OnceLock::new();
|
||||
|
||||
// 集中维护 api-server HTTP 观测,避免在 handler 中散落高基数字段或重复创建 instrument。
|
||||
pub async fn record_http_observability(
|
||||
State(state): State<AppState>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
let method = request.method().as_str().to_string();
|
||||
let route = observability_route(request.uri().path());
|
||||
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 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 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]);
|
||||
|
||||
if slow_request {
|
||||
warn!(
|
||||
request_id = %request_id,
|
||||
http.request.method = %method,
|
||||
http.route = %route,
|
||||
url.scheme = %scheme,
|
||||
url.path = %path,
|
||||
http.response.status_code = status,
|
||||
status,
|
||||
status_class,
|
||||
latency_ms,
|
||||
slow_request = true,
|
||||
"http request completed slowly"
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
request_id = %request_id,
|
||||
http.request.method = %method,
|
||||
http.route = %route,
|
||||
url.scheme = %scheme,
|
||||
url.path = %path,
|
||||
http.response.status_code = status,
|
||||
status,
|
||||
status_class,
|
||||
latency_ms,
|
||||
slow_request = false,
|
||||
"http request completed"
|
||||
);
|
||||
}
|
||||
|
||||
track_response_body_in_flight(response)
|
||||
}
|
||||
|
||||
pub(crate) fn update_http_request_permits_available(
|
||||
pool: HttpRequestPermitPoolKind,
|
||||
available: usize,
|
||||
) {
|
||||
HTTP_REQUEST_PERMITS_AVAILABLE
|
||||
.get_or_init(register_http_request_permits_available_metric)
|
||||
.store(pool, available);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_hit() {
|
||||
puzzle_gallery_cache_metrics().hits.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_stale_hit() {
|
||||
puzzle_gallery_cache_metrics().stale_hits.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_miss() {
|
||||
puzzle_gallery_cache_metrics().misses.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_refresh_started() {
|
||||
puzzle_gallery_cache_metrics().refreshes_started.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_refresh_failed() {
|
||||
puzzle_gallery_cache_metrics().refreshes_failed.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_puzzle_gallery_cache_rebuild(
|
||||
duration: std::time::Duration,
|
||||
data_bytes: usize,
|
||||
) {
|
||||
let metrics = puzzle_gallery_cache_metrics();
|
||||
metrics.rebuilds.add(1, &[]);
|
||||
metrics.rebuild_duration.record(duration.as_secs_f64(), &[]);
|
||||
metrics
|
||||
.data_json_bytes
|
||||
.record(data_bytes.min(u64::MAX as usize) as u64, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_tracking_outbox_enqueued() {
|
||||
tracking_outbox_metrics().enqueued.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_tracking_outbox_dropped(reason: &'static str) {
|
||||
tracking_outbox_metrics()
|
||||
.dropped
|
||||
.add(1, &[KeyValue::new("reason", reason)]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_tracking_outbox_sealed(reason: &'static str) {
|
||||
tracking_outbox_metrics()
|
||||
.sealed_files
|
||||
.add(1, &[KeyValue::new("reason", reason)]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_tracking_outbox_corrupt_file() {
|
||||
tracking_outbox_metrics().corrupt_files.add(1, &[]);
|
||||
}
|
||||
|
||||
pub(crate) fn record_tracking_outbox_flush(
|
||||
duration: std::time::Duration,
|
||||
accepted_count: u32,
|
||||
file_bytes: u64,
|
||||
failed: bool,
|
||||
) {
|
||||
let status_class = if failed { "error" } else { "ok" };
|
||||
let labels = [KeyValue::new("status_class", status_class)];
|
||||
let metrics = tracking_outbox_metrics();
|
||||
metrics.flushes.add(1, &labels);
|
||||
metrics
|
||||
.flush_duration
|
||||
.record(duration.as_secs_f64(), &labels);
|
||||
metrics
|
||||
.flushed_events
|
||||
.add(u64::from(accepted_count), &labels);
|
||||
metrics.flushed_bytes.add(file_bytes, &labels);
|
||||
}
|
||||
|
||||
pub(crate) fn update_tracking_outbox_pending_bytes(bytes: u64) {
|
||||
TRACKING_OUTBOX_PENDING_BYTES.store(bytes.min(i64::MAX as u64) as i64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn update_tracking_outbox_pending_files(files: usize) {
|
||||
TRACKING_OUTBOX_PENDING_FILES.store(files.min(i64::MAX as usize) as i64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn track_response_body_in_flight(response: Response<Body>) -> Response<Body> {
|
||||
response.map(|body| {
|
||||
HTTP_RESPONSE_BODY_IN_FLIGHT.fetch_add(1, Ordering::Relaxed);
|
||||
let guard = ResponseBodyInFlightGuard;
|
||||
Body::new(body.map_frame(move |frame| {
|
||||
let _guard = &guard;
|
||||
frame
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
struct HttpMetrics {
|
||||
requests: Counter<u64>,
|
||||
in_flight: opentelemetry::metrics::UpDownCounter<i64>,
|
||||
duration: opentelemetry::metrics::Histogram<f64>,
|
||||
}
|
||||
|
||||
struct PuzzleGalleryCacheMetrics {
|
||||
hits: Counter<u64>,
|
||||
stale_hits: Counter<u64>,
|
||||
misses: Counter<u64>,
|
||||
refreshes_started: Counter<u64>,
|
||||
refreshes_failed: Counter<u64>,
|
||||
rebuilds: Counter<u64>,
|
||||
rebuild_duration: opentelemetry::metrics::Histogram<f64>,
|
||||
data_json_bytes: opentelemetry::metrics::Histogram<u64>,
|
||||
}
|
||||
|
||||
struct TrackingOutboxMetrics {
|
||||
enqueued: Counter<u64>,
|
||||
dropped: Counter<u64>,
|
||||
sealed_files: Counter<u64>,
|
||||
corrupt_files: Counter<u64>,
|
||||
flushes: Counter<u64>,
|
||||
flush_duration: opentelemetry::metrics::Histogram<f64>,
|
||||
flushed_events: Counter<u64>,
|
||||
flushed_bytes: Counter<u64>,
|
||||
}
|
||||
|
||||
struct HttpRequestPermitsAvailableGauges {
|
||||
default: Arc<AtomicI64>,
|
||||
gallery: Arc<AtomicI64>,
|
||||
detail: Arc<AtomicI64>,
|
||||
admin: Arc<AtomicI64>,
|
||||
}
|
||||
|
||||
impl HttpRequestPermitsAvailableGauges {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
default: Arc::new(AtomicI64::new(0)),
|
||||
gallery: Arc::new(AtomicI64::new(0)),
|
||||
detail: Arc::new(AtomicI64::new(0)),
|
||||
admin: Arc::new(AtomicI64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn store(&self, pool: HttpRequestPermitPoolKind, available: usize) {
|
||||
let value = available.min(i64::MAX as usize) as i64;
|
||||
match pool {
|
||||
HttpRequestPermitPoolKind::Default => &self.default,
|
||||
HttpRequestPermitPoolKind::Gallery => &self.gallery,
|
||||
HttpRequestPermitPoolKind::Detail => &self.detail,
|
||||
HttpRequestPermitPoolKind::Admin => &self.admin,
|
||||
}
|
||||
.store(value, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
struct ResponseBodyInFlightGuard;
|
||||
|
||||
impl Drop for ResponseBodyInFlightGuard {
|
||||
fn drop(&mut self) {
|
||||
HTTP_RESPONSE_BODY_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn http_metrics() -> &'static HttpMetrics {
|
||||
static METRICS: std::sync::OnceLock<HttpMetrics> = std::sync::OnceLock::new();
|
||||
METRICS.get_or_init(|| {
|
||||
let meter = global::meter("genarrative-api");
|
||||
HttpMetrics {
|
||||
requests: meter
|
||||
.u64_counter("genarrative.http.server.requests")
|
||||
.with_description("HTTP request count grouped by route and status class")
|
||||
.build(),
|
||||
in_flight: meter
|
||||
.i64_up_down_counter("http.server.active_requests")
|
||||
.with_unit("{request}")
|
||||
.with_description("Number of active HTTP server requests")
|
||||
.build(),
|
||||
duration: meter
|
||||
.f64_histogram("http.server.request.duration")
|
||||
.with_unit("s")
|
||||
.with_description("Duration of HTTP server requests")
|
||||
.build(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn puzzle_gallery_cache_metrics() -> &'static PuzzleGalleryCacheMetrics {
|
||||
static METRICS: std::sync::OnceLock<PuzzleGalleryCacheMetrics> = std::sync::OnceLock::new();
|
||||
METRICS.get_or_init(|| {
|
||||
let meter = global::meter("genarrative-api");
|
||||
PuzzleGalleryCacheMetrics {
|
||||
hits: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.hits")
|
||||
.with_description("Puzzle gallery response cache hits")
|
||||
.build(),
|
||||
stale_hits: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.stale_hits")
|
||||
.with_description("Puzzle gallery stale response cache hits")
|
||||
.build(),
|
||||
misses: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.misses")
|
||||
.with_description("Puzzle gallery response cache misses")
|
||||
.build(),
|
||||
refreshes_started: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.refreshes_started")
|
||||
.with_description("Puzzle gallery background refresh start count")
|
||||
.build(),
|
||||
refreshes_failed: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.refreshes_failed")
|
||||
.with_description("Puzzle gallery background refresh failure count")
|
||||
.build(),
|
||||
rebuilds: meter
|
||||
.u64_counter("genarrative.puzzle_gallery.cache.rebuilds")
|
||||
.with_description("Puzzle gallery response cache rebuild count")
|
||||
.build(),
|
||||
rebuild_duration: meter
|
||||
.f64_histogram("genarrative.puzzle_gallery.cache.rebuild.duration")
|
||||
.with_unit("s")
|
||||
.with_description("Puzzle gallery response cache rebuild duration")
|
||||
.build(),
|
||||
data_json_bytes: meter
|
||||
.u64_histogram("genarrative.puzzle_gallery.cache.data_json_bytes")
|
||||
.with_unit("By")
|
||||
.with_description("Serialized puzzle gallery data JSON size")
|
||||
.build(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tracking_outbox_metrics() -> &'static TrackingOutboxMetrics {
|
||||
static METRICS: std::sync::OnceLock<TrackingOutboxMetrics> = std::sync::OnceLock::new();
|
||||
METRICS.get_or_init(|| {
|
||||
let meter = global::meter("genarrative-api");
|
||||
TrackingOutboxMetrics {
|
||||
enqueued: meter
|
||||
.u64_counter("genarrative.tracking_outbox.events.enqueued")
|
||||
.with_description("Tracking events appended to the local outbox")
|
||||
.build(),
|
||||
dropped: meter
|
||||
.u64_counter("genarrative.tracking_outbox.events.dropped")
|
||||
.with_description("Tracking events dropped by local outbox protection")
|
||||
.build(),
|
||||
sealed_files: meter
|
||||
.u64_counter("genarrative.tracking_outbox.files.sealed")
|
||||
.with_description("Tracking outbox active files sealed for flushing")
|
||||
.build(),
|
||||
corrupt_files: meter
|
||||
.u64_counter("genarrative.tracking_outbox.files.corrupt")
|
||||
.with_description(
|
||||
"Tracking outbox sealed files quarantined because they could not be parsed",
|
||||
)
|
||||
.build(),
|
||||
flushes: meter
|
||||
.u64_counter("genarrative.tracking_outbox.flushes")
|
||||
.with_description("Tracking outbox sealed file flush attempts")
|
||||
.build(),
|
||||
flush_duration: meter
|
||||
.f64_histogram("genarrative.tracking_outbox.flush.duration")
|
||||
.with_unit("s")
|
||||
.with_description("Tracking outbox sealed file flush duration")
|
||||
.build(),
|
||||
flushed_events: meter
|
||||
.u64_counter("genarrative.tracking_outbox.events.flushed")
|
||||
.with_description("Tracking events accepted by SpacetimeDB batch procedure")
|
||||
.build(),
|
||||
flushed_bytes: meter
|
||||
.u64_counter("genarrative.tracking_outbox.bytes.flushed")
|
||||
.with_unit("By")
|
||||
.with_description("Tracking outbox bytes removed after successful flush")
|
||||
.build(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn register_http_request_permits_available_metric() -> HttpRequestPermitsAvailableGauges {
|
||||
let gauges = HttpRequestPermitsAvailableGauges::new();
|
||||
let meter = global::meter("genarrative-api");
|
||||
let default_gauge = gauges.default.clone();
|
||||
let gallery_gauge = gauges.gallery.clone();
|
||||
let detail_gauge = gauges.detail.clone();
|
||||
let admin_gauge = gauges.admin.clone();
|
||||
meter
|
||||
.i64_observable_up_down_counter("genarrative.http.server.request_permits.available")
|
||||
.with_unit("{permit}")
|
||||
.with_description("Available api-server HTTP backpressure permits")
|
||||
.with_callback(move |observer| {
|
||||
observer.observe(
|
||||
default_gauge.load(Ordering::Relaxed),
|
||||
&[KeyValue::new(
|
||||
"pool",
|
||||
HttpRequestPermitPoolKind::Default.as_str(),
|
||||
)],
|
||||
);
|
||||
observer.observe(
|
||||
gallery_gauge.load(Ordering::Relaxed),
|
||||
&[KeyValue::new(
|
||||
"pool",
|
||||
HttpRequestPermitPoolKind::Gallery.as_str(),
|
||||
)],
|
||||
);
|
||||
observer.observe(
|
||||
detail_gauge.load(Ordering::Relaxed),
|
||||
&[KeyValue::new(
|
||||
"pool",
|
||||
HttpRequestPermitPoolKind::Detail.as_str(),
|
||||
)],
|
||||
);
|
||||
observer.observe(
|
||||
admin_gauge.load(Ordering::Relaxed),
|
||||
&[KeyValue::new(
|
||||
"pool",
|
||||
HttpRequestPermitPoolKind::Admin.as_str(),
|
||||
)],
|
||||
);
|
||||
})
|
||||
.build();
|
||||
gauges
|
||||
}
|
||||
|
||||
pub(crate) fn register_http_runtime_metrics() {
|
||||
static REGISTERED: OnceLock<()> = OnceLock::new();
|
||||
REGISTERED.get_or_init(|| {
|
||||
let meter = global::meter("genarrative-api");
|
||||
meter
|
||||
.i64_observable_up_down_counter("genarrative.http.server.response_bodies.in_flight")
|
||||
.with_unit("{response}")
|
||||
.with_description("HTTP response bodies still owned by Axum/Hyper")
|
||||
.with_callback(|observer| {
|
||||
observer.observe(HTTP_RESPONSE_BODY_IN_FLIGHT.load(Ordering::Relaxed), &[]);
|
||||
})
|
||||
.build();
|
||||
meter
|
||||
.i64_observable_up_down_counter("genarrative.tracking_outbox.pending.bytes")
|
||||
.with_unit("By")
|
||||
.with_description("Tracking outbox bytes waiting on local disk")
|
||||
.with_callback(|observer| {
|
||||
observer.observe(TRACKING_OUTBOX_PENDING_BYTES.load(Ordering::Relaxed), &[]);
|
||||
})
|
||||
.build();
|
||||
meter
|
||||
.i64_observable_up_down_counter("genarrative.tracking_outbox.pending.files")
|
||||
.with_unit("{file}")
|
||||
.with_description("Tracking outbox sealed files waiting for flush")
|
||||
.with_callback(|observer| {
|
||||
observer.observe(TRACKING_OUTBOX_PENDING_FILES.load(Ordering::Relaxed), &[]);
|
||||
})
|
||||
.build();
|
||||
});
|
||||
}
|
||||
|
||||
fn http_base_labels(method: String, route: String) -> Vec<KeyValue> {
|
||||
vec![
|
||||
KeyValue::new("http.request.method", method),
|
||||
KeyValue::new("http.route", route),
|
||||
]
|
||||
}
|
||||
|
||||
fn http_response_labels(mut labels: Vec<KeyValue>, status: u16) -> Vec<KeyValue> {
|
||||
labels.push(KeyValue::new("status_class", status_class(status)));
|
||||
labels
|
||||
}
|
||||
|
||||
fn status_class(status: u16) -> &'static str {
|
||||
match status {
|
||||
100..=199 => "1xx",
|
||||
200..=299 => "2xx",
|
||||
300..=399 => "3xx",
|
||||
400..=499 => "4xx",
|
||||
500..=599 => "5xx",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn observability_route(path: &str) -> String {
|
||||
if path.starts_with("/api/runtime/puzzle/gallery") {
|
||||
"/api/runtime/puzzle/gallery".to_string()
|
||||
} else if path.starts_with("/api/runtime/custom-world-gallery") {
|
||||
"/api/runtime/custom-world-gallery".to_string()
|
||||
} else if path.starts_with("/admin/api/") {
|
||||
"/admin/api/*".to_string()
|
||||
} else if path.starts_with("/api/") {
|
||||
"/api/*".to_string()
|
||||
} else {
|
||||
"other".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_request_scheme(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(',').next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("http")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
use super::{observability_route, resolve_request_scheme};
|
||||
|
||||
#[test]
|
||||
fn observability_route_keeps_metrics_labels_low_cardinality() {
|
||||
assert_eq!(
|
||||
observability_route("/api/runtime/puzzle/gallery?cursor=abc"),
|
||||
"/api/runtime/puzzle/gallery"
|
||||
);
|
||||
assert_eq!(
|
||||
observability_route("/api/runtime/puzzle/runs/run-123/history"),
|
||||
"/api/*"
|
||||
);
|
||||
assert_eq!(observability_route("/admin/api/debug/http"), "/admin/api/*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_request_scheme_uses_forwarded_proto_first_value() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-proto", HeaderValue::from_static("https, http"));
|
||||
|
||||
assert_eq!(resolve_request_scheme(&headers), "https");
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ pub async fn record_route_tracking_event_after_success(
|
||||
draft.owner_user_id = draft.user_id.clone();
|
||||
}
|
||||
|
||||
record_tracking_event_after_success(state, request_context, draft).await;
|
||||
record_route_tracking_event_via_outbox_after_success(state, request_context, draft).await;
|
||||
}
|
||||
|
||||
fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option<RouteTrackingSpec> {
|
||||
@@ -524,26 +524,101 @@ pub async fn record_tracking_event_after_success(
|
||||
request_context: &RequestContext,
|
||||
draft: TrackingEventDraft,
|
||||
) {
|
||||
let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let event_id = build_tracking_event_id(&draft, occurred_at_micros);
|
||||
let event_key = draft.event_key.to_string();
|
||||
let scope_kind = draft.scope_kind;
|
||||
let scope_id = draft.scope_id;
|
||||
let metadata_json = draft.metadata.to_string();
|
||||
record_tracking_event_input_after_success(
|
||||
state,
|
||||
request_context,
|
||||
build_tracking_event_input(draft),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn record_route_tracking_event_via_outbox_after_success(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
draft: TrackingEventDraft,
|
||||
) {
|
||||
let event = build_tracking_event_input(draft);
|
||||
let event_key = event.event_key.clone();
|
||||
let scope_kind = event.scope_kind;
|
||||
let scope_id = event.scope_id.clone();
|
||||
|
||||
if let Some(outbox) = state.tracking_outbox() {
|
||||
match outbox.enqueue(event.clone()).await {
|
||||
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Enqueued) => {
|
||||
tracing::debug!(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
event_key = %event_key,
|
||||
scope_kind = %scope_kind.as_str(),
|
||||
scope_id = %scope_id,
|
||||
"后端 route 埋点已写入本机 outbox"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Dropped { reason }) => {
|
||||
tracing::warn!(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
event_key = %event_key,
|
||||
scope_kind = %scope_kind.as_str(),
|
||||
scope_id = %scope_id,
|
||||
reason,
|
||||
"后端 route 埋点因 outbox 保护阈值被丢弃,主业务流程继续"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
event_key = %event_key,
|
||||
scope_kind = %scope_kind.as_str(),
|
||||
scope_id = %scope_id,
|
||||
error = %error,
|
||||
"后端 route 埋点写入 outbox 失败,回退同步直写 SpacetimeDB"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record_tracking_event_input_after_success(state, request_context, event).await;
|
||||
}
|
||||
|
||||
async fn record_tracking_event_input_after_success(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
event: module_runtime::RuntimeTrackingEventInput,
|
||||
) {
|
||||
let event_key = event.event_key.clone();
|
||||
let log_scope_kind = event.scope_kind;
|
||||
let scope_id = event.scope_id.clone();
|
||||
|
||||
let module_runtime::RuntimeTrackingEventInput {
|
||||
event_id,
|
||||
event_key: procedure_event_key,
|
||||
scope_kind: procedure_scope_kind,
|
||||
scope_id: procedure_scope_id,
|
||||
user_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
module_key,
|
||||
metadata_json,
|
||||
occurred_at_micros,
|
||||
} = event;
|
||||
|
||||
match state
|
||||
.spacetime_client()
|
||||
.record_tracking_event(
|
||||
event_id,
|
||||
event_key.clone(),
|
||||
scope_kind,
|
||||
scope_id.clone(),
|
||||
draft.user_id,
|
||||
draft.owner_user_id,
|
||||
draft.profile_id,
|
||||
draft.module_key.map(str::to_string),
|
||||
procedure_event_key,
|
||||
procedure_scope_kind,
|
||||
procedure_scope_id,
|
||||
user_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
module_key,
|
||||
metadata_json,
|
||||
occurred_at_micros as i64,
|
||||
occurred_at_micros,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -551,7 +626,7 @@ pub async fn record_tracking_event_after_success(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
event_key = %event_key,
|
||||
scope_kind = %scope_kind.as_str(),
|
||||
scope_kind = %log_scope_kind.as_str(),
|
||||
scope_id = %scope_id,
|
||||
"后端埋点已记录"
|
||||
),
|
||||
@@ -559,7 +634,7 @@ pub async fn record_tracking_event_after_success(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
event_key = %event_key,
|
||||
scope_kind = %scope_kind.as_str(),
|
||||
scope_kind = %log_scope_kind.as_str(),
|
||||
scope_id = %scope_id,
|
||||
error = %error,
|
||||
"后端埋点记录失败,主业务流程继续"
|
||||
@@ -567,6 +642,26 @@ pub async fn record_tracking_event_after_success(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tracking_event_input(
|
||||
draft: TrackingEventDraft,
|
||||
) -> module_runtime::RuntimeTrackingEventInput {
|
||||
let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let event_id = build_tracking_event_id(&draft, occurred_at_micros);
|
||||
|
||||
module_runtime::RuntimeTrackingEventInput {
|
||||
event_id,
|
||||
event_key: draft.event_key.to_string(),
|
||||
scope_kind: draft.scope_kind,
|
||||
scope_id: draft.scope_id,
|
||||
user_id: draft.user_id,
|
||||
owner_user_id: draft.owner_user_id,
|
||||
profile_id: draft.profile_id,
|
||||
module_key: draft.module_key.map(str::to_string),
|
||||
metadata_json: draft.metadata.to_string(),
|
||||
occurred_at_micros: occurred_at_micros as i64,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tracking_event_id(draft: &TrackingEventDraft, occurred_at_micros: i128) -> String {
|
||||
if draft.event_key == "daily_login"
|
||||
&& draft.scope_kind == RuntimeTrackingScopeKind::User
|
||||
|
||||
621
server-rs/crates/api-server/src/tracking_outbox.rs
Normal file
621
server-rs/crates/api-server/src/tracking_outbox.rs
Normal file
@@ -0,0 +1,621 @@
|
||||
use std::{
|
||||
fmt,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use module_runtime::RuntimeTrackingEventInput;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use spacetime_client::{SpacetimeClient, SpacetimeClientError};
|
||||
use tokio::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||
sync::{Mutex, Notify},
|
||||
time::sleep,
|
||||
};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
const ACTIVE_FILE_NAME: &str = "active.ndjson";
|
||||
const SEALED_FILE_PREFIX: &str = "sealed-";
|
||||
const CORRUPT_FILE_PREFIX: &str = "corrupt-";
|
||||
const SEALED_FILE_EXTENSION: &str = ".ndjson";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TrackingOutbox {
|
||||
dir: PathBuf,
|
||||
batch_size: usize,
|
||||
flush_interval: Duration,
|
||||
max_bytes: u64,
|
||||
spacetime_client: SpacetimeClient,
|
||||
inner: Arc<Mutex<TrackingOutboxInner>>,
|
||||
flush_notify: Arc<Notify>,
|
||||
}
|
||||
|
||||
struct TrackingOutboxInner {
|
||||
initialized: bool,
|
||||
active_file: Option<File>,
|
||||
active_count: usize,
|
||||
active_bytes: u64,
|
||||
total_bytes: u64,
|
||||
last_sealed_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TrackingOutboxEnqueueOutcome {
|
||||
Enqueued,
|
||||
Dropped { reason: &'static str },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TrackingOutboxError {
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
Spacetime(SpacetimeClientError),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
struct TrackingOutboxRecord {
|
||||
event: RuntimeTrackingEventInput,
|
||||
}
|
||||
|
||||
impl TrackingOutbox {
|
||||
pub fn from_config(config: &AppConfig, spacetime_client: SpacetimeClient) -> Option<Arc<Self>> {
|
||||
if !config.tracking_outbox_enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let total_bytes = directory_size_if_exists(&config.tracking_outbox_dir).unwrap_or(0);
|
||||
let outbox = Self {
|
||||
dir: config.tracking_outbox_dir.clone(),
|
||||
batch_size: config.tracking_outbox_batch_size.max(1),
|
||||
flush_interval: config.tracking_outbox_flush_interval,
|
||||
max_bytes: config.tracking_outbox_max_bytes,
|
||||
spacetime_client,
|
||||
inner: Arc::new(Mutex::new(TrackingOutboxInner {
|
||||
initialized: false,
|
||||
active_file: None,
|
||||
active_count: 0,
|
||||
active_bytes: 0,
|
||||
total_bytes,
|
||||
last_sealed_at: Instant::now(),
|
||||
})),
|
||||
flush_notify: Arc::new(Notify::new()),
|
||||
};
|
||||
crate::telemetry::update_tracking_outbox_pending_bytes(total_bytes);
|
||||
Some(Arc::new(outbox))
|
||||
}
|
||||
|
||||
pub async fn enqueue(
|
||||
&self,
|
||||
event: RuntimeTrackingEventInput,
|
||||
) -> Result<TrackingOutboxEnqueueOutcome, TrackingOutboxError> {
|
||||
let record = TrackingOutboxRecord { event };
|
||||
let mut line = serde_json::to_vec(&record)?;
|
||||
line.push(b'\n');
|
||||
let line_bytes = line.len().min(u64::MAX as usize) as u64;
|
||||
|
||||
let mut inner = self.inner.lock().await;
|
||||
self.ensure_initialized_locked(&mut inner).await?;
|
||||
|
||||
if inner.total_bytes.saturating_add(line_bytes) > self.max_bytes {
|
||||
crate::telemetry::record_tracking_outbox_dropped("max_bytes");
|
||||
return Ok(TrackingOutboxEnqueueOutcome::Dropped {
|
||||
reason: "max_bytes",
|
||||
});
|
||||
}
|
||||
|
||||
let active_path = self.active_path();
|
||||
if inner.active_file.is_none() {
|
||||
inner.active_file = Some(
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&active_path)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
let file = inner
|
||||
.active_file
|
||||
.as_mut()
|
||||
.expect("active file should be open before append");
|
||||
file.write_all(&line).await?;
|
||||
inner.active_count = inner.active_count.saturating_add(1);
|
||||
inner.active_bytes = inner.active_bytes.saturating_add(line_bytes);
|
||||
inner.total_bytes = inner.total_bytes.saturating_add(line_bytes);
|
||||
crate::telemetry::record_tracking_outbox_enqueued();
|
||||
crate::telemetry::update_tracking_outbox_pending_bytes(inner.total_bytes);
|
||||
|
||||
if inner.active_count >= self.batch_size {
|
||||
self.seal_active_locked(&mut inner, "batch_size").await?;
|
||||
self.flush_notify.notify_one();
|
||||
}
|
||||
|
||||
Ok(TrackingOutboxEnqueueOutcome::Enqueued)
|
||||
}
|
||||
|
||||
pub fn spawn_worker(self: Arc<Self>) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = sleep(self.flush_interval) => {
|
||||
if let Err(error) = self.seal_active_if_due().await {
|
||||
warn!(error = %error, "tracking outbox 定时封存 active 文件失败");
|
||||
}
|
||||
if let Err(error) = self.flush_sealed_files_once().await {
|
||||
warn!(error = %error, "tracking outbox 批量写入 SpacetimeDB 失败,将保留 sealed 文件等待重试");
|
||||
}
|
||||
}
|
||||
_ = self.flush_notify.notified() => {
|
||||
if let Err(error) = self.flush_sealed_files_once().await {
|
||||
warn!(error = %error, "tracking outbox 批量写入 SpacetimeDB 失败,将保留 sealed 文件等待重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn seal_active_if_due(&self) -> Result<(), TrackingOutboxError> {
|
||||
let mut inner = self.inner.lock().await;
|
||||
self.ensure_initialized_locked(&mut inner).await?;
|
||||
if inner.active_count == 0 || inner.last_sealed_at.elapsed() < self.flush_interval {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.seal_active_locked(&mut inner, "flush_interval").await
|
||||
}
|
||||
|
||||
async fn flush_sealed_files_once(&self) -> Result<(), TrackingOutboxError> {
|
||||
self.ensure_initialized().await?;
|
||||
|
||||
let sealed_files = self.list_sealed_files().await?;
|
||||
crate::telemetry::update_tracking_outbox_pending_files(sealed_files.len());
|
||||
for path in sealed_files {
|
||||
let started_at = Instant::now();
|
||||
let metadata = fs::metadata(&path).await?;
|
||||
let file_bytes = metadata.len();
|
||||
let events = match read_outbox_events(&path).await {
|
||||
Ok(events) => events,
|
||||
Err(error) if error.is_data_corruption() => {
|
||||
let corrupt_path = self.corrupt_path_for(&path);
|
||||
fs::rename(&path, &corrupt_path).await?;
|
||||
self.subtract_total_bytes(file_bytes).await;
|
||||
crate::telemetry::record_tracking_outbox_corrupt_file();
|
||||
warn!(
|
||||
error = %error,
|
||||
source = %path.display(),
|
||||
target = %corrupt_path.display(),
|
||||
"tracking outbox sealed 文件含无法解析的记录,已隔离并继续处理后续文件"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if events.is_empty() {
|
||||
fs::remove_file(&path).await?;
|
||||
self.subtract_total_bytes(file_bytes).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.spacetime_client.record_tracking_events(events).await {
|
||||
Ok(accepted_count) => {
|
||||
fs::remove_file(&path).await?;
|
||||
self.subtract_total_bytes(file_bytes).await;
|
||||
crate::telemetry::record_tracking_outbox_flush(
|
||||
started_at.elapsed(),
|
||||
accepted_count,
|
||||
file_bytes,
|
||||
false,
|
||||
);
|
||||
debug!(
|
||||
accepted_count,
|
||||
file_bytes,
|
||||
path = %path.display(),
|
||||
"tracking outbox sealed 文件已批量入库并删除"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
crate::telemetry::record_tracking_outbox_flush(
|
||||
started_at.elapsed(),
|
||||
0,
|
||||
file_bytes,
|
||||
true,
|
||||
);
|
||||
return Err(TrackingOutboxError::Spacetime(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_initialized(&self) -> Result<(), TrackingOutboxError> {
|
||||
let mut inner = self.inner.lock().await;
|
||||
self.ensure_initialized_locked(&mut inner).await
|
||||
}
|
||||
|
||||
async fn ensure_initialized_locked(
|
||||
&self,
|
||||
inner: &mut TrackingOutboxInner,
|
||||
) -> Result<(), TrackingOutboxError> {
|
||||
if inner.initialized {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fs::create_dir_all(&self.dir).await?;
|
||||
self.seal_existing_active_file().await?;
|
||||
inner.total_bytes = directory_size(&self.dir).await?;
|
||||
inner.initialized = true;
|
||||
inner.last_sealed_at = Instant::now();
|
||||
crate::telemetry::update_tracking_outbox_pending_bytes(inner.total_bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seal_active_locked(
|
||||
&self,
|
||||
inner: &mut TrackingOutboxInner,
|
||||
reason: &'static str,
|
||||
) -> Result<(), TrackingOutboxError> {
|
||||
if inner.active_count == 0 && inner.active_bytes == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(mut file) = inner.active_file.take() {
|
||||
file.flush().await?;
|
||||
file.sync_data().await?;
|
||||
drop(file);
|
||||
}
|
||||
|
||||
let active_path = self.active_path();
|
||||
match fs::metadata(&active_path).await {
|
||||
Ok(metadata) if metadata.len() > 0 => {
|
||||
let sealed_path = self.next_sealed_path();
|
||||
fs::rename(&active_path, &sealed_path).await?;
|
||||
crate::telemetry::record_tracking_outbox_sealed(reason);
|
||||
debug!(
|
||||
reason,
|
||||
event_count = inner.active_count,
|
||||
file_bytes = metadata.len(),
|
||||
path = %sealed_path.display(),
|
||||
"tracking outbox active 文件已封存"
|
||||
);
|
||||
}
|
||||
Ok(_) => {
|
||||
let _ = fs::remove_file(&active_path).await;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
|
||||
inner.active_count = 0;
|
||||
inner.active_bytes = 0;
|
||||
inner.last_sealed_at = Instant::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seal_existing_active_file(&self) -> Result<(), TrackingOutboxError> {
|
||||
let active_path = self.active_path();
|
||||
match fs::metadata(&active_path).await {
|
||||
Ok(metadata) if metadata.len() > 0 => {
|
||||
fs::rename(&active_path, self.next_sealed_path()).await?;
|
||||
crate::telemetry::record_tracking_outbox_sealed("startup");
|
||||
}
|
||||
Ok(_) => {
|
||||
let _ = fs::remove_file(&active_path).await;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_sealed_files(&self) -> Result<Vec<PathBuf>, TrackingOutboxError> {
|
||||
let mut entries = fs::read_dir(&self.dir).await?;
|
||||
let mut files = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if name.starts_with(SEALED_FILE_PREFIX) && name.ends_with(SEALED_FILE_EXTENSION) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
async fn subtract_total_bytes(&self, bytes: u64) {
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.total_bytes = inner.total_bytes.saturating_sub(bytes);
|
||||
crate::telemetry::update_tracking_outbox_pending_bytes(inner.total_bytes);
|
||||
}
|
||||
|
||||
fn active_path(&self) -> PathBuf {
|
||||
self.dir.join(ACTIVE_FILE_NAME)
|
||||
}
|
||||
|
||||
fn next_sealed_path(&self) -> PathBuf {
|
||||
self.dir.join(format!(
|
||||
"{SEALED_FILE_PREFIX}{}-{uuid}{SEALED_FILE_EXTENSION}",
|
||||
current_unix_micros(),
|
||||
uuid = uuid::Uuid::new_v4()
|
||||
))
|
||||
}
|
||||
|
||||
fn corrupt_path_for(&self, path: &Path) -> PathBuf {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("unknown.ndjson");
|
||||
self.dir.join(format!(
|
||||
"{CORRUPT_FILE_PREFIX}{}-{uuid}-{name}",
|
||||
current_unix_micros(),
|
||||
uuid = uuid::Uuid::new_v4()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TrackingOutbox {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("TrackingOutbox")
|
||||
.field("dir", &self.dir)
|
||||
.field("batch_size", &self.batch_size)
|
||||
.field("flush_interval", &self.flush_interval)
|
||||
.field("max_bytes", &self.max_bytes)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TrackingOutboxError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(f, "{error}"),
|
||||
Self::Json(error) => write!(f, "{error}"),
|
||||
Self::Spacetime(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for TrackingOutboxError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for TrackingOutboxError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::Json(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackingOutboxError {
|
||||
fn is_data_corruption(&self) -> bool {
|
||||
matches!(self, Self::Json(_))
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_outbox_events(
|
||||
path: &Path,
|
||||
) -> Result<Vec<RuntimeTrackingEventInput>, TrackingOutboxError> {
|
||||
let file = File::open(path).await?;
|
||||
let mut lines = BufReader::new(file).lines();
|
||||
let mut events = Vec::new();
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let record = serde_json::from_str::<TrackingOutboxRecord>(&line)?;
|
||||
events.push(record.event);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
async fn directory_size(path: &Path) -> Result<u64, TrackingOutboxError> {
|
||||
let mut total = 0u64;
|
||||
let mut entries = fs::read_dir(path).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if !is_pending_outbox_file_name(&entry.file_name()) {
|
||||
continue;
|
||||
}
|
||||
let metadata = entry.metadata().await?;
|
||||
if metadata.is_file() {
|
||||
total = total.saturating_add(metadata.len());
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn directory_size_if_exists(path: &Path) -> Result<u64, std::io::Error> {
|
||||
if !path.is_dir() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut total = 0u64;
|
||||
for entry in std::fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
if !is_pending_outbox_file_name(&entry.file_name()) {
|
||||
continue;
|
||||
}
|
||||
let metadata = entry.metadata()?;
|
||||
if metadata.is_file() {
|
||||
total = total.saturating_add(metadata.len());
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn current_unix_micros() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_micros()
|
||||
}
|
||||
|
||||
fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool {
|
||||
name.to_str().is_some_and(|value| {
|
||||
value == ACTIVE_FILE_NAME
|
||||
|| (value.starts_with(SEALED_FILE_PREFIX) && value.ends_with(SEALED_FILE_EXTENSION))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_event(event_id: &str) -> RuntimeTrackingEventInput {
|
||||
RuntimeTrackingEventInput {
|
||||
event_id: event_id.to_string(),
|
||||
event_key: "puzzle_route_success".to_string(),
|
||||
scope_kind: module_runtime::RuntimeTrackingScopeKind::Site,
|
||||
scope_id: "site".to_string(),
|
||||
user_id: None,
|
||||
owner_user_id: None,
|
||||
profile_id: None,
|
||||
module_key: Some("puzzle".to_string()),
|
||||
metadata_json: "{}".to_string(),
|
||||
occurred_at_micros: 1_713_680_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"genarrative-tracking-outbox-{name}-{}",
|
||||
current_unix_micros()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
fn test_outbox(dir: PathBuf, batch_size: usize, max_bytes: u64) -> Arc<TrackingOutbox> {
|
||||
let config = AppConfig {
|
||||
tracking_outbox_dir: dir,
|
||||
tracking_outbox_batch_size: batch_size,
|
||||
tracking_outbox_max_bytes: max_bytes,
|
||||
tracking_outbox_flush_interval: Duration::from_secs(60),
|
||||
..AppConfig::default()
|
||||
};
|
||||
TrackingOutbox::from_config(
|
||||
&config,
|
||||
SpacetimeClient::new(spacetime_client::SpacetimeClientConfig {
|
||||
server_url: "http://127.0.0.1:1".to_string(),
|
||||
database: "missing".to_string(),
|
||||
token: None,
|
||||
pool_size: 1,
|
||||
procedure_timeout: Duration::from_millis(10),
|
||||
}),
|
||||
)
|
||||
.expect("outbox should be enabled")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_seals_active_file_when_batch_size_reached_and_rotates_active() {
|
||||
let dir = test_dir("batch");
|
||||
let outbox = test_outbox(dir.clone(), 2, 1024 * 1024);
|
||||
|
||||
outbox.enqueue(sample_event("event-1")).await.unwrap();
|
||||
outbox.enqueue(sample_event("event-2")).await.unwrap();
|
||||
|
||||
assert!(!dir.join(ACTIVE_FILE_NAME).exists());
|
||||
let sealed_count = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX))
|
||||
})
|
||||
.count();
|
||||
assert_eq!(sealed_count, 1);
|
||||
|
||||
outbox.enqueue(sample_event("event-3")).await.unwrap();
|
||||
|
||||
let active_contents = std::fs::read_to_string(dir.join(ACTIVE_FILE_NAME)).unwrap();
|
||||
assert!(active_contents.contains("event-3"));
|
||||
let sealed_count_after_rotate = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX))
|
||||
})
|
||||
.count();
|
||||
assert_eq!(sealed_count_after_rotate, 1);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_drops_when_outbox_exceeds_max_bytes() {
|
||||
let dir = test_dir("max-bytes");
|
||||
let outbox = test_outbox(dir.clone(), 500, 1);
|
||||
|
||||
let outcome = outbox.enqueue(sample_event("event-1")).await.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
TrackingOutboxEnqueueOutcome::Dropped {
|
||||
reason: "max_bytes"
|
||||
}
|
||||
));
|
||||
assert!(!dir.join(ACTIVE_FILE_NAME).exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flush_quarantines_corrupt_sealed_file() {
|
||||
let dir = test_dir("corrupt");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let sealed_path = dir.join(format!("{SEALED_FILE_PREFIX}bad{SEALED_FILE_EXTENSION}"));
|
||||
std::fs::write(&sealed_path, b"{not-json}\n").unwrap();
|
||||
let outbox = test_outbox(dir.clone(), 500, 1024 * 1024);
|
||||
|
||||
outbox.flush_sealed_files_once().await.unwrap();
|
||||
|
||||
assert!(!sealed_path.exists());
|
||||
let corrupt_count = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX))
|
||||
})
|
||||
.count();
|
||||
assert_eq!(corrupt_count, 1);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_size_excludes_quarantined_corrupt_files() {
|
||||
let dir = test_dir("directory-size");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(ACTIVE_FILE_NAME), b"active").unwrap();
|
||||
std::fs::write(
|
||||
dir.join(format!("{SEALED_FILE_PREFIX}one{SEALED_FILE_EXTENSION}")),
|
||||
b"sealed",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join(format!("{CORRUPT_FILE_PREFIX}one{SEALED_FILE_EXTENSION}")),
|
||||
b"corrupt",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let total = directory_size_if_exists(&dir).unwrap();
|
||||
|
||||
assert_eq!(total, 12);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user