Files
Genarrative/server-rs/crates/api-server/src/modules/editor_project.rs
T
kdletters 5bf036bb81
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
收敛后端依赖装配与鉴权并完善异步追踪 (#425)
HTTP 请求取消后,在途计数原先无法释放;项目元数据和 External API 鉴权依赖完整 AppState,相同鉴权和追踪配置也散落在多个入口。本次集中装配这些依赖和横切能力,保持现有公开 API、权限、计费、幂等和事务规则。

## 修改

- 91 个受保护路由集中应用鉴权;保留方法级 404/405/HEAD/Allow、公开入口、MCP、精选缓存头及 2/4 MiB 请求限制。
- 七个项目元数据入口改用缓存的 EditorProjectState,External/MCP 鉴权改用 ExternalApiAuthState;生产实现复用 SpacetimeClient,媒体修复维持原有上传和登记顺序。
- RAII 覆盖请求 Future 取消和 panic unwind 的计数清理;正常与降级服务复用 TraceLayer,指标采用 MatchedPath 模板及固定兜底。
- LLM 普通与流式调用增加跳过参数的异步 span,保持父上下文、流式回调、错误与重试行为;补充替代依赖测试并同步锁文件和文档。

## 验证

| 验证面 | 结果 |
| --- | --- |
| platform-llm 完整本地回归 | 161 个单元测试、3 个集成测试通过;1 个真实 Provider 用例按原配置忽略 |
| api-server 完整回归 | 执行时 1075 通过、12 失败、6 忽略;其中 1 个新增公开读取 fixture 断言已修正,14 个路由契约回归随后全部通过;剩余 11 个是下述既有 Windows 失败 |
| 窄依赖、取消与追踪 | 元数据 owner/幂等/revision、鉴权及 MCP 错误传播、取消/panic/流式响应、追踪父子关系与敏感参数省略均通过 |
| 实际本地服务 | 独立 SpacetimeDB 上 102/102 检查通过,两个动态项目 ID 的路由模板及请求 ID 日志核验 3/3 通过 |
| 编译与边界 | api-server cargo check、AGC 锁文件下 platform-llm cargo check、rustfmt、编码、文档索引、DDD 与 diff 检查通过 |
| 合入最新 master 后 | 后端源码及锁文件保持已测内容;再次通过 14 个路由契约测试、3 个 Provider 追踪测试及编码/文档/DDD/diff 检查 |

实际服务检查覆盖 health/ready、两账号登录、项目 CRUD、幂等重复、跨 owner 拒绝、revision 冲突、External/MCP 读取、Key 撤销及 404/405。使用既有 test 环境的本地 Router 拒绝 fixture,未调用真实付费 Provider;自建服务已关闭,原开发实例保留。

## 已知测试限制

API 全量测试尚未全绿:11 个 wallet_refund_outbox 用例在 Windows 的目录同步处失败。其生产文件与变更前内容一致;标准库隔离复现确认 File::open(目录) 返回 OS 5,而普通文件写入、同步及 hard_link 正常。这个已有的目录持久化问题未混入本次重构,也未通过跳过或弱化相关断言掩盖。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/425
2026-09-19 12:29:19 +08:00

672 lines
24 KiB
Rust

use axum::{
Router,
extract::{DefaultBodyLimit, Request},
handler::Handler,
http::{HeaderValue, header},
middleware,
middleware::Next,
response::Response,
routing::{get, patch, post},
};
use crate::{
auth::require_bearer_auth,
editor_agent::api::editor_agent_message,
editor_agent::{
cancel_editor_agent_tool_call, confirm_editor_agent_tool_call,
create_editor_agent_conversation, delete_editor_agent_conversation,
get_editor_agent_conversation, list_editor_agent_conversations,
},
editor_project::{
EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES, create_editor_asset, create_editor_asset_folder,
create_editor_project, create_editor_project_resource, delete_editor_asset,
delete_editor_asset_folder, delete_editor_project, edit_editor_image,
extract_editor_ui_design_assets, generate_editor_image, generate_editor_scene,
get_editor_asset_library, get_editor_generation_pricing, get_editor_project,
list_editor_projects, list_public_editor_project_resources, load_recent_editor_project,
remove_editor_image_background, rename_editor_project, save_editor_project_layout,
snap_editor_image_to_pixel_art, submit_editor_asset_showcase,
toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder,
update_editor_project_resource_showcase,
},
editor_project_icon::{
generate_editor_icon_spritesheet, generate_icon_spec, split_editor_icon_spritesheet,
},
llm::icon_specs::{refine_art_style, refine_game_play},
state::AppState,
};
const EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024;
async fn add_showcase_private_response_headers(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
response
.headers_mut()
.append(header::VARY, HeaderValue::from_static("Authorization"));
response
}
pub fn router(state: AppState) -> Router<AppState> {
let auth = middleware::from_fn_with_state(state, require_bearer_auth);
let protected_routes = [
(
"/api/editor/projects/recent",
get(load_recent_editor_project),
),
(
"/api/editor/projects",
get(list_editor_projects).post(create_editor_project),
),
(
"/api/editor/projects/{project_id}",
get(get_editor_project)
.patch(
save_editor_project_layout
.layer(DefaultBodyLimit::max(EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES)),
)
.delete(delete_editor_project),
),
(
"/api/editor/projects/{project_id}/metadata",
patch(rename_editor_project),
),
(
"/api/editor/projects/{project_id}/resources",
post(create_editor_project_resource),
),
(
"/api/editor/projects/{project_id}/agent-conversations",
get(list_editor_agent_conversations).post(create_editor_agent_conversation),
),
(
"/api/editor/agent-conversations/{conversation_id}",
get(get_editor_agent_conversation).delete(delete_editor_agent_conversation),
),
(
"/api/editor/agent-conversations/{conversation_id}/messages",
post(editor_agent_message)
.layer(DefaultBodyLimit::max(EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES)),
),
(
"/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/confirm",
post(confirm_editor_agent_tool_call),
),
(
"/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/cancel",
post(cancel_editor_agent_tool_call),
),
(
"/api/editor/project-resources/{resource_id}/showcase",
patch(update_editor_project_resource_showcase),
),
("/api/editor/assets/library", get(get_editor_asset_library)),
(
"/api/editor/assets/folders",
post(create_editor_asset_folder),
),
(
"/api/editor/assets/folders/{folder_id}",
patch(update_editor_asset_folder).delete(delete_editor_asset_folder),
),
("/api/editor/assets", post(create_editor_asset)),
(
"/api/editor/assets/{asset_id}",
patch(update_editor_asset).delete(delete_editor_asset),
),
(
"/api/editor/assets/{asset_id}/showcase-submissions",
post(submit_editor_asset_showcase),
),
(
"/api/editor/images/generations",
post(generate_editor_image),
),
(
"/api/editor/scenes/generations",
post(generate_editor_scene),
),
(
"/api/editor/llm/icon-specs/refine-game-play",
post(refine_game_play),
),
(
"/api/editor/llm/icon-specs/refine-art-style",
post(refine_art_style),
),
(
"/api/editor/icon-specs/generations",
post(generate_icon_spec),
),
("/api/editor/images/edits", post(edit_editor_image)),
(
"/api/editor/images/background-removals",
post(remove_editor_image_background),
),
(
"/api/editor/images/pixel-art-snaps",
post(snap_editor_image_to_pixel_art),
),
(
"/api/editor/icon-spritesheets/generations",
post(generate_editor_icon_spritesheet),
),
(
"/api/editor/icon-spritesheets/slices",
post(split_editor_icon_spritesheet),
),
(
"/api/editor/ui-designs/assets/extractions",
post(extract_editor_ui_design_assets),
),
];
// 在方法层统一鉴权,未支持的方法仍由 Axum 返回 405。
let protected_router = protected_routes
.into_iter()
.fold(Router::new(), |router, (path, methods)| {
router.route(path, methods.route_layer(auth.clone()))
});
// 点赞拒绝响应也必须带私有缓存头,保持该层位于鉴权外侧。
let showcase_likes_router = Router::new().route(
"/api/editor/showcase/assets/{showcase_id}/likes",
post(toggle_editor_showcase_asset_like)
.route_layer(auth)
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
);
Router::new()
.route(
"/api/editor/generation-pricing",
get(get_editor_generation_pricing),
)
.route(
"/api/editor/showcase/resources",
get(list_public_editor_project_resources)
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
)
.merge(protected_router)
.merge(showcase_likes_router)
}
#[cfg(test)]
mod tests {
use axum::{
Router,
body::Body,
http::{Request, header},
middleware,
response::{IntoResponse, Response},
routing::get,
};
use tower::ServiceExt;
use super::add_showcase_private_response_headers;
async fn response_with_existing_vary() -> Response {
let mut response = "ok".into_response();
response
.headers_mut()
.insert(header::VARY, "Origin".parse().expect("valid Vary value"));
response
}
#[tokio::test]
async fn showcase_private_headers_preserve_existing_vary_fields() {
let app = Router::new().route(
"/",
get(response_with_existing_vary)
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
);
let response = app
.oneshot(
Request::builder()
.uri("/")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let vary_values = response
.headers()
.get_all(header::VARY)
.iter()
.map(|value| value.to_str().expect("Vary should be ASCII"))
.collect::<Vec<_>>();
assert_eq!(vary_values, vec!["Origin", "Authorization"]);
}
}
#[cfg(test)]
mod route_contract_tests {
use axum::{
body::Body,
http::{Request, StatusCode, header},
middleware,
};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::router;
use crate::{config::AppConfig, request_context::attach_request_context, state::AppState};
const PROTECTED_ROUTES: &[(&str, &[&str])] = &[
("/api/editor/projects/recent", &["GET"]),
("/api/editor/projects", &["GET", "POST"]),
(
"/api/editor/projects/{project_id}",
&["GET", "PATCH", "DELETE"],
),
("/api/editor/projects/{project_id}/metadata", &["PATCH"]),
("/api/editor/projects/{project_id}/resources", &["POST"]),
(
"/api/editor/projects/{project_id}/agent-conversations",
&["GET", "POST"],
),
(
"/api/editor/agent-conversations/{conversation_id}",
&["GET", "DELETE"],
),
(
"/api/editor/agent-conversations/{conversation_id}/messages",
&["POST"],
),
(
"/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/confirm",
&["POST"],
),
(
"/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/cancel",
&["POST"],
),
(
"/api/editor/project-resources/{resource_id}/showcase",
&["PATCH"],
),
("/api/editor/assets/library", &["GET"]),
("/api/editor/assets/folders", &["POST"]),
(
"/api/editor/assets/folders/{folder_id}",
&["PATCH", "DELETE"],
),
("/api/editor/assets", &["POST"]),
("/api/editor/assets/{asset_id}", &["PATCH", "DELETE"]),
(
"/api/editor/assets/{asset_id}/showcase-submissions",
&["POST"],
),
("/api/editor/showcase/assets/{showcase_id}/likes", &["POST"]),
("/api/editor/images/generations", &["POST"]),
("/api/editor/scenes/generations", &["POST"]),
("/api/editor/llm/icon-specs/refine-game-play", &["POST"]),
("/api/editor/llm/icon-specs/refine-art-style", &["POST"]),
("/api/editor/icon-specs/generations", &["POST"]),
("/api/editor/images/edits", &["POST"]),
("/api/editor/images/background-removals", &["POST"]),
("/api/editor/images/pixel-art-snaps", &["POST"]),
("/api/editor/icon-spritesheets/generations", &["POST"]),
("/api/editor/icon-spritesheets/slices", &["POST"]),
("/api/editor/ui-designs/assets/extractions", &["POST"]),
];
fn app() -> axum::Router {
let state = AppState::new(AppConfig::default()).expect("state should build");
router(state.clone())
.layer(middleware::from_fn(attach_request_context))
.with_state(state)
}
#[tokio::test]
async fn protected_route_matrix_keeps_auth_methods_and_head_behavior() {
let app = app();
for (template, supported) in PROTECTED_ROUTES {
let path = template
.split('/')
.map(|segment| {
if segment.starts_with('{') {
"fixture"
} else {
segment
}
})
.collect::<Vec<_>>()
.join("/");
let mut allowed = supported.to_vec();
if allowed.contains(&"GET") {
allowed.push("HEAD");
}
allowed.sort_unstable();
for credential in [None, Some("Bearer invalid-token")] {
for method in [
"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE",
] {
let mut request = Request::builder().method(method).uri(&path);
if let Some(credential) = credential {
request = request.header(header::AUTHORIZATION, credential);
}
let response = app
.clone()
.oneshot(request.body(Body::empty()).expect("request should build"))
.await
.expect("request should complete");
let expected = if allowed.contains(&method) {
StatusCode::UNAUTHORIZED
} else {
StatusCode::METHOD_NOT_ALLOWED
};
assert_eq!(
response.status(),
expected,
"{method} {path}, credential={credential:?}"
);
if expected == StatusCode::METHOD_NOT_ALLOWED {
let mut actual = response.headers()[header::ALLOW]
.to_str()
.expect("Allow should be ASCII")
.split(',')
.map(str::trim)
.collect::<Vec<_>>();
actual.sort_unstable();
assert_eq!(actual, allowed, "Allow for {method} {path}");
}
if method == "HEAD" {
assert!(
response
.into_body()
.collect()
.await
.expect("HEAD body should collect")
.to_bytes()
.is_empty(),
"HEAD {path} must not return a body"
);
}
}
}
}
}
#[tokio::test]
async fn unknown_paths_remain_not_found_before_authentication() {
for credential in [None, Some("Bearer invalid-token")] {
let mut request = Request::builder().uri("/api/editor/missing/unknown/route");
if let Some(credential) = credential {
request = request.header(header::AUTHORIZATION, credential);
}
let response = app()
.oneshot(request.body(Body::empty()).expect("request should build"))
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
#[tokio::test]
async fn public_reads_and_showcase_rejections_keep_cache_boundaries() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::time::Duration;
// 精选读取会访问正式 facade;隔离上游返回 503,明确验证匿名请求已进入 handler。
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("fixture should bind");
let address = listener
.local_addr()
.expect("fixture should have an address");
let calls = Arc::new(AtomicUsize::new(0));
let fixture_calls = calls.clone();
let fixture = axum::Router::new().fallback(move || {
fixture_calls.fetch_add(1, Ordering::SeqCst);
async { StatusCode::SERVICE_UNAVAILABLE }
});
let server = tokio::spawn(async move {
axum::serve(listener, fixture)
.await
.expect("fixture should serve");
});
let state = AppState::new(AppConfig {
spacetime_server_url: format!("http://{address}"),
spacetime_database: "route-showcase-unavailable-fixture".to_string(),
spacetime_procedure_timeout: Duration::from_secs(2),
..AppConfig::default()
})
.expect("state should build");
let app = router(state.clone())
.layer(middleware::from_fn(attach_request_context))
.with_state(state);
let pricing = app
.clone()
.oneshot(
Request::builder()
.uri("/api/editor/generation-pricing")
.body(Body::empty())
.expect("pricing request should build"),
)
.await
.expect("pricing request should complete");
assert_eq!(pricing.status(), StatusCode::OK);
assert_eq!(calls.load(Ordering::SeqCst), 0);
let response = tokio::time::timeout(
Duration::from_secs(5),
app.clone().oneshot(
Request::builder()
.uri("/api/editor/showcase/resources")
.body(Body::empty())
.expect("showcase request should build"),
),
)
.await
.expect("showcase request should remain bounded")
.expect("showcase request should complete");
server.abort();
let _ = server.await;
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert!(
calls.load(Ordering::SeqCst) > 0,
"anonymous request must reach the database facade"
);
assert_eq!(
response.headers()[header::CACHE_CONTROL],
"private, no-store"
);
assert!(
response
.headers()
.get_all(header::VARY)
.iter()
.any(|value| value == "Authorization")
);
let body = response
.into_body()
.collect()
.await
.expect("error body should collect")
.to_bytes();
let payload: serde_json::Value =
serde_json::from_slice(&body).expect("error should be JSON");
assert_eq!(payload["error"]["code"], "UPSTREAM_ERROR");
assert_eq!(payload["error"]["details"]["provider"], "spacetimedb");
assert!(
payload["error"]["details"]["message"]
.as_str()
.is_some_and(|message| message.contains("503"))
);
for (method, path) in [
("GET", "/api/editor/showcase/resources"),
("POST", "/api/editor/showcase/assets/fixture/likes"),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(path)
.header(header::AUTHORIZATION, "Bearer invalid-token")
.body(Body::empty())
.expect("showcase request should build"),
)
.await
.expect("showcase request should complete");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
response.headers()[header::CACHE_CONTROL],
"private, no-store"
);
assert!(
response
.headers()
.get_all(header::VARY)
.iter()
.any(|value| value == "Authorization")
);
}
}
#[tokio::test]
async fn authentication_precedes_layout_and_agent_body_limits() {
let app = app();
for (method, path, limit) in [
(
"PATCH",
"/api/editor/projects/fixture",
super::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES,
),
(
"POST",
"/api/editor/agent-conversations/fixture/messages",
super::EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES,
),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(path)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from("x".repeat(limit + 1)))
.expect("oversized request should build"),
)
.await
.expect("oversized request should complete");
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"{method} {path}"
);
}
}
#[tokio::test]
async fn authenticated_layout_and_agent_requests_keep_distinct_body_limits() {
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
sign_access_token,
};
let state = AppState::new(AppConfig::default()).expect("state should build");
let user = state
.seed_test_phone_user_with_password("13800138991", "secret123")
.await;
let session_id =
state.seed_test_refresh_session_for_user(&user, "session-route-body-limits");
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id.clone(),
session_id,
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some(user.display_name.clone()),
},
state.auth_jwt_config(),
time::OffsetDateTime::now_utc(),
)
.expect("claims should build");
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
let app = router(state.clone())
.layer(middleware::from_fn(attach_request_context))
.with_state(state);
for (method, path, size, expected) in [
(
"PATCH",
"/api/editor/projects/fixture",
2 * 1024 * 1024 + 1,
StatusCode::UNPROCESSABLE_ENTITY,
),
(
"PATCH",
"/api/editor/projects/fixture",
super::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES + 1,
StatusCode::PAYLOAD_TOO_LARGE,
),
(
"POST",
"/api/editor/agent-conversations/fixture/messages",
super::EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES + 1,
StatusCode::PAYLOAD_TOO_LARGE,
),
] {
// 用有效 JSON 字符串触发类型校验,避免进入业务 handler 或产生持久化副作用。
let body = format!("\"{}\"", "x".repeat(size));
let response = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(path)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.expect("body limit request should build"),
)
.await
.expect("body limit request should complete");
assert_eq!(response.status(), expected, "{method} {path}, size={size}");
}
}
#[tokio::test]
async fn method_auth_layer_leaves_405_outside_authentication() {
async fn reject(_: Request<Body>, _: axum::middleware::Next) -> StatusCode {
StatusCode::UNAUTHORIZED
}
let method_layer = axum::Router::new().route(
"/only-get",
axum::routing::get(|| async { "ok" }).route_layer(middleware::from_fn(reject)),
);
let router_layer = axum::Router::new()
.route("/only-get", axum::routing::get(|| async { "ok" }))
.route_layer(middleware::from_fn(reject));
for (app, expected) in [
(method_layer, StatusCode::METHOD_NOT_ALLOWED),
(router_layer, StatusCode::UNAUTHORIZED),
] {
let response = app
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/only-get")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), expected);
}
}
}