修复客户端资源编辑参考媒体上传
Project CI / Repository checks (pull_request) Failing after 48s
Project CI / Frontend tests (pull_request) Failing after 2m10s
Project CI / Native shell tests (pull_request) Failing after 3m0s
Project CI / Backend tests (pull_request) Successful in 3m47s

图片精修改用合法私有上传命名空间
全类型本地媒体统一复用授权前缀
细分参考资源准备失败阶段并保持错误脱敏
补充上传确认顺序和非破坏性编辑回归测试
同步技术合同与项目共享记忆
This commit is contained in:
2026-08-10 16:03:59 +08:00
parent fe02999904
commit f6732a9c97
6 changed files with 653 additions and 55 deletions
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,8 @@ const RESOURCE_EDIT_AUDIO_MAX_BYTES: usize = 64 * 1024 * 1024;
const RESOURCE_EDIT_VIDEO_MAX_BYTES: usize = 128 * 1024 * 1024;
const RESOURCE_EDIT_ROOT: &str = ".agent/resource-edits";
const RESOURCE_EDIT_QUEUE_SOURCE: &str = "game-creator-resource-editor";
const RESOURCE_EDIT_UPLOAD_LEGACY_PREFIX: &str = "generated-character-drafts";
const RESOURCE_EDIT_UPLOAD_NAMESPACE: &str = "resource-editor-references";
static RESOURCE_EDIT_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
@@ -782,15 +784,12 @@ async fn request_resource_edit_upload_ticket(
let response = client
.post(format!("{api_base_url}/api/assets/direct-upload-tickets"))
.bearer_auth(access_token)
.json(&serde_json::json!({
"legacyPrefix": "resource-editor-references",
"pathSegments": [input.expected_project_id.as_str(), input.operation_id.as_str()],
"fileName": file_name,
"contentType": source.media_type,
"access": "private",
"maxSizeBytes": bytes.len(),
"successActionStatus": 204,
}))
.json(&resource_edit_upload_ticket_payload(
input,
source,
&file_name,
bytes.len(),
))
.send()
.await
.map_err(|_| "创建源资源上传凭证失败".to_string())?;
@@ -847,6 +846,28 @@ async fn request_resource_edit_upload_ticket(
})
}
fn resource_edit_upload_ticket_payload(
input: &DeriveLocalProjectResourceInput,
source: &ResourceEditSourceSnapshot,
file_name: &str,
byte_length: usize,
) -> serde_json::Value {
serde_json::json!({
"legacyPrefix": RESOURCE_EDIT_UPLOAD_LEGACY_PREFIX,
"pathSegments": [
"editor",
RESOURCE_EDIT_UPLOAD_NAMESPACE,
input.expected_project_id.as_str(),
input.operation_id.as_str()
],
"fileName": file_name,
"contentType": source.media_type,
"access": "private",
"maxSizeBytes": byte_length,
"successActionStatus": 204,
})
}
async fn upload_resource_edit_source(
ticket: &ResourceEditUploadTicket,
source: &ResourceEditSourceSnapshot,
@@ -2042,6 +2063,46 @@ mod tests {
);
}
#[test]
fn local_media_upload_ticket_uses_legal_private_editor_namespace() {
let directory = tempfile::tempdir().expect("create resource editor fixture");
let operation_id = Uuid::new_v4().to_string();
let request = input(
directory.path(),
operation_id.clone(),
LocalProjectResourceEditKind::Video,
"local-asset:video-1".to_string(),
);
let source = ResourceEditSourceSnapshot {
canonical_resource_id: "local-asset:video-1".to_string(),
source_path: Some("assets/source-video.mp4".to_string()),
media_type: "video/mp4".to_string(),
asset_kind: "video".to_string(),
source_sha256: "a".repeat(64),
bytes: Some(vec![1, 2, 3]),
text: None,
source_asset: None,
source_version: None,
};
let payload = resource_edit_upload_ticket_payload(&request, &source, "source-video.mp4", 3);
assert_eq!(payload["legacyPrefix"], "generated-character-drafts");
assert_eq!(
payload["pathSegments"],
serde_json::json!([
"editor",
"resource-editor-references",
PROJECT_ID,
operation_id
])
);
assert_eq!(payload["fileName"], "source-video.mp4");
assert_eq!(payload["contentType"], "video/mp4");
assert_eq!(payload["access"], "private");
assert_eq!(payload["maxSizeBytes"], 3);
assert_eq!(payload["successActionStatus"], 204);
}
#[test]
fn staging_replay_accepts_identical_bytes_and_rejects_conflicting_bytes() {
let directory = tempfile::tempdir().expect("create resource editor fixture");
@@ -1,5 +1,13 @@
# 决策记录
## 2026-08-10 客户端参考媒体直传复用已授权私有前缀
- 根因:客户端素材画布和全类型资源编辑把内部用途目录 `asset-canvas-references``resource-editor-references` 直接作为 `legacyPrefix`api-server 只接受 `platform-oss` 权威白名单,因此请求在票据阶段返回 `400`,OSS 上传、对象确认、生成提交和扣费都没有发生。
- 存储决策:继续复用合法私有前缀 `generated-character-drafts`,不扩大 legacy 白名单、不增加平行上传接口。图片画布使用 `editor/asset-canvas-references/<projectId>/<draftId>/<generationId>`,全类型资源编辑使用 `editor/resource-editor-references/<projectId>/<operationId>` 作为 `pathSegments`;生成请求只消费 confirm 后的稳定 `objectKey`
- 错误边界:图片参考资源准备分别投影 `reference-material-invalid``reference-ticket-failed``reference-object-upload-failed``reference-confirm-failed``401/403` 继续收敛为 `authentication-required`。这些错误只返回安全阶段,不暴露 ticket host、formFields、policy、signature、Token、API Key 或 Provider 内部正文;取得稳定对象前必须保持 `operationId` 和生成 request body 为空。
- 验证:客户端端到端夹具锁定 ticket → OSS form POST → object confirm → image edit POST 顺序,并断言源图片文件和 manifest asset 保留、派生图片追加;失败夹具锁定票据失败后零生成 POST。api-server 契约测试锁定精确前缀、目录和私有对象 key。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs``apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs``server-rs/crates/api-server/src/assets.rs``docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`
## 2026-08-10 客户端现有资源编辑扩展到全部现役类型
- 产品入口:继续禁用“新增资源”,资源聚焦态的 manifest asset、已完成任务产物、上传附件、Agent 文本回执和项目版本统一显示“编辑资源”。静态图片复用 refine 图片画布,其他类型进入同一资源编辑壳,不建立平行资源总览。
@@ -4501,6 +4501,14 @@
- 处理:ticket 结构不实现 Serialize/Deserializehost/formFields 只在本次内存调用中使用。账本在上传前只保存稳定 bucket/objectKey;重启先用这组身份调用 object confirm,确认成功后只保留 objectKey/assetObjectId 并清掉上传中间态。账本测试必须直接序列化完整 ledger,扫描 Provider URL、Authorization、policy、signature、API Key 和 ticket 字段名。
- 验证:运行 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml private_generation_ledger_never_serializes_upload_credentials_or_provider_url`,并继续检查公开草稿、manifest、事件和普通错误不含 prompt、operationId、Key、绝对路径或媒体正文。
## 客户端内部用途目录不能直接作为 legacyPrefix2026-08-10
- 现象:本地图片精修或视频、音频等全类型资源编辑点击生成后立即提示参考资源上传失败;私有账本的 `uploadBucket/uploadObjectKey/operationId/requestBodyJson` 全为空,服务端也没有 OSS、confirm、生成或扣费记录。
- 原因:direct-upload ticket 的 `legacyPrefix` 不是任意业务目录,而是 `platform-oss::LegacyAssetPrefix` 的权威白名单值。把 `asset-canvas-references``resource-editor-references` 直接放在该字段会被 api-server 在签名之前以 `400` 拒绝;客户端若把票据、OSS 和 confirm 全折叠成一个错误码,还会掩盖真正失败阶段。
- 处理:客户端编辑器统一使用合法私有 `legacyPrefix=generated-character-drafts`,把业务用途放入 `pathSegments`:图片画布为 `editor/asset-canvas-references/<projectId>/<draftId>/<generationId>`,全类型资源编辑为 `editor/resource-editor-references/<projectId>/<operationId>`。仍严格执行 ticket → OSS form POST → object confirm,只有 confirm 返回自洽稳定 `objectKey/assetObjectId` 后才允许提交生成;不要为内部目录扩白名单或新建上传接口。图片路径按本地校验、票据、对象上传、对象确认分别使用安全错误码,票据材料继续只驻留内存。
- 验证:客户端端到端测试必须断言 confirm 早于生成 POST、请求使用精确前缀与 pathSegments、账本只持久化稳定对象身份;票据失败时断言 `operationId/requestBodyJson` 为空且 manifest 只有源资产。api-server 测试应断言生成的 key 位于 `generated-character-drafts/editor/...` 且 access 为 private。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs``apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs``server-rs/crates/api-server/src/assets.rs`
## prepared journal 之前同样存在正式事务崩溃窗口(2026-08-05)
- 现象:事务依次安装 before/after 快照后才写 journal;若进程在首个快照、全部快照或 journal 已写但 ledger 未写时退出,重启扫描看到 transaction 目录却无法进入原先只覆盖 prepared 之后的恢复状态机,可能留下孤儿目录或阻塞项目后续提交。
@@ -954,6 +954,8 @@ confirmation-required
- 普通客户生成使用 `POST /api/editor/images/generations`refine 使用 `POST /api/editor/images/edits`;两者携带当前登录 Bearer Token 和稳定 `Idempotency-Key`,由后端将 owner 与幂等身份绑定并沿统一生成队列扣费。提交响应中的 `queueState.operationId` 是轮询身份,状态使用 `GET /api/runtime/external-generation/jobs/{operationId}`completed 必须返回与 External 通道一致语义的 compact `result`,供客户端解析稳定引用。
- 开发者模式才使用对应 `/api/external/v1/editor/images/*`;两种模式支持相同 prompt、`1:1 | 2:3 | 3:2 | 9:16 | 16:9``0.5K | 1K | 2K`、合法 `assetKind` 与参考资源约束。refine 的 `sourceImageSrc` 必须来自源资产的 objectKey、服务端项目 resourceId 或素材 assetId`assetObjectId` 本身不是该字段接受的引用。本地独有图片在用户确认后先走现有 direct-upload ticket → OSS form → object confirm,确认后的 objectKey 才可进入生成请求。`sourceResourceId` 只在源资产确有服务端 resourceId 时发送,不能用本地 `local-asset:*`、objectKey 或 assetObjectId 冒充。额外参考最多 8 个。
- 客户端参考媒体直传固定复用 `legacyPrefix=generated-character-drafts`,不得把内部用途目录作为新 legacy prefix,也不得扩大服务端白名单。图片画布的 `pathSegments` 固定为 `editor / asset-canvas-references / <projectId> / <draftId> / <generationId>`;全类型资源编辑的本地视频、音频等源媒体固定为 `editor / resource-editor-references / <projectId> / <operationId>`。两条路径都只持久化 confirm 后的稳定 objectKey,不持久化 ticket 或签名 URL。
- 图片参考资源准备失败按阶段投影安全错误码:本地读取/校验为 `reference-material-invalid`,票据为 `reference-ticket-failed`OSS 表单上传为 `reference-object-upload-failed`,对象确认为 `reference-confirm-failed``401/403` 仍为 `authentication-required`。任一阶段失败都必须保持 External `operationId=null`、生成 endpoint/request body 未建立,不得进入扣费或生成提交。普通错误不得包含 ticket host、formFields、policy、signature、Token、API Key、Provider 响应正文或本机绝对路径。
- `accepted/running` 重启只查询原 operation`prepared` 只以账本中的原 endpoint、原始字节和原键恢复同一 POST。轮询超时、临时鉴权/网关错误或结果未知保持可恢复,不自动转 failed 或换键扣费。
- 用户“停止等待”只撤销当前 session 的聚焦意图,不伪装远端取消。后台 command 可以继续安全 reconcile;应用退出后由项目恢复扫描继续原 operation。迟到完成可以更新原项目权威缓存和 manifest,但不能切回旧画布、清搜索或抢焦点。
- 登录失效、余额不足、平台生成服务配置错误、远端 terminal failed、稳定引用缺失、下载失败、staging/正式提交失败都不得返回资源创建成功。客户模式不得把缺少开发者 API Key 显示为失败原因。只有 terminal failed 才允许用户通过新的独立确认建立新 intent;其它未知或对账状态必须保留原身份。
+66
View File
@@ -1394,6 +1394,72 @@ mod tests {
assert!(payload["data"]["upload"].get("publicUrl").is_none());
}
#[tokio::test]
async fn direct_upload_ticket_accepts_asset_canvas_reference_namespace() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
let token =
seed_authenticated_token(&state, "13800138122", "sess_asset_canvas_reference_ticket")
.await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/direct-upload-tickets")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "1")
.body(Body::from(
json!({
"legacyPrefix": "generated-character-drafts",
"pathSegments": [
"editor",
"asset-canvas-references",
"project-123",
"draft-456",
"generation-789"
],
"fileName": "reference-sha256.png",
"contentType": "image/png",
"access": "private",
"maxSizeBytes": 4096,
"successActionStatus": 204
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
let expected_object_key = "generated-character-drafts/editor/asset-canvas-references/project-123/draft-456/generation-789/reference-sha256.png";
assert_eq!(payload["data"]["upload"]["objectKey"], expected_object_key);
assert_eq!(
payload["data"]["upload"]["formFields"]["key"],
expected_object_key
);
assert_eq!(payload["data"]["upload"]["access"], "private");
assert_eq!(payload["data"]["upload"]["successActionStatus"], 204);
assert_eq!(payload["data"]["upload"]["maxSizeBytes"], 4096);
}
#[tokio::test]
async fn read_url_fails_closed_when_asset_metadata_authority_is_unavailable() {
let config = AppConfig {