修复客户端资源编辑参考媒体上传
图片精修改用合法私有上传命名空间 全类型本地媒体统一复用授权前缀 细分参考资源准备失败阶段并保持错误脱敏 补充上传确认顺序和非破坏性编辑回归测试 同步技术合同与项目共享记忆
This commit is contained in:
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/Deserialize,host/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、绝对路径或媒体正文。
|
||||
|
||||
## 客户端内部用途目录不能直接作为 legacyPrefix(2026-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;其它未知或对账状态必须保留原身份。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user