清理后端未使用代码及废弃测试
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m49s
Project CI / Backend tests (pull_request) Successful in 3m38s
Project CI / Frontend tests (pull_request) Successful in 1m47s
Project CI / Native shell tests (pull_request) Successful in 5m40s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m17s
Project CI / Repository checks (pull_request) Successful in 1m48s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m18s
Project CI / AI game creator shell Rust crates (push) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m52s
Project CI / Backend tests (push) Successful in 3m41s
Project CI / Frontend tests (push) Successful in 1m54s
Project CI / Native shell tests (push) Successful in 5m39s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m42s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m30s
Project CI / Repository checks (push) Successful in 2m7s
Project CI / AI game creator shell web tests (push) Successful in 1m35s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m49s
Project CI / Backend tests (pull_request) Successful in 3m38s
Project CI / Frontend tests (pull_request) Successful in 1m47s
Project CI / Native shell tests (pull_request) Successful in 5m40s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m17s
Project CI / Repository checks (pull_request) Successful in 1m48s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m18s
Project CI / AI game creator shell Rust crates (push) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m52s
Project CI / Backend tests (push) Successful in 3m41s
Project CI / Frontend tests (push) Successful in 1m54s
Project CI / Native shell tests (push) Successful in 5m39s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m42s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m30s
Project CI / Repository checks (push) Successful in 2m7s
Project CI / AI game creator shell web tests (push) Successful in 1m35s
删除编辑器旧分段持久化、生成任务收尾和音频发布链路 删除未使用辅助函数及其专属测试,修正受影响的现有测试边界 保留现役实现,将纯退款测试辅助函数移入测试模块 同步原子提交、角色生成文档和计费验证说明 验证:API 全目标编译零警告,格式、编码和 diff 检查通过 定向测试:391 项通过,11 项退款 outbox 测试失败,含 Windows 权限错误
This commit was merged in pull request #486.
This commit is contained in:
@@ -1123,7 +1123,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
- 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。
|
||||
- 原因:前端展示价和后端价格计算只证明价格能被展示 / 解析;如果 handler 没有包进 `execute_billable_asset_operation_with_cost`,或异步音频发布目标没有携带本次模型价格,外部 provider 仍会被调用但不会真实扣费。
|
||||
- 处理:新增或改造编辑器外部生成入口时,确认前端请求不携带 `priceMudPoints`,后端按运行时模型定价重新计算价格,并用该价格进入资产扣费 wrapper。音频提交 / 发布分离时,把后端计算出的价格写入 `AudioAssetBindingTarget.billing_points_cost`。
|
||||
- 验证:结构性测试覆盖对应 handler 包含 `execute_billable_asset_operation_with_cost` 和价格变量;音频测试覆盖 `resolve_creation_audio_points_cost` 优先读取 editor target 的 `billing_points_cost`。
|
||||
- 验证:结构性测试覆盖对应 handler 包含 `execute_billable_asset_operation_with_cost` 和价格变量;`worker_billing_context_freezes_charge_and_preserves_job_metadata` 与 `logged_in_background_music_queue_preparation_uses_canonical_prompt_and_frozen_price` 覆盖现役队列冻结价格,原子计费测试覆盖提交失败与结果未知时的退款边界。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/api-server/src/vector_engine_audio_generation/`、`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`。
|
||||
|
||||
## 本地 dev 启动日志先看成功锚点,不要把非阻断 warning 当失败
|
||||
|
||||
@@ -77,6 +77,7 @@ EditorGenerationResultPersistInput {
|
||||
|
||||
## api-server 接入
|
||||
|
||||
- 旧分段持久化、worker 独立完成及其重复结果序列化实现,在失去现役调用方后直接删除;不保留只供旧测试调用的生产副本。废弃实现的专属测试随实现删除,不因清理而将旧用例改接到现役函数;现有现役行为测试保持原有覆盖。手动拆分、上传等现役路径使用的底层 helper 继续保留,HTTP / DTO、inline 模式和历史任务解析兼容不受清理影响。
|
||||
- 通用持久化改为 `prepare -> build canvas candidate -> atomic commit`。prepare 阶段只生成稳定 ID、上传/验证对象和构造候选 DTO,不创建 resource/asset。
|
||||
- api-server 继续复用现有画布 completion / replacement 逻辑计算候选 `layers_json` 和 `expected_revision`;统一 procedure 在最终事务内重新执行既有 layout 校验和 CAS。
|
||||
- CAS 冲突只刷新当前 project、重新计算 layout 并重试 prepared commit;相同 operation、slot、对象和记录候选保持不变,禁止重跑 Provider。
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
- 角色面板增加紧凑的 `像素艺术` 勾选项,请求使用可选字符串字段 `style`:未勾选传 `"none"`,勾选传 `"pixelArt"`。该选择可以随现有生成器快照和队列 payload 保存,但不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。
|
||||
- `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 在 `kind="character"` 时启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。同一图片生成请求 DTO 被其它 `kind` 复用时,只有普通图片和 `character` 支持 `"pixelArt"`,其它 `kind` 收到该值也按不支持风格降级。
|
||||
- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。角色链路使用「角色主体为像素风格」,**不得**使用「画面为像素风格」——角色生成后要按纯色抠像,绿幕底必须保持平整,同一段提示词里已写死「纯色背景必须平整无纹理、无渐变」,画面级像素化要求会与之互相拆台;且该提示词已禁止出现角色以外的场景内容,因此只需点名角色本身。注入发生在 `build_editor_character_image_prompt` 返回之后,该函数签名和输出契约不变。约束句**不会**进入角色链路的任何 `editor_project_resource`:原图 resource 的 prompt 列存的是 `role_setting`(用户原文),透明结果的 `output_prompt` 在抠图成功后被无条件覆盖为 `"去除纯色背景"`;完整提交提示词是否留存取决于 provider:`persist_editor_provider_source_image` 写原图 asset object 元数据时用的是 `actual_prompt.unwrap_or(prompt)`,provider 未回 `actualPrompt` 时才存 `submitted_prompt`(含约束句),此时排障可按 `object_key` 查;provider 回了 `actualPrompt` 就存 provider 改写后的文本,该次生成的 `submitted_prompt` 在系统内一处都不落——外部 API 审计的 `request_payload` 只记 `promptChars` 字符数,没有提示词原文。响应体返回的是用户原文,前端显示不变。以上 prompt 列写入与 asset object 元数据规则都是既有行为,与 `web/master` 逐行一致,本次未改动。角色提示词里既有的「严格基于图1的角色美术视觉规范的美术风格」与像素约束存在潜在冲突,本次未改写,等实测。
|
||||
- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。角色链路使用「角色主体为像素风格」,**不得**使用「画面为像素风格」——角色生成后要按纯色抠像,绿幕底必须保持平整,同一段提示词里已写死「纯色背景必须平整无纹理、无渐变」,画面级像素化要求会与之互相拆台;且该提示词已禁止出现角色以外的场景内容,因此只需点名角色本身。注入发生在 `build_editor_character_image_prompt` 返回之后,该函数签名和输出契约不变。约束句**不会**进入角色链路的任何 `editor_project_resource`:原图 resource 的 prompt 列存的是 `role_setting`(用户原文),透明结果的 `output_prompt` 在抠图成功后被无条件覆盖为 `"去除纯色背景"`;原图 asset object 元数据由现役 `prepare_editor_generated_image` / upload-only helper 按传入的 `prompt` 构造,resource / asset 的 `actualPrompt` 由原子提交候选单独保存;排障应核对现役调用参数与记录,不能再按已退役的 provider-source 分步持久化函数推断提示词来源。角色提示词里既有的「严格基于图1的角色美术视觉规范的美术风格」与像素约束存在潜在冲突,本次未改写,等实测。
|
||||
- 角色 provider 回图先按统一业务像素矩阵执行交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。归一后的带纯色背景图先持久化并作为 BgFilter 输入;BgFilter 正常成功后,把 Alpha 蒙版回贴到这张同尺寸平底原图,再执行像素规整并上传透明主图。网格分析源使用已收口到实际交付尺寸的平底原图,RGBA 采样源使用 Alpha 已回贴的透明图;软 Alpha 只参与单格覆盖率和 Alpha 加权 RGB 计算,输出 Alpha 硬化为 `0 / 255`。
|
||||
- 首版参数固定为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格覆盖率 `Σ(A / 255) / N >= 0.375` 且 `ΣA > 0` 时输出 `A=255`,颜色按 `Σ(A × RGB) / ΣA` 计算;否则输出 `[0,0,0,0]`。分析色数不限制最终输出色数。
|
||||
- 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级。
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
http::{HeaderValue, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
#[cfg(test)]
|
||||
@@ -41,30 +32,6 @@ where
|
||||
Json(serde_json::to_value(data).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn json_success_data_bytes_response(
|
||||
request_context: Option<&RequestContext>,
|
||||
data_json: Bytes,
|
||||
) -> Response {
|
||||
if let Some(context) = request_context
|
||||
&& context.wants_envelope()
|
||||
{
|
||||
let meta = serde_json::to_vec(&build_api_response_meta(Some(context)))
|
||||
.map(Bytes::from)
|
||||
.unwrap_or_else(|_| Bytes::from_static(b"null"));
|
||||
let chunks = [
|
||||
Bytes::from_static(b"{\"ok\":true,\"data\":"),
|
||||
data_json,
|
||||
Bytes::from_static(b",\"error\":null,\"meta\":"),
|
||||
meta,
|
||||
Bytes::from_static(b"}"),
|
||||
];
|
||||
let stream = stream::iter(chunks.into_iter().map(Ok::<Bytes, Infallible>));
|
||||
return json_body_response(Body::from_stream(stream));
|
||||
}
|
||||
|
||||
json_bytes_response(data_json)
|
||||
}
|
||||
|
||||
pub fn json_error_body(
|
||||
request_context: Option<&RequestContext>,
|
||||
error: &ApiErrorPayload,
|
||||
@@ -98,19 +65,6 @@ fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiRespo
|
||||
)
|
||||
}
|
||||
|
||||
fn json_bytes_response(bytes: Bytes) -> Response {
|
||||
json_body_response(Body::from(bytes))
|
||||
}
|
||||
|
||||
fn json_body_response(body: Body) -> Response {
|
||||
let mut response = body.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -152,31 +106,6 @@ mod tests {
|
||||
assert!(body.get("meta").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn success_response_streams_cached_data_inside_standard_envelope() {
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
let request_context = build_request_context(true);
|
||||
let response = json_success_data_bytes_response(
|
||||
Some(&request_context),
|
||||
Bytes::from_static(br#"{"items":[]}"#),
|
||||
);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value = serde_json::from_slice(&body).expect("body should be json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(payload["data"]["items"], Value::Array(Vec::new()));
|
||||
assert_eq!(
|
||||
payload["meta"]["requestId"],
|
||||
Value::String("req-test".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_body_returns_legacy_shape_without_envelope_header() {
|
||||
let request_context = build_request_context(false);
|
||||
|
||||
@@ -256,7 +256,6 @@ mod tests {
|
||||
use super::{build_router, build_spacetime_unavailable_router};
|
||||
|
||||
const TEST_PASSWORD: &str = "secret123";
|
||||
const INTERNAL_TEST_SECRET: &str = "test-internal-secret";
|
||||
|
||||
/// 需 scripts/agc-analytics-smoke.mjs 创建的隔离数据库;认证用户为测试夹具,
|
||||
/// HTTP、facade、数据库事务和后台查询均走正式实现。
|
||||
@@ -1120,25 +1119,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(outbox_dir);
|
||||
}
|
||||
|
||||
#[cfg(any())]
|
||||
fn build_internal_creative_agent_app() -> Router {
|
||||
let mut config = AppConfig::default();
|
||||
config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string());
|
||||
build_router(AppState::new(config).expect("state should build"))
|
||||
}
|
||||
|
||||
#[cfg(any())]
|
||||
fn internal_creative_agent_request(method: &str, uri: &str, body: Value) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json")
|
||||
.header("x-genarrative-authenticated-user-id", "user-creative-test")
|
||||
.header("x-genarrative-internal-api-secret", INTERNAL_TEST_SECRET)
|
||||
.body(Body::from(body.to_string()))
|
||||
.expect("creative agent request should build")
|
||||
}
|
||||
|
||||
async fn read_json_response(response: axum::response::Response) -> Value {
|
||||
let body = response
|
||||
.into_body()
|
||||
@@ -1149,16 +1129,6 @@ mod tests {
|
||||
serde_json::from_slice(&body).expect("response body should be valid json")
|
||||
}
|
||||
|
||||
async fn read_text_response(response: axum::response::Response) -> String {
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
String::from_utf8(body.to_vec()).expect("response body should be utf8")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_legacy_compatible_payload_and_headers() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
@@ -1998,247 +1968,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any())]
|
||||
#[tokio::test]
|
||||
async fn creative_agent_draft_edit_rejects_unconfirmed_template_session() {
|
||||
let app = build_internal_creative_agent_app();
|
||||
|
||||
let create_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
"/api/runtime/creative-agent/sessions",
|
||||
serde_json::json!({
|
||||
"text": "做一个生日拼图",
|
||||
"entryContext": "creation_home"
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("create session request should succeed");
|
||||
assert_eq!(create_response.status(), StatusCode::OK);
|
||||
let create_payload = read_json_response(create_response).await;
|
||||
let session_id = create_payload["session"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id should exist");
|
||||
|
||||
let edit_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream"),
|
||||
serde_json::json!({
|
||||
"clientMessageId": "creative-edit-test",
|
||||
"instruction": "把标题改轻松一点",
|
||||
"targetPuzzleSessionId": "puzzle-session-unconfirmed",
|
||||
"currentDraft": {
|
||||
"workTitle": "旧标题",
|
||||
"workDescription": "旧描述",
|
||||
"summary": "旧描述",
|
||||
"themeTags": ["创意", "拼图", "灵感"],
|
||||
"levels": [{
|
||||
"levelId": "puzzle-level-1",
|
||||
"levelName": "第一关",
|
||||
"pictureDescription": "旧图面",
|
||||
"pictureReference": null,
|
||||
"generationStatus": "idle",
|
||||
"candidates": []
|
||||
}]
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("draft edit request should be handled");
|
||||
|
||||
assert_eq!(edit_response.status(), StatusCode::BAD_REQUEST);
|
||||
let edit_payload = read_json_response(edit_response).await;
|
||||
assert_eq!(
|
||||
edit_payload["error"]["details"]["message"],
|
||||
Value::String("尚未绑定拼图草稿".to_string())
|
||||
);
|
||||
|
||||
let session_response = app
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"GET",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
|
||||
Value::Null,
|
||||
))
|
||||
.await
|
||||
.expect("get session request should succeed");
|
||||
let session_payload = read_json_response(session_response).await;
|
||||
assert_eq!(session_payload["session"]["targetBinding"], Value::Null);
|
||||
}
|
||||
|
||||
#[cfg(any())]
|
||||
#[tokio::test]
|
||||
async fn creative_agent_message_stream_returns_template_confirmation_events() {
|
||||
let app = build_internal_creative_agent_app();
|
||||
|
||||
let create_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
"/api/runtime/creative-agent/sessions",
|
||||
serde_json::json!({
|
||||
"text": "做一个生日拼图",
|
||||
"entryContext": "creation_home"
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("create session request should succeed");
|
||||
assert_eq!(create_response.status(), StatusCode::OK);
|
||||
let create_payload = read_json_response(create_response).await;
|
||||
let session_id = create_payload["session"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id should exist");
|
||||
|
||||
let stream_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}/messages/stream"),
|
||||
serde_json::json!({
|
||||
"clientMessageId": "creative-message-stream-test",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "做一个温暖的生日拼图"
|
||||
}]
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("message stream request should be handled");
|
||||
|
||||
assert_eq!(stream_response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
stream_response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("text/event-stream")
|
||||
);
|
||||
let stream_body = read_text_response(stream_response).await;
|
||||
|
||||
assert!(stream_body.contains("event: stage"));
|
||||
assert!(stream_body.contains("event: tool_started"));
|
||||
assert!(stream_body.contains("event: tool_completed"));
|
||||
assert!(stream_body.contains("event: puzzle_template_catalog"));
|
||||
assert!(!stream_body.contains("event: puzzle_template_selection"));
|
||||
assert!(!stream_body.contains("event: puzzle_cost_range"));
|
||||
assert!(stream_body.contains("event: done"));
|
||||
let tool_started_id = stream_body
|
||||
.lines()
|
||||
.skip_while(|line| *line != "event: tool_started")
|
||||
.nth(1)
|
||||
.and_then(|line| line.strip_prefix("data: "))
|
||||
.and_then(|data| serde_json::from_str::<Value>(data).ok())
|
||||
.and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string))
|
||||
.expect("tool_started should include toolCallId");
|
||||
let tool_completed_id = stream_body
|
||||
.lines()
|
||||
.skip_while(|line| *line != "event: tool_completed")
|
||||
.nth(1)
|
||||
.and_then(|line| line.strip_prefix("data: "))
|
||||
.and_then(|data| serde_json::from_str::<Value>(data).ok())
|
||||
.and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string))
|
||||
.expect("tool_completed should include toolCallId");
|
||||
assert_eq!(tool_started_id, tool_completed_id);
|
||||
|
||||
let session_response = app
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"GET",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
|
||||
Value::Null,
|
||||
))
|
||||
.await
|
||||
.expect("get session request should succeed");
|
||||
let session_payload = read_json_response(session_response).await;
|
||||
assert_eq!(
|
||||
session_payload["session"]["stage"],
|
||||
Value::String("waiting_template_confirmation".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
session_payload["session"]["puzzleTemplateSelection"],
|
||||
Value::Null
|
||||
);
|
||||
assert!(
|
||||
session_payload["session"]["puzzleTemplateCatalog"]
|
||||
.as_array()
|
||||
.map(|templates| templates.len() >= 3)
|
||||
.unwrap_or(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any())]
|
||||
#[tokio::test]
|
||||
async fn creative_agent_confirm_template_rejects_non_puzzle_template() {
|
||||
let app = build_internal_creative_agent_app();
|
||||
|
||||
let create_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
"/api/runtime/creative-agent/sessions",
|
||||
serde_json::json!({
|
||||
"text": "做一个角色扮演开场",
|
||||
"entryContext": "creation_home"
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("create session request should succeed");
|
||||
assert_eq!(create_response.status(), StatusCode::OK);
|
||||
let create_payload = read_json_response(create_response).await;
|
||||
let session_id = create_payload["session"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("session id should exist");
|
||||
|
||||
let confirm_response = app
|
||||
.clone()
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"POST",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}/confirm-template"),
|
||||
serde_json::json!({
|
||||
"selection": {
|
||||
"templateId": "rpg.unsupported",
|
||||
"title": "RPG",
|
||||
"reason": "用户想创建 RPG",
|
||||
"costRange": {
|
||||
"minPoints": 2,
|
||||
"maxPoints": 12,
|
||||
"pricingUnit": "point",
|
||||
"reason": "按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准"
|
||||
},
|
||||
"supportedLevelMode": "single_or_multi",
|
||||
"selectedLevelMode": "single_level",
|
||||
"plannedLevelCount": 1,
|
||||
"requiresUserConfirmation": true
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.expect("confirm template request should be handled");
|
||||
|
||||
assert_eq!(confirm_response.status(), StatusCode::BAD_REQUEST);
|
||||
let confirm_payload = read_json_response(confirm_response).await;
|
||||
assert_eq!(
|
||||
confirm_payload["error"]["details"]["provider"],
|
||||
Value::String("module-puzzle".to_string())
|
||||
);
|
||||
|
||||
let session_response = app
|
||||
.oneshot(internal_creative_agent_request(
|
||||
"GET",
|
||||
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
|
||||
Value::Null,
|
||||
))
|
||||
.await
|
||||
.expect("get session request should succeed");
|
||||
let session_payload = read_json_response(session_response).await;
|
||||
assert_eq!(
|
||||
session_payload["session"]["stage"],
|
||||
Value::String("idle".to_string())
|
||||
);
|
||||
assert_eq!(session_payload["session"]["targetBinding"], Value::Null);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_story_legacy_routes_are_not_mounted() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
@@ -13,8 +13,6 @@ use crate::{
|
||||
wallet_refund_outbox::{WalletRefundOutboxEnqueueOutcome, WalletRefundOutboxRecord},
|
||||
};
|
||||
|
||||
pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ExternalGenerationBillingContext {
|
||||
job_id: String,
|
||||
@@ -145,29 +143,7 @@ where
|
||||
.await
|
||||
}
|
||||
|
||||
/// 资产操作统一执行入口:业务层只声明操作类型与资源 ID,钱包扣退费由服务层收口。
|
||||
pub(crate) async fn execute_billable_asset_operation<T, Fut>(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
asset_kind: &str,
|
||||
asset_id: &str,
|
||||
operation: Fut,
|
||||
) -> Result<T, AppError>
|
||||
where
|
||||
Fut: Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
execute_billable_asset_operation_with_cost(
|
||||
state,
|
||||
owner_user_id,
|
||||
asset_kind,
|
||||
asset_id,
|
||||
ASSET_OPERATION_POINTS_COST,
|
||||
operation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 生图等特殊操作可声明独立泥点成本,避免修改全局资产操作默认价格。
|
||||
/// 资产操作统一执行入口:业务层声明后端计算的泥点成本,钱包扣退费由服务层收口。
|
||||
pub(crate) async fn execute_billable_asset_operation_with_cost<T, Fut>(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -706,25 +682,6 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn should_skip_asset_operation_billing_for_connectivity(
|
||||
error: &SpacetimeClientError,
|
||||
) -> bool {
|
||||
match error {
|
||||
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => true,
|
||||
SpacetimeClientError::Build(message)
|
||||
| SpacetimeClientError::Procedure(message)
|
||||
| SpacetimeClientError::Runtime(message) => {
|
||||
message.contains("503")
|
||||
|| message.contains("Service Unavailable")
|
||||
|| message.contains("Failed to connect")
|
||||
|| message.contains("WebSocket")
|
||||
|| message.contains("No such procedure")
|
||||
|| message.contains("连接已断开")
|
||||
|| message.contains("连接在返回结果前已断开")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_use_wallet_refund_emergency_spool(error: &SpacetimeClientError) -> bool {
|
||||
match error {
|
||||
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => true,
|
||||
@@ -875,27 +832,6 @@ mod tests {
|
||||
assert_eq!(wallet.balance_delta, -37);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_operation_connectivity_errors_are_classified_for_non_billing_fallbacks() {
|
||||
assert_eq!(ASSET_OPERATION_POINTS_COST, 1);
|
||||
assert!(should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::ConnectDropped
|
||||
));
|
||||
assert!(should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::Runtime(
|
||||
"Failed to connect: HTTP error: 503 Service Unavailable".to_string(),
|
||||
),
|
||||
));
|
||||
assert!(should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::Procedure(
|
||||
"No such procedure: consume_profile_wallet_points_and_return".to_string(),
|
||||
),
|
||||
));
|
||||
assert!(!should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::Procedure("泥点余额不足".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_refund_emergency_spool_requires_database_unavailability() {
|
||||
assert!(should_use_wallet_refund_emergency_spool(
|
||||
|
||||
@@ -298,13 +298,6 @@ pub enum ExternalGenerationMode {
|
||||
}
|
||||
|
||||
impl ExternalGenerationMode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inline => "inline",
|
||||
Self::Queue => "queue",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_inline(self) -> bool {
|
||||
matches!(self, Self::Inline)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,3 @@ pub use api::{
|
||||
create_editor_agent_conversation, delete_editor_agent_conversation,
|
||||
get_editor_agent_conversation, list_editor_agent_conversations,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use reconcile::reconcile_completed_editor_agent_tool_call_for_test;
|
||||
|
||||
@@ -226,15 +226,6 @@ fn reconcile_completed_editor_agent_tool_call(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reconcile_completed_editor_agent_tool_call_for_test(
|
||||
message: &mut EditorAgentMessage,
|
||||
result_payload_json: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
reconcile_completed_editor_agent_tool_call(message, result_payload_json)
|
||||
.map_err(|error| format!("{error:?}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -146,17 +146,6 @@ fn editor_screen_background_color_prompt(color: EditorScreenBackgroundColor) ->
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_green_screen_asset_prompt_clause(
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
format!(
|
||||
"背景必须是{},且{},方便扣除背景;{}",
|
||||
editor_screen_background_color_prompt(color),
|
||||
EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS,
|
||||
EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_green_screen_character_prompt_clause(
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1249,7 +1249,6 @@ pub(crate) async fn prepare_editor_spritesheet_slices_for_generation(
|
||||
Some(upload.asset_object),
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: input.project_id.clone(),
|
||||
owner_user_id: caller.owner_user_id.clone(),
|
||||
folder_id: input.asset_folder_id.clone(),
|
||||
label: upload.name.clone(),
|
||||
image_src: upload.image_src.clone(),
|
||||
@@ -1830,7 +1829,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
Some(source_persisted.asset_object),
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: payload.project_id.clone(),
|
||||
owner_user_id: caller.owner_user_id.clone(),
|
||||
folder_id: asset_folder_id.clone(),
|
||||
label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"),
|
||||
image_src: source_image_src.clone(),
|
||||
@@ -2066,7 +2064,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
Some(spritesheet_prepared_image.asset_object.clone()),
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: payload.project_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
folder_id: asset_folder_id.clone(),
|
||||
label: spritesheet_label,
|
||||
image_src: spritesheet_image_src.clone(),
|
||||
|
||||
@@ -1897,10 +1897,6 @@ fn extract_router_token_info(
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_router_token_id(payload: &Value, token_name: &str) -> Result<Option<String>, String> {
|
||||
extract_router_token_info(payload, token_name).map(|token| token.map(|token| token.id))
|
||||
}
|
||||
|
||||
fn provider_payload_data(value: &Value) -> &Value {
|
||||
value.get("data").unwrap_or(value)
|
||||
}
|
||||
@@ -2377,32 +2373,6 @@ mod tests {
|
||||
assert_eq!(payload["group"], "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_fixed_router_tokens_require_reconciliation() {
|
||||
let payload = json!({
|
||||
"data": [
|
||||
{"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER},
|
||||
{"id": 88, "name": LLM_ROUTER_TOKEN_IDENTIFIER}
|
||||
]
|
||||
});
|
||||
|
||||
let error = extract_router_token_id(&payload, LLM_ROUTER_TOKEN_IDENTIFIER)
|
||||
.expect_err("multiple fixed-name tokens must be rejected");
|
||||
assert!(error.contains("多个不同 token id"));
|
||||
|
||||
let duplicate_same_id = json!({
|
||||
"data": [
|
||||
{"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER},
|
||||
{"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER}
|
||||
]
|
||||
});
|
||||
assert_eq!(
|
||||
extract_router_token_id(&duplicate_same_id, LLM_ROUTER_TOKEN_IDENTIFIER)
|
||||
.expect("same token repeated in an envelope is not ambiguous"),
|
||||
Some("77".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_api_user_update_uses_numeric_id_and_fixed_group() {
|
||||
let payload = router_user_update_request(42, "router_abcd", "user_full-id");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -839,36 +839,6 @@ fn is_responses_terminal_sse_event(event: &str) -> bool {
|
||||
.is_some_and(|kind| matches!(kind.as_str(), "response.completed" | "response.incomplete"))
|
||||
}
|
||||
|
||||
const SSE_DONE_MARKER: &[u8] = b"data: [DONE]";
|
||||
|
||||
fn find_sse_done_marker(bytes: &[u8]) -> Option<usize> {
|
||||
bytes
|
||||
.windows(SSE_DONE_MARKER.len())
|
||||
.enumerate()
|
||||
.find_map(|(position, window)| {
|
||||
(window == SSE_DONE_MARKER && (position == 0 || bytes[position - 1] == b'\n'))
|
||||
.then_some(position)
|
||||
})
|
||||
}
|
||||
|
||||
fn find_sse_event_end(bytes: &[u8], event_start: usize) -> Option<usize> {
|
||||
let event = &bytes[event_start..];
|
||||
if let Some(offset) = event.windows(2).position(|window| window == b"\n\n") {
|
||||
return Some(event_start + offset + 2);
|
||||
}
|
||||
event
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|offset| event_start + offset + 4)
|
||||
}
|
||||
|
||||
fn sse_done_marker_suffix_len(bytes: &[u8]) -> usize {
|
||||
(1..SSE_DONE_MARKER.len())
|
||||
.rev()
|
||||
.find(|&length| bytes.ends_with(&SSE_DONE_MARKER[..length]))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn resolve_llm_router_client(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -1588,18 +1558,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_sse_done_marker_is_found_only_at_event_line_start() {
|
||||
let bytes = b"data: {\"text\":\"data: [DONE]\"}\n\ndata: [DONE]\n\n";
|
||||
let done_start = find_sse_done_marker(bytes).expect("done event should be found");
|
||||
assert_eq!(
|
||||
&bytes[done_start..done_start + SSE_DONE_MARKER.len()],
|
||||
SSE_DONE_MARKER
|
||||
);
|
||||
assert_eq!(find_sse_event_end(bytes, done_start), Some(bytes.len()));
|
||||
assert_eq!(sse_done_marker_suffix_len(b"data: [DON"), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_utf8_chunk_preserves_multibyte_characters_split_across_chunks() {
|
||||
let mut text = String::new();
|
||||
|
||||
@@ -2,7 +2,6 @@ use axum::http::StatusCode;
|
||||
use platform_image::{
|
||||
DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage,
|
||||
VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client,
|
||||
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
|
||||
create_vector_engine_image_edit_with_references_and_model,
|
||||
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
|
||||
create_vector_engine_nanobanana_generate_content,
|
||||
@@ -18,8 +17,7 @@ use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
external_api_audit::{
|
||||
ExternalApiFailureDraft, build_external_api_failure_draft_from_platform_image_audit,
|
||||
record_external_api_failure,
|
||||
build_external_api_failure_draft_from_platform_image_audit, record_external_api_failure,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
@@ -248,83 +246,6 @@ pub(crate) async fn create_openai_nanobanana_generate_content(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_openai_image_edit(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
reference_image: &OpenAiReferenceImage,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"size": size,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": 1,
|
||||
});
|
||||
let result = create_vector_engine_image_edit(
|
||||
http_client,
|
||||
&settings.provider_settings(),
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
reference_image,
|
||||
failure_context,
|
||||
)
|
||||
.await;
|
||||
map_platform_image_result(
|
||||
settings,
|
||||
result,
|
||||
"image_edit",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_openai_image_edit_with_references(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[OpenAiReferenceImage],
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"size": size,
|
||||
"candidateCount": candidate_count,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": reference_images.len(),
|
||||
});
|
||||
let result = create_vector_engine_image_edit_with_references(
|
||||
http_client,
|
||||
&settings.provider_settings(),
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
reference_images,
|
||||
failure_context,
|
||||
)
|
||||
.await;
|
||||
map_platform_image_result(
|
||||
settings,
|
||||
result,
|
||||
"image_edit_with_references",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn create_openai_image_edit_with_references_and_model(
|
||||
http_client: &reqwest::Client,
|
||||
@@ -386,16 +307,6 @@ pub(crate) fn build_openai_image_request_body(
|
||||
}
|
||||
|
||||
impl OpenAiImageSettings {
|
||||
pub(crate) fn with_external_api_audit_actor(
|
||||
mut self,
|
||||
user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
) -> 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_context(
|
||||
mut self,
|
||||
request_context: &RequestContext,
|
||||
@@ -501,14 +412,6 @@ async fn record_openai_image_failure_audit_if_configured(
|
||||
record_external_api_failure(state, draft).await;
|
||||
}
|
||||
|
||||
pub(crate) fn build_openai_image_failure_audit_draft(
|
||||
error: &PlatformImageError,
|
||||
) -> Option<ExternalApiFailureDraft> {
|
||||
error
|
||||
.audit()
|
||||
.map(build_external_api_failure_draft_from_platform_image_audit)
|
||||
}
|
||||
|
||||
pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
|
||||
let error = error.into_final_error();
|
||||
let status = match error.status_hint() {
|
||||
@@ -705,37 +608,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_engine_multi_reference_edit_rejects_empty_references() {
|
||||
let settings = OpenAiImageSettings {
|
||||
base_url: "https://vector.example".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
request_deadline: None,
|
||||
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();
|
||||
|
||||
let result = create_openai_image_edit_with_references(
|
||||
&http_client,
|
||||
&settings,
|
||||
"提示词",
|
||||
None,
|
||||
"1:1",
|
||||
1,
|
||||
&[],
|
||||
"测试图片编辑失败",
|
||||
)
|
||||
.await;
|
||||
|
||||
let error = result.expect_err("empty references should be rejected locally");
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
assert!(error.body_text().contains("缺少参考图"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_data_url_stays_provider_owned() {
|
||||
let source = format!(
|
||||
|
||||
@@ -6,40 +6,6 @@ use crate::{http_error::AppError, request_context::RequestContext};
|
||||
|
||||
use super::types::VECTOR_ENGINE_PROVIDER;
|
||||
|
||||
pub(super) fn normalize_limited_text(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
max_chars: usize,
|
||||
) -> Result<String, AppError> {
|
||||
let normalized = value.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"field": field,
|
||||
"message": format!("{field} 不能为空"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
if normalized.chars().count() > max_chars {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"field": field,
|
||||
"message": format!("{field} 超过 {} 字符", max_chars),
|
||||
})),
|
||||
);
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub(super) fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(super) fn map_asset_field_error(error: module_assets::AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
@@ -47,13 +13,6 @@ pub(super) fn map_asset_field_error(error: module_assets::AssetObjectFieldError)
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn map_spacetime_error(error: spacetime_client::SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn map_platform_audio_error(error: AudioError) -> AppError {
|
||||
let status = match error.status_hint() {
|
||||
AudioStatusHint::BadRequest => StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -571,7 +571,6 @@ impl SoundEffectWorkerStages for ProductionSoundEffectWorkerStages<'_> {
|
||||
Some(asset_object),
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: normalize_optional_string(self.project_id.clone()),
|
||||
owner_user_id: self.owner_user_id.to_string(),
|
||||
folder_id: self.asset_folder_id.clone(),
|
||||
label,
|
||||
image_src: input.persisted_audio.audio_src.clone(),
|
||||
@@ -1374,7 +1373,6 @@ async fn persist_editor_audio_generation(
|
||||
Some(input.generated.prepared.asset_object.clone()),
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: project_id.clone(),
|
||||
owner_user_id: caller.owner_user_id.clone(),
|
||||
folder_id: input.asset_folder_id.clone(),
|
||||
label,
|
||||
image_src: input.generated.audio_src.clone(),
|
||||
|
||||
@@ -12,18 +12,9 @@ use serde_json::json;
|
||||
use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppState};
|
||||
|
||||
use super::{
|
||||
clock::current_utc_micros,
|
||||
errors::map_asset_field_error,
|
||||
types::{AudioAssetBindingTarget, AudioAssetSlot},
|
||||
clock::current_utc_micros, errors::map_asset_field_error, types::AudioAssetBindingTarget,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct PersistedAudioAsset {
|
||||
pub(super) asset_object_id: String,
|
||||
pub(super) object_key: String,
|
||||
pub(super) audio_src: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct PreparedAudioAsset {
|
||||
pub(super) asset_object: AssetObjectUpsertInput,
|
||||
@@ -32,24 +23,6 @@ pub(super) struct PreparedAudioAsset {
|
||||
pub(super) audio_src: String,
|
||||
}
|
||||
|
||||
pub(super) async fn persist_generated_audio_asset(
|
||||
_state: &AppState,
|
||||
_http_client: &reqwest::Client,
|
||||
_owner_user_id: &str,
|
||||
_task_id: &str,
|
||||
_slot: AudioAssetSlot,
|
||||
_task_kind: platform_audio::AudioTaskKind,
|
||||
_target: AudioAssetBindingTarget,
|
||||
_audio: DownloadedAudio,
|
||||
) -> Result<PersistedAudioAsset, AppError> {
|
||||
Err(
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "editor-generation-operation",
|
||||
"message": "旧音频分步持久化路径已禁用;编辑器生成必须使用原子结果提交。",
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn prepare_generated_audio_asset(
|
||||
state: &AppState,
|
||||
|
||||
@@ -1,187 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use shared_contracts::creation_audio;
|
||||
|
||||
use crate::{
|
||||
asset_billing::execute_billable_asset_operation_with_cost, http_error::AppError,
|
||||
state::AppState,
|
||||
use super::types::{
|
||||
AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST,
|
||||
};
|
||||
|
||||
use super::{
|
||||
errors::{map_platform_audio_error, vector_engine_bad_gateway},
|
||||
persist::persist_generated_audio_asset,
|
||||
settings::require_vector_engine_audio_settings,
|
||||
types::{AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST},
|
||||
};
|
||||
|
||||
#[cfg(any())]
|
||||
pub(super) async fn publish_generated_audio_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
task_id: String,
|
||||
slot: AudioAssetSlot,
|
||||
target: AudioAssetBindingTarget,
|
||||
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
|
||||
publish_generated_audio_asset_with_task_kind(
|
||||
state,
|
||||
owner_user_id,
|
||||
task_id,
|
||||
slot,
|
||||
slot.task_kind(),
|
||||
target,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn publish_generated_audio_asset_with_task_kind(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
task_id: String,
|
||||
slot: AudioAssetSlot,
|
||||
task_kind: platform_audio::AudioTaskKind,
|
||||
target: AudioAssetBindingTarget,
|
||||
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
|
||||
let task_id = platform_audio::normalize_limited_text(&task_id, "taskId", 160)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let settings = require_vector_engine_audio_settings(state)?;
|
||||
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let (status, audio_urls): (String, Vec<String>) =
|
||||
platform_audio::resolve_audio_task_download_urls(
|
||||
&http_client,
|
||||
&settings,
|
||||
task_kind,
|
||||
&task_id,
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_audio_error)?;
|
||||
|
||||
if platform_audio::is_pending_task_status(&status) && audio_urls.is_empty() {
|
||||
return Ok(creation_audio::GeneratedAudioAssetResponse {
|
||||
kind: slot.creation_contract_kind(),
|
||||
task_id,
|
||||
provider: task_kind.provider().to_string(),
|
||||
status: status.clone(),
|
||||
asset_object_id: None,
|
||||
object_key: None,
|
||||
asset_kind: None,
|
||||
audio_src: None,
|
||||
});
|
||||
}
|
||||
|
||||
if platform_audio::is_failed_task_status(&status) {
|
||||
return Err(vector_engine_bad_gateway(
|
||||
"音频生成任务失败,请调整提示词后重试",
|
||||
));
|
||||
}
|
||||
|
||||
let audio_url = audio_urls
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| vector_engine_bad_gateway("音频生成尚未返回可下载地址"))?;
|
||||
let billing_asset_kind = target.asset_kind.clone();
|
||||
let billing_asset_id = build_audio_billing_asset_id(&task_id, slot, &target);
|
||||
let points_cost = resolve_creation_audio_points_cost(slot, &target);
|
||||
let persisted = execute_billable_asset_operation_with_cost(
|
||||
state,
|
||||
owner_user_id,
|
||||
billing_asset_kind.as_str(),
|
||||
billing_asset_id.as_str(),
|
||||
points_cost,
|
||||
async {
|
||||
let audio = platform_audio::download_generated_audio(
|
||||
&http_client,
|
||||
&audio_url,
|
||||
task_kind.provider(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_platform_audio_error)?;
|
||||
persist_generated_audio_asset(
|
||||
state,
|
||||
&http_client,
|
||||
owner_user_id,
|
||||
&task_id,
|
||||
slot,
|
||||
task_kind,
|
||||
target.clone(),
|
||||
audio,
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(creation_audio::GeneratedAudioAssetResponse {
|
||||
kind: slot.creation_contract_kind(),
|
||||
task_id,
|
||||
provider: task_kind.provider().to_string(),
|
||||
status: "completed".to_string(),
|
||||
asset_object_id: Some(persisted.asset_object_id),
|
||||
object_key: Some(persisted.object_key),
|
||||
asset_kind: Some(target.asset_kind),
|
||||
audio_src: Some(persisted.audio_src),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_generated_audio_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
task_id: String,
|
||||
slot: AudioAssetSlot,
|
||||
target: AudioAssetBindingTarget,
|
||||
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
|
||||
wait_for_generated_audio_asset_with_task_kind(
|
||||
state,
|
||||
owner_user_id,
|
||||
task_id,
|
||||
slot,
|
||||
slot.task_kind(),
|
||||
target,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_generated_audio_asset_with_task_kind(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
task_id: String,
|
||||
slot: AudioAssetSlot,
|
||||
task_kind: platform_audio::AudioTaskKind,
|
||||
target: AudioAssetBindingTarget,
|
||||
) -> Result<creation_audio::GeneratedAudioAssetResponse, AppError> {
|
||||
let mut latest_status = String::new();
|
||||
for _ in 0..40 {
|
||||
let response = publish_generated_audio_asset_with_task_kind(
|
||||
state,
|
||||
owner_user_id,
|
||||
task_id.clone(),
|
||||
slot,
|
||||
task_kind,
|
||||
target.clone(),
|
||||
)
|
||||
.await?;
|
||||
if response
|
||||
.audio_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
latest_status = response.status;
|
||||
tokio::time::sleep(Duration::from_millis(3_000)).await;
|
||||
}
|
||||
|
||||
Err(vector_engine_bad_gateway(format!(
|
||||
"音频生成超时:{}",
|
||||
if latest_status.trim().is_empty() {
|
||||
task_id
|
||||
} else {
|
||||
latest_status
|
||||
}
|
||||
)))
|
||||
}
|
||||
|
||||
pub(super) fn build_audio_billing_asset_id(
|
||||
task_id: &str,
|
||||
slot: AudioAssetSlot,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
use axum::http::StatusCode;
|
||||
use platform_oss::LegacyAssetPrefix;
|
||||
use serde_json::json;
|
||||
use shared_contracts::{creation_audio, visual_novel as contract};
|
||||
|
||||
use crate::http_error::AppError;
|
||||
|
||||
use super::{
|
||||
errors::{normalize_limited_text, normalize_optional_text},
|
||||
types::{AUDIO_ENTITY_KIND, AudioAssetBindingTarget, AudioAssetSlot, VECTOR_ENGINE_PROVIDER},
|
||||
};
|
||||
|
||||
pub(super) fn build_visual_novel_audio_target(
|
||||
payload: contract::PublishVisualNovelGeneratedAudioAssetRequest,
|
||||
slot: AudioAssetSlot,
|
||||
) -> Result<AudioAssetBindingTarget, AppError> {
|
||||
let entity_id = normalize_limited_text(&payload.scene_id, "sceneId", 160)?;
|
||||
Ok(AudioAssetBindingTarget {
|
||||
entity_kind: AUDIO_ENTITY_KIND.to_string(),
|
||||
entity_id,
|
||||
slot: slot.slot().to_string(),
|
||||
asset_kind: slot.asset_kind().to_string(),
|
||||
profile_id: normalize_optional_text(payload.profile_id.as_deref()),
|
||||
storage_prefix: LegacyAssetPrefix::CustomWorldScenes,
|
||||
storage_scope: "visual-novel".to_string(),
|
||||
billing_points_cost: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_creation_audio_target(
|
||||
payload: creation_audio::PublishGeneratedAudioAssetRequest,
|
||||
_slot: AudioAssetSlot,
|
||||
) -> Result<AudioAssetBindingTarget, AppError> {
|
||||
Err(creation_audio_generation_disabled_error_for_target(payload))
|
||||
}
|
||||
|
||||
pub(super) fn creation_audio_generation_disabled_error() -> AppError {
|
||||
AppError::from_status(StatusCode::GONE).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "当前创作音频目标未开放",
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn creation_audio_generation_disabled_error_for_target(
|
||||
payload: creation_audio::PublishGeneratedAudioAssetRequest,
|
||||
) -> AppError {
|
||||
creation_audio_generation_disabled_error().with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "当前创作音频目标未开放",
|
||||
"entityKind": payload.entity_kind.trim(),
|
||||
"slot": payload.slot.trim(),
|
||||
}))
|
||||
}
|
||||
@@ -3,16 +3,6 @@ use platform_oss::LegacyAssetPrefix;
|
||||
use shared_contracts::creation_audio;
|
||||
|
||||
pub(super) const VECTOR_ENGINE_PROVIDER: &str = platform_audio::VECTOR_ENGINE_PROVIDER;
|
||||
#[cfg(any())]
|
||||
pub(super) const AUDIO_ENTITY_KIND: &str = "visual_novel_scene";
|
||||
#[cfg(any())]
|
||||
pub(super) const MUSIC_ASSET_KIND: &str = "visual_novel_music";
|
||||
#[cfg(any())]
|
||||
pub(super) const AMBIENT_SOUND_ASSET_KIND: &str = "visual_novel_ambient_sound";
|
||||
#[cfg(any())]
|
||||
pub(super) const MUSIC_SLOT: &str = "music";
|
||||
#[cfg(any())]
|
||||
pub(super) const AMBIENT_SOUND_SLOT: &str = "ambient_sound";
|
||||
pub(super) const CREATION_BACKGROUND_MUSIC_POINTS_COST: u64 = 5;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -39,22 +29,6 @@ impl AudioAssetSlot {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any())]
|
||||
pub(super) fn asset_kind(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundMusic => MUSIC_ASSET_KIND,
|
||||
Self::SoundEffect => AMBIENT_SOUND_ASSET_KIND,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any())]
|
||||
pub(super) fn slot(self) -> &'static str {
|
||||
match self {
|
||||
Self::BackgroundMusic => MUSIC_SLOT,
|
||||
Self::SoundEffect => AMBIENT_SOUND_SLOT,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn file_stem(self) -> &'static str {
|
||||
self.task_kind().file_stem()
|
||||
}
|
||||
|
||||
@@ -678,15 +678,6 @@ fn ledger_id_hash(ledger_id: &str) -> String {
|
||||
hex::encode(Sha256::digest(ledger_id.as_bytes()))
|
||||
}
|
||||
|
||||
fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool {
|
||||
name.to_str().is_some_and(|value| {
|
||||
(value.starts_with(PENDING_FILE_PREFIX)
|
||||
|| value.starts_with(OVERFLOW_FILE_PREFIX)
|
||||
|| value.starts_with(TEMP_FILE_PREFIX))
|
||||
&& value.ends_with(OUTBOX_FILE_EXTENSION)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_capped_outbox_file_name(name: &std::ffi::OsStr) -> bool {
|
||||
name.to_str().is_some_and(|value| {
|
||||
((value.starts_with(PENDING_FILE_PREFIX) && !value.starts_with(OVERFLOW_FILE_PREFIX))
|
||||
@@ -718,6 +709,15 @@ async fn sync_directory_metadata(path: &Path) -> Result<(), WalletRefundOutboxEr
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool {
|
||||
name.to_str().is_some_and(|value| {
|
||||
(value.starts_with(PENDING_FILE_PREFIX)
|
||||
|| value.starts_with(OVERFLOW_FILE_PREFIX)
|
||||
|| value.starts_with(TEMP_FILE_PREFIX))
|
||||
&& value.ends_with(OUTBOX_FILE_EXTENSION)
|
||||
})
|
||||
}
|
||||
|
||||
fn sample_record(ledger_id: &str) -> WalletRefundOutboxRecord {
|
||||
WalletRefundOutboxRecord {
|
||||
owner_user_id: "user-1".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user