补齐外部OpenAPI编辑器素材能力

移除外部OpenAPI中的API Key管理接口暴露
补齐编辑器项目列表、最近项目、重命名和删除外部接口
补齐素材直传凭证、素材对象确认和签名读取外部接口
复用站内编辑器生成、素材库和项目资源链路并按API Key账号归属
更新外部OpenAPI、后端契约和项目共享记忆文档
This commit is contained in:
2026-06-21 13:40:09 +08:00
parent 98545d74d1
commit d8dd2f1857
16 changed files with 4075 additions and 583 deletions
File diff suppressed because it is too large Load Diff
@@ -19,7 +19,7 @@
## 2026-06-19 外部 OpenAPI 与 API Key 管理走 server-rs 正式链路
- 背景:外部调用方需要稳定调用图片画布项目创建、画布布局保存和编辑器美术生图能力,同时需要可撤销的开发者凭据,不能依赖前端临时状态或人工分发密钥。
- 决策:外部 API 固定放在 `/api/external/v1` 命名空间,v1 暴露项目创建 / 读取、默认画布保存、编辑器美术生图`/api/external/v1/openapi.json`。API Key 管理走登录态 `/api/profile/api-keys`,外部调用使用 `Authorization: Bearer tnr_sk_xxx`;后端只保存 `key_hash``key_prefix`,明文只在创建响应返回一次。外部 API 鉴权、项目 / 画布 / 素材写回全部经 `api-server -> spacetime-client -> spacetime-module`,生成图片成功后同时写入账号级 `editor_asset`,带 `projectId` 时写入 `editor_project_resource`
- 决策:外部 API 固定放在 `/api/external/v1` 命名空间,v1 暴露素材直传凭证 / asset object 确认 / 签名读取、项目列表 / 最近 / 创建 / 读取 / 重命名 / 删除、默认画布保存、账号级素材库、项目资源记录、编辑器图片 / 视频 / 音频生成`/api/external/v1/openapi.json`。API Key 管理走登录态 `/api/profile/api-keys`,外部调用使用 `Authorization: Bearer tnr_sk_xxx`;后端只保存 `key_hash``key_prefix`,明文只在创建响应返回一次。外部 API 鉴权、项目 / 画布 / 素材写回全部经 `api-server -> spacetime-client -> spacetime-module`,生成素材成功后按请求写入账号级 `editor_asset`,带 `projectId` 时写入 `editor_project_resource`;外部确认 asset object 时 owner 固定为 API Key 所属账号。API Key 管理接口不进入外部 OpenAPI JSON
- 影响范围:`server-rs/crates/api-server/src/external_*``server-rs/crates/api-server/src/modules/external_api.rs``server-rs/crates/spacetime-module/src/external_api_key_storage.rs``server-rs/crates/spacetime-client/src/external_api_key.rs``docs/openapi/genarrative-external-v1.openapi.json` 和后端数据契约文档。
- 验证方式:`cargo test -p api-server external_api --manifest-path server-rs/Cargo.toml``cargo test -p api-server external_editor_api --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema``npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`
@@ -59,6 +59,7 @@ npm run check:server-rs-ddd
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
- 平台基础能力:`/api/llm/*``/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
- 资产基础能力:`/api/assets/direct-upload-tickets``/api/assets/sts-upload-credentials``/api/assets/objects/*``/api/assets/read-*`,负责直传、确认、绑定和读取。
- 外部 OpenAPI`/api/external/v1/openapi.json``/api/external/v1/assets/direct-upload-tickets``/api/external/v1/assets/objects/confirm``/api/external/v1/assets/read-url``/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。
- 创作 / 游玩支撑能力:`/api/creation-entry/config``/api/ai/tasks*``/api/runtime/chat/*``/api/runtime/settings``/api/runtime/save/snapshot``/api/profile/browse-history``/api/profile/save-archives*``/api/profile/play-stats``/api/assets/history``/api/assets/character-visual/*``/api/assets/character-animation/*``/api/assets/character-workflow-cache*``/api/assets/hyper3d/*``/api/runtime/custom-world/asset-studio/*``/api/editor/projects*`
- 后台入口配置:`/admin/api/creation-entry/config``/admin/api/creation-entry/config/banners``/admin/api/creation-entry/config/interactions`
- 自定义世界 / RPG`/api/runtime/custom-world*``/api/story/*``/api/runtime/chat/*`
@@ -433,7 +434,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`ExternalApiKey`
- 源码:`server-rs/crates/spacetime-module/src/external_api_key_storage.rs`
- 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB。
- 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB,且 API Key 管理接口不写入外部 OpenAPI JSON。v1 默认作用域为 `editor:project``editor:canvas``editor:image-generate``editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作
- 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。
### `editor_project`
@@ -2,7 +2,7 @@
## 背景
外部调用方需要通过稳定 HTTP 契约使用图片画布编辑器内的美术生图能力,并能创建项目、保存画板布局。该能力必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态或生成结果放到前端临时状态中。
外部调用方需要通过稳定 HTTP 契约使用图片画布编辑器内的素材生成、编辑和管理能力,并能创建项目、保存画板布局和管理账号级素材库。该能力必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态、素材状态或生成结果放到前端临时状态中。
## v1 范围
@@ -14,13 +14,35 @@
v1 只开放以下能力:
- `POST /api/external/v1/assets/direct-upload-tickets`:创建素材直传 OSS 凭证。
- `POST /api/external/v1/assets/objects/confirm`:确认已上传素材对象,`ownerUserId` 固定为 API Key 所属账号。
- `GET /api/external/v1/assets/read-url`:获取私有素材读取签名 URL。
- `GET /api/external/v1/editor/projects`:列出当前 API Key 所属账号的图片画布项目。
- `POST /api/external/v1/editor/projects`:创建图片画布项目。
- `GET /api/external/v1/editor/projects/recent`:读取当前账号最近图片画布项目。
- `GET /api/external/v1/editor/projects/{projectId}`:读取项目与默认画布。
- `PATCH /api/external/v1/editor/projects/{projectId}/metadata`:更新图片画布项目标题。
- `DELETE /api/external/v1/editor/projects/{projectId}`:删除图片画布项目,并级联清理默认画布和项目资源元数据。
- `PATCH /api/external/v1/editor/projects/{projectId}/canvas`:保存默认画布的 viewport 和 layers。
- `POST /api/external/v1/editor/images/generations`:调用编辑器美术生图能力;可选传入 `projectId`,生成后自动写入 `editor_project_resource`,同时写入账号级 `editor_asset` 素材库
- `POST /api/external/v1/editor/projects/{projectId}/resources`:创建项目画布资源记录
- `GET /api/external/v1/editor/assets/library`:读取账号级编辑器素材库。
- `POST /api/external/v1/editor/assets/folders`:创建素材文件夹。
- `PATCH /api/external/v1/editor/assets/folders/{folderId}`:更新素材文件夹名称或折叠状态。
- `DELETE /api/external/v1/editor/assets/folders/{folderId}`:删除素材文件夹并返回最新素材库。
- `POST /api/external/v1/editor/assets`:创建素材记录。
- `PATCH /api/external/v1/editor/assets/{assetId}`:更新素材名称或所在文件夹。
- `DELETE /api/external/v1/editor/assets/{assetId}`:删除素材记录。
- `POST /api/external/v1/editor/images/generations`:调用编辑器图片素材生成能力;通过 `kind` 支持普通图、规范图 `spec`、角色图 `character`、快速编辑参考图 `quick-edit`、UI 设计图 `ui-design` 和宣发素材 `publication-material`。可选传入 `projectId``assetFolderId`,生成后按站内编辑器规则写入 `editor_project_resource` 和账号级 `editor_asset`
- `POST /api/external/v1/editor/images/edits`:重绘 / 调整已有图片,结果可写入项目资源和素材库。
- `POST /api/external/v1/editor/icon-spritesheets/generations`:按规范图生成图标 spritesheet,并拆分为独立图标素材。
- `POST /api/external/v1/editor/ui-designs/assets/extractions`:从 UI 设计图中提取 / 拆分素材。
- `POST /api/external/v1/editor/character-animations/generations`:基于角色图片生成角色动画预览和帧序列。
- `POST /api/external/v1/editor/videos/generations`:生成编辑器视频素材,支持现有 Seedance / Kling / Veo 模型参数和参考媒体限制。
- `POST /api/external/v1/editor/audios/sound-effects/generations`:生成编辑器音效素材。
- `POST /api/external/v1/editor/audios/background-music/generations`:生成编辑器背景音乐素材。
- `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。
管理 API Key 的登录态接口:
管理 API Key 的登录态接口保留在站内个人中心链路,但不写入外部 OpenAPI JSON
```text
GET /api/profile/api-keys
@@ -28,7 +50,7 @@ POST /api/profile/api-keys
DELETE /api/profile/api-keys/{keyId}
```
前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。
前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。外部 OpenAPI 只描述 `/api/external/v1` 下可由 API Key 调用的接口,不混入登录态 API Key 管理接口。
## 鉴权
@@ -61,7 +83,7 @@ external_api_key
- `name`:用户可识别名称。
- `key_prefix`:前缀片段,用于列表展示和排障。
- `key_hash`:完整 Key 的 SHA-256 十六进制摘要,唯一。
- `scopes_json`:作用域 JSONv1 固定包含 `editor:project``editor:canvas``editor:image-generate`
- `scopes_json`:作用域 JSONv1 固定包含 `editor:project``editor:canvas``editor:image-generate``editor:asset`。其中 `editor:image-generate` 覆盖图片生成、重绘、规范图、宣发图、图标拆分、UI 素材拆分、角色动画、视频、音效和音乐生成
- `created_at` / `last_used_at` / `revoked_at` / `updated_at`
SpacetimeDB procedure
@@ -71,9 +93,16 @@ SpacetimeDB procedure
- `revoke_external_api_key_and_return`
- `authenticate_external_api_key_and_return`
## 生成落库
## 素材生成落库
外部生接口复用编辑器`VectorEngine` / `gpt-image-2` 生成链路,后端拿到图片后
外部生接口复用站内编辑器已有 handler 和 DTO,不维护第二套生成语义
- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations``/api/editor/images/edits` 的校验、模型归一、计费和持久化规则。
- 图标 spritesheet 和 UI 设计图素材提取复用站内拆分逻辑,生成图集后按连通域切片,并把图集与切片都按请求写入项目资源和素材库。
- 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;音频类外部调用使用 API Key 所属账号作为 asset owner。
- API Key 管理接口仍只属于登录态个人中心,不进入外部 OpenAPI JSON。
图片类外部生成成功后,后端拿到素材后:
1. 通过 OSS / asset object adapter 持久化图片。
2. 写入 `editor_asset`,让生成图进入账号级素材库。
@@ -82,6 +111,17 @@ SpacetimeDB procedure
如果请求未带 `projectId`,只生成并写入素材库;调用方可随后创建项目或自行保存画板布局。
## 素材与项目资源操作
外部素材操作复用站内图片画布素材库和项目资源的后端事实源:
- 外部上传素材时先调用 `POST /api/external/v1/assets/direct-upload-tickets` 获取 OSS 表单直传参数,上传完成后调用 `POST /api/external/v1/assets/objects/confirm` 写入 `asset_object`,再用 `assetObjectId` / `objectKey` 创建账号级素材或项目资源记录。
- 外部读取私有 generated / uploaded 素材预览时调用 `GET /api/external/v1/assets/read-url` 获取短期签名 URL;不开放浏览器 STS 写权限。
- 素材库读取、文件夹创建 / 更新 / 删除、素材创建 / 更新 / 删除、项目画布资源创建全部通过 `api-server -> spacetime-client -> spacetime-module`
- 所有外部素材接口使用 API Key 所属的 `owner_user_id`,不能由请求体传入 owner。
- 素材库仍是账号级事实源,不归属于单个项目;项目画布内资源继续使用 `editor_project_resource`
- 素材创建和项目资源创建接口只保存记录和元数据;图片二进制上传、签名读取和生成图持久化继续复用已有资产 / 生成链路。
## OpenAPI 导出
OpenAPI 3.1 JSON 固定落在:
@@ -96,6 +136,11 @@ docs/openapi/genarrative-external-v1.openapi.json
- API Key 创建只返回一次明文,列表不返回明文。
- 撤销后的 API Key 调用外部接口返回 `401`
- 外部图成功后,生成结果同时出现在画布资源和账号级素材库。
- 外部图片生成、重绘、图标拆分和 UI 素材拆分成功后,生成结果按请求同时出现在画布资源和账号级素材库。
- 外部视频、角色动画、音效和音乐接口使用站内编辑器相同的请求校验、模型限制和价格校验。
- OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。
- OpenAPI JSON 不包含 `/api/profile/api-keys``UserAccessToken` 或 API Key 管理 schema。
- 外部素材库接口覆盖当前已有素材操作:直传凭证、素材对象确认、签名读取、读取素材库、创建 / 更新 / 删除文件夹、创建 / 更新 / 删除素材、创建项目画布资源。
- 外部项目接口覆盖当前已有项目管理操作:项目列表、最近项目、创建、读取、重命名、删除和默认画布保存。
- 外部素材生成接口覆盖当前已有编辑器素材操作:生图、重绘 / 调整、规范图生成、宣发素材生成、图标素材生成与拆分、UI 设计图生成与拆分、角色动画、视频、音效和背景音乐。
- 修改 SpacetimeDB schema 后运行 `npm run spacetime:generate``npm run check:spacetime-schema`
+53 -20
View File
@@ -56,6 +56,21 @@ pub async fn create_direct_upload_ticket(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<CreateDirectUploadTicketRequest>,
) -> Result<Json<Value>, AppError> {
create_direct_upload_ticket_for_owner(
&state,
&request_context,
authenticated.claims().user_id(),
payload,
)
.await
}
pub(crate) async fn create_direct_upload_ticket_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
payload: CreateDirectUploadTicketRequest,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
@@ -95,9 +110,9 @@ pub async fn create_direct_upload_ticket(
let upload = direct_upload_ticket_payload_from_oss(signed);
record_asset_tracking_event(
&state,
&request_context,
&authenticated,
state,
request_context,
owner_user_id,
"asset_upload_ticket_create",
json!({
"asset": {
@@ -117,7 +132,7 @@ pub async fn create_direct_upload_ticket(
.await;
Ok(json_success_body(
Some(&request_context),
Some(request_context),
CreateDirectUploadTicketResponse { upload },
))
}
@@ -126,6 +141,14 @@ pub async fn get_asset_read_url(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Query(query): Query<GetReadUrlQuery>,
) -> Result<Json<Value>, AppError> {
get_asset_read_url_with_query(&state, &request_context, query).await
}
pub(crate) async fn get_asset_read_url_with_query(
state: &AppState,
request_context: &RequestContext,
query: GetReadUrlQuery,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
@@ -154,7 +177,7 @@ pub async fn get_asset_read_url(
})?;
Ok(json_success_body(
Some(&request_context),
Some(request_context),
GetAssetReadUrlResponse {
read: asset_read_url_payload_from_oss(signed),
},
@@ -384,6 +407,21 @@ pub async fn confirm_asset_object(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ConfirmAssetObjectRequest>,
) -> Result<Json<Value>, AppError> {
confirm_asset_object_for_owner(
&state,
&request_context,
authenticated.claims().user_id(),
payload,
)
.await
}
pub(crate) async fn confirm_asset_object_for_owner(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
payload: ConfirmAssetObjectRequest,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
@@ -395,13 +433,9 @@ pub async fn confirm_asset_object(
let result = state
.spacetime_client()
.confirm_asset_object(
build_confirm_asset_object_upsert_input(
oss_client,
payload,
authenticated.claims().user_id(),
)
.await
.map_err(map_confirm_asset_object_prepare_error)?,
build_confirm_asset_object_upsert_input(oss_client, payload, owner_user_id)
.await
.map_err(map_confirm_asset_object_prepare_error)?,
)
.await
.map_err(map_confirm_asset_object_error)?;
@@ -425,9 +459,9 @@ pub async fn confirm_asset_object(
};
record_asset_tracking_event(
&state,
&request_context,
&authenticated,
state,
request_context,
owner_user_id,
"asset_upload_confirm",
json!({
"asset": {
@@ -451,7 +485,7 @@ pub async fn confirm_asset_object(
.await;
Ok(json_success_body(
Some(&request_context),
Some(request_context),
ConfirmAssetObjectResponse { asset_object },
))
}
@@ -498,7 +532,7 @@ pub async fn bind_asset_object_to_entity(
record_asset_tracking_event(
&state,
&request_context,
&authenticated,
authenticated.claims().user_id(),
"asset_bind",
json!({
"asset": {
@@ -526,12 +560,11 @@ pub async fn bind_asset_object_to_entity(
async fn record_asset_tracking_event(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
user_id: &str,
event_key: &'static str,
metadata: Value,
) {
let user_id = authenticated.claims().user_id().to_string();
let mut draft = TrackingEventDraft::user(event_key, "asset", user_id.as_str());
let mut draft = TrackingEventDraft::user(event_key, "asset", user_id);
draft.metadata = metadata;
record_tracking_event_after_success(state, request_context, draft).await;
}
@@ -520,6 +520,21 @@ pub async fn generate_editor_character_animation(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<EditorCharacterAnimationGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
generate_editor_character_animation_for_owner(
state,
request_context,
"editor-character-animation".to_string(),
payload,
)
.await
}
pub(crate) async fn generate_editor_character_animation_for_owner(
state: AppState,
request_context: RequestContext,
owner_user_id: String,
payload: Result<Json<EditorCharacterAnimationGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
character_animation_error_response(
@@ -538,7 +553,6 @@ pub async fn generate_editor_character_animation(
let extraction_settings = resolve_backend_frame_extraction_settings(&state);
let http_client = build_upstream_http_client(settings.ark.request_timeout_ms)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let owner_user_id = "editor-character-animation".to_string();
let task_id = generate_ai_task_id(current_utc_micros());
let result = async {
@@ -601,6 +615,16 @@ pub async fn generate_editor_video(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
payload: Result<Json<EditorVideoGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
generate_editor_video_for_owner(state, request_context, "editor-video".to_string(), payload)
.await
}
pub(crate) async fn generate_editor_video_for_owner(
state: AppState,
request_context: RequestContext,
owner_user_id: String,
payload: Result<Json<EditorVideoGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
editor_video_error_response(
@@ -624,7 +648,7 @@ pub async fn generate_editor_video(
&state,
&http_client,
&settings,
"editor-video",
owner_user_id.as_str(),
task_id.as_str(),
&normalized,
)
@@ -81,7 +81,11 @@ fn editor_video_model_resolution_mud_points_per_second(
EDITOR_VIDEO_GENERATION_720P_MUD_POINTS_PER_SECOND,
),
};
if resolution == "720p" { rate_720p } else { rate_480p }
if resolution == "720p" {
rate_720p
} else {
rate_480p
}
}
pub(crate) fn editor_video_model_generation_mud_points(
+160 -69
View File
@@ -57,7 +57,7 @@ use crate::{
pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-";
pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-";
pub(crate) const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-";
pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024;
pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
@@ -164,56 +164,73 @@ pub struct EditorAssetUpdateRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageGenerationRequest {
prompt: String,
size: Option<String>,
kind: Option<String>,
model: Option<String>,
aspect_ratio: Option<String>,
image_size: Option<String>,
reference_image_srcs: Option<Vec<String>>,
project_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
asset_folder_id: Option<String>,
asset_label: Option<String>,
source_resource_id: Option<String>,
pub(crate) prompt: String,
pub(crate) size: Option<String>,
pub(crate) kind: Option<String>,
pub(crate) model: Option<String>,
pub(crate) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
pub(crate) reference_image_srcs: Option<Vec<String>>,
pub(crate) project_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) asset_label: Option<String>,
pub(crate) source_resource_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorImageEditRequest {
prompt: String,
source_image_src: String,
project_id: Option<String>,
asset_kind: Option<String>,
generation_inputs: Option<Value>,
asset_folder_id: Option<String>,
asset_label: Option<String>,
source_resource_id: Option<String>,
pub(crate) prompt: String,
pub(crate) source_image_src: String,
pub(crate) project_id: Option<String>,
pub(crate) asset_kind: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) asset_label: Option<String>,
pub(crate) source_resource_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorIconSpritesheetGenerationRequest {
reference_image_src: String,
icon_descriptions: Vec<String>,
model: Option<String>,
aspect_ratio: Option<String>,
image_size: Option<String>,
price_mud_points: u32,
project_id: Option<String>,
generation_inputs: Option<Value>,
asset_folder_id: Option<String>,
pub(crate) reference_image_src: String,
pub(crate) icon_descriptions: Vec<String>,
pub(crate) model: Option<String>,
pub(crate) aspect_ratio: Option<String>,
pub(crate) image_size: Option<String>,
pub(crate) price_mud_points: u32,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorUiDesignAssetExtractionRequest {
source_image_src: String,
project_id: Option<String>,
generation_inputs: Option<Value>,
asset_folder_id: Option<String>,
spritesheet_label: Option<String>,
pub(crate) source_image_src: String,
pub(crate) project_id: Option<String>,
pub(crate) generation_inputs: Option<Value>,
pub(crate) asset_folder_id: Option<String>,
pub(crate) spritesheet_label: Option<String>,
}
pub(crate) struct EditorGenerationCaller {
pub(crate) owner_user_id: String,
pub(crate) audit_subject_user_id: Option<String>,
pub(crate) audit_project_id: Option<String>,
}
impl EditorGenerationCaller {
fn from_authenticated(authenticated: &AuthenticatedAccessToken) -> Self {
let owner_user_id = authenticated.claims().user_id().to_string();
Self {
audit_subject_user_id: Some(owner_user_id.clone()),
owner_user_id,
audit_project_id: None,
}
}
}
#[derive(Debug, Serialize)]
@@ -835,6 +852,21 @@ pub async fn generate_editor_image(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorImageGenerationRequest>,
) -> Result<Json<Value>, AppError> {
generate_editor_image_for_owner(
&state,
&request_context,
EditorGenerationCaller::from_authenticated(&authenticated),
payload,
)
.await
}
pub(crate) async fn generate_editor_image_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorImageGenerationRequest,
) -> Result<Json<Value>, AppError> {
let role_setting = payload.prompt.trim().to_string();
if role_setting.is_empty() {
@@ -899,10 +931,13 @@ pub async fn generate_editor_image(
.filter(|source| !source.is_empty())
.take(reference_limit)
.collect::<Vec<_>>();
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(authenticated.claims().user_id().to_string()),
None,
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let negative_prompt = Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体");
@@ -977,8 +1012,8 @@ pub async fn generate_editor_image(
};
let generated = if is_publication_material_generation {
execute_billable_asset_operation_with_cost(
&state,
authenticated.claims().user_id(),
state,
caller.owner_user_id.as_str(),
EDITOR_PUBLICATION_MATERIAL_ASSET_KIND,
request_context.request_id(),
u64::from(configured_price_mud_points),
@@ -1004,8 +1039,8 @@ pub async fn generate_editor_image(
let persisted = if is_character_generation {
Some(
persist_editor_generated_image(
&state,
authenticated.claims().user_id(),
state,
caller.owner_user_id.as_str(),
generated.task_id.as_str(),
&image,
submitted_prompt.as_str(),
@@ -1025,10 +1060,10 @@ pub async fn generate_editor_image(
BASE64_STANDARD.encode(image.bytes.as_slice())
);
let generated_asset = persist_editor_generated_asset(
&state,
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id,
owner_user_id: current_owner_user_id(&authenticated),
owner_user_id: caller.owner_user_id,
folder_id: payload.asset_folder_id,
label: normalize_optional_string(payload.asset_label)
.unwrap_or_else(|| role_setting.clone()),
@@ -1197,6 +1232,21 @@ pub async fn edit_editor_image(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorImageEditRequest>,
) -> Result<Json<Value>, AppError> {
edit_editor_image_for_owner(
&state,
&request_context,
EditorGenerationCaller::from_authenticated(&authenticated),
payload,
)
.await
}
pub(crate) async fn edit_editor_image_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorImageEditRequest,
) -> Result<Json<Value>, AppError> {
let prompt = payload.prompt.trim().to_string();
if prompt.is_empty() {
@@ -1209,10 +1259,13 @@ pub async fn edit_editor_image(
}
let reference_image = parse_editor_reference_image(payload.source_image_src.as_str())?;
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(authenticated.claims().user_id().to_string()),
None,
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_edit_with_references(
@@ -1241,10 +1294,10 @@ pub async fn edit_editor_image(
BASE64_STANDARD.encode(image.bytes.as_slice())
);
let generated_asset = persist_editor_generated_asset(
&state,
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id,
owner_user_id: current_owner_user_id(&authenticated),
owner_user_id: caller.owner_user_id,
folder_id: payload.asset_folder_id,
label: normalize_optional_string(payload.asset_label)
.unwrap_or_else(|| "图片修改结果".to_string()),
@@ -1290,6 +1343,21 @@ pub async fn generate_editor_icon_spritesheet(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorIconSpritesheetGenerationRequest>,
) -> Result<Json<Value>, AppError> {
generate_editor_icon_spritesheet_for_owner(
&state,
&request_context,
EditorGenerationCaller::from_authenticated(&authenticated),
payload,
)
.await
}
pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorIconSpritesheetGenerationRequest,
) -> Result<Json<Value>, AppError> {
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
let reference_image = parse_editor_reference_image(payload.reference_image_src.as_str())
@@ -1312,10 +1380,13 @@ pub async fn generate_editor_icon_spritesheet(
let size = generation_options.size.as_str();
let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions);
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(authenticated.claims().user_id().to_string()),
None,
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
@@ -1372,9 +1443,9 @@ pub async fn generate_editor_icon_spritesheet(
let spritesheet_image_src =
data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice());
let generation_inputs = payload.generation_inputs.clone();
let owner_user_id = current_owner_user_id(&authenticated);
let owner_user_id = caller.owner_user_id;
let spritesheet_record = persist_editor_generated_asset(
&state,
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
@@ -1400,7 +1471,7 @@ pub async fn generate_editor_icon_spritesheet(
for icon in icon_slices {
let icon_response = editor_icon_response_from_slice(icon);
let icon_record = persist_editor_generated_asset(
&state,
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
@@ -1453,6 +1524,21 @@ pub async fn extract_editor_ui_design_assets(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorUiDesignAssetExtractionRequest>,
) -> Result<Json<Value>, AppError> {
extract_editor_ui_design_assets_for_owner(
&state,
&request_context,
EditorGenerationCaller::from_authenticated(&authenticated),
payload,
)
.await
}
pub(crate) async fn extract_editor_ui_design_assets_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: EditorGenerationCaller,
payload: EditorUiDesignAssetExtractionRequest,
) -> Result<Json<Value>, AppError> {
let reference_image =
parse_editor_reference_image(payload.source_image_src.as_str()).map_err(|error| {
@@ -1462,10 +1548,13 @@ pub async fn extract_editor_ui_design_assets(
"message": "UI设计图必须是图片 Data URL。",
}))
})?;
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
&request_context,
Some(authenticated.claims().user_id().to_string()),
None,
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_edit_with_references_and_model(
@@ -1505,9 +1594,9 @@ pub async fn extract_editor_ui_design_assets(
data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice());
let prompt = EDITOR_UI_DESIGN_ASSET_EXTRACTION_PROMPT.to_string();
let generation_inputs = payload.generation_inputs.clone();
let owner_user_id = current_owner_user_id(&authenticated);
let owner_user_id = caller.owner_user_id;
let spritesheet_record = persist_editor_generated_asset(
&state,
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
@@ -1534,7 +1623,7 @@ pub async fn extract_editor_ui_design_assets(
for icon in icon_slices {
let icon_response = editor_icon_response_from_slice(icon);
let icon_record = persist_editor_generated_asset(
&state,
state,
PersistEditorGeneratedAssetInput {
project_id: payload.project_id.clone(),
owner_user_id: owner_user_id.clone(),
@@ -1649,7 +1738,7 @@ pub(crate) fn editor_project_resource_payload_from_record(
}
}
fn editor_asset_library_payload_from_record(
pub(crate) fn editor_asset_library_payload_from_record(
record: EditorAssetLibraryRecord,
) -> EditorAssetLibraryPayload {
EditorAssetLibraryPayload {
@@ -1666,7 +1755,7 @@ fn editor_asset_library_payload_from_record(
}
}
fn editor_asset_folder_payload_from_record(
pub(crate) fn editor_asset_folder_payload_from_record(
record: EditorAssetFolderRecord,
) -> EditorAssetFolderPayload {
EditorAssetFolderPayload {
@@ -1825,7 +1914,9 @@ pub(crate) fn serialize_editor_layers(layers: Value) -> Result<String, AppError>
Ok(payload)
}
fn serialize_editor_asset_metadata(metadata: Option<Value>) -> Result<Option<String>, AppError> {
pub(crate) fn serialize_editor_asset_metadata(
metadata: Option<Value>,
) -> Result<Option<String>, AppError> {
let Some(metadata) = metadata else {
return Ok(None);
};
@@ -21,8 +21,12 @@ use crate::{
const EXTERNAL_API_KEY_ID_PREFIX: &str = "external-api-key-";
const EXTERNAL_API_KEY_SECRET_PREFIX: &str = "tnr_sk_";
const EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS: usize = 18;
const EXTERNAL_API_KEY_SCOPES: [&str; 3] =
["editor:project", "editor:canvas", "editor:image-generate"];
const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [
"editor:project",
"editor:canvas",
"editor:image-generate",
"editor:asset",
];
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -237,6 +241,7 @@ mod tests {
"editor:project".to_string(),
"editor:canvas".to_string(),
"editor:image-generate".to_string(),
"editor:asset".to_string(),
]
);
}
@@ -0,0 +1,75 @@
use axum::{
Json,
extract::{Extension, Query, State},
http::StatusCode,
};
use serde_json::{Value, json};
use shared_contracts::assets::{
ConfirmAssetObjectRequest, CreateDirectUploadTicketRequest, GetReadUrlQuery,
};
use crate::{
assets::{
confirm_asset_object_for_owner, create_direct_upload_ticket_for_owner,
get_asset_read_url_with_query,
},
external_api_auth::ExternalApiPrincipal,
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
const SCOPE_EDITOR_ASSET: &str = "editor:asset";
const EXTERNAL_ASSET_PROVIDER: &str = "external-assets-api";
pub async fn create_external_direct_upload_ticket(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(payload): Json<CreateDirectUploadTicketRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
create_direct_upload_ticket_for_owner(
&state,
&request_context,
principal.owner_user_id(),
payload,
)
.await
}
pub async fn confirm_external_asset_object(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Json(mut payload): Json<ConfirmAssetObjectRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
payload.owner_user_id = Some(principal.owner_user_id().to_string());
confirm_asset_object_for_owner(&state, &request_context, principal.owner_user_id(), payload)
.await
}
pub async fn get_external_asset_read_url(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
Query(query): Query<GetReadUrlQuery>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_ASSET)?;
get_asset_read_url_with_query(&state, &request_context, query).await
}
fn require_scope(principal: &ExternalApiPrincipal, scope: &str) -> Result<(), AppError> {
if principal.has_scope(scope) {
return Ok(());
}
Err(
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
"provider": EXTERNAL_ASSET_PROVIDER,
"keyId": principal.key_id(),
"scope": scope,
"message": "API Key 缺少所需权限",
})),
)
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -44,6 +44,7 @@ mod error_middleware;
mod external_api_audit;
mod external_api_auth;
mod external_api_keys;
mod external_assets_api;
mod external_editor_api;
mod external_generation;
mod external_generation_worker;
@@ -7,28 +7,82 @@ use axum::{
use crate::{
external_api_auth::require_external_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_project, generate_external_editor_image,
get_external_editor_project, openapi_json, save_external_editor_canvas,
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_sound_effect,
generate_external_editor_video, get_external_editor_asset_library,
get_external_editor_project, list_external_editor_projects,
load_recent_external_editor_project, openapi_json, rename_external_editor_project,
save_external_editor_canvas, update_external_editor_asset,
update_external_editor_asset_folder,
},
state::AppState,
};
const EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
const EXTERNAL_EDITOR_VIDEO_BODY_LIMIT_BYTES: usize = 64 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route("/api/external/v1/openapi.json", get(openapi_json))
.route(
"/api/external/v1/assets/direct-upload-tickets",
post(create_external_direct_upload_ticket).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/assets/objects/confirm",
post(confirm_external_asset_object).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/assets/read-url",
get(get_external_asset_read_url).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/projects",
post(create_external_editor_project).route_layer(middleware::from_fn_with_state(
get(list_external_editor_projects)
.post(create_external_editor_project)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/projects/recent",
get(load_recent_external_editor_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/projects/{project_id}",
get(get_external_editor_project).route_layer(middleware::from_fn_with_state(
get(get_external_editor_project)
.delete(delete_external_editor_project)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/projects/{project_id}/metadata",
patch(rename_external_editor_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
@@ -40,6 +94,51 @@ pub fn router(state: AppState) -> Router<AppState> {
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/projects/{project_id}/resources",
post(create_external_editor_project_resource).route_layer(
middleware::from_fn_with_state(state.clone(), require_external_api_key),
),
)
.route(
"/api/external/v1/editor/assets/library",
get(get_external_editor_asset_library).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/assets/folders",
post(create_external_editor_asset_folder).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/assets/folders/{folder_id}",
patch(update_external_editor_asset_folder)
.delete(delete_external_editor_asset_folder)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/assets",
post(create_external_editor_asset).route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/assets/{asset_id}",
patch(update_external_editor_asset)
.delete(delete_external_editor_asset)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/images/generations",
post(generate_external_editor_image)
@@ -47,8 +146,75 @@ pub fn router(state: AppState) -> Router<AppState> {
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state,
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/images/edits",
post(edit_external_editor_image)
.layer(DefaultBodyLimit::max(
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/icon-spritesheets/generations",
post(generate_external_editor_icon_spritesheet)
.layer(DefaultBodyLimit::max(
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/ui-designs/assets/extractions",
post(extract_external_editor_ui_design_assets)
.layer(DefaultBodyLimit::max(
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/character-animations/generations",
post(generate_external_editor_character_animation)
.layer(DefaultBodyLimit::max(
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/videos/generations",
post(generate_external_editor_video)
.layer(DefaultBodyLimit::max(
EXTERNAL_EDITOR_VIDEO_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_external_api_key,
)),
)
.route(
"/api/external/v1/editor/audios/sound-effects/generations",
post(generate_external_editor_sound_effect).route_layer(
middleware::from_fn_with_state(state.clone(), require_external_api_key),
),
)
.route(
"/api/external/v1/editor/audios/background-music/generations",
post(generate_external_editor_background_music).route_layer(
middleware::from_fn_with_state(state, require_external_api_key),
),
)
}
@@ -12,6 +12,9 @@ mod tests;
mod types;
pub use generation::{generate_editor_background_music, generate_editor_sound_effect};
pub(crate) use generation::{
generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner,
};
pub use handlers::{
create_background_music_task, create_sound_effect_task,
create_visual_novel_background_music_task, create_visual_novel_sound_effect_task,
@@ -121,6 +121,21 @@ pub async fn generate_editor_sound_effect(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<assets::EditorSoundEffectGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
generate_editor_sound_effect_for_owner(
state,
request_context,
authenticated.claims().user_id().to_string(),
payload,
)
.await
}
pub(crate) async fn generate_editor_sound_effect_for_owner(
state: AppState,
request_context: RequestContext,
owner_user_id: String,
payload: Result<Json<assets::EditorSoundEffectGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let normalized = normalize_editor_sound_effect_request(payload)
@@ -145,7 +160,7 @@ pub async fn generate_editor_sound_effect(
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let generated = wait_for_generated_audio_asset_with_task_kind(
&state,
authenticated.claims().user_id(),
owner_user_id.as_str(),
task.task_id.clone(),
AudioAssetSlot::SoundEffect,
task.kind,
@@ -184,6 +199,21 @@ pub async fn generate_editor_background_music(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<assets::EditorBackgroundMusicGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
generate_editor_background_music_for_owner(
state,
request_context,
authenticated.claims().user_id().to_string(),
payload,
)
.await
}
pub(crate) async fn generate_editor_background_music_for_owner(
state: AppState,
request_context: RequestContext,
owner_user_id: String,
payload: Result<Json<assets::EditorBackgroundMusicGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let normalized = normalize_editor_background_music_request(payload)
@@ -207,7 +237,7 @@ pub async fn generate_editor_background_music(
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let generated = wait_for_generated_audio_asset(
&state,
authenticated.claims().user_id(),
owner_user_id.as_str(),
task.task_id.clone(),
AudioAssetSlot::BackgroundMusic,
build_editor_audio_target(&task.task_id, "background_music", "editor_background_music"),
@@ -3,7 +3,7 @@ use crate::*;
const EXTERNAL_API_KEY_MAX_NAME_CHARS: usize = 80;
const EXTERNAL_API_KEY_DEFAULT_NAME: &str = "外部 API Key";
const EXTERNAL_API_KEY_DEFAULT_SCOPES_JSON: &str =
"[\"editor:project\",\"editor:canvas\",\"editor:image-generate\"]";
"[\"editor:project\",\"editor:canvas\",\"editor:image-generate\",\"editor:asset\"]";
#[spacetimedb::table(
accessor = external_api_key,