From 771b0411a37fa5e7bad5b16dcdd0b26bbf95aa8c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 28 May 2026 14:50:13 +0800 Subject: [PATCH] fix: enrich image failure audit metadata --- .hermes/shared-memory/pitfalls.md | 8 +++ ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 23 +++++++- .../crates/api-server/src/bark_battle.rs | 5 +- .../api-server/src/character_visual_assets.rs | 9 +++- .../crates/api-server/src/custom_world_ai.rs | 19 +++++-- .../src/custom_world_ai/opening_cg.rs | 6 ++- .../api-server/src/edutainment_baby_object.rs | 2 + .../api-server/src/external_api_audit.rs | 36 +++++++++++++ server-rs/crates/api-server/src/jump_hop.rs | 11 +++- .../api-server/src/match3d/item_assets.rs | 5 +- .../crates/api-server/src/match3d/works.rs | 15 ++++-- .../api-server/src/openai_image_generation.rs | 52 +++++++++++++++++++ .../crates/api-server/src/puzzle/draft.rs | 11 +++- .../api-server/src/puzzle/generation.rs | 18 ++++++- .../crates/api-server/src/puzzle/handlers.rs | 10 +++- .../api-server/src/puzzle/vector_engine.rs | 17 ++++++ .../src/square_hole/visual_assets.rs | 5 +- 18 files changed, 234 insertions(+), 20 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index a6a0e5c6..23371fc5 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -23,6 +23,14 @@ - 验证:触发任一平台级异步失败时,页面应出现包含“错误来源”和“错误内容”的弹窗;复制内容应包含来源和错误正文;旧页面内错误 banner 不再重复出现。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformErrorDialog.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## VectorEngine 图片生成 SendRequest 超时要按传输失败排查 + +- 现象:`external_api_call_failure` 里看到 `failureStage=request_send`、`timeout=true`、`statusCode=null`、`errorSource=client error (SendRequest)`,前端只知道图片生成失败。 +- 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。 +- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId` 定位触发者与草稿 / 作品;`request_send + timeout=true` 优先查请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。若记录有 `502` 或 `429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。 +- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。 +- 关联:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## “我的”页每日任务卡不要硬编码进度 - 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 5bf2a3e2..4d9db3b7 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -167,7 +167,7 @@ npm run check:server-rs-ddd - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。 - 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 - OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。 -- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 +- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 - 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。 ## SpacetimeDB 表目录 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index f0dfdc3c..befe256c 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -281,7 +281,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 - debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。 - api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。 - HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。 -- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、status_class、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。排障时先按 provider / failureStage 聚合,再结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。 +- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、status_class、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` 中记录触发者与草稿 / 作品作用域。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。 - SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 - 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。 - Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。 @@ -340,7 +340,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms 个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。 -外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。常用查询: +外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId 和 profileId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,入口拿不到上下文时允许为空。常用查询: ```sql SELECT event_id, scope_id AS provider, metadata_json, occurred_at @@ -350,6 +350,25 @@ ORDER BY occurred_at DESC LIMIT 50; ``` +按失败阶段、触发者和作品作用域聚合时: + +```sql +SELECT + json_extract(metadata_json, '$.failureStage') AS failure_stage, + user_id, + profile_id, + COUNT(*) AS failures, + MIN(occurred_at) AS first_seen, + MAX(occurred_at) AS last_seen +FROM tracking_event +WHERE event_key = 'external_api_call_failure' +GROUP BY failure_stage, user_id, profile_id +ORDER BY failures DESC, last_seen DESC +LIMIT 100; +``` + +VectorEngine `request_send` 且 `timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource = client error (SendRequest)` 是 Hyper 发送请求阶段的错误来源标签,不等于最终根因。若 `statusCode` 为空,应优先查同一时间窗口的 `api-server` request 日志、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502`、`429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。 + tracking outbox 默认配置: ```env diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index beb9d940..a2366218 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -1204,7 +1204,10 @@ async fn generate_and_persist_bark_battle_image_asset( prompt: &str, size: &str, ) -> Result { - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + Some(owner_user_id.to_string()), + Some(draft_id.unwrap_or(asset_id).to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index b119453d..b1a14fac 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -94,7 +94,9 @@ pub async fn generate_character_visual( .map_err(|error| character_visual_error_response(&request_context, error))?; let result = async { - let settings = require_openai_image_settings(&state)?; + let settings = require_openai_image_settings(&state)? + .with_external_api_audit_context(Some(owner_user_id.clone()), Some(character_id.clone())) + .with_external_api_audit_request_id(Some(request_context.request_id().to_string())); let http_client = build_openai_image_http_client(&settings)?; state @@ -318,7 +320,10 @@ pub(crate) async fn generate_character_primary_visual_for_profile( &model, &prompt, )?; - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + Some(owner_user_id.to_string()), + Some(character_id.clone()), + ); let http_client = build_openai_image_http_client(&settings)?; state .ai_task_service() diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 649999dd..ac6f2d41 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -553,7 +553,12 @@ pub async fn generate_custom_world_scene_image( "scene_image", asset_id.as_str(), async { - let settings = require_openai_image_settings(&state)?; + let settings = require_openai_image_settings(&state)? + .with_external_api_audit_context( + Some(owner_user_id.to_string()), + normalized.profile_id.clone(), + ) + .with_external_api_audit_request_id(Some(request_context.request_id().to_string())); let http_client = build_openai_image_http_client(&settings)?; let reference_image = if let Some(reference_image_src) = normalized.reference_image_src.as_deref() { @@ -675,7 +680,10 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile( }), }; let normalized = normalize_scene_image_request(payload)?; - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + Some(owner_user_id.to_string()), + normalized.profile_id.clone(), + ); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, @@ -1011,7 +1019,12 @@ pub async fn generate_custom_world_opening_cg( opening_cg_id.as_str(), OPENING_CG_POINTS_COST, async { - let image_settings = require_openai_image_settings(&state)?; + let image_settings = require_openai_image_settings(&state)? + .with_external_api_audit_context( + Some(owner_user_id.clone()), + normalized.profile_id.clone(), + ) + .with_external_api_audit_request_id(Some(request_context.request_id().to_string())); let image_http_client = build_openai_image_http_client(&image_settings)?; let video_settings = require_ark_video_settings(&state)?; let video_http_client = build_upstream_http_client(video_settings.request_timeout_ms)?; diff --git a/server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs b/server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs index 5e45bbb7..6d99f2b7 100644 --- a/server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs +++ b/server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs @@ -8,9 +8,13 @@ pub(super) async fn generate_opening_cg_storyboard( normalized: &NormalizedOpeningCgRequest, reference_images: &[String], ) -> Result { + let audit_settings = settings.clone().with_external_api_audit_context( + Some(owner_user_id.to_string()), + normalized.profile_id.clone(), + ); let generated = create_openai_image_generation( http_client, - settings, + &audit_settings, normalized.storyboard_prompt.as_str(), None, OPENING_CG_STORYBOARD_IMAGE_SIZE, diff --git a/server-rs/crates/api-server/src/edutainment_baby_object.rs b/server-rs/crates/api-server/src/edutainment_baby_object.rs index 5fcd8687..dda85bf5 100644 --- a/server-rs/crates/api-server/src/edutainment_baby_object.rs +++ b/server-rs/crates/api-server/src/edutainment_baby_object.rs @@ -1050,6 +1050,8 @@ mod tests { api_key: "secret".to_string(), request_timeout_ms: 180_000, external_api_audit_state: None, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, }); assert_eq!( diff --git a/server-rs/crates/api-server/src/external_api_audit.rs b/server-rs/crates/api-server/src/external_api_audit.rs index 1d225034..8cc82fa1 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -27,6 +27,9 @@ pub(crate) struct ExternalApiFailureDraft { pub(crate) prompt_chars: Option, pub(crate) reference_image_count: Option, pub(crate) image_model: Option<&'static str>, + pub(crate) user_id: Option, + pub(crate) profile_id: Option, + pub(crate) request_id: Option, } impl ExternalApiFailureDraft { @@ -53,6 +56,9 @@ impl ExternalApiFailureDraft { prompt_chars: None, reference_image_count: None, image_model: None, + user_id: None, + profile_id: None, + request_id: None, } } @@ -108,6 +114,21 @@ impl ExternalApiFailureDraft { self.image_model = image_model; self } + + pub(crate) fn with_user_id(mut self, user_id: Option) -> Self { + self.user_id = user_id; + self + } + + pub(crate) fn with_profile_id(mut self, profile_id: Option) -> Self { + self.profile_id = profile_id; + self + } + + pub(crate) fn with_request_id(mut self, request_id: Option) -> Self { + self.request_id = request_id; + self + } } pub(crate) fn build_external_api_failure_draft_from_platform_image_audit( @@ -130,6 +151,9 @@ pub(crate) fn build_external_api_failure_draft_from_platform_image_audit( .with_prompt_chars(audit.prompt_chars) .with_reference_image_count(audit.reference_image_count) .with_image_model(audit.image_model) + .with_user_id(None) + .with_profile_id(None) + .with_request_id(None) } /// 中文注释:下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。 @@ -203,6 +227,9 @@ pub(crate) fn build_external_api_failure_tracking_draft( ); draft.scope_kind = RuntimeTrackingScopeKind::Module; draft.scope_id = failure.provider.to_string(); + draft.user_id = failure.user_id.clone(); + draft.owner_user_id = failure.user_id.clone(); + draft.profile_id = failure.profile_id.clone(); draft.metadata = build_external_api_failure_metadata(failure); draft } @@ -233,6 +260,15 @@ fn build_external_api_failure_metadata(failure: &ExternalApiFailureDraft) -> Val if let Some(image_model) = failure.image_model { metadata["imageModel"] = json!(image_model); } + if let Some(user_id) = failure.user_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + metadata["userId"] = json!(truncate_field(user_id, 1_000)); + } + if let Some(profile_id) = failure.profile_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + metadata["profileId"] = json!(truncate_field(profile_id, 1_000)); + } + if let Some(request_id) = failure.request_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + metadata["requestId"] = json!(truncate_field(request_id, 1_000)); + } if let Some(source) = failure .error_source .as_deref() diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 2339e842..04e025e7 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -412,7 +412,16 @@ async fn maybe_generate_jump_hop_assets( .unwrap_or_else(|| build_prefixed_uuid_id("jump-hop-profile-")); payload.profile_id = Some(profile_id.clone()); - let settings = require_openai_image_settings(state).map_err(|error| { + let settings = require_openai_image_settings(state) + .map(|settings| { + settings + .with_external_api_audit_context( + Some(owner_user_id.to_string()), + Some(profile_id.clone()), + ) + .with_external_api_audit_request_id(Some(request_context.request_id().to_string())) + }) + .map_err(|error| { jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) })?; let http_client = build_openai_image_http_client(&settings).map_err(|error| { diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 1365faee..f3b04fb6 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -753,7 +753,10 @@ async fn generate_match3d_material_sheet_from_level_scene( config: &Match3DConfigJson, background_asset: Option<&Match3DGeneratedBackgroundAsset>, ) -> Result { - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let prompt = build_match3d_item_spritesheet_prompt(); let reference = load_match3d_level_scene_reference_image(state, background_asset).await?; diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs index b8f8bc2a..7d64c723 100644 --- a/server-rs/crates/api-server/src/match3d/works.rs +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -301,7 +301,10 @@ pub(super) async fn generate_match3d_cover_image_asset( reference_image_srcs: Vec, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let cover_prompt = build_match3d_cover_generation_prompt(config, prompt); let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit( @@ -448,7 +451,10 @@ pub(super) async fn generate_match3d_level_asset_bundle( prompt: &str, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let level_scene_prompt = build_match3d_level_scene_generation_prompt(config); @@ -590,7 +596,10 @@ pub(super) async fn generate_match3d_container_image( prompt: &str, ) -> Result { require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let reference_image = load_match3d_container_reference_image()?; let container_prompt = build_match3d_container_generation_prompt(config, prompt); diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index c4e2a0f0..7f3ae424 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -34,6 +34,9 @@ pub(crate) struct OpenAiImageSettings { pub api_key: String, pub request_timeout_ms: u64, pub external_api_audit_state: Option, + pub external_api_audit_user_id: Option, + pub external_api_audit_profile_id: Option, + pub external_api_audit_request_id: Option, } impl std::fmt::Debug for OpenAiImageSettings { @@ -47,6 +50,18 @@ impl std::fmt::Debug for OpenAiImageSettings { "external_api_audit_enabled", &self.external_api_audit_state.is_some(), ) + .field( + "external_api_audit_user_id", + &self.external_api_audit_user_id, + ) + .field( + "external_api_audit_profile_id", + &self.external_api_audit_profile_id, + ) + .field( + "external_api_audit_request_id", + &self.external_api_audit_request_id, + ) .finish() } } @@ -87,6 +102,9 @@ pub(crate) fn require_openai_image_settings( 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()), + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }) } @@ -240,6 +258,21 @@ pub(crate) fn build_openai_image_request_body( } impl OpenAiImageSettings { + pub(crate) fn with_external_api_audit_context( + mut self, + user_id: Option, + profile_id: Option, + ) -> Self { + self.external_api_audit_user_id = user_id; + self.external_api_audit_profile_id = profile_id; + self + } + + pub(crate) fn with_external_api_audit_request_id(mut self, request_id: Option) -> Self { + self.external_api_audit_request_id = request_id; + self + } + fn provider_settings(&self) -> VectorEngineImageSettings { VectorEngineImageSettings { base_url: self.base_url.clone(), @@ -310,6 +343,10 @@ pub(crate) async fn record_openai_image_failure_if_configured( let Some(draft) = build_openai_image_failure_audit_draft(error) else { return; }; + let draft = draft + .with_user_id(settings.external_api_audit_user_id.clone()) + .with_profile_id(settings.external_api_audit_profile_id.clone()) + .with_request_id(settings.external_api_audit_request_id.clone()); record_external_api_failure(state, draft).await; } @@ -422,12 +459,18 @@ mod tests { api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: 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, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }; assert_eq!( @@ -447,12 +490,18 @@ mod tests { api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: 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, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }; assert_eq!( @@ -472,6 +521,9 @@ mod tests { api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }; let http_client = reqwest::Client::new(); diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index d639f8f2..1f3b53db 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -1085,6 +1085,7 @@ pub(crate) fn attach_puzzle_level_asset_bundle( pub(crate) async fn generate_puzzle_initial_ui_background_required( state: &PuzzleApiState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, draft: &PuzzleResultDraftRecord, @@ -1093,6 +1094,7 @@ pub(crate) async fn generate_puzzle_initial_ui_background_required( let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level); let generated = generate_puzzle_ui_background_image( state, + request_context, owner_user_id, session_id, target_level.level_name.as_str(), @@ -1104,6 +1106,7 @@ pub(crate) async fn generate_puzzle_initial_ui_background_required( pub(crate) async fn generate_puzzle_level_asset_bundle_required( state: &PuzzleApiState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, target_level: &PuzzleDraftLevelRecord, @@ -1111,6 +1114,7 @@ pub(crate) async fn generate_puzzle_level_asset_bundle_required( ) -> Result { generate_puzzle_level_asset_bundle( state, + request_context, owner_user_id, session_id, target_level.level_name.as_str(), @@ -1175,6 +1179,7 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>( pub(crate) async fn compile_puzzle_draft_with_initial_cover( state: &PuzzleApiState, + request_context: &RequestContext, session_id: String, owner_user_id: String, prompt_text: Option<&str>, @@ -1195,6 +1200,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( })?; let mut target_level = select_puzzle_level_for_api(&draft, None)?; let fallback_level_name = target_level.level_name.clone(); + let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); let image_prompt = resolve_puzzle_draft_cover_prompt( prompt_text, &target_level.picture_description, @@ -1209,6 +1215,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( let mut candidates = generate_puzzle_image_candidates( state, owner_user_id.as_str(), + Some(profile_id.as_str()), &compiled_session.session_id, &target_level.level_name, &image_prompt, @@ -1262,6 +1269,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( { let asset_bundle = generate_puzzle_level_asset_bundle_required( state, + request_context, owner_user_id.as_str(), compiled_session.session_id.as_str(), &target_level, @@ -1369,7 +1377,6 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( Err(error) } })?; - let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); match state .spacetime_client() .update_puzzle_work(PuzzleWorkUpsertRecordInput { @@ -1441,6 +1448,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( state: &PuzzleApiState, + request_context: &RequestContext, session_id: String, owner_user_id: String, prompt_text: Option<&str>, @@ -1544,6 +1552,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( .await?; let asset_bundle = generate_puzzle_level_asset_bundle_required( state, + request_context, owner_user_id.as_str(), compiled_session.session_id.as_str(), &target_level, diff --git a/server-rs/crates/api-server/src/puzzle/generation.rs b/server-rs/crates/api-server/src/puzzle/generation.rs index 5a3d9a2a..208b0717 100644 --- a/server-rs/crates/api-server/src/puzzle/generation.rs +++ b/server-rs/crates/api-server/src/puzzle/generation.rs @@ -78,6 +78,7 @@ pub(crate) async fn create_uploaded_puzzle_image_candidate( pub(crate) async fn generate_puzzle_image_candidates( state: &PuzzleApiState, owner_user_id: &str, + profile_id: Option<&str>, session_id: &str, level_name: &str, prompt: &str, @@ -150,6 +151,11 @@ pub(crate) async fn generate_puzzle_image_candidates( // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。 // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 let settings = require_puzzle_vector_engine_settings(state)?; + let settings = PuzzleVectorEngineSettings { + external_api_audit_user_id: Some(owner_user_id.to_string()), + external_api_audit_profile_id: profile_id.map(ToOwned::to_owned), + ..settings + }; let vector_engine_started_at = Instant::now(); let generated = if should_use_reference_image_generation { let reference_image = reference_image.as_ref().ok_or_else(|| { @@ -255,12 +261,18 @@ pub(crate) async fn generate_puzzle_image_candidates( pub(crate) async fn generate_puzzle_ui_background_image( state: &PuzzleApiState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, level_name: &str, prompt: &str, ) -> Result { - let settings = require_openai_image_settings(state.root_state())?; + let settings = require_openai_image_settings(state.root_state())? + .with_external_api_audit_context( + Some(owner_user_id.to_string()), + Some(session_id.to_string()), + ) + .with_external_api_audit_request_id(Some(request_context.request_id().to_string())); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, @@ -292,12 +304,14 @@ pub(crate) async fn generate_puzzle_ui_background_image( pub(crate) async fn generate_puzzle_level_asset_bundle( state: &PuzzleApiState, + request_context: &RequestContext, owner_user_id: &str, session_id: &str, level_name: &str, puzzle_image: &PuzzleDownloadedImage, ) -> Result { - let settings = require_puzzle_vector_engine_settings(state)?; + let settings = require_puzzle_vector_engine_settings(state)? + .with_external_api_audit_request_id(Some(request_context.request_id().to_string())); let http_client = build_puzzle_image_http_client(state, PuzzleImageModel::GptImage2)?; let puzzle_reference = build_puzzle_downloaded_image_reference(puzzle_image); let scene_generated = create_puzzle_vector_engine_image_generation( diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index 20645497..ab594f07 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -71,12 +71,14 @@ pub async fn generate_puzzle_onboarding_work( let now = current_utc_micros(); let session_id = build_prefixed_uuid_id("puzzle-onboarding-"); + let onboarding_profile_id = format!("onboarding-profile-{now}"); let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await; let tags = generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await; let candidates = generate_puzzle_image_candidates( &state, "onboarding-guest", + Some(onboarding_profile_id.as_str()), session_id.as_str(), naming.level_name.as_str(), prompt_text.as_str(), @@ -132,7 +134,7 @@ pub async fn generate_puzzle_onboarding_work( )); let item = PuzzleWorkProfileRecord { work_id: format!("onboarding-work-{now}"), - profile_id: format!("onboarding-profile-{now}"), + profile_id: onboarding_profile_id, owner_user_id: "onboarding-guest".to_string(), source_session_id: None, author_display_name: "陶泥儿主".to_string(), @@ -675,6 +677,7 @@ pub async fn execute_puzzle_agent_action( async { compile_puzzle_draft_with_initial_cover( &state, + &request_context, compile_session_id.clone(), owner_user_id.clone(), prompt_text, @@ -689,6 +692,7 @@ pub async fn execute_puzzle_agent_action( } else { compile_puzzle_draft_with_uploaded_cover( &state, + &request_context, compile_session_id.clone(), owner_user_id.clone(), prompt_text, @@ -861,9 +865,11 @@ pub async fn execute_puzzle_agent_action( .await?, ] } else { + let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id); generate_puzzle_image_candidates( &state, owner_user_id.as_str(), + Some(profile_id.as_str()), &session.session_id, &target_level.level_name, &prompt, @@ -920,6 +926,7 @@ pub async fn execute_puzzle_agent_action( })?; let asset_bundle = generate_puzzle_level_asset_bundle_required( &state, + &request_context, owner_user_id.as_str(), &session.session_id, &target_level, @@ -1079,6 +1086,7 @@ pub async fn execute_puzzle_agent_action( ); let generated = generate_puzzle_ui_background_image( &state, + &request_context, owner_user_id.as_str(), &session.session_id, &target_level.level_name, diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index d6bde919..8b7db2cb 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -31,6 +31,9 @@ pub(crate) struct PuzzleVectorEngineSettings { pub(crate) api_key: String, pub(crate) request_timeout_ms: u64, pub(crate) external_api_audit_state: Option, + pub(crate) external_api_audit_user_id: Option, + pub(crate) external_api_audit_profile_id: Option, + pub(crate) external_api_audit_request_id: Option, } pub(crate) struct PuzzleGeneratedImages { @@ -100,8 +103,19 @@ impl PuzzleVectorEngineSettings { api_key: self.api_key.clone(), request_timeout_ms: self.request_timeout_ms, external_api_audit_state: self.external_api_audit_state.clone(), + external_api_audit_user_id: self.external_api_audit_user_id.clone(), + external_api_audit_profile_id: self.external_api_audit_profile_id.clone(), + external_api_audit_request_id: self.external_api_audit_request_id.clone(), } } + + pub(crate) fn with_external_api_audit_request_id( + mut self, + request_id: Option, + ) -> Self { + self.external_api_audit_request_id = request_id; + self + } } pub(crate) struct ParsedPuzzleImageDataUrl { @@ -177,6 +191,9 @@ pub(crate) fn require_puzzle_vector_engine_settings( api_key: api_key.to_string(), request_timeout_ms: state.vector_engine_image_request_timeout_ms().max(1), external_api_audit_state: Some(state.root_state().clone()), + external_api_audit_user_id: None, + external_api_audit_profile_id: None, + external_api_audit_request_id: None, }) } diff --git a/server-rs/crates/api-server/src/square_hole/visual_assets.rs b/server-rs/crates/api-server/src/square_hole/visual_assets.rs index 1a8670be..9f6dff5f 100644 --- a/server-rs/crates/api-server/src/square_hole/visual_assets.rs +++ b/server-rs/crates/api-server/src/square_hole/visual_assets.rs @@ -389,7 +389,10 @@ async fn generate_square_hole_image_data_url( size: &str, failure_context: &str, ) -> Result { - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state)?.with_external_api_audit_context( + Some(owner_user_id.to_string()), + Some(profile_id.to_string()), + ); let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client,