merge: master into codex/bark-battle

This commit is contained in:
kdletters
2026-05-19 17:04:32 +08:00
307 changed files with 40711 additions and 26022 deletions

View File

@@ -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 }

View File

@@ -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);

View File

@@ -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(

View File

@@ -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,

View 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
);
}
}

View File

@@ -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

View File

@@ -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

View 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,
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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()

View 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,
}
}

File diff suppressed because it is too large Load Diff

View 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",
}
}

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

View 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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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(),
}
}

View 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));
}

File diff suppressed because it is too large Load Diff

View 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());
}
}

View File

@@ -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 stateAppState 外层必须保持浅拷贝。
#[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()
}

View 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");
}
}

View File

@@ -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

View 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);
}
}