Files
Genarrative/server-rs/crates/api-server/src/modules/external_api.rs
T
lhk229 ae2d1a3a21
Project CI / AI game creator shell Rust crates (push) Successful in 1m47s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m1s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 5m18s
Project CI / Frontend tests (push) Successful in 2m27s
Project CI / Backend tests (push) Successful in 5m49s
Project CI / Native shell tests (push) Successful in 6m21s
Project CI / Repository checks (push) Successful in 3m36s
Project CI / AI game creator shell web tests (push) Successful in 2m54s
修复 AGC 生成准备依赖全量素材库的问题 (#592)
修复已有项目和素材目录绑定时,AGC 生成准备仍读取完整账户素材库、导致无关素材读取失败阻断生成的问题。

- 新增站内与 External v1 的单目录 GET,按主键读取并校验账号归属;复用目录 DTO,不读取素材列表或创建默认目录。
- 已有 binding 和 partial 恢复改为确认目标目录;首次建绑直接沿用稳定幂等创建,不再读取全库。
- 目录缺失、越权、超时、响应身份不符或会话切换时停止,保留恢复证据,不自动重建、不换键重发生成;已有生成账本继续沿用冻结快照恢复。
- 既有素材库与目录创建、修改、删除 API 合同保持不变,持久化表结构不变。同步 OpenAPI、生成绑定及技术文档。

验证:
- AGC Rust:目录绑定 7 项、生成 94 项、资源编辑 60 项、抠图 5 项通过(目录绑定用例包含在生成组中)。
- api-server:External Editor 30 项、受保护路由矩阵 3 项通过。
- 真实隔离 SpacetimeDB:目录读取成功、空目录、缺失、越权、删除后缺失、只读不变,以及既有幂等/并发/孤儿检查通过。
- smoke 脚本测试 4 项通过;module 编译、生成绑定、87 张表 schema guard、DDD、编码、文档索引、定向 Rust 格式与 diff 检查通过。
- 已比较 OpenAPI:除新增单目录 GET 外,原有定义逐项保持一致。

验证限制:未完成独立 API `/healthz` 联调,本地隔离数据库与授权配置未就绪;启动尝试已停止并清理。未运行全量测试或真实付费生成。

发布顺序:先发布后端模块与 API,再发布新客户端;不提供全库回退。#574 的素材分页、筛选及按素材 ID 读取不在本 PR 范围内。

Closes #550

Reviewed-on: #592
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-10-03 14:48:54 +08:00

493 lines
18 KiB
Rust

use axum::{
Extension, Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, patch, post},
};
use crate::{
editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES,
external_api_auth::{require_external_api_key, require_external_mcp_api_key},
external_assets_api::{
confirm_external_asset_object, create_external_direct_upload_ticket,
get_external_asset_read_url,
},
external_editor_api::{
create_external_editor_asset, create_external_editor_asset_folder,
create_external_editor_project, create_external_editor_project_resource,
delete_external_editor_asset, delete_external_editor_asset_folder,
delete_external_editor_project, edit_external_editor_image,
extract_external_editor_ui_design_assets, generate_external_editor_background_music,
generate_external_editor_character_animation, generate_external_editor_icon_spritesheet,
generate_external_editor_image, generate_external_editor_scene,
generate_external_editor_sound_effect, generate_external_editor_video,
get_external_editor_asset_folder, get_external_editor_asset_library,
get_external_editor_generation_job, get_external_editor_project,
list_external_editor_projects, load_recent_external_editor_project, openapi_json,
remove_external_editor_image_background, rename_external_editor_project,
save_external_editor_canvas, update_external_editor_asset,
update_external_editor_asset_folder,
},
external_mcp,
external_skill_api::{
download_external_skill_archive, get_external_agent_integration_manifest,
get_external_skill_entry,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
let mcp_router = Router::new()
.nest_service("/api/external/v1/mcp", external_mcp::service())
.layer(Extension(state.clone()))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_mcp_api_key,
));
let auth = middleware::from_fn_with_state(state, require_external_api_key);
let protected_routes = [
(
"/api/external/v1/assets/direct-upload-tickets",
post(create_external_direct_upload_ticket),
),
(
"/api/external/v1/assets/objects/confirm",
post(confirm_external_asset_object),
),
(
"/api/external/v1/assets/read-url",
get(get_external_asset_read_url),
),
(
"/api/external/v1/editor/projects",
get(list_external_editor_projects).post(create_external_editor_project),
),
(
"/api/external/v1/editor/projects/recent",
get(load_recent_external_editor_project),
),
(
"/api/external/v1/editor/projects/{project_id}",
get(get_external_editor_project).delete(delete_external_editor_project),
),
(
"/api/external/v1/editor/projects/{project_id}/metadata",
patch(rename_external_editor_project),
),
(
"/api/external/v1/editor/projects/{project_id}/canvas",
patch(save_external_editor_canvas)
.layer(DefaultBodyLimit::max(EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES)),
),
(
"/api/external/v1/editor/projects/{project_id}/resources",
post(create_external_editor_project_resource),
),
(
"/api/external/v1/editor/assets/library",
get(get_external_editor_asset_library),
),
(
"/api/external/v1/editor/assets/folders",
post(create_external_editor_asset_folder),
),
(
"/api/external/v1/editor/assets/folders/{folder_id}",
get(get_external_editor_asset_folder)
.patch(update_external_editor_asset_folder)
.delete(delete_external_editor_asset_folder),
),
(
"/api/external/v1/editor/assets",
post(create_external_editor_asset),
),
(
"/api/external/v1/editor/assets/{asset_id}",
patch(update_external_editor_asset).delete(delete_external_editor_asset),
),
(
"/api/external/v1/generations/{operation_id}",
get(get_external_editor_generation_job),
),
(
"/api/external/v1/editor/images/generations",
post(generate_external_editor_image),
),
(
"/api/external/v1/editor/scenes/generations",
post(generate_external_editor_scene),
),
(
"/api/external/v1/editor/images/edits",
post(edit_external_editor_image),
),
(
"/api/external/v1/editor/images/background-removals",
post(remove_external_editor_image_background),
),
(
"/api/external/v1/editor/icon-spritesheets/generations",
post(generate_external_editor_icon_spritesheet),
),
(
"/api/external/v1/editor/ui-designs/assets/extractions",
post(extract_external_editor_ui_design_assets),
),
(
"/api/external/v1/editor/character-animations/generations",
post(generate_external_editor_character_animation),
),
(
"/api/external/v1/editor/videos/generations",
post(generate_external_editor_video),
),
(
"/api/external/v1/editor/audios/sound-effects/generations",
post(generate_external_editor_sound_effect),
),
(
"/api/external/v1/editor/audios/background-music/generations",
post(generate_external_editor_background_music),
),
];
// 在方法层统一鉴权,未支持的方法仍由 Axum 返回 405。
let protected_router = protected_routes
.into_iter()
.fold(Router::new(), |router, (path, methods)| {
router.route(path, methods.route_layer(auth.clone()))
});
Router::new()
.route("/api/external/v1/openapi.json", get(openapi_json))
.route(
"/api/external/v1/agent-integration.json",
get(get_external_agent_integration_manifest),
)
.route(
"/api/external/v1/skill/SKILL.md",
get(get_external_skill_entry),
)
.route(
"/api/external/v1/skill.zip",
get(download_external_skill_archive),
)
.merge(protected_router)
.merge(mcp_router)
}
#[cfg(test)]
mod route_contract_tests {
use std::sync::Arc;
use axum::{
body::Body,
http::{Request, StatusCode, header},
middleware,
};
use futures_util::future::BoxFuture;
use http_body_util::BodyExt;
use spacetime_client::{
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord, SpacetimeClientError,
};
use tower::ServiceExt;
use super::router;
use crate::{
config::AppConfig,
request_context::attach_request_context,
state::{AppState, ExternalApiAuthState, external_api_auth::ExternalApiKeyAuthenticator},
};
const VALID_KEY: &str = "tnr_sk_route-body-limit-fixture";
struct FixtureKeyAuthenticator;
impl ExternalApiKeyAuthenticator for FixtureKeyAuthenticator {
fn authenticate_external_api_key(
&self,
input: ExternalApiKeyAuthenticateRecordInput,
) -> BoxFuture<'_, Result<ExternalApiKeyRecord, SpacetimeClientError>> {
Box::pin(async move {
if input.key_hash != crate::external_api_keys::hash_external_api_key(VALID_KEY) {
return Err(SpacetimeClientError::Procedure(
"API Key 不存在".to_string(),
));
}
Ok(ExternalApiKeyRecord {
key_id: "key-route-fixture".to_string(),
owner_user_id: "owner-route-fixture".to_string(),
name: "route fixture".to_string(),
key_prefix: "tnr_sk_route".to_string(),
scopes: Vec::new(),
created_at: "2026-09-18T00:00:00Z".to_string(),
last_used_at: None,
revoked_at: None,
updated_at: "2026-09-18T00:00:00Z".to_string(),
})
})
}
}
const PROTECTED_ROUTES: &[(&str, &[&str])] = &[
("/api/external/v1/assets/direct-upload-tickets", &["POST"]),
("/api/external/v1/assets/objects/confirm", &["POST"]),
("/api/external/v1/assets/read-url", &["GET"]),
("/api/external/v1/editor/projects", &["GET", "POST"]),
("/api/external/v1/editor/projects/recent", &["GET"]),
(
"/api/external/v1/editor/projects/{project_id}",
&["GET", "DELETE"],
),
(
"/api/external/v1/editor/projects/{project_id}/metadata",
&["PATCH"],
),
(
"/api/external/v1/editor/projects/{project_id}/canvas",
&["PATCH"],
),
(
"/api/external/v1/editor/projects/{project_id}/resources",
&["POST"],
),
("/api/external/v1/editor/assets/library", &["GET"]),
("/api/external/v1/editor/assets/folders", &["POST"]),
(
"/api/external/v1/editor/assets/folders/{folder_id}",
&["GET", "PATCH", "DELETE"],
),
("/api/external/v1/editor/assets", &["POST"]),
(
"/api/external/v1/editor/assets/{asset_id}",
&["PATCH", "DELETE"],
),
("/api/external/v1/generations/{operation_id}", &["GET"]),
("/api/external/v1/editor/images/generations", &["POST"]),
("/api/external/v1/editor/scenes/generations", &["POST"]),
("/api/external/v1/editor/images/edits", &["POST"]),
(
"/api/external/v1/editor/images/background-removals",
&["POST"],
),
(
"/api/external/v1/editor/icon-spritesheets/generations",
&["POST"],
),
(
"/api/external/v1/editor/ui-designs/assets/extractions",
&["POST"],
),
(
"/api/external/v1/editor/character-animations/generations",
&["POST"],
),
("/api/external/v1/editor/videos/generations", &["POST"]),
(
"/api/external/v1/editor/audios/sound-effects/generations",
&["POST"],
),
(
"/api/external/v1/editor/audios/background-music/generations",
&["POST"],
),
];
fn app() -> axum::Router {
let state = AppState::new(AppConfig::default())
.expect("state should build")
.with_external_api_auth_state(ExternalApiAuthState::new(Arc::new(
FixtureKeyAuthenticator,
)));
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/external/v1/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 integration_documents_remain_public_and_mcp_keeps_its_auth_challenge() {
let app = app();
for path in [
"/api/external/v1/openapi.json",
"/api/external/v1/agent-integration.json",
"/api/external/v1/skill/SKILL.md",
"/api/external/v1/skill.zip",
] {
for method in ["GET", "HEAD"] {
let response = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(path)
.header(header::AUTHORIZATION, "Bearer invalid-token")
.body(Body::empty())
.expect("document request should build"),
)
.await
.expect("document request should complete");
assert_eq!(response.status(), StatusCode::OK, "{method} {path}");
if method == "HEAD" {
assert!(
response
.into_body()
.collect()
.await
.expect("HEAD body should collect")
.to_bytes()
.is_empty()
);
}
}
}
for method in ["GET", "POST", "DELETE", "OPTIONS"] {
let response = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri("/api/external/v1/mcp")
.body(Body::empty())
.expect("MCP request should build"),
)
.await
.expect("MCP request should complete");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "MCP {method}");
assert_eq!(
response.headers()[header::WWW_AUTHENTICATE],
"Bearer realm=\"genarrative-external-editor\""
);
}
}
#[tokio::test]
async fn canvas_authentication_precedes_body_limit_rejection() {
let response = app()
.oneshot(
Request::builder()
.method("PATCH")
.uri("/api/external/v1/editor/projects/fixture/canvas")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from("x".repeat(
crate::editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES + 1,
)))
.expect("oversized request should build"),
)
.await
.expect("oversized request should complete");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn authenticated_canvas_keeps_its_four_mebibyte_body_limit() {
let app = app();
for (size, expected) in [
(2 * 1024 * 1024 + 1, StatusCode::UNPROCESSABLE_ENTITY),
(
crate::editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES + 1,
StatusCode::PAYLOAD_TOO_LARGE,
),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri("/api/external/v1/editor/projects/fixture/canvas")
.header(header::AUTHORIZATION, format!("Bearer {VALID_KEY}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!("\"{}\"", "x".repeat(size))))
.expect("canvas request should build"),
)
.await
.expect("canvas request should complete");
assert_eq!(response.status(), expected, "size={size}");
}
}
}