合并远端主分支

同步外部 MCP 项目选择与 API 契约修复
同步 Gitea CI 网络稳定性与构建镜像配置
This commit is contained in:
2026-08-07 19:50:29 +08:00
16 changed files with 962 additions and 53 deletions
@@ -1,6 +1,6 @@
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
extract::{Extension, Path, Query, State, rejection::JsonRejection},
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
@@ -16,7 +16,7 @@ use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput,
EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
EditorProjectGetRecordInput, EditorProjectRenameRecordInput,
EditorProjectGetRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput,
ExternalGenerationJobRecord, SpacetimeClientError,
};
@@ -65,6 +65,22 @@ const OPENAPI_JSON: &str =
include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json");
const EXTERNAL_GENERATION_POLL_AFTER_MS: u64 = 1_500;
const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key";
const PROJECT_COVER_SNAPSHOT_ASSET_KIND: &str = "project-cover-snapshot";
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum ExternalEditorProjectListView {
#[default]
Full,
Summary,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectListQuery {
#[serde(default)]
view: ExternalEditorProjectListView,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -168,6 +184,31 @@ pub struct ExternalEditorProjectListResponse {
projects: Vec<EditorProjectPayload>,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectSummary {
project_id: String,
title: String,
updated_at: String,
cover: Option<ExternalEditorProjectCoverSummary>,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectCoverSummary {
resource_id: String,
object_key: String,
width: u32,
height: u32,
updated_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectSummaryListResponse {
projects: Vec<ExternalEditorProjectSummary>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalEditorProjectDeleteResponse {
@@ -240,6 +281,7 @@ pub async fn create_external_editor_project(
pub async fn list_external_editor_projects(
State(state): State<AppState>,
Query(query): Query<ExternalEditorProjectListQuery>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
) -> Result<Json<Value>, AppError> {
@@ -248,15 +290,65 @@ pub async fn list_external_editor_projects(
.spacetime_client()
.list_editor_projects(principal.owner_user_id().to_string())
.await
.map_err(map_editor_project_error)?
.into_iter()
.map(editor_project_payload_from_record)
.collect();
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectListResponse { projects },
))
match query.view {
ExternalEditorProjectListView::Full => Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectListResponse {
projects: projects
.into_iter()
.map(editor_project_payload_from_record)
.collect(),
},
)),
ExternalEditorProjectListView::Summary => Ok(json_success_body(
Some(&request_context),
ExternalEditorProjectSummaryListResponse {
projects: projects
.into_iter()
.map(external_editor_project_summary_from_record)
.collect(),
},
)),
}
}
fn external_editor_project_summary_from_record(
record: EditorProjectRecord,
) -> ExternalEditorProjectSummary {
let cover = record
.resources
.iter()
.filter_map(|resource| {
(resource.asset_kind.as_deref() == Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND))
.then_some(resource)
.zip(
resource
.object_key
.as_deref()
.filter(|object_key| !object_key.trim().is_empty()),
)
})
.max_by(|(left, _), (right, _)| {
left.updated_at
.cmp(&right.updated_at)
.then_with(|| left.resource_id.cmp(&right.resource_id))
})
.map(|(resource, object_key)| ExternalEditorProjectCoverSummary {
resource_id: resource.resource_id.clone(),
object_key: object_key.to_string(),
width: resource.width,
height: resource.height,
updated_at: resource.updated_at.clone(),
});
ExternalEditorProjectSummary {
project_id: record.project_id,
title: record.title,
updated_at: record.updated_at,
cover,
}
}
pub async fn load_recent_external_editor_project(
@@ -1026,6 +1118,75 @@ fn serialize_external_editor_image_sequence_frames(
#[cfg(test)]
mod tests {
use super::*;
use spacetime_client::{
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectResourceRecord,
};
fn external_editor_project_resource_fixture(
resource_id: &str,
asset_kind: Option<&str>,
object_key: Option<&str>,
updated_at: &str,
) -> EditorProjectResourceRecord {
serde_json::from_value(json!({
"resource_id": resource_id,
"project_id": "proj-summary-test",
"owner_user_id": "user-1",
"asset_object_id": format!("asset-object-{resource_id}"),
"image_src": format!("/api/assets/{resource_id}"),
"object_key": object_key,
"width": 320,
"height": 240,
"source_type": "uploaded",
"prompt": null,
"actual_prompt": null,
"model": null,
"provider": null,
"task_id": null,
"source_resource_id": null,
"asset_kind": asset_kind,
"generation_inputs": null,
"public_showcase_enabled": false,
"created_at": updated_at,
"updated_at": updated_at
}))
.expect("项目资源 fixture 应兼容可选字段扩展")
}
fn external_editor_project_record_fixture(
resources: Vec<EditorProjectResourceRecord>,
) -> EditorProjectRecord {
EditorProjectRecord {
project_id: "proj-summary-test".to_string(),
owner_user_id: "user-1".to_string(),
title: "摘要项目".to_string(),
canvas: EditorCanvasRecord {
canvas_id: "canvas-summary-test".to_string(),
project_id: "proj-summary-test".to_string(),
title: "摘要项目".to_string(),
viewport: EditorCanvasViewportRecord {
x: 0.0,
y: 0.0,
scale: 1.0,
},
layers: json!([{"large": "canvas payload must not enter summary"}]),
revision: 3,
layout_storage_version: 1,
background_color: None,
created_at: "2026-08-01T00:00:00Z".to_string(),
updated_at: "2026-08-07T00:00:00Z".to_string(),
},
viewport: EditorCanvasViewportRecord {
x: 0.0,
y: 0.0,
scale: 1.0,
},
layers: json!([{"large": "project payload must not enter summary"}]),
resources,
created_at: "2026-08-01T00:00:00Z".to_string(),
updated_at: "2026-08-07T00:00:00Z".to_string(),
}
}
const EXTERNAL_MEDIA_CREATE_REQUEST_SCHEMAS: [&str; 2] = [
"ExternalEditorAssetCreateRequest",
@@ -1073,6 +1234,160 @@ mod tests {
)
}
#[test]
fn external_editor_project_list_query_defaults_to_full_and_accepts_summary() {
let without_view = "http://localhost/api/external/v1/editor/projects"
.parse()
.expect("测试 URI 应合法");
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&without_view)
.expect("缺省 view 应保持 full 兼容语义");
assert_eq!(query.view, ExternalEditorProjectListView::Full);
let explicit_full = "http://localhost/api/external/v1/editor/projects?view=full"
.parse()
.expect("测试 URI 应合法");
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&explicit_full)
.expect("显式 full 应合法");
assert_eq!(query.view, ExternalEditorProjectListView::Full);
let summary = "http://localhost/api/external/v1/editor/projects?view=summary"
.parse()
.expect("测试 URI 应合法");
let Query(query) = Query::<ExternalEditorProjectListQuery>::try_from_uri(&summary)
.expect("summary 应合法");
assert_eq!(query.view, ExternalEditorProjectListView::Summary);
let unknown = "http://localhost/api/external/v1/editor/projects?view=compact"
.parse()
.expect("测试 URI 应合法");
assert!(
Query::<ExternalEditorProjectListQuery>::try_from_uri(&unknown).is_err(),
"未知 view 不得静默回落到 full"
);
}
#[test]
fn external_editor_project_summary_selects_latest_persisted_cover() {
let project = external_editor_project_record_fixture(vec![
external_editor_project_resource_fixture(
"resource-cover-old",
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
Some("editor/project-covers/old.webp"),
"2026-08-02T00:00:00Z",
),
external_editor_project_resource_fixture(
"resource-other-newer",
Some("editor_generated_image"),
Some("editor/generated/newer.webp"),
"2026-08-06T00:00:00Z",
),
external_editor_project_resource_fixture(
"resource-cover-empty-key",
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
Some(" "),
"2026-08-07T00:00:00Z",
),
external_editor_project_resource_fixture(
"resource-cover-latest",
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
Some("editor/project-covers/latest.webp"),
"2026-08-05T00:00:00Z",
),
]);
let summary = external_editor_project_summary_from_record(project);
let serialized = serde_json::to_value(&summary).expect("项目摘要应可序列化");
assert_eq!(
serialized,
json!({
"projectId": "proj-summary-test",
"title": "摘要项目",
"updatedAt": "2026-08-07T00:00:00Z",
"cover": {
"resourceId": "resource-cover-latest",
"objectKey": "editor/project-covers/latest.webp",
"width": 320,
"height": 240,
"updatedAt": "2026-08-05T00:00:00Z"
}
})
);
assert!(serialized.get("canvas").is_none());
assert!(serialized.get("layers").is_none());
assert!(serialized.get("resources").is_none());
}
#[test]
fn external_editor_project_summary_returns_null_without_persisted_cover() {
let project = external_editor_project_record_fixture(vec![
external_editor_project_resource_fixture(
"resource-non-cover",
Some("editor_generated_image"),
Some("editor/generated/image.webp"),
"2026-08-06T00:00:00Z",
),
external_editor_project_resource_fixture(
"resource-cover-without-object",
Some(PROJECT_COVER_SNAPSHOT_ASSET_KIND),
None,
"2026-08-07T00:00:00Z",
),
]);
let summary = external_editor_project_summary_from_record(project);
assert_eq!(summary.cover, None);
assert_eq!(
serde_json::to_value(summary).expect("无封面摘要应可序列化")["cover"],
Value::Null
);
}
#[test]
fn nineteen_large_projects_stay_below_mcp_limit_in_summary_view() {
const MCP_LIMIT_BYTES: usize = 4 * 1024 * 1024;
let projects = (0..19)
.map(|index| {
let mut project = external_editor_project_record_fixture(Vec::new());
project.project_id = format!("proj-summary-{index}");
project.canvas.project_id = project.project_id.clone();
project.title = format!("项目 {index}");
project.layers = json!({"largePayload": "x".repeat(160_000)});
project.canvas.layers = json!({"largePayload": "x".repeat(160_000)});
project
})
.collect::<Vec<_>>();
let full = serde_json::to_vec(
&projects
.iter()
.cloned()
.map(editor_project_payload_from_record)
.collect::<Vec<_>>(),
)
.expect("完整项目列表应可序列化");
let summaries = projects
.into_iter()
.map(external_editor_project_summary_from_record)
.collect::<Vec<_>>();
let summary = serde_json::to_vec(&summaries).expect("项目摘要列表应可序列化");
assert!(
full.len() > MCP_LIMIT_BYTES,
"测试数据必须先复现完整列表超过 MCP 上限"
);
assert_eq!(summaries.len(), 19);
assert!(
summary.len() < MCP_LIMIT_BYTES,
"同一批项目的摘要必须低于 MCP 上限"
);
assert!(
!summary
.windows(b"largePayload".len())
.any(|window| { window == b"largePayload" })
);
}
#[test]
fn external_editor_canvas_save_request_requires_expected_revision() {
let missing_revision = serde_json::from_value::<ExternalEditorCanvasSaveRequest>(json!({