From f732555a44d3267903a207a4106ad4c6ef58d8b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 15:16:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BATripo=E6=8F=90=E4=BA=A4=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E8=A1=A5=E5=BA=94=E7=94=A8=E7=BA=A7=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 走真实 build_router 断言两个端点未鉴权一律 401,路径写错或漏 merge 会退化成 404 - 断言缺 Idempotency-Key 在解析之后、组合校验与定价之前返回 400 - 断言贴图档位冲突与定价必填字段缺失都在定价与 provider 副作用之前返回 400 并定位字段 - 实施计划进展补记该组用例与当前全量测试通过数 --- ...Tripo生成Worker执行链路与API路由-2026-09-21.md | 3 +- .../crates/api-server/src/tripo3d/routes.rs | 194 +++++++++++++++++- 2 files changed, 195 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md b/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md index 8c835ccd4..405eff0b6 100644 --- a/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md +++ b/docs/project-memory/plans/【实施计划】Tripo生成Worker执行链路与API路由-2026-09-21.md @@ -71,9 +71,10 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】Tripo生成Worker执 步骤 1 至 6 已落地:配置与路由、组合校验、定价、入队、图片输入解析、产物 OSS 写入与 worker 执行链路均已提交;步骤 7 的接线(`app.rs` merge、worker 分支、长任务超时)与文档同步也已完成。尚未完成的是需要真实环境的项: - 图片输入在提交时已补元数据预检(`tripo3d::image_source::preflight_image_source`):按 `source.kind` 分支配对记录类型并复用既有定点引用查询 `resolve_editor_reference_record_by_id_for_owner`,取代原先“列工程 / 列素材库再筛”的宽查询;跨 owner、未登记、已删除与 `kind` 不符都在扣费与入队前返回 400,worker 执行时再重新确认同一事实。 +- 提交路由已补应用级验收用例:走真实 `build_router` 断言两个端点未鉴权 401、缺 `Idempotency-Key` 400、组合校验与定价必填字段冲突在定价与 provider 之前 400,因此路由 merge、鉴权中间件与校验顺序都有可执行证据,不依赖 provider 与 SpacetimeDB 环境。 - 默认配置里的 `model3d` 泥点定价数值仍未确认,缺段时提交按 fail closed 拒绝。 -- 没有跑过端到端真实调用(提交 → worker → OSS → 资源登记),现有证据只到 `cargo test -p api-server` 全绿(1085 通过)。 +- 没有跑过端到端真实调用(提交 → worker → OSS → 资源登记),现有证据只到 `cargo test -p api-server` 全绿(1089 通过)与应用级路由验收用例。 - 新增的 `tripo3d/` 文件里,`storage.rs` 的完整字节写入、`artifacts.rs` 的完整字节读取都留着流式 TODO。 ## 验证命令 diff --git a/server-rs/crates/api-server/src/tripo3d/routes.rs b/server-rs/crates/api-server/src/tripo3d/routes.rs index e1a98e862..be6f6f83e 100644 --- a/server-rs/crates/api-server/src/tripo3d/routes.rs +++ b/server-rs/crates/api-server/src/tripo3d/routes.rs @@ -200,7 +200,18 @@ fn require_idempotency_key(headers: &HeaderMap) -> Result<&str, Response> { #[cfg(test)] mod tests { use super::*; - use axum::http::HeaderValue; + use axum::{ + body::Body, + http::{HeaderValue, Request}, + }; + use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; + use time::OffsetDateTime; + use tower::ServiceExt; + + use crate::{app::build_router, config::AppConfig, state::AppState}; #[test] fn idempotency_key_is_required_and_validated() { @@ -222,4 +233,185 @@ mod tests { spaced.insert("idempotency-key", HeaderValue::from_static("a b")); assert!(require_idempotency_key(&spaced).is_err()); } + + /// 两个路由必须真的挂在应用路由上并走统一鉴权:路径写错或漏 merge 会退化成 404。 + #[tokio::test] + async fn tripo_submit_routes_require_bearer_auth() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + for route in [TEXT_TO_MODEL_ROUTE, IMAGE_TO_MODEL_ROUTE] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(route) + .header("content-type", "application/json") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{route} 未鉴权时必须 401" + ); + } + } + + /// 缺 `Idempotency-Key` 必须在任何查询与扣费之前拦下,且不产生 operation。 + #[tokio::test] + async fn image_to_model_requires_idempotency_key_before_any_work() { + let (state, token) = authenticated_state().await; + let response = post_json( + &state, + &token, + IMAGE_TO_MODEL_ROUTE, + None, + valid_image_body(), + ) + .await; + + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert!( + response.1.contains("Idempotency-Key"), + "错误文案必须点名缺失的请求头:{}", + response.1 + ); + } + + /// 组合校验前置于定价与 provider:贴图档位与 `texture=false` 冲突时直接 400。 + #[tokio::test] + async fn text_to_model_rejects_param_composition_before_pricing() { + let (state, token) = authenticated_state().await; + let mut body = valid_body(); + body["generation"]["texture"] = json!(false); + body["generation"]["textureQuality"] = json!("standard"); + + let response = post_json(&state, &token, TEXT_TO_MODEL_ROUTE, Some("issue-1"), body).await; + + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert!( + response.1.contains("generation.textureQuality"), + "错误必须定位到冲突字段:{}", + response.1 + ); + } + + /// 组合校验前置于定价与 provider:定价参数缺省时报错并定位到缺失字段。 + #[tokio::test] + async fn text_to_model_rejects_missing_pricing_params_before_pricing() { + let (state, token) = authenticated_state().await; + let mut body = valid_body(); + body["generation"] + .as_object_mut() + .expect("generation 应为对象") + .remove("quad"); + + let response = post_json(&state, &token, TEXT_TO_MODEL_ROUTE, Some("issue-2"), body).await; + + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert!( + response.1.contains("generation.quad"), + "错误必须定位到缺失字段:{}", + response.1 + ); + } + + /// 可解析、可校验的 spec 形状请求体;只在进入组合校验之前使用。 + fn valid_body() -> serde_json::Value { + json!({ + "generation": { + "prompt": "一只木箱", + "model": "v3.1-20260211", + "texture": true, + "textureQuality": "standard", + "pbr": true, + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "测试" } + }) + } + + /// 图生 3D 的参数集与文生不同:没有 `prompt`,多了图片对齐与输入朝向。 + fn valid_image_body() -> serde_json::Value { + json!({ + "source": { "kind": "resource", "resourceId": "resource-1" }, + "generation": { + "model": "v3.1-20260211", + "texture": true, + "textureQuality": "standard", + "pbr": true, + "geometryQuality": "standard", + "quad": false, + "smartLowPoly": false, + "generateParts": false + }, + "target": { "kind": "assetLibrary", "folderId": "folder-1", "label": "测试" } + }) + } + + async fn authenticated_state() -> (AppState, String) { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let user_id = state + .seed_test_phone_user_with_password("13800138121", "secret123") + .await + .id; + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: user_id.clone(), + session_id: state.seed_test_refresh_session_for_user_id(&user_id, "sess_tripo"), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 2, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some("3D 用户".to_string()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign"); + (state, token) + } + + async fn post_json( + state: &AppState, + token: &str, + route: &str, + idempotency_key: Option<&str>, + body: serde_json::Value, + ) -> (StatusCode, String) { + let mut request = Request::builder() + .method("POST") + .uri(route) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "v1"); + if let Some(key) = idempotency_key { + request = request.header("idempotency-key", key); + } + let response = build_router(state.clone()) + .oneshot( + request + .body(Body::from(body.to_string())) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + (status, String::from_utf8_lossy(&bytes).into_owned()) + } }