Merge remote-tracking branch 'origin/master' into codex/wooden-fish-template

This commit is contained in:
2026-05-21 23:34:35 +08:00
76 changed files with 4259 additions and 662 deletions

View File

@@ -394,9 +394,13 @@ pub async fn confirm_asset_object(
let result = state
.spacetime_client()
.confirm_asset_object(
build_confirm_asset_object_upsert_input(oss_client, payload)
.await
.map_err(map_confirm_asset_object_prepare_error)?,
build_confirm_asset_object_upsert_input(
oss_client,
payload,
authenticated.claims().user_id(),
)
.await
.map_err(map_confirm_asset_object_prepare_error)?,
)
.await
.map_err(map_confirm_asset_object_error)?;
@@ -592,6 +596,7 @@ fn supported_asset_history_kind_message() -> String {
async fn build_confirm_asset_object_upsert_input(
oss_client: &platform_oss::OssClient,
payload: ConfirmAssetObjectRequest,
authenticated_owner_user_id: &str,
) -> Result<module_assets::AssetObjectUpsertInput, ConfirmAssetObjectPrepareError> {
let configured_bucket = oss_client.config_bucket().to_string();
let resolved_bucket = payload
@@ -629,6 +634,14 @@ async fn build_confirm_asset_object_upsert_input(
{
return Err(ConfirmAssetObjectPrepareError::ContentLengthMismatch);
}
let owner_user_id = normalize_optional_value(payload.owner_user_id).or_else(|| {
let owner = authenticated_owner_user_id.trim();
if owner.is_empty() {
None
} else {
Some(owner.to_string())
}
});
let now_micros = current_utc_micros();
build_asset_object_upsert_input(
@@ -645,7 +658,7 @@ async fn build_confirm_asset_object_upsert_input(
normalize_optional_value(payload.content_hash),
payload.asset_kind,
payload.source_job_id,
payload.owner_user_id,
owner_user_id,
payload.profile_id,
payload.entity_id,
now_micros,

View File

@@ -1049,6 +1049,7 @@ mod tests {
base_url: "https://vector.example".to_string(),
api_key: "secret".to_string(),
request_timeout_ms: 180_000,
external_api_audit_state: None,
});
assert_eq!(

View File

@@ -0,0 +1,372 @@
use axum::http::StatusCode;
use module_runtime::RuntimeTrackingScopeKind;
use serde_json::{Value, json};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::{state::AppState, tracking::TrackingEventDraft};
pub(crate) const EXTERNAL_API_FAILURE_EVENT_KEY: &str = "external_api_call_failure";
pub(crate) const EXTERNAL_API_AUDIT_MODULE_KEY: &str = "external-api";
#[derive(Clone, Debug)]
pub(crate) struct ExternalApiFailureDraft {
pub(crate) provider: &'static str,
pub(crate) endpoint: String,
pub(crate) operation: String,
pub(crate) failure_stage: &'static str,
pub(crate) status_code: Option<u16>,
pub(crate) status_class: Option<&'static str>,
pub(crate) timeout: bool,
pub(crate) retryable: bool,
pub(crate) error_message: String,
pub(crate) error_source: Option<String>,
pub(crate) raw_excerpt: Option<String>,
pub(crate) latency_ms: Option<u64>,
pub(crate) prompt_chars: Option<usize>,
pub(crate) reference_image_count: Option<usize>,
pub(crate) image_model: Option<&'static str>,
}
impl ExternalApiFailureDraft {
pub(crate) fn new(
provider: &'static str,
endpoint: impl Into<String>,
operation: impl Into<String>,
failure_stage: &'static str,
error_message: impl Into<String>,
) -> Self {
Self {
provider,
endpoint: endpoint.into(),
operation: operation.into(),
failure_stage,
status_code: None,
status_class: None,
timeout: false,
retryable: false,
error_message: error_message.into(),
error_source: None,
raw_excerpt: None,
latency_ms: None,
prompt_chars: None,
reference_image_count: None,
image_model: None,
}
}
pub(crate) fn with_status_code(mut self, status_code: Option<u16>) -> Self {
self.status_code = status_code;
self
}
pub(crate) fn with_optional_status_class(mut self, status_class: Option<&'static str>) -> Self {
self.status_class = status_class;
self
}
pub(crate) fn with_timeout(mut self, timeout: bool) -> Self {
self.timeout = timeout;
self
}
pub(crate) fn with_retryable(mut self, retryable: bool) -> Self {
self.retryable = retryable;
self
}
pub(crate) fn with_error_source(mut self, error_source: Option<String>) -> Self {
self.error_source = error_source;
self
}
pub(crate) fn with_raw_excerpt(mut self, raw_excerpt: Option<String>) -> Self {
self.raw_excerpt = raw_excerpt;
self
}
pub(crate) fn with_latency_ms(mut self, latency_ms: Option<u64>) -> Self {
self.latency_ms = latency_ms;
self
}
pub(crate) fn with_prompt_chars(mut self, prompt_chars: Option<usize>) -> Self {
self.prompt_chars = prompt_chars;
self
}
pub(crate) fn with_reference_image_count(
mut self,
reference_image_count: Option<usize>,
) -> Self {
self.reference_image_count = reference_image_count;
self
}
pub(crate) fn with_image_model(mut self, image_model: Option<&'static str>) -> Self {
self.image_model = image_model;
self
}
}
/// 中文注释下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。
pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str {
status_class(Some(status_code.as_u16()))
}
/// 中文注释:外部供应商失败同时进入 OTLP 和 tracking_event失败审计不能反向阻断主业务错误返回。
pub(crate) async fn record_external_api_failure(state: &AppState, draft: ExternalApiFailureDraft) {
record_external_api_failure_otlp(&draft);
let tracking_event = build_external_api_failure_tracking_draft(&draft);
if let Some(outbox) = state.tracking_outbox() {
match outbox
.enqueue(crate::tracking::build_tracking_event_input(
tracking_event.clone(),
))
.await
{
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Enqueued) => {}
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Dropped { reason }) => {
tracing::warn!(
provider = draft.provider,
endpoint = %draft.endpoint,
operation = %draft.operation,
failure_stage = draft.failure_stage,
reason,
"外部 API 失败审计写入 outbox 被保护阈值拒绝,回退同步直写 SpacetimeDB"
);
crate::tracking::record_tracking_event_after_success(
state,
&audit_request_context(),
tracking_event,
)
.await;
}
Err(error) => {
tracing::warn!(
provider = draft.provider,
endpoint = %draft.endpoint,
operation = %draft.operation,
failure_stage = draft.failure_stage,
error = %error,
"外部 API 失败审计写入 outbox 失败,回退同步直写 SpacetimeDB"
);
crate::tracking::record_tracking_event_after_success(
state,
&audit_request_context(),
tracking_event,
)
.await;
}
}
return;
}
crate::tracking::record_tracking_event_after_success(
state,
&audit_request_context(),
tracking_event,
)
.await;
}
pub(crate) fn build_external_api_failure_tracking_draft(
failure: &ExternalApiFailureDraft,
) -> TrackingEventDraft {
let mut draft = TrackingEventDraft::new(
EXTERNAL_API_FAILURE_EVENT_KEY,
EXTERNAL_API_AUDIT_MODULE_KEY,
);
draft.scope_kind = RuntimeTrackingScopeKind::Module;
draft.scope_id = failure.provider.to_string();
draft.metadata = build_external_api_failure_metadata(failure);
draft
}
fn build_external_api_failure_metadata(failure: &ExternalApiFailureDraft) -> Value {
let mut metadata = json!({
"provider": failure.provider,
"endpoint": failure.endpoint,
"operation": failure.operation,
"failureStage": failure.failure_stage,
"statusCode": failure.status_code,
"statusClass": failure.status_class.unwrap_or_else(|| status_class(failure.status_code)),
"timeout": failure.timeout,
"retryable": failure.retryable,
"errorMessage": truncate_field(failure.error_message.as_str(), 1_000),
"occurredAt": current_utc_iso_text(),
});
if let Some(latency_ms) = failure.latency_ms {
metadata["latencyMs"] = json!(latency_ms);
}
if let Some(prompt_chars) = failure.prompt_chars {
metadata["promptChars"] = json!(prompt_chars);
}
if let Some(reference_image_count) = failure.reference_image_count {
metadata["referenceImageCount"] = json!(reference_image_count);
}
if let Some(image_model) = failure.image_model {
metadata["imageModel"] = json!(image_model);
}
if let Some(source) = failure
.error_source
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
metadata["errorSource"] = json!(truncate_field(source, 1_000));
}
if let Some(excerpt) = failure
.raw_excerpt
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
metadata["rawExcerpt"] = json!(truncate_field(excerpt, 800));
}
metadata
}
pub(crate) fn is_retryable_external_api_failure(
status_code: Option<u16>,
timeout: bool,
connect: bool,
) -> bool {
timeout
|| connect
|| status_code.is_some_and(|status| {
status == StatusCode::TOO_MANY_REQUESTS.as_u16()
|| status == StatusCode::REQUEST_TIMEOUT.as_u16()
|| status >= 500
})
}
fn record_external_api_failure_otlp(failure: &ExternalApiFailureDraft) {
crate::telemetry::record_external_api_failure(
failure.provider,
failure.failure_stage,
failure
.status_class
.unwrap_or_else(|| status_class(failure.status_code)),
failure.retryable,
);
tracing::error!(
provider = failure.provider,
endpoint = %failure.endpoint,
operation = %failure.operation,
failure_stage = failure.failure_stage,
status_code = failure.status_code,
status_class = failure.status_class.unwrap_or_else(|| status_class(failure.status_code)),
timeout = failure.timeout,
retryable = failure.retryable,
latency_ms = failure.latency_ms,
prompt_chars = failure.prompt_chars,
reference_image_count = failure.reference_image_count,
image_model = failure.image_model,
error = %failure.error_message,
"外部 API 调用失败"
);
}
fn status_class(status_code: Option<u16>) -> &'static str {
match status_code {
Some(100..=199) => "1xx",
Some(200..=299) => "2xx",
Some(300..=399) => "3xx",
Some(400..=499) => "4xx",
Some(500..=599) => "5xx",
Some(_) => "unknown",
None => "transport",
}
}
fn audit_request_context() -> crate::request_context::RequestContext {
crate::request_context::RequestContext::new(
format!("external-api-audit-{}", Uuid::new_v4()),
"external-api audit".to_string(),
std::time::Duration::ZERO,
false,
)
}
fn truncate_field(value: &str, max_chars: usize) -> String {
value.chars().take(max_chars).collect()
}
fn current_utc_iso_text() -> String {
shared_kernel::format_rfc3339(OffsetDateTime::now_utc())
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::*;
#[test]
fn external_api_failure_tracking_draft_uses_module_scope_and_safe_metadata() {
let draft = build_external_api_failure_tracking_draft(
&ExternalApiFailureDraft::new(
"vector-engine",
"https://vector.example/v1/images/generations",
"拼图 UI 背景图生成失败",
"upstream_status",
"上游 429",
)
.with_status_code(Some(429))
.with_retryable(true)
.with_latency_ms(Some(1234))
.with_prompt_chars(Some(88))
.with_reference_image_count(Some(2))
.with_image_model(Some("gpt-image-2-all")),
);
assert_eq!(draft.event_key, EXTERNAL_API_FAILURE_EVENT_KEY);
assert_eq!(draft.scope_kind, RuntimeTrackingScopeKind::Module);
assert_eq!(draft.scope_id, "vector-engine");
assert_eq!(draft.module_key, Some(EXTERNAL_API_AUDIT_MODULE_KEY));
let metadata = draft.metadata;
assert_eq!(metadata["provider"], "vector-engine");
assert_eq!(metadata["statusCode"], 429);
assert_eq!(metadata["statusClass"], "4xx");
assert_eq!(metadata["retryable"], true);
assert_eq!(metadata["latencyMs"], 1234);
assert_eq!(metadata["promptChars"], 88);
assert_eq!(metadata["referenceImageCount"], 2);
assert_eq!(metadata["imageModel"], "gpt-image-2-all");
assert!(matches!(metadata["occurredAt"], Value::String(_)));
}
#[test]
fn retryable_classification_keeps_transport_and_overload_failures_actionable() {
assert!(is_retryable_external_api_failure(None, true, false));
assert!(is_retryable_external_api_failure(None, false, true));
assert!(is_retryable_external_api_failure(Some(429), false, false));
assert!(is_retryable_external_api_failure(Some(502), false, false));
assert!(!is_retryable_external_api_failure(Some(400), false, false));
}
#[test]
fn app_error_status_class_can_override_successful_upstream_status() {
let draft = build_external_api_failure_tracking_draft(
&ExternalApiFailureDraft::new(
"vector-engine",
"https://cdn.example/generated.png",
"下载生成图片",
"image_download",
"下载生成图片失败",
)
.with_status_code(Some(200))
.with_optional_status_class(Some(app_error_status_class(StatusCode::BAD_GATEWAY))),
);
assert_eq!(draft.metadata["statusCode"], 200);
assert_eq!(draft.metadata["statusClass"], "5xx");
}
}

View File

@@ -39,6 +39,7 @@ mod custom_world_rpg_draft_prompts;
mod edutainment_baby_drawing;
mod edutainment_baby_object;
mod error_middleware;
mod external_api_audit;
pub(crate) mod generated_asset_sheets;
mod generated_image_assets;
mod health;

View File

@@ -109,8 +109,9 @@ const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o";
const MATCH3D_ITEM_SIZE_LARGE: &str = "";
const MATCH3D_ITEM_SIZE_MEDIUM: &str = "";
const MATCH3D_ITEM_SIZE_SMALL: &str = "";
const MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH: &str =
"public/match3d-background-references/pot-fused-reference.png";
const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] =
include_bytes!("../../../../public/match3d-background-references/pot-fused-reference.png");
const MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX: &str = "public/";
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10你要创作的关卡是难度几";

View File

@@ -1156,6 +1156,21 @@ fn match3d_public_reference_image_paths_are_limited_to_known_assets() {
);
}
#[test]
fn match3d_container_reference_image_is_embedded_for_api_only_deploy() {
let reference = load_match3d_container_reference_image()
.expect("container reference image should be compiled into api-server");
assert_eq!(reference.mime_type, "image/png");
assert_eq!(reference.file_name, "match3d-container-reference.png");
assert!(
reference
.bytes
.starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]),
"container reference image should be PNG bytes"
);
}
#[test]
fn match3d_cover_reference_prompt_marks_reference_images() {
let prompt = build_match3d_cover_reference_generation_prompt("水果封面", true);
@@ -1684,44 +1699,44 @@ fn match3d_required_item_images_require_five_views() {
assert!(!has_match3d_required_item_images(&assets, 3));
let five_view_assets = (1..=3)
.map(|index| Match3DGeneratedItemAsset {
item_id: format!("match3d-item-{index}"),
item_name: format!("物品{index}"),
item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()),
image_src: Some(format!(
"/generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
)),
image_object_key: Some(format!(
"generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
)),
image_views: (1..=MATCH3D_ITEM_VIEW_COUNT)
.map(|view_index| Match3DGeneratedItemImageView {
view_id: format!("view-{view_index:02}"),
view_index: view_index as u32,
image_src: Some(format!(
"/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
)),
image_object_key: Some(format!(
"generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
)),
})
.collect(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
})
.collect::<Vec<_>>();
.map(|index| Match3DGeneratedItemAsset {
item_id: format!("match3d-item-{index}"),
item_name: format!("物品{index}"),
item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()),
image_src: Some(format!(
"/generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
)),
image_object_key: Some(format!(
"generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
)),
image_views: (1..=MATCH3D_ITEM_VIEW_COUNT)
.map(|view_index| Match3DGeneratedItemImageView {
view_id: format!("view-{view_index:02}"),
view_index: view_index as u32,
image_src: Some(format!(
"/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
)),
image_object_key: Some(format!(
"generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
)),
})
.collect(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
})
.collect::<Vec<_>>();
assert!(has_match3d_required_item_images(&five_view_assets, 3));
}

View File

@@ -386,7 +386,7 @@ pub(super) async fn generate_match3d_background_image(
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_match3d_container_reference_image().await?;
let reference_image = load_match3d_container_reference_image()?;
let generated_background = create_openai_image_generation(
&http_client,
&settings,
@@ -486,7 +486,7 @@ pub(super) async fn generate_match3d_container_image(
require_match3d_oss_client(state)?;
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let reference_image = load_match3d_container_reference_image().await?;
let reference_image = load_match3d_container_reference_image()?;
let container_prompt = build_match3d_container_generation_prompt(config, prompt);
let generated_container = create_openai_image_edit(
&http_client,
@@ -563,15 +563,10 @@ pub(super) fn merge_match3d_container_image_into_background_asset(
}
}
async fn load_match3d_container_reference_image() -> Result<OpenAiReferenceImage, AppError> {
let bytes = tokio::fs::read(MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH)
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": format!("读取抓大鹅容器参考图失败:{error}"),
}))
})?;
pub(super) fn load_match3d_container_reference_image() -> Result<OpenAiReferenceImage, AppError> {
// 中文注释:生产 API 单独发布二进制Web 静态资源可能在另一轮流水线发布。
// 容器参考图属于后端生图协议输入,必须随 api-server 编译进二进制,不能依赖运行时 cwd 下存在 public/。
let bytes = MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES.to_vec();
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
@@ -992,7 +987,9 @@ pub(super) fn normalize_match3d_public_reference_image_path(source: &str) -> Opt
) {
return None;
}
Some(format!("public/{source}"))
Some(format!(
"{MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX}{source}"
))
}
pub(super) fn collect_match3d_cover_reference_image_sources(

View File

@@ -1,6 +1,6 @@
use axum::{
Router,
extract::DefaultBodyLimit,
extract::{DefaultBodyLimit, FromRef},
middleware,
routing::{get, post},
};
@@ -17,12 +17,13 @@ use crate::{
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
update_puzzle_run_pause, use_puzzle_runtime_prop,
},
state::AppState,
state::{AppState, PuzzleApiState},
};
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
// 中文注释:拼图 handler 只接收 PuzzleApiState鉴权层仍使用全局 AppState。
Router::new()
.route(
"/api/runtime/puzzle/agent/sessions",
@@ -181,4 +182,6 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.with_state(PuzzleApiState::from_ref(&state))
.with_state(state)
}

View File

@@ -1,21 +1,44 @@
use std::time::Duration;
use std::{error::Error, time::Duration};
use axum::http::StatusCode;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use reqwest::header;
use serde_json::{Map, Value, json};
use crate::{http_error::AppError, state::AppState};
use crate::{
external_api_audit::{
ExternalApiFailureDraft, app_error_status_class, is_retryable_external_api_failure,
record_external_api_failure,
},
http_error::AppError,
state::AppState,
};
pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2";
pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = "gpt-image-2-all";
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
#[derive(Clone, Debug)]
#[derive(Clone)]
pub(crate) struct OpenAiImageSettings {
pub base_url: String,
pub api_key: String,
pub request_timeout_ms: u64,
pub external_api_audit_state: Option<AppState>,
}
impl std::fmt::Debug for OpenAiImageSettings {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("OpenAiImageSettings")
.field("base_url", &self.base_url)
.field("api_key", &"<redacted>")
.field("request_timeout_ms", &self.request_timeout_ms)
.field(
"external_api_audit_enabled",
&self.external_api_audit_state.is_some(),
)
.finish()
}
}
#[derive(Clone, Debug)]
@@ -74,6 +97,7 @@ pub(crate) fn require_openai_image_settings(
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),
external_api_audit_state: Some(state.clone()),
})
}
@@ -103,15 +127,18 @@ pub(crate) async fn create_openai_image_generation(
reference_images: &[String],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let request_url = vector_engine_images_generation_url(settings);
let normalized_size = normalize_image_size(size);
let request_body = build_openai_image_request_body(
prompt,
negative_prompt,
size,
normalized_size.as_str(),
candidate_count,
reference_images,
);
let response = http_client
.post(vector_engine_images_generation_url(settings))
let started_at = std::time::Instant::now();
let response = match http_client
.post(request_url.as_str())
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
@@ -121,16 +148,106 @@ pub(crate) async fn create_openai_image_generation(
.json(&request_body)
.send()
.await
.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:创建图片生成任务失败:{error}"
))
})?;
{
Ok(response) => response,
Err(error) => {
let latency_ms = started_at.elapsed().as_millis() as u64;
let timeout = error.is_timeout();
let connect = error.is_connect();
let source = error.source().map(ToString::to_string);
let message = format!("{failure_context}:创建图片生成任务失败:{error}");
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"request_send",
None,
None,
timeout,
connect,
message.as_str(),
source,
None,
Some(latency_ms),
Some(prompt.chars().count()),
Some(reference_images.len()),
),
)
.await;
return Err(map_openai_image_reqwest_error(
format!("{failure_context}:创建图片生成任务失败").as_str(),
request_url.as_str(),
error,
));
}
};
let response_status = response.status();
let response_text = response.text().await.map_err(|error| {
map_openai_image_request_error(format!("{failure_context}:读取图片生成响应失败:{error}"))
})?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
status = response_status.as_u16(),
prompt_chars = prompt.chars().count(),
size = %normalized_size,
reference_image_count = reference_images.len(),
elapsed_ms = started_at.elapsed().as_millis() as u64,
failure_context,
"VectorEngine 图片生成 HTTP 返回"
);
let response_text = match response.text().await {
Ok(response_text) => response_text,
Err(error) => {
let latency_ms = started_at.elapsed().as_millis() as u64;
let timeout = error.is_timeout();
let connect = error.is_connect();
let source = error.source().map(ToString::to_string);
let message = format!("{failure_context}:读取图片生成响应失败:{error}");
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"response_body",
Some(response_status.as_u16()),
None,
timeout,
connect,
message.as_str(),
source,
None,
Some(latency_ms),
Some(prompt.chars().count()),
Some(reference_images.len()),
),
)
.await;
return Err(map_openai_image_reqwest_error(
format!("{failure_context}:读取图片生成响应失败").as_str(),
request_url.as_str(),
error,
));
}
};
if !response_status.is_success() {
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"upstream_status",
Some(response_status.as_u16()),
None,
false,
false,
parse_api_error_message(response_text.as_str(), failure_context).as_str(),
None,
Some(truncate_raw(response_text.as_str())),
Some(started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(reference_images.len()),
),
)
.await;
return Err(map_openai_image_upstream_error(
response_status.as_u16(),
response_text.as_str(),
@@ -138,26 +255,114 @@ pub(crate) async fn create_openai_image_generation(
));
}
let response_json = parse_json_payload(response_text.as_str(), failure_context)?;
let response_json = match parse_json_payload(response_text.as_str(), failure_context) {
Ok(response_json) => response_json,
Err(error) => {
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"response_parse",
Some(response_status.as_u16()),
None,
false,
false,
error.body_text().as_str(),
None,
Some(truncate_raw(response_text.as_str())),
Some(started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(reference_images.len()),
),
)
.await;
return Err(error);
}
};
let generation_id = extract_generation_id(&response_json.payload)
.unwrap_or_else(|| format!("vector-engine-{}", current_utc_micros()));
let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt")
.or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt"));
let image_urls = extract_image_urls(&response_json.payload);
if !image_urls.is_empty() {
let mut generated =
download_images_from_urls(http_client, generation_id, image_urls, candidate_count)
.await?;
let download_started_at = std::time::Instant::now();
let mut generated = match download_images_from_urls(
http_client,
generation_id,
image_urls,
candidate_count,
)
.await
{
Ok(generated) => generated,
Err(error) => {
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"image_download",
Some(response_status.as_u16()),
Some(app_error_status_class(error.status_code())),
false,
false,
error.body_text().as_str(),
None,
None,
Some(download_started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(reference_images.len()),
),
)
.await;
return Err(error);
}
};
generated.actual_prompt = actual_prompt;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
image_count = generated.images.len(),
elapsed_ms = download_started_at.elapsed().as_millis() as u64,
failure_context,
"VectorEngine 图片下载完成"
);
return Ok(generated);
}
let b64_images = extract_b64_images(&response_json.payload);
if !b64_images.is_empty() {
let mut generated = images_from_base64(generation_id, b64_images, candidate_count);
generated.actual_prompt = actual_prompt;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
image_count = generated.images.len(),
failure_context,
"VectorEngine 图片 base64 解码完成"
);
return Ok(generated);
}
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"missing_image",
Some(response_status.as_u16()),
None,
false,
false,
format!("{failure_context}VectorEngine 未返回图片地址").as_str(),
None,
Some(truncate_raw(response_text.as_str())),
Some(started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(reference_images.len()),
),
)
.await;
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
@@ -176,6 +381,8 @@ pub(crate) async fn create_openai_image_edit(
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let task_id = format!("vector-engine-edit-{}", current_utc_micros());
let request_url = vector_engine_images_edit_url(settings);
let normalized_size = normalize_image_size(size);
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
.file_name(reference_image.file_name.clone())
.mime_str(reference_image.mime_type.as_str())
@@ -190,9 +397,10 @@ pub(crate) async fn create_openai_image_edit(
build_prompt_with_negative(prompt, negative_prompt),
)
.text("n", "1")
.text("size", normalize_image_size(size));
let response = http_client
.post(vector_engine_images_edit_url(settings).as_str())
.text("size", normalized_size.clone());
let started_at = std::time::Instant::now();
let response = match http_client
.post(request_url.as_str())
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
@@ -201,16 +409,106 @@ pub(crate) async fn create_openai_image_edit(
.multipart(form)
.send()
.await
.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:创建图片编辑任务失败:{error}"
))
})?;
{
Ok(response) => response,
Err(error) => {
let latency_ms = started_at.elapsed().as_millis() as u64;
let timeout = error.is_timeout();
let connect = error.is_connect();
let source = error.source().map(ToString::to_string);
let message = format!("{failure_context}:创建图片编辑任务失败:{error}");
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"request_send",
None,
None,
timeout,
connect,
message.as_str(),
source,
None,
Some(latency_ms),
Some(prompt.chars().count()),
Some(1),
),
)
.await;
return Err(map_openai_image_reqwest_error(
format!("{failure_context}:创建图片编辑任务失败").as_str(),
request_url.as_str(),
error,
));
}
};
let response_status = response.status();
let response_text = response.text().await.map_err(|error| {
map_openai_image_request_error(format!("{failure_context}:读取图片编辑响应失败:{error}"))
})?;
tracing::info!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
status = response_status.as_u16(),
prompt_chars = prompt.chars().count(),
size = %normalized_size,
reference_image_count = 1usize,
elapsed_ms = started_at.elapsed().as_millis() as u64,
failure_context,
"VectorEngine 图片编辑 HTTP 返回"
);
let response_text = match response.text().await {
Ok(response_text) => response_text,
Err(error) => {
let latency_ms = started_at.elapsed().as_millis() as u64;
let timeout = error.is_timeout();
let connect = error.is_connect();
let source = error.source().map(ToString::to_string);
let message = format!("{failure_context}:读取图片编辑响应失败:{error}");
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"response_body",
Some(response_status.as_u16()),
None,
timeout,
connect,
message.as_str(),
source,
None,
Some(latency_ms),
Some(prompt.chars().count()),
Some(1),
),
)
.await;
return Err(map_openai_image_reqwest_error(
format!("{failure_context}:读取图片编辑响应失败").as_str(),
request_url.as_str(),
error,
));
}
};
if !response_status.is_success() {
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"upstream_status",
Some(response_status.as_u16()),
None,
false,
false,
parse_api_error_message(response_text.as_str(), failure_context).as_str(),
None,
Some(truncate_raw(response_text.as_str())),
Some(started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(1),
),
)
.await;
return Err(map_openai_image_upstream_error(
response_status.as_u16(),
response_text.as_str(),
@@ -218,12 +516,62 @@ pub(crate) async fn create_openai_image_edit(
));
}
let response_json = parse_json_payload(response_text.as_str(), failure_context)?;
let response_json = match parse_json_payload(response_text.as_str(), failure_context) {
Ok(response_json) => response_json,
Err(error) => {
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"response_parse",
Some(response_status.as_u16()),
None,
false,
false,
error.body_text().as_str(),
None,
Some(truncate_raw(response_text.as_str())),
Some(started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(1),
),
)
.await;
return Err(error);
}
};
let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt")
.or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt"));
let image_urls = extract_image_urls(&response_json.payload);
if !image_urls.is_empty() {
let mut generated = download_images_from_urls(http_client, task_id, image_urls, 1).await?;
let download_started_at = std::time::Instant::now();
let mut generated =
match download_images_from_urls(http_client, task_id, image_urls, 1).await {
Ok(generated) => generated,
Err(error) => {
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"image_download",
Some(response_status.as_u16()),
Some(app_error_status_class(error.status_code())),
false,
false,
error.body_text().as_str(),
None,
None,
Some(download_started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(1),
),
)
.await;
return Err(error);
}
};
generated.actual_prompt = actual_prompt;
return Ok(generated);
}
@@ -234,6 +582,25 @@ pub(crate) async fn create_openai_image_edit(
return Ok(generated);
}
record_openai_image_failure_if_configured(
settings,
build_openai_image_failure_audit_draft(
request_url.as_str(),
failure_context,
"missing_image",
Some(response_status.as_u16()),
None,
false,
false,
format!("{failure_context}VectorEngine 未返回编辑图片").as_str(),
None,
Some(truncate_raw(response_text.as_str())),
Some(started_at.elapsed().as_millis() as u64),
Some(prompt.chars().count()),
Some(1),
),
)
.await;
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
@@ -402,6 +769,44 @@ fn map_openai_image_request_error(message: String) -> AppError {
}))
}
fn map_openai_image_reqwest_error(
context: &str,
request_url: &str,
error: reqwest::Error,
) -> AppError {
let is_timeout = error.is_timeout();
let is_connect = error.is_connect();
let source = error.source().map(ToString::to_string).unwrap_or_default();
let message = format!("{context}{error}");
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
endpoint = %request_url,
timeout = is_timeout,
connect = is_connect,
request = error.is_request(),
body = error.is_body(),
source = %source,
message = %message,
"VectorEngine 图片请求发送失败"
);
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": message,
"endpoint": request_url,
"timeout": is_timeout,
"connect": is_connect,
"request": error.is_request(),
"body": error.is_body(),
"source": source,
}))
}
fn map_openai_image_upstream_error(
upstream_status: u16,
raw_text: &str,
@@ -423,6 +828,53 @@ fn map_openai_image_upstream_error(
}))
}
async fn record_openai_image_failure_if_configured(
settings: &OpenAiImageSettings,
draft: ExternalApiFailureDraft,
) {
if let Some(state) = settings.external_api_audit_state.as_ref() {
record_external_api_failure(state, draft).await;
}
}
fn build_openai_image_failure_audit_draft(
request_url: &str,
failure_context: &str,
failure_stage: &'static str,
status_code: Option<u16>,
status_class: Option<&'static str>,
timeout: bool,
connect: bool,
error_message: &str,
error_source: Option<String>,
raw_excerpt: Option<String>,
latency_ms: Option<u64>,
prompt_chars: Option<usize>,
reference_image_count: Option<usize>,
) -> ExternalApiFailureDraft {
ExternalApiFailureDraft::new(
VECTOR_ENGINE_PROVIDER,
request_url.to_string(),
failure_context.to_string(),
failure_stage,
error_message.to_string(),
)
.with_status_code(status_code)
.with_optional_status_class(status_class)
.with_timeout(timeout)
.with_retryable(is_retryable_external_api_failure(
status_code,
timeout,
connect,
))
.with_error_source(error_source)
.with_raw_excerpt(raw_excerpt)
.with_latency_ms(latency_ms)
.with_prompt_chars(prompt_chars)
.with_reference_image_count(reference_image_count)
.with_image_model(Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL))
}
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if raw_text.trim().is_empty() {
return fallback_message.to_string();
@@ -629,11 +1081,13 @@ mod tests {
base_url: "https://vector.example".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000_000,
external_api_audit_state: None,
};
let v1_settings = OpenAiImageSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000_000,
external_api_audit_state: None,
};
assert_eq!(
@@ -658,4 +1112,41 @@ mod tests {
assert_eq!(images.images[0].mime_type, "image/png");
assert_eq!(images.images[0].extension, "png");
}
#[test]
fn vector_engine_upstream_failure_builds_tracking_ready_audit_event() {
let audit = build_openai_image_failure_audit_draft(
"https://vector.example/v1/images/generations",
"拼图 UI 背景图生成失败",
"upstream_status",
Some(429),
None,
false,
false,
"上游限流",
None,
Some("{\"error\":\"rate limited\"}".to_string()),
Some(321),
Some(42),
Some(1),
);
let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft(&audit);
assert_eq!(
tracking.event_key,
crate::external_api_audit::EXTERNAL_API_FAILURE_EVENT_KEY
);
assert_eq!(tracking.scope_id, VECTOR_ENGINE_PROVIDER);
assert_eq!(tracking.metadata["provider"], VECTOR_ENGINE_PROVIDER);
assert_eq!(tracking.metadata["statusCode"], 429);
assert_eq!(tracking.metadata["statusClass"], "4xx");
assert_eq!(tracking.metadata["failureStage"], "upstream_status");
assert_eq!(tracking.metadata["retryable"], true);
assert_eq!(tracking.metadata["promptChars"], 42);
assert_eq!(tracking.metadata["referenceImageCount"], 1);
assert_eq!(
tracking.metadata["imageModel"],
VECTOR_ENGINE_GPT_IMAGE_2_MODEL
);
}
}

View File

@@ -21,10 +21,8 @@ use module_assets::{
};
use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
};
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
use serde_json::{Map, Value, json};
use shared_contracts::{
creation_audio::CreationAudioAsset,
@@ -105,12 +103,9 @@ use crate::{
},
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
request_context::RequestContext,
state::AppState,
vector_engine_audio_generation::{
GeneratedCreationAudioTarget, generate_background_music_asset_for_creation,
},
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
state::PuzzleApiState,
work_author::resolve_puzzle_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success},
};
const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
@@ -121,8 +116,6 @@ const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
const PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW: &str = "gemini-3.1-flash-image-preview";
const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2;
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
#[cfg(test)]
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";

View File

@@ -24,7 +24,7 @@ pub(crate) fn build_puzzle_form_seed_text_from_parts(
}
pub(crate) async fn save_puzzle_form_payload_before_compile(
state: &AppState,
state: &PuzzleApiState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
@@ -76,7 +76,7 @@ pub(crate) async fn save_puzzle_form_payload_before_compile(
}
pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing(
state: &AppState,
state: &PuzzleApiState,
request_context: &RequestContext,
session_id: &str,
owner_user_id: &str,
@@ -209,7 +209,7 @@ pub(crate) fn parse_puzzle_level_records_from_module_json(
}
pub(crate) async fn get_puzzle_session_for_image_generation(
state: &AppState,
state: &PuzzleApiState,
session_id: String,
owner_user_id: String,
payload: &ExecutePuzzleAgentActionRequest,
@@ -469,7 +469,7 @@ impl PuzzleLevelNaming {
}
pub(crate) async fn generate_puzzle_first_level_name(
state: &AppState,
state: &PuzzleApiState,
picture_description: &str,
) -> PuzzleLevelNaming {
if let Some(llm_client) = state.llm_client() {
@@ -511,7 +511,7 @@ pub(crate) async fn generate_puzzle_first_level_name(
}
pub(crate) async fn generate_puzzle_first_level_name_from_image(
state: &AppState,
state: &PuzzleApiState,
picture_description: &str,
image: &PuzzleDownloadedImage,
) -> Option<PuzzleLevelNaming> {
@@ -1033,42 +1033,8 @@ pub(crate) fn attach_puzzle_level_ui_background(
levels[index].ui_background_image_object_key = Some(generated.object_key);
}
pub(crate) async fn generate_puzzle_background_music_required(
state: &AppState,
owner_user_id: &str,
profile_id: &str,
title: &str,
) -> Result<CreationAudioAsset, AppError> {
let normalized_title = title.trim();
if normalized_title.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成",
})),
);
}
generate_background_music_asset_for_creation(
state,
owner_user_id,
String::new(),
normalized_title.to_string(),
Some("轻快, 拼图, 循环, instrumental".to_string()),
None,
GeneratedCreationAudioTarget {
entity_kind: PUZZLE_ENTITY_KIND.to_string(),
entity_id: profile_id.to_string(),
slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(),
asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(),
profile_id: Some(profile_id.to_string()),
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
},
)
.await
}
pub(crate) async fn generate_puzzle_initial_ui_background_required(
state: &AppState,
state: &PuzzleApiState,
owner_user_id: &str,
session_id: &str,
draft: &PuzzleResultDraftRecord,
@@ -1128,7 +1094,7 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
}
pub(crate) async fn compile_puzzle_draft_with_initial_cover(
state: &AppState,
state: &PuzzleApiState,
session_id: String,
owner_user_id: String,
prompt_text: Option<&str>,
@@ -1398,7 +1364,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
}
pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
state: &AppState,
state: &PuzzleApiState,
session_id: String,
owner_user_id: String,
prompt_text: Option<&str>,
@@ -1417,7 +1383,12 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
})?;
let http_client = reqwest::Client::new();
let uploaded_downloaded_image =
resolve_puzzle_reference_image_as_data_url(state, &http_client, uploaded_image_src)
resolve_puzzle_reference_image(
state,
&http_client,
uploaded_image_src,
Some(owner_user_id.as_str()),
)
.await
.map(PuzzleDownloadedImage::from_resolved_reference_image)
.map_err(|error| {
@@ -1425,7 +1396,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"field": "referenceImageSrc",
"message": "关闭 AI 重绘时上传图必须是图片 Data URL 或历史生成图片路径。",
"message": "关闭 AI 重绘时上传图必须是拼图图片 assetObjectId、图片 Data URL 或历史生成图片路径。",
}))
} else {
error

View File

@@ -18,7 +18,7 @@ pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppErr
}
pub(crate) async fn generate_puzzle_image_candidates(
state: &AppState,
state: &PuzzleApiState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
@@ -58,8 +58,13 @@ pub(crate) async fn generate_puzzle_image_candidates(
.filter(|_| should_use_reference_image_edit)
{
Some(source) => {
let resolved =
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
let resolved = resolve_puzzle_reference_image(
state,
&http_client,
source,
Some(owner_user_id),
)
.await?;
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
@@ -219,13 +224,13 @@ pub(crate) async fn generate_puzzle_image_candidates(
}
pub(crate) async fn generate_puzzle_ui_background_image(
state: &AppState,
state: &PuzzleApiState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
let settings = require_openai_image_settings(state)?;
let settings = require_openai_image_settings(state.root_state())?;
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,

View File

@@ -1,7 +1,7 @@
use super::*;
pub async fn create_puzzle_agent_session(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CreatePuzzleAgentSessionRequest>, JsonRejection>,
@@ -46,7 +46,7 @@ pub async fn create_puzzle_agent_session(
}
pub async fn generate_puzzle_onboarding_work(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<PuzzleOnboardingGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
@@ -161,7 +161,7 @@ pub async fn generate_puzzle_onboarding_work(
}
pub async fn save_puzzle_onboarding_work(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<PuzzleOnboardingSaveRequest>, JsonRejection>,
@@ -270,7 +270,7 @@ pub async fn save_puzzle_onboarding_work(
}
pub async fn get_puzzle_agent_session(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(session_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -303,7 +303,7 @@ pub async fn get_puzzle_agent_session(
}
pub async fn submit_puzzle_agent_message(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(session_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -359,7 +359,7 @@ pub async fn submit_puzzle_agent_message(
llm_client: state.llm_client(),
session: &submitted_session,
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
enable_web_search: state.creation_agent_llm_web_search_enabled(),
},
|_| {},
)
@@ -401,7 +401,7 @@ pub async fn submit_puzzle_agent_message(
}
pub async fn stream_puzzle_agent_message(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(session_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -464,7 +464,7 @@ pub async fn stream_puzzle_agent_message(
llm_client: state.llm_client(),
session: &session,
quick_fill_requested,
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
enable_web_search: state.creation_agent_llm_web_search_enabled(),
},
move |text| {
let _ = reply_tx.send(text.to_string());
@@ -554,7 +554,7 @@ pub async fn stream_puzzle_agent_message(
}
pub async fn execute_puzzle_agent_action(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(session_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -595,6 +595,8 @@ pub async fn execute_puzzle_agent_action(
has_reference_image = has_puzzle_reference_images(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
payload.reference_image_asset_object_id.as_deref(),
payload.reference_image_asset_object_ids.as_slice(),
),
"拼图 Agent action 开始执行"
);
@@ -604,6 +606,8 @@ pub async fn execute_puzzle_agent_action(
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
payload.reference_image_asset_object_id.as_deref(),
payload.reference_image_asset_object_ids.as_slice(),
);
let primary_reference_image_src = reference_image_sources.first().map(String::as_str);
let prompt_text = payload
@@ -627,7 +631,7 @@ pub async fn execute_puzzle_agent_action(
};
let session = if ai_redraw {
execute_billable_asset_operation_with_cost(
&state,
state.root_state(),
&owner_user_id,
"puzzle_initial_image",
&billing_asset_id,
@@ -652,7 +656,7 @@ pub async fn execute_puzzle_agent_action(
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
payload.reference_image_src.as_deref(),
primary_reference_image_src,
now,
)
.await
@@ -737,7 +741,7 @@ pub async fn execute_puzzle_agent_action(
}))
});
let session = execute_billable_asset_operation_with_cost(
&state,
state.root_state(),
&owner_user_id,
"puzzle_generated_image",
&billing_asset_id,
@@ -787,6 +791,8 @@ pub async fn execute_puzzle_agent_action(
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
payload.reference_image_asset_object_id.as_deref(),
payload.reference_image_asset_object_ids.as_slice(),
);
let primary_reference_image_src =
reference_image_sources.first().map(String::as_str);
@@ -942,7 +948,7 @@ pub async fn execute_puzzle_agent_action(
}))
});
let session = execute_billable_asset_operation_with_cost(
&state,
state.root_state(),
&owner_user_id,
"puzzle_ui_background_image",
&billing_asset_id,
@@ -1147,7 +1153,7 @@ pub async fn execute_puzzle_agent_action(
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id);
let author_display_name = resolve_author_display_name(&state, &authenticated);
let profile = execute_billable_asset_operation(
&state,
state.root_state(),
&owner_user_id,
"puzzle_publish_work",
&work_id,
@@ -1235,7 +1241,7 @@ pub async fn execute_puzzle_agent_action(
}
pub async fn get_puzzle_works(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
@@ -1263,7 +1269,7 @@ pub async fn get_puzzle_works(
}
pub async fn get_puzzle_work_detail(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
@@ -1296,7 +1302,7 @@ pub async fn get_puzzle_work_detail(
}
pub async fn put_puzzle_work(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -1355,7 +1361,7 @@ pub async fn put_puzzle_work(
}
pub async fn delete_puzzle_work(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -1391,7 +1397,7 @@ pub async fn delete_puzzle_work(
}
pub async fn claim_puzzle_work_point_incentive(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -1428,7 +1434,7 @@ pub async fn claim_puzzle_work_point_incentive(
}
pub async fn list_puzzle_gallery(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Response, Response> {
if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await {
@@ -1487,7 +1493,7 @@ pub async fn list_puzzle_gallery(
}
pub async fn get_puzzle_gallery_detail(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
@@ -1519,7 +1525,7 @@ pub async fn get_puzzle_gallery_detail(
}
pub async fn record_puzzle_gallery_like(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -1556,7 +1562,7 @@ pub async fn record_puzzle_gallery_like(
}
pub async fn remix_puzzle_gallery_work(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -1599,7 +1605,7 @@ pub async fn remix_puzzle_gallery_work(
}
pub async fn start_puzzle_run(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<StartPuzzleRunRequest>, JsonRejection>,
@@ -1639,7 +1645,7 @@ pub async fn start_puzzle_run(
)
})?;
record_work_play_start_after_success(
record_puzzle_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
@@ -1665,7 +1671,7 @@ pub async fn start_puzzle_run(
}
pub async fn get_puzzle_run(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -1693,7 +1699,7 @@ pub async fn get_puzzle_run(
}
pub async fn swap_puzzle_pieces(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -1750,7 +1756,7 @@ pub async fn swap_puzzle_pieces(
}
pub async fn drag_puzzle_piece_or_group(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -1802,7 +1808,7 @@ pub async fn drag_puzzle_piece_or_group(
}
pub async fn advance_puzzle_next_level(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -1854,7 +1860,7 @@ pub async fn advance_puzzle_next_level(
}
pub async fn update_puzzle_run_pause(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -1898,7 +1904,7 @@ pub async fn update_puzzle_run_pause(
}
pub async fn use_puzzle_runtime_prop(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
@@ -1944,7 +1950,7 @@ pub async fn use_puzzle_runtime_prop(
let fallback_run_id = run_id.clone();
let fallback_owner_user_id = owner_user_id.clone();
let run_result = execute_billable_asset_operation(
&state,
state.root_state(),
&owner_user_id,
billing_asset_kind,
billing_asset_id.as_str(),
@@ -1996,7 +2002,7 @@ pub async fn use_puzzle_runtime_prop(
}
pub async fn submit_puzzle_leaderboard(
State(state): State<AppState>,
State(state): State<PuzzleApiState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,

View File

@@ -343,11 +343,11 @@ fn has_puzzle_level_image(level: &PuzzleDraftLevelRecord) -> bool {
}
pub(super) fn map_puzzle_work_summary_response(
state: &AppState,
state: &PuzzleApiState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkSummaryResponse {
let generation_status = resolve_puzzle_work_generation_status(&item);
let author = resolve_work_author_by_user_id(
let author = resolve_puzzle_work_author_by_user_id(
state,
&item.owner_user_id,
Some(&item.author_display_name),
@@ -391,10 +391,10 @@ pub(super) fn map_puzzle_work_summary_response(
}
pub(super) fn map_puzzle_gallery_card_response(
state: &AppState,
state: &PuzzleApiState,
item: PuzzleGalleryCardRecord,
) -> PuzzleWorkSummaryResponse {
let author = resolve_work_author_by_user_id(
let author = resolve_puzzle_work_author_by_user_id(
state,
&item.owner_user_id,
Some(&item.author_display_name),
@@ -434,7 +434,7 @@ pub(super) fn map_puzzle_gallery_card_response(
}
pub(super) fn map_puzzle_work_profile_response(
state: &AppState,
state: &PuzzleApiState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkProfileResponse {
let mut summary = map_puzzle_work_summary_response(state, item.clone());
@@ -491,7 +491,7 @@ pub(super) fn map_puzzle_recommended_next_work_response(
}
pub(super) async fn enrich_puzzle_run_author_name(
state: &AppState,
state: &PuzzleApiState,
mut run: PuzzleRunRecord,
) -> PuzzleRunRecord {
if let Some(level) = run.current_level.as_mut() {
@@ -500,7 +500,7 @@ pub(super) async fn enrich_puzzle_run_author_name(
.get_puzzle_gallery_detail(level.profile_id.clone())
.await
{
level.author_display_name = resolve_work_author_by_user_id(
level.author_display_name = resolve_puzzle_work_author_by_user_id(
state,
&profile.owner_user_id,
Some(&profile.author_display_name),
@@ -632,7 +632,7 @@ pub(super) fn map_puzzle_board_response(
}
pub(super) fn resolve_author_display_name(
state: &AppState,
state: &PuzzleApiState,
authenticated: &AuthenticatedAccessToken,
) -> String {
state

View File

@@ -1,7 +1,7 @@
use super::*;
pub(super) async fn generate_puzzle_work_tags(
state: &AppState,
state: &PuzzleApiState,
work_title: &str,
work_description: &str,
) -> Vec<String> {
@@ -143,7 +143,7 @@ pub(super) fn build_fallback_puzzle_tags(
}
pub(super) async fn save_generated_puzzle_tags_to_session(
state: &AppState,
state: &PuzzleApiState,
session_id: &str,
owner_user_id: &str,
payload: &ExecutePuzzleAgentActionRequest,

View File

@@ -41,6 +41,7 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
mime_type: "image/png".to_string(),
bytes_len: cursor.get_ref().len(),
bytes: cursor.into_inner(),
signed_read_url: None,
};
let body = build_puzzle_vector_engine_image_request_body(
@@ -64,6 +65,33 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
);
}
#[test]
fn puzzle_vector_engine_generation_prefers_signed_reference_url() {
let reference_image = PuzzleResolvedReferenceImage {
mime_type: "image/png".to_string(),
bytes_len: 4,
bytes: b"test".to_vec(),
signed_read_url: Some(
"https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc"
.to_string(),
),
};
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),
);
assert_eq!(
body["image"][0],
"https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc"
);
}
#[test]
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
let settings = PuzzleVectorEngineSettings {
@@ -131,6 +159,8 @@ fn puzzle_reference_image_sources_are_deduped_and_limited() {
"data:image/png;base64,e".to_string(),
"data:image/png;base64,f".to_string(),
],
None,
&[],
);
assert_eq!(sources.len(), 5);
@@ -139,6 +169,62 @@ fn puzzle_reference_image_sources_are_deduped_and_limited() {
assert!(!sources.contains(&"data:image/png;base64,f".to_string()));
}
#[test]
fn puzzle_reference_image_sources_prefer_asset_object_ids() {
let sources = collect_puzzle_reference_image_sources(
Some("data:image/png;base64,legacy"),
&["/generated-puzzle-assets/legacy.png".to_string()],
Some("asset-main-1"),
&[
"asset-main-1".to_string(),
"asset-prompt-1".to_string(),
"asset-prompt-2".to_string(),
],
);
assert_eq!(
sources,
vec![
"asset-object:asset-main-1".to_string(),
"asset-object:asset-prompt-1".to_string(),
"asset-object:asset-prompt-2".to_string(),
"data:image/png;base64,legacy".to_string(),
"/generated-puzzle-assets/legacy.png".to_string(),
]
);
}
#[test]
fn puzzle_asset_object_reference_requires_matching_owner() {
let asset_object = module_assets::AssetObjectRecord {
asset_object_id: "assetobj_reference_1".to_string(),
bucket: "genarrative-assets".to_string(),
object_key: "generated-puzzle-assets/reference/image.png".to_string(),
access_policy: module_assets::AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 1024,
content_hash: None,
version: 1,
source_job_id: None,
owner_user_id: Some("user-other".to_string()),
profile_id: None,
entity_id: None,
asset_kind: "puzzle_cover_image".to_string(),
created_at: "2026-05-21T00:00:00Z".to_string(),
updated_at: "2026-05-21T00:00:00Z".to_string(),
};
let error = validate_puzzle_reference_asset_object(
&asset_object,
Some("user-current"),
"genarrative-assets",
)
.expect_err("其他账号的参考图资产应被拒绝");
assert_eq!(error.status_code(), StatusCode::FORBIDDEN);
assert!(error.body_text().contains("不属于当前账号"));
}
#[test]
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_request_error(
@@ -250,6 +336,8 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
prompt_text: None,
reference_image_src: None,
reference_image_srcs: Vec::new(),
reference_image_asset_object_id: None,
reference_image_asset_object_ids: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
@@ -383,6 +471,7 @@ fn puzzle_uploaded_cover_can_reuse_resolved_history_image() {
mime_type: "image/png".to_string(),
bytes_len: 8,
bytes: b"pngbytes".to_vec(),
signed_read_url: None,
};
let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved);
@@ -410,6 +499,8 @@ fn puzzle_first_level_name_snapshot_defaults_work_title() {
prompt_text: None,
reference_image_src: None,
reference_image_srcs: Vec::new(),
reference_image_asset_object_id: None,
reference_image_asset_object_ids: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
@@ -614,7 +705,9 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
#[test]
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
let app_state = crate::state::AppState::new(crate::config::AppConfig::default())
.expect("state should build");
let state: PuzzleApiState = axum::extract::FromRef::from_ref(&app_state);
let level = PuzzleDraftLevelRecord {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),

View File

@@ -37,6 +37,7 @@ pub(crate) struct PuzzleResolvedReferenceImage {
pub(crate) mime_type: String,
pub(crate) bytes_len: usize,
pub(crate) bytes: Vec<u8>,
pub(crate) signed_read_url: Option<String>,
}
pub(crate) struct GeneratedPuzzleImageCandidate {
@@ -109,13 +110,9 @@ pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageMode
}
pub(crate) fn require_puzzle_vector_engine_settings(
state: &AppState,
state: &PuzzleApiState,
) -> Result<PuzzleVectorEngineSettings, AppError> {
let base_url = state
.config
.vector_engine_base_url
.trim()
.trim_end_matches('/');
let base_url = state.vector_engine_base_url().trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
@@ -127,9 +124,7 @@ pub(crate) fn require_puzzle_vector_engine_settings(
}
let api_key = state
.config
.vector_engine_api_key
.as_deref()
.vector_engine_api_key()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
@@ -147,11 +142,11 @@ pub(crate) fn require_puzzle_vector_engine_settings(
}
pub(crate) fn build_puzzle_image_http_client(
state: &AppState,
state: &PuzzleApiState,
image_model: PuzzleImageModel,
) -> Result<reqwest::Client, AppError> {
let provider = image_model.provider_name();
let request_timeout_ms = state.config.vector_engine_image_request_timeout_ms;
let request_timeout_ms = state.vector_engine_image_request_timeout_ms();
reqwest::Client::builder()
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
@@ -397,11 +392,19 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body(
("n".to_string(), json!(candidate_count.clamp(1, 1))),
("size".to_string(), Value::String(size.to_string())),
]);
if let Some(reference_image) = reference_image
&& let Some(reference_data_url) =
if let Some(reference_image) = reference_image {
if let Some(signed_read_url) = reference_image
.signed_read_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
body.insert("image".to_string(), json!([signed_read_url]));
} else if let Some(reference_data_url) =
build_puzzle_generation_reference_image_data_url(reference_image)
{
body.insert("image".to_string(), json!([reference_data_url]));
{
body.insert("image".to_string(), json!([reference_data_url]));
}
}
Value::Object(body)
@@ -462,6 +465,48 @@ pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> b
pub(crate) fn collect_puzzle_reference_image_sources(
legacy_reference_image_src: Option<&str>,
reference_image_srcs: &[String],
reference_image_asset_object_id: Option<&str>,
reference_image_asset_object_ids: &[String],
) -> Vec<String> {
let mut sources = Vec::new();
for source in reference_image_asset_object_id
.into_iter()
.chain(reference_image_asset_object_ids.iter().map(String::as_str))
.map(|asset_object_id| {
asset_object_id
.trim()
.strip_prefix("asset-object:")
.unwrap_or_else(|| asset_object_id.trim())
})
.filter(|asset_object_id| !asset_object_id.is_empty())
.map(|asset_object_id| format!("asset-object:{asset_object_id}"))
.chain(
legacy_reference_image_src
.into_iter()
.chain(reference_image_srcs.iter().map(String::as_str))
.map(str::to_string),
)
{
let normalized = source.trim();
if normalized.is_empty() {
continue;
}
if !sources
.iter()
.any(|existing: &String| existing == normalized)
{
sources.push(normalized.to_string());
}
if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT {
break;
}
}
sources
}
pub(crate) fn collect_legacy_puzzle_reference_image_sources(
legacy_reference_image_src: Option<&str>,
reference_image_srcs: &[String],
) -> Vec<String> {
let mut sources = Vec::new();
for source in legacy_reference_image_src
@@ -488,9 +533,16 @@ pub(crate) fn collect_puzzle_reference_image_sources(
pub(crate) fn has_puzzle_reference_images(
legacy_reference_image_src: Option<&str>,
reference_image_srcs: &[String],
reference_image_asset_object_id: Option<&str>,
reference_image_asset_object_ids: &[String],
) -> bool {
!collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs)
.is_empty()
!collect_puzzle_reference_image_sources(
legacy_reference_image_src,
reference_image_srcs,
reference_image_asset_object_id,
reference_image_asset_object_ids,
)
.is_empty()
}
pub(crate) fn should_use_puzzle_reference_image_edit(
@@ -546,10 +598,19 @@ pub(crate) async fn download_puzzle_images_from_urls(
Ok(PuzzleGeneratedImages { task_id, images })
}
pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
state: &AppState,
pub(crate) fn parse_puzzle_asset_object_reference(source: &str) -> Option<&str> {
source
.trim()
.strip_prefix("asset-object:")
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub(crate) async fn resolve_puzzle_reference_image(
state: &PuzzleApiState,
http_client: &reqwest::Client,
source: &str,
owner_user_id: Option<&str>,
) -> Result<PuzzleResolvedReferenceImage, AppError> {
let trimmed = source.trim();
if trimmed.is_empty() {
@@ -562,6 +623,16 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
);
}
if let Some(asset_object_id) = parse_puzzle_asset_object_reference(trimmed) {
return resolve_puzzle_reference_asset_object(
state,
http_client,
asset_object_id,
owner_user_id,
)
.await;
}
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
let bytes_len = parsed.bytes.len();
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
@@ -579,6 +650,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
mime_type: parsed.mime_type,
bytes_len,
bytes: parsed.bytes,
signed_read_url: None,
});
}
@@ -587,7 +659,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "参考图必须是 Data URL 或 /generated-* 旧路径。",
"message": "参考图必须是 assetObjectId、Data URL 或 /generated-* 旧路径。",
})),
);
}
@@ -598,7 +670,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "参考图当前只支持 /generated-* 旧路径。",
"message": "参考图当前只支持 assetObjectId 或 /generated-* 旧路径。",
})),
);
}
@@ -615,8 +687,159 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
expire_seconds: Some(60),
})
.map_err(map_puzzle_asset_oss_error)?;
let signed_read_url = signed.signed_url;
download_signed_puzzle_reference_image(
http_client,
signed_read_url,
object_key,
None,
"referenceImageSrc",
)
.await
}
pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
state: &PuzzleApiState,
http_client: &reqwest::Client,
source: &str,
) -> Result<PuzzleResolvedReferenceImage, AppError> {
resolve_puzzle_reference_image(state, http_client, source, None).await
}
async fn resolve_puzzle_reference_asset_object(
state: &PuzzleApiState,
http_client: &reqwest::Client,
asset_object_id: &str,
owner_user_id: Option<&str>,
) -> Result<PuzzleResolvedReferenceImage, AppError> {
let asset_object = state
.spacetime_client()
.get_asset_object(asset_object_id.to_string())
.await
.map_err(map_puzzle_client_error)?
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object_id,
"message": "参考图资产不存在或当前账号不可见。",
}))
})?;
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
validate_puzzle_reference_asset_object(
&asset_object,
owner_user_id,
oss_client.config_bucket(),
)?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: asset_object.object_key.clone(),
expire_seconds: Some(60),
})
.map_err(map_puzzle_asset_oss_error)?;
let content_type = asset_object.content_type.clone();
download_signed_puzzle_reference_image(
http_client,
signed.signed_url,
asset_object.object_key.as_str(),
content_type.as_deref(),
"referenceImageAssetObjectId",
)
.await
}
pub(crate) fn validate_puzzle_reference_asset_object(
asset_object: &module_assets::AssetObjectRecord,
owner_user_id: Option<&str>,
oss_bucket: &str,
) -> Result<(), AppError> {
if asset_object.bucket.trim() != oss_bucket.trim() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": "参考图资产 bucket 与当前服务 OSS 配置不一致。",
})),
);
}
if asset_object.asset_kind.trim() != "puzzle_cover_image" {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": "参考图资产类型不属于拼图图片。",
})),
);
}
let content_type = asset_object
.content_type
.as_deref()
.map(str::trim)
.unwrap_or_default();
if !content_type.starts_with("image/") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": "参考图资产不是图片类型。",
})),
);
}
if asset_object.content_length == 0
|| asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64
{
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": "参考图资产大小不符合拼图生成要求。",
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
"actualBytes": asset_object.content_length,
})),
);
}
if let Some(expected_owner_user_id) = owner_user_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
let actual_owner_user_id = asset_object
.owner_user_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
if actual_owner_user_id != Some(expected_owner_user_id) {
return Err(
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": "asset-object",
"field": "referenceImageAssetObjectId",
"assetObjectId": asset_object.asset_object_id,
"message": "参考图资产不属于当前账号。",
})),
);
}
}
Ok(())
}
async fn download_signed_puzzle_reference_image(
http_client: &reqwest::Client,
signed_read_url: String,
object_key: &str,
fallback_content_type: Option<&str>,
field: &str,
) -> Result<PuzzleResolvedReferenceImage, AppError> {
let response = http_client
.get(signed.signed_url)
.get(signed_read_url.as_str())
.send()
.await
.map_err(|error| map_puzzle_image_request_error(format!("读取拼图参考图失败:{error}")))?;
@@ -625,6 +848,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.or(fallback_content_type)
.unwrap_or("image/png")
.to_string();
let body = response.bytes().await.map_err(|error| {
@@ -636,6 +860,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
"provider": "aliyun-oss",
"message": format!("读取参考图失败,状态码:{status}"),
"objectKey": object_key,
"field": field,
})),
);
}
@@ -645,6 +870,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
"provider": "aliyun-oss",
"message": "读取参考图失败:对象内容为空",
"objectKey": object_key,
"field": field,
})),
);
}
@@ -655,6 +881,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
mime_type,
bytes_len,
bytes: body.to_vec(),
signed_read_url: Some(signed_read_url),
})
}
@@ -693,7 +920,7 @@ pub(crate) async fn download_puzzle_remote_image(
}
pub(crate) async fn persist_puzzle_generated_asset(
state: &AppState,
state: &PuzzleApiState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
@@ -805,7 +1032,7 @@ pub(crate) async fn persist_puzzle_generated_asset(
}
pub(crate) async fn persist_puzzle_ui_background_image(
state: &AppState,
state: &PuzzleApiState,
owner_user_id: &str,
session_id: &str,
level_name: &str,

View File

@@ -141,6 +141,86 @@ impl FromRef<AppState> for BackpressureState {
}
}
#[derive(Clone, Debug)]
pub struct PuzzleApiState {
root_state: AppState,
spacetime_client: SpacetimeClient,
puzzle_gallery_cache: PuzzleGalleryCache,
oss_client: Option<OssClient>,
auth_user_service: AuthUserService,
llm_client: Option<LlmClient>,
creative_agent_gpt5_client: Option<LlmClient>,
creation_agent_llm_web_search_enabled: bool,
vector_engine_image_request_timeout_ms: u64,
}
impl PuzzleApiState {
pub fn root_state(&self) -> &AppState {
&self.root_state
}
pub fn spacetime_client(&self) -> &SpacetimeClient {
&self.spacetime_client
}
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
&self.puzzle_gallery_cache
}
pub fn oss_client(&self) -> Option<&OssClient> {
self.oss_client.as_ref()
}
pub fn auth_user_service(&self) -> &AuthUserService {
&self.auth_user_service
}
pub fn llm_client(&self) -> Option<&LlmClient> {
self.llm_client.as_ref()
}
pub fn creative_agent_gpt5_client(&self) -> Option<&LlmClient> {
self.creative_agent_gpt5_client.as_ref()
}
pub fn creation_agent_llm_web_search_enabled(&self) -> bool {
self.creation_agent_llm_web_search_enabled
}
pub fn vector_engine_image_request_timeout_ms(&self) -> u64 {
self.vector_engine_image_request_timeout_ms
}
pub fn vector_engine_base_url(&self) -> &str {
self.root_state.config.vector_engine_base_url.as_str()
}
pub fn vector_engine_api_key(&self) -> Option<&str> {
self.root_state.config.vector_engine_api_key.as_deref()
}
}
impl FromRef<AppState> for PuzzleApiState {
fn from_ref(state: &AppState) -> Self {
// 中文注释:拼图路由只暴露本能力需要的依赖快照,避免 handler 直接看见完整 AppState。
Self {
root_state: state.clone(),
spacetime_client: state.spacetime_client.clone(),
puzzle_gallery_cache: state.puzzle_gallery_cache.clone(),
oss_client: state.oss_client.clone(),
auth_user_service: state.auth_user_service.clone(),
llm_client: state.llm_client.clone(),
creative_agent_gpt5_client: state.creative_agent_gpt5_client.clone(),
creation_agent_llm_web_search_enabled: state
.config
.creation_agent_llm_web_search_enabled,
vector_engine_image_request_timeout_ms: state
.config
.vector_engine_image_request_timeout_ms,
}
}
}
// Axum/Hyper 会在路由树和连接 service 上频繁 clone stateAppState 外层必须保持浅拷贝。
#[derive(Debug)]
pub struct AppStateInner {
@@ -1350,4 +1430,23 @@ mod tests {
);
assert!(client.config().official_fallback());
}
#[test]
fn puzzle_api_state_exposes_puzzle_dependency_snapshot() {
let mut config = AppConfig::default();
config.creation_agent_llm_web_search_enabled = false;
config.vector_engine_image_request_timeout_ms = 987_654;
let state = AppState::new(config).expect("state should build");
let puzzle_state: PuzzleApiState = FromRef::from_ref(&state);
assert!(!puzzle_state.creation_agent_llm_web_search_enabled());
assert_eq!(
puzzle_state.vector_engine_image_request_timeout_ms(),
987_654
);
assert!(puzzle_state.llm_client().is_none());
assert!(puzzle_state.creative_agent_gpt5_client().is_none());
assert!(puzzle_state.oss_client().is_none());
}
}

View File

@@ -172,6 +172,23 @@ 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);
}
pub(crate) fn record_external_api_failure(
provider: &'static str,
failure_stage: &'static str,
status_class: &'static str,
retryable: bool,
) {
external_api_metrics().failures.add(
1,
&[
KeyValue::new("provider", provider),
KeyValue::new("failure_stage", failure_stage),
KeyValue::new("status_class", status_class),
KeyValue::new("retryable", retryable),
],
);
}
fn track_response_body_in_flight(response: Response<Body>) -> Response<Body> {
response.map(|body| {
HTTP_RESPONSE_BODY_IN_FLIGHT.fetch_add(1, Ordering::Relaxed);
@@ -211,6 +228,10 @@ struct TrackingOutboxMetrics {
flushed_bytes: Counter<u64>,
}
struct ExternalApiMetrics {
failures: Counter<u64>,
}
struct HttpRequestPermitsAvailableGauges {
default: Arc<AtomicI64>,
gallery: Arc<AtomicI64>,
@@ -359,6 +380,21 @@ fn tracking_outbox_metrics() -> &'static TrackingOutboxMetrics {
})
}
fn external_api_metrics() -> &'static ExternalApiMetrics {
static METRICS: std::sync::OnceLock<ExternalApiMetrics> = std::sync::OnceLock::new();
METRICS.get_or_init(|| {
let meter = global::meter("genarrative-api");
ExternalApiMetrics {
failures: meter
.u64_counter("genarrative.external_api.failures")
.with_description(
"External API call failures grouped by provider and failure stage",
)
.build(),
}
})
}
fn register_http_request_permits_available_metric() -> HttpRequestPermitsAvailableGauges {
let gauges = HttpRequestPermitsAvailableGauges::new();
let meter = global::meter("genarrative-api");

View File

@@ -584,6 +584,26 @@ async fn record_route_tracking_event_via_outbox_after_success(
record_tracking_event_input_after_success(state, request_context, event).await;
}
pub(crate) 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,
}
}
async fn record_tracking_event_input_after_success(
state: &AppState,
request_context: &RequestContext,
@@ -642,26 +662,6 @@ async fn record_tracking_event_input_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

@@ -1,6 +1,6 @@
use module_auth::AuthUser;
use crate::state::AppState;
use crate::state::{AppState, PuzzleApiState};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkAuthorSummary {
@@ -14,6 +14,34 @@ pub fn resolve_work_author_by_user_id(
owner_user_id: &str,
fallback_display_name: Option<&str>,
fallback_public_user_code: Option<&str>,
) -> WorkAuthorSummary {
resolve_work_author_by_user_id_with_service(
state.auth_user_service(),
owner_user_id,
fallback_display_name,
fallback_public_user_code,
)
}
pub fn resolve_puzzle_work_author_by_user_id(
state: &PuzzleApiState,
owner_user_id: &str,
fallback_display_name: Option<&str>,
fallback_public_user_code: Option<&str>,
) -> WorkAuthorSummary {
resolve_work_author_by_user_id_with_service(
state.auth_user_service(),
owner_user_id,
fallback_display_name,
fallback_public_user_code,
)
}
fn resolve_work_author_by_user_id_with_service(
auth_user_service: &module_auth::AuthUserService,
owner_user_id: &str,
fallback_display_name: Option<&str>,
fallback_public_user_code: Option<&str>,
) -> WorkAuthorSummary {
let fallback_display_name =
normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string());
@@ -26,7 +54,7 @@ pub fn resolve_work_author_by_user_id(
};
};
match state.auth_user_service().get_user_by_id(&owner_user_id) {
match auth_user_service.get_user_by_id(&owner_user_id) {
Ok(Some(user)) => map_auth_user_to_work_author_summary(user, fallback_display_name),
Ok(None) | Err(_) => WorkAuthorSummary {
display_name: fallback_display_name,

View File

@@ -4,7 +4,7 @@ use serde_json::{Value, json};
use crate::{
auth::AuthenticatedAccessToken,
request_context::RequestContext,
state::AppState,
state::{AppState, PuzzleApiState},
tracking::{TrackingEventDraft, record_tracking_event_after_success},
};
@@ -68,6 +68,22 @@ pub(crate) async fn record_work_play_start_after_success(
state: &AppState,
request_context: &RequestContext,
draft: WorkPlayTrackingDraft,
) {
record_work_play_start_input_after_success(state, request_context, draft).await;
}
pub(crate) async fn record_puzzle_work_play_start_after_success(
state: &PuzzleApiState,
request_context: &RequestContext,
draft: WorkPlayTrackingDraft,
) {
record_work_play_start_input_after_success(state.root_state(), request_context, draft).await;
}
async fn record_work_play_start_input_after_success(
state: &AppState,
request_context: &RequestContext,
draft: WorkPlayTrackingDraft,
) {
let mut metadata = json!({
"operation": WORK_PLAY_START_EVENT_KEY,