补齐外部OpenAPI编辑器素材能力
移除外部OpenAPI中的API Key管理接口暴露 补齐编辑器项目列表、最近项目、重命名和删除外部接口 补齐素材直传凭证、素材对象确认和签名读取外部接口 复用站内编辑器生成、素材库和项目资源链路并按API Key账号归属 更新外部OpenAPI、后端契约和项目共享记忆文档
This commit is contained in:
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`:作用域 JSON,v1 固定包含 `editor:project`、`editor:canvas`、`editor:image-generate`。
|
||||
- `scopes_json`:作用域 JSON,v1 固定包含 `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`。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user