完善图片画布素材库持久化
新增账号级素材文件夹和素材表,并接入 SpacetimeDB procedure、spacetime-client facade 与 api-server BFF。 编辑器素材栏支持文件夹新建、折叠、重命名、删除、多文件上传、拖拽定向上传、框选和批量删除。 画布支持拖拽上传落点创建图层、图层打组、小地图拖拽、普通滚轮纵向滚动和 Ctrl 滚轮缩放。 更新图片画布技术方案、后端数据契约、TRACKING 和团队决策记录。
This commit is contained in:
@@ -8,8 +8,11 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
use spacetime_client::{
|
||||
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
|
||||
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
|
||||
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
|
||||
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput,
|
||||
EditorAssetLibraryRecord, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord,
|
||||
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
|
||||
EditorProjectGetRecordInput,
|
||||
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError,
|
||||
};
|
||||
@@ -29,6 +32,8 @@ use crate::{
|
||||
|
||||
const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-";
|
||||
const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
|
||||
const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-";
|
||||
const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
|
||||
const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024;
|
||||
const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
|
||||
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
|
||||
@@ -77,6 +82,45 @@ pub struct EditorProjectResourceCreateRequest {
|
||||
source_resource_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetFolderCreateRequest {
|
||||
label: String,
|
||||
sort_order: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetFolderUpdateRequest {
|
||||
label: Option<String>,
|
||||
collapsed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetCreateRequest {
|
||||
folder_id: String,
|
||||
label: String,
|
||||
image_src: String,
|
||||
object_key: Option<String>,
|
||||
asset_object_id: Option<String>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
source_type: String,
|
||||
prompt: Option<String>,
|
||||
actual_prompt: Option<String>,
|
||||
model: Option<String>,
|
||||
provider: Option<String>,
|
||||
task_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetUpdateRequest {
|
||||
label: Option<String>,
|
||||
folder_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorImageGenerationRequest {
|
||||
@@ -120,6 +164,30 @@ pub struct EditorProjectResourceResponse {
|
||||
resource: EditorProjectResourcePayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetLibraryResponse {
|
||||
library: EditorAssetLibraryPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetFolderResponse {
|
||||
folder: EditorAssetFolderPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetFolderDeleteResponse {
|
||||
library: EditorAssetLibraryPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetResponse {
|
||||
asset: EditorAssetPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorImageGenerationResponse {
|
||||
@@ -179,6 +247,46 @@ pub struct EditorProjectResourcePayload {
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetLibraryPayload {
|
||||
folders: Vec<EditorAssetFolderPayload>,
|
||||
assets: Vec<EditorAssetPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetFolderPayload {
|
||||
folder_id: String,
|
||||
label: String,
|
||||
sort_order: u32,
|
||||
collapsed: bool,
|
||||
system_default: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAssetPayload {
|
||||
asset_id: String,
|
||||
folder_id: String,
|
||||
label: String,
|
||||
image_src: String,
|
||||
object_key: Option<String>,
|
||||
asset_object_id: Option<String>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
source_type: String,
|
||||
prompt: Option<String>,
|
||||
actual_prompt: Option<String>,
|
||||
model: Option<String>,
|
||||
provider: Option<String>,
|
||||
task_id: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn load_recent_editor_project(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -386,6 +494,189 @@ pub async fn create_editor_project_resource(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_editor_asset_library(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let library = state
|
||||
.spacetime_client()
|
||||
.get_editor_asset_library(current_owner_user_id(&authenticated), current_utc_micros())
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorAssetLibraryResponse {
|
||||
library: editor_asset_library_payload_from_record(library),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_editor_asset_folder(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorAssetFolderCreateRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let folder = state
|
||||
.spacetime_client()
|
||||
.create_editor_asset_folder(EditorAssetFolderCreateRecordInput {
|
||||
folder_id: build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX),
|
||||
owner_user_id: current_owner_user_id(&authenticated),
|
||||
label: payload.label,
|
||||
sort_order: payload.sort_order.unwrap_or(100),
|
||||
now_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorAssetFolderResponse {
|
||||
folder: editor_asset_folder_payload_from_record(folder),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update_editor_asset_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorAssetFolderUpdateRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let folder = state
|
||||
.spacetime_client()
|
||||
.update_editor_asset_folder(EditorAssetFolderUpdateRecordInput {
|
||||
folder_id,
|
||||
owner_user_id: current_owner_user_id(&authenticated),
|
||||
label: normalize_optional_string(payload.label),
|
||||
collapsed: payload.collapsed,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorAssetFolderResponse {
|
||||
folder: editor_asset_folder_payload_from_record(folder),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_editor_asset_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let library = state
|
||||
.spacetime_client()
|
||||
.delete_editor_asset_folder(EditorAssetFolderDeleteRecordInput {
|
||||
folder_id,
|
||||
owner_user_id: current_owner_user_id(&authenticated),
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorAssetFolderDeleteResponse {
|
||||
library: editor_asset_library_payload_from_record(library),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_editor_asset(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorAssetCreateRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let asset = state
|
||||
.spacetime_client()
|
||||
.create_editor_asset(EditorAssetCreateRecordInput {
|
||||
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
|
||||
owner_user_id: current_owner_user_id(&authenticated),
|
||||
folder_id: payload.folder_id,
|
||||
label: payload.label,
|
||||
asset_object_id: normalize_optional_string(payload.asset_object_id),
|
||||
image_src: payload.image_src,
|
||||
object_key: normalize_optional_string(payload.object_key),
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
source_type: payload.source_type,
|
||||
prompt: normalize_optional_string(payload.prompt),
|
||||
actual_prompt: normalize_optional_string(payload.actual_prompt),
|
||||
model: normalize_optional_string(payload.model),
|
||||
provider: normalize_optional_string(payload.provider),
|
||||
task_id: normalize_optional_string(payload.task_id),
|
||||
now_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorAssetResponse {
|
||||
asset: editor_asset_payload_from_record(asset),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update_editor_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(asset_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorAssetUpdateRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let asset = state
|
||||
.spacetime_client()
|
||||
.update_editor_asset(EditorAssetUpdateRecordInput {
|
||||
asset_id,
|
||||
owner_user_id: current_owner_user_id(&authenticated),
|
||||
label: normalize_optional_string(payload.label),
|
||||
folder_id: normalize_optional_string(payload.folder_id),
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorAssetResponse {
|
||||
asset: editor_asset_payload_from_record(asset),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_editor_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(asset_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let asset = state
|
||||
.spacetime_client()
|
||||
.delete_editor_asset(EditorAssetDeleteRecordInput {
|
||||
asset_id,
|
||||
owner_user_id: current_owner_user_id(&authenticated),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorAssetResponse {
|
||||
asset: editor_asset_payload_from_record(asset),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_editor_image(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -576,6 +867,58 @@ fn editor_project_resource_payload_from_record(
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_asset_library_payload_from_record(
|
||||
record: EditorAssetLibraryRecord,
|
||||
) -> EditorAssetLibraryPayload {
|
||||
EditorAssetLibraryPayload {
|
||||
folders: record
|
||||
.folders
|
||||
.into_iter()
|
||||
.map(editor_asset_folder_payload_from_record)
|
||||
.collect(),
|
||||
assets: record
|
||||
.assets
|
||||
.into_iter()
|
||||
.map(editor_asset_payload_from_record)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_asset_folder_payload_from_record(
|
||||
record: EditorAssetFolderRecord,
|
||||
) -> EditorAssetFolderPayload {
|
||||
EditorAssetFolderPayload {
|
||||
folder_id: record.folder_id,
|
||||
label: record.label,
|
||||
sort_order: record.sort_order,
|
||||
collapsed: record.collapsed,
|
||||
system_default: record.system_default,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload {
|
||||
EditorAssetPayload {
|
||||
asset_id: record.asset_id,
|
||||
folder_id: record.folder_id,
|
||||
label: record.label,
|
||||
image_src: record.image_src,
|
||||
object_key: record.object_key,
|
||||
asset_object_id: record.asset_object_id,
|
||||
width: record.width,
|
||||
height: record.height,
|
||||
source_type: record.source_type,
|
||||
prompt: record.prompt,
|
||||
actual_prompt: record.actual_prompt,
|
||||
model: record.model,
|
||||
provider: record.provider,
|
||||
task_id: record.task_id,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorCanvasViewportPayload {
|
||||
fn into_record(self) -> EditorCanvasViewportRecord {
|
||||
EditorCanvasViewportRecord {
|
||||
@@ -586,6 +929,10 @@ impl EditorCanvasViewportPayload {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_owner_user_id(authenticated: &AuthenticatedAccessToken) -> String {
|
||||
authenticated.claims().user_id().to_string()
|
||||
}
|
||||
|
||||
fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
|
||||
let payload = serde_json::to_string(&layers).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
|
||||
@@ -6,9 +6,12 @@ use axum::{
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
editor_project::{
|
||||
create_editor_project, create_editor_project_resource, delete_editor_project,
|
||||
edit_editor_image, generate_editor_image, get_editor_project, list_editor_projects,
|
||||
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, generate_editor_image,
|
||||
get_editor_asset_library, get_editor_project, list_editor_projects,
|
||||
load_recent_editor_project, rename_editor_project, save_editor_project_layout,
|
||||
update_editor_asset, update_editor_asset_folder,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -55,6 +58,45 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/assets/library",
|
||||
get(get_editor_asset_library).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/assets/folders",
|
||||
post(create_editor_asset_folder).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/assets/folders/{folder_id}",
|
||||
patch(update_editor_asset_folder)
|
||||
.delete(delete_editor_asset_folder)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/assets",
|
||||
post(create_editor_asset).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/assets/{asset_id}",
|
||||
patch(update_editor_asset)
|
||||
.delete(delete_editor_asset)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/images/generations",
|
||||
post(generate_editor_image).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
Reference in New Issue
Block a user