接入外部 OpenAPI 与 API Key 管理
新增外部编辑器 OpenAPI 路由与 openapi.json 导出 新增账号级 API Key 表、鉴权、创建、列表和撤销链路 新增个人中心开发者 API Key 管理弹窗 补充前端契约、客户端方法、测试与项目文档
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 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`。
|
||||
- 影响范围:`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`。
|
||||
|
||||
## 2026-06-19 图片画布生成按钮价格统一绑定模型定价配置
|
||||
|
||||
- 背景:图片画布的生成图片、生成视频、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。
|
||||
|
||||
@@ -429,6 +429,13 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`DatabaseMigrationOperator`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/migration.rs`
|
||||
|
||||
### `external_api_key`
|
||||
|
||||
- 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。
|
||||
- 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。
|
||||
|
||||
### `editor_project`
|
||||
|
||||
- Rust 结构体:`EditorProject`
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# 外部 OpenAPI 与 API Key 接入方案
|
||||
|
||||
## 背景
|
||||
|
||||
外部调用方需要通过稳定 HTTP 契约使用图片画布编辑器内的美术生图能力,并能创建项目、保存画板布局。该能力必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态或生成结果放到前端临时状态中。
|
||||
|
||||
## v1 范围
|
||||
|
||||
本期新增外部 API 命名空间:
|
||||
|
||||
```text
|
||||
/api/external/v1
|
||||
```
|
||||
|
||||
v1 只开放以下能力:
|
||||
|
||||
- `POST /api/external/v1/editor/projects`:创建图片画布项目。
|
||||
- `GET /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` 素材库。
|
||||
- `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。
|
||||
|
||||
管理 API Key 的登录态接口:
|
||||
|
||||
```text
|
||||
GET /api/profile/api-keys
|
||||
POST /api/profile/api-keys
|
||||
DELETE /api/profile/api-keys/{keyId}
|
||||
```
|
||||
|
||||
前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。
|
||||
|
||||
## 鉴权
|
||||
|
||||
外部调用使用 Bearer API Key:
|
||||
|
||||
```http
|
||||
Authorization: Bearer tnr_sk_xxx
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- API Key 归属于 `owner_user_id`,外部接口只能访问该账号自己的项目、画布和生成素材。
|
||||
- 明文 Key 只在创建接口返回一次,后端只保存 `key_hash` 与 `key_prefix`。
|
||||
- API Key 被撤销后立即不可再用于外部接口。
|
||||
- 外部 API 鉴权不复用登录态 JWT,不检查 refresh session;它是独立开发者凭据。
|
||||
- OpenAPI JSON 公共可读,不需要鉴权。
|
||||
|
||||
## 数据模型
|
||||
|
||||
新增 SpacetimeDB private 表:
|
||||
|
||||
```text
|
||||
external_api_key
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
- `key_id`:主键。
|
||||
- `owner_user_id`:所属账号。
|
||||
- `name`:用户可识别名称。
|
||||
- `key_prefix`:前缀片段,用于列表展示和排障。
|
||||
- `key_hash`:完整 Key 的 SHA-256 十六进制摘要,唯一。
|
||||
- `scopes_json`:作用域 JSON,v1 固定包含 `editor:project`、`editor:canvas`、`editor:image-generate`。
|
||||
- `created_at` / `last_used_at` / `revoked_at` / `updated_at`。
|
||||
|
||||
SpacetimeDB procedure:
|
||||
|
||||
- `create_external_api_key_and_return`
|
||||
- `list_external_api_keys_and_return`
|
||||
- `revoke_external_api_key_and_return`
|
||||
- `authenticate_external_api_key_and_return`
|
||||
|
||||
## 生成图落库
|
||||
|
||||
外部生图接口复用编辑器内 `VectorEngine` / `gpt-image-2` 生成链路,后端拿到图片后:
|
||||
|
||||
1. 通过 OSS / asset object adapter 持久化图片。
|
||||
2. 写入 `editor_asset`,让生成图进入账号级素材库。
|
||||
3. 如果请求带 `projectId`,写入 `editor_project_resource`。
|
||||
4. 返回图片读取地址、素材 ID、资源 ID、尺寸、prompt、model、provider 和 taskId。
|
||||
|
||||
如果请求未带 `projectId`,只生成并写入素材库;调用方可随后创建项目或自行保存画板布局。
|
||||
|
||||
## OpenAPI 导出
|
||||
|
||||
OpenAPI 3.1 JSON 固定落在:
|
||||
|
||||
```text
|
||||
docs/openapi/genarrative-external-v1.openapi.json
|
||||
```
|
||||
|
||||
服务端 `GET /api/external/v1/openapi.json` 使用同一份 JSON,通过 `include_str!` 导出,避免运行时生成结果与仓库文档漂移。
|
||||
|
||||
## 验收
|
||||
|
||||
- API Key 创建只返回一次明文,列表不返回明文。
|
||||
- 撤销后的 API Key 调用外部接口返回 `401`。
|
||||
- 外部生图成功后,生成结果同时出现在画布资源和账号级素材库。
|
||||
- OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。
|
||||
- 修改 SpacetimeDB schema 后运行 `npm run spacetime:generate` 与 `npm run check:spacetime-schema`。
|
||||
@@ -75,6 +75,30 @@ export type ProfileWalletLedgerResponse = {
|
||||
entries: ProfileWalletLedgerEntry[];
|
||||
};
|
||||
|
||||
export type ExternalApiKeyProfile = {
|
||||
keyId: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
scopes: string[];
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ExternalApiKeyListResponse = {
|
||||
keys: ExternalApiKeyProfile[];
|
||||
};
|
||||
|
||||
export type ExternalApiKeyCreateResponse = {
|
||||
apiKey: string;
|
||||
key: ExternalApiKeyProfile;
|
||||
};
|
||||
|
||||
export type ExternalApiKeyMutationResponse = {
|
||||
key: ExternalApiKeyProfile;
|
||||
};
|
||||
|
||||
export type ProfileRechargeProductKind = 'points' | 'membership';
|
||||
export type ProfileMembershipStatus = 'normal' | 'active';
|
||||
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
|
||||
|
||||
@@ -42,6 +42,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(modules::internal::router(state.clone()))
|
||||
.merge(modules::auth::router(state.clone()))
|
||||
.merge(modules::profile::router(state.clone()))
|
||||
.merge(modules::external_api::router(state.clone()))
|
||||
.merge(modules::assets::router(state.clone()))
|
||||
.merge(modules::editor_project::router(state.clone()))
|
||||
.merge(modules::platform::router(state.clone()))
|
||||
|
||||
@@ -54,12 +54,12 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-";
|
||||
const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-";
|
||||
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-";
|
||||
const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
|
||||
pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-";
|
||||
const EDITOR_LAYOUT_MAX_BYTES: usize = 256 * 1024;
|
||||
const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
|
||||
pub(crate) const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布";
|
||||
const EDITOR_IMAGE_GENERATION_SIZE: &str = "1024x1024";
|
||||
const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview";
|
||||
const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2";
|
||||
@@ -88,8 +88,8 @@ pub struct EditorCanvasViewportPayload {
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorProjectLayoutSaveRequest {
|
||||
viewport: EditorCanvasViewportPayload,
|
||||
layers: Value,
|
||||
pub(crate) viewport: EditorCanvasViewportPayload,
|
||||
pub(crate) layers: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -932,13 +932,16 @@ pub async fn generate_editor_image(
|
||||
.unwrap_or((1024, 1024));
|
||||
let persisted = if is_character_generation {
|
||||
Some(
|
||||
persist_editor_character_image(
|
||||
persist_editor_generated_image(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
generated.task_id.as_str(),
|
||||
&image,
|
||||
submitted_prompt.as_str(),
|
||||
generated.actual_prompt.as_deref(),
|
||||
EDITOR_CHARACTER_IMAGE_ASSET_KIND,
|
||||
"character-images",
|
||||
EDITOR_CHARACTER_IMAGE_SLOT,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
@@ -1345,7 +1348,9 @@ pub async fn extract_editor_ui_design_assets(
|
||||
))
|
||||
}
|
||||
|
||||
fn editor_project_payload_from_record(record: EditorProjectRecord) -> EditorProjectPayload {
|
||||
pub(crate) fn editor_project_payload_from_record(
|
||||
record: EditorProjectRecord,
|
||||
) -> EditorProjectPayload {
|
||||
let canvas = editor_canvas_payload_from_record(record.canvas);
|
||||
EditorProjectPayload {
|
||||
project_id: record.project_id,
|
||||
@@ -1382,7 +1387,7 @@ fn editor_canvas_payload_from_record(record: EditorCanvasRecord) -> EditorCanvas
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_project_resource_payload_from_record(
|
||||
pub(crate) fn editor_project_resource_payload_from_record(
|
||||
record: EditorProjectResourceRecord,
|
||||
) -> EditorProjectResourcePayload {
|
||||
EditorProjectResourcePayload {
|
||||
@@ -1436,7 +1441,7 @@ fn editor_asset_folder_payload_from_record(
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload {
|
||||
pub(crate) fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPayload {
|
||||
EditorAssetPayload {
|
||||
asset_id: record.asset_id,
|
||||
folder_id: record.folder_id,
|
||||
@@ -1458,7 +1463,7 @@ fn editor_asset_payload_from_record(record: EditorAssetRecord) -> EditorAssetPay
|
||||
}
|
||||
|
||||
impl EditorCanvasViewportPayload {
|
||||
fn into_record(self) -> EditorCanvasViewportRecord {
|
||||
pub(crate) fn into_record(self) -> EditorCanvasViewportRecord {
|
||||
EditorCanvasViewportRecord {
|
||||
x: self.x,
|
||||
y: self.y,
|
||||
@@ -1471,7 +1476,7 @@ fn current_owner_user_id(authenticated: &AuthenticatedAccessToken) -> String {
|
||||
authenticated.claims().user_id().to_string()
|
||||
}
|
||||
|
||||
fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
|
||||
pub(crate) fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
|
||||
let payload = serde_json::to_string(&layers).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-project",
|
||||
@@ -1489,13 +1494,13 @@ fn serialize_editor_layers(layers: Value) -> Result<String, AppError> {
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
pub(crate) fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
}
|
||||
|
||||
fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String {
|
||||
pub(crate) fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.chars()
|
||||
@@ -1620,7 +1625,7 @@ fn prepare_editor_character_image_for_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String {
|
||||
pub(crate) fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String {
|
||||
format!(
|
||||
"data:{};base64,{}",
|
||||
mime_type,
|
||||
@@ -1648,18 +1653,21 @@ struct EditorGenerationOptions {
|
||||
provider_image_size: &'static str,
|
||||
}
|
||||
|
||||
struct PersistedEditorGeneratedImage {
|
||||
object_key: String,
|
||||
asset_object_id: String,
|
||||
pub(crate) struct PersistedEditorGeneratedImage {
|
||||
pub(crate) object_key: String,
|
||||
pub(crate) asset_object_id: String,
|
||||
}
|
||||
|
||||
async fn persist_editor_character_image(
|
||||
pub(crate) async fn persist_editor_generated_image(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
task_id: &str,
|
||||
image: &DownloadedOpenAiImage,
|
||||
prompt: &str,
|
||||
actual_prompt: Option<&str>,
|
||||
asset_kind: &str,
|
||||
path_kind: &str,
|
||||
slot: &str,
|
||||
) -> Result<PersistedEditorGeneratedImage, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
@@ -1672,7 +1680,7 @@ async fn persist_editor_character_image(
|
||||
prefix: LegacyAssetPrefix::CharacterDrafts,
|
||||
path_segments: vec![
|
||||
"editor".to_string(),
|
||||
"character-images".to_string(),
|
||||
sanitize_editor_storage_segment(path_kind, "generated-images"),
|
||||
sanitize_editor_storage_segment(task_id, "task"),
|
||||
],
|
||||
file_stem: "image".to_string(),
|
||||
@@ -1682,11 +1690,11 @@ async fn persist_editor_character_image(
|
||||
},
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: GeneratedImageAssetAdapterMetadata {
|
||||
asset_kind: Some(EDITOR_CHARACTER_IMAGE_ASSET_KIND.to_string()),
|
||||
asset_kind: Some(asset_kind.to_string()),
|
||||
owner_user_id: Some(owner_user_id.to_string()),
|
||||
entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()),
|
||||
entity_id: Some(task_id.to_string()),
|
||||
slot: Some(EDITOR_CHARACTER_IMAGE_SLOT.to_string()),
|
||||
slot: Some(slot.to_string()),
|
||||
provider: Some("vector-engine".to_string()),
|
||||
task_id: Some(task_id.to_string()),
|
||||
},
|
||||
@@ -1728,7 +1736,7 @@ async fn persist_editor_character_image(
|
||||
head.content_type.or(Some(persisted_mime_type)),
|
||||
head.content_length,
|
||||
Some(actual_prompt.unwrap_or(prompt).to_string()),
|
||||
EDITOR_CHARACTER_IMAGE_ASSET_KIND.to_string(),
|
||||
asset_kind.to_string(),
|
||||
Some(task_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
None,
|
||||
@@ -1751,7 +1759,7 @@ async fn persist_editor_character_image(
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, AppError> {
|
||||
pub(crate) fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, AppError> {
|
||||
let Some((header, data)) = source.trim().split_once(',') else {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
@@ -1803,7 +1811,7 @@ fn parse_editor_reference_image(source: &str) -> Result<OpenAiReferenceImage, Ap
|
||||
})
|
||||
}
|
||||
|
||||
fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
|
||||
pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
|
||||
match error {
|
||||
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
|
||||
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
|
||||
@@ -1829,14 +1837,14 @@ fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError {
|
||||
pub(crate) fn map_editor_asset_field_error(error: AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
pub(crate) fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use spacetime_client::ExternalApiKeyAuthenticateRecordInput;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
editor_project::current_utc_micros,
|
||||
external_api_keys::{hash_external_api_key, map_external_api_key_error},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExternalApiPrincipal {
|
||||
owner_user_id: String,
|
||||
key_id: String,
|
||||
scopes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ExternalApiPrincipal {
|
||||
pub fn owner_user_id(&self) -> &str {
|
||||
self.owner_user_id.as_str()
|
||||
}
|
||||
|
||||
pub fn key_id(&self) -> &str {
|
||||
self.key_id.as_str()
|
||||
}
|
||||
|
||||
pub fn has_scope(&self, scope: &str) -> bool {
|
||||
self.scopes.iter().any(|item| item == scope)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_external_api_key(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let request_id = request
|
||||
.extensions()
|
||||
.get::<RequestContext>()
|
||||
.map(|context| context.request_id().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let raw_key = extract_external_api_bearer(request.headers())?;
|
||||
let key = state
|
||||
.spacetime_client()
|
||||
.authenticate_external_api_key(ExternalApiKeyAuthenticateRecordInput {
|
||||
key_hash: hash_external_api_key(raw_key.as_str()),
|
||||
used_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
warn!(
|
||||
%request_id,
|
||||
error = %error,
|
||||
"外部 API Key 校验失败"
|
||||
);
|
||||
map_external_api_key_error(error)
|
||||
})?;
|
||||
let principal = ExternalApiPrincipal {
|
||||
owner_user_id: key.owner_user_id,
|
||||
key_id: key.key_id,
|
||||
scopes: key.scopes,
|
||||
};
|
||||
request.extensions_mut().insert(principal.clone());
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
response.extensions_mut().insert(principal);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn extract_external_api_bearer(headers: &HeaderMap) -> Result<String, AppError> {
|
||||
let authorization = headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?;
|
||||
|
||||
authorization
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| authorization.strip_prefix("bearer "))
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use platform_auth::hash_refresh_session_token;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use shared_kernel::{build_prefixed_uuid_id, new_uuid_simple_string};
|
||||
use spacetime_client::{
|
||||
ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput,
|
||||
SpacetimeClientError,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken,
|
||||
editor_project::current_utc_micros, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
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"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalApiKeyCreateRequest {
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalApiKeyPayload {
|
||||
key_id: String,
|
||||
name: String,
|
||||
key_prefix: String,
|
||||
scopes: Vec<String>,
|
||||
created_at: String,
|
||||
last_used_at: Option<String>,
|
||||
revoked_at: Option<String>,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalApiKeyCreateResponse {
|
||||
api_key: String,
|
||||
key: ExternalApiKeyPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalApiKeyListResponse {
|
||||
keys: Vec<ExternalApiKeyPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalApiKeyResponse {
|
||||
key: ExternalApiKeyPayload,
|
||||
}
|
||||
|
||||
pub async fn list_external_api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let keys = state
|
||||
.spacetime_client()
|
||||
.list_external_api_keys(authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(map_external_api_key_error)?
|
||||
.into_iter()
|
||||
.map(external_api_key_payload_from_record)
|
||||
.collect();
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalApiKeyListResponse { keys },
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_external_api_key(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<ExternalApiKeyCreateRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let raw_key = generate_external_api_key_secret();
|
||||
let key_prefix = external_api_key_prefix(raw_key.as_str());
|
||||
let key = state
|
||||
.spacetime_client()
|
||||
.create_external_api_key(ExternalApiKeyCreateRecordInput {
|
||||
key_id: build_prefixed_uuid_id(EXTERNAL_API_KEY_ID_PREFIX),
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
name: payload
|
||||
.name
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "外部 API Key".to_string()),
|
||||
key_prefix,
|
||||
key_hash: hash_external_api_key(raw_key.as_str()),
|
||||
scopes: default_external_api_key_scopes(),
|
||||
now_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_external_api_key_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalApiKeyCreateResponse {
|
||||
api_key: raw_key,
|
||||
key: external_api_key_payload_from_record(key),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn revoke_external_api_key(
|
||||
State(state): State<AppState>,
|
||||
Path(key_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let key = state
|
||||
.spacetime_client()
|
||||
.revoke_external_api_key(ExternalApiKeyRevokeRecordInput {
|
||||
key_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
revoked_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_external_api_key_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalApiKeyResponse {
|
||||
key: external_api_key_payload_from_record(key),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn hash_external_api_key(raw_key: &str) -> String {
|
||||
hash_refresh_session_token(raw_key)
|
||||
}
|
||||
|
||||
pub(crate) fn default_external_api_key_scopes() -> Vec<String> {
|
||||
EXTERNAL_API_KEY_SCOPES
|
||||
.iter()
|
||||
.map(|scope| (*scope).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_external_api_key_secret() -> String {
|
||||
format!(
|
||||
"{EXTERNAL_API_KEY_SECRET_PREFIX}{}.{}",
|
||||
new_uuid_simple_string(),
|
||||
new_uuid_simple_string()
|
||||
)
|
||||
}
|
||||
|
||||
fn external_api_key_prefix(raw_key: &str) -> String {
|
||||
raw_key
|
||||
.chars()
|
||||
.take(EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn external_api_key_payload_from_record(record: ExternalApiKeyRecord) -> ExternalApiKeyPayload {
|
||||
ExternalApiKeyPayload {
|
||||
key_id: record.key_id,
|
||||
name: record.name,
|
||||
key_prefix: record.key_prefix,
|
||||
scopes: record.scopes,
|
||||
created_at: record.created_at,
|
||||
last_used_at: record.last_used_at,
|
||||
revoked_at: record.revoked_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_external_api_key_error(error: SpacetimeClientError) -> AppError {
|
||||
match error {
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if message.contains("不存在") || message.contains("已失效") =>
|
||||
{
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
|
||||
"provider": "external-api-key",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
|
||||
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
|
||||
"provider": "external-api-key",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
SpacetimeClientError::Runtime(message) | SpacetimeClientError::Procedure(message) => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "external-api-key",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": other.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_external_api_key_uses_public_prefix_only_for_display() {
|
||||
let raw_key = generate_external_api_key_secret();
|
||||
let key_prefix = external_api_key_prefix(raw_key.as_str());
|
||||
|
||||
assert!(raw_key.starts_with(EXTERNAL_API_KEY_SECRET_PREFIX));
|
||||
assert_eq!(
|
||||
key_prefix.chars().count(),
|
||||
EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS
|
||||
);
|
||||
assert!(raw_key.starts_with(key_prefix.as_str()));
|
||||
assert_ne!(hash_external_api_key(raw_key.as_str()), raw_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_external_api_key_scopes_cover_editor_openapi_v1() {
|
||||
let scopes = default_external_api_key_scopes();
|
||||
|
||||
assert_eq!(
|
||||
scopes,
|
||||
vec![
|
||||
"editor:project".to_string(),
|
||||
"editor:canvas".to_string(),
|
||||
"editor:image-generate".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::{StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
use spacetime_client::{
|
||||
EditorAssetCreateRecordInput, EditorProjectCreateRecordInput, EditorProjectGetRecordInput,
|
||||
EditorProjectLayoutSaveRecordInput, EditorProjectResourceCreateRecordInput,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
editor_project::{
|
||||
EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE, EDITOR_PROJECT_ID_PREFIX,
|
||||
EDITOR_RESOURCE_ID_PREFIX, EditorAssetPayload, EditorCanvasViewportPayload,
|
||||
EditorProjectPayload, EditorProjectResourcePayload, current_utc_micros,
|
||||
data_url_from_image_bytes, editor_asset_payload_from_record,
|
||||
editor_project_payload_from_record, editor_project_resource_payload_from_record,
|
||||
map_editor_project_error, normalize_optional_string, persist_editor_generated_image,
|
||||
serialize_editor_layers,
|
||||
},
|
||||
external_api_auth::ExternalApiPrincipal,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
GPT_IMAGE_2_MODEL, build_openai_image_http_client,
|
||||
create_openai_image_edit_with_references_and_model,
|
||||
create_openai_image_generation_with_model, require_openai_image_settings,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const EXTERNAL_EDITOR_PROVIDER: &str = "external-editor-api";
|
||||
const EXTERNAL_EDITOR_IMAGE_ASSET_KIND: &str = "editor_external_image";
|
||||
const EXTERNAL_EDITOR_IMAGE_SLOT: &str = "external-image";
|
||||
const EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE: &str = "generated";
|
||||
const EXTERNAL_EDITOR_IMAGE_PROVIDER: &str = "VectorEngine";
|
||||
const EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE: &str = "1024x1024";
|
||||
const SCOPE_EDITOR_PROJECT: &str = "editor:project";
|
||||
const SCOPE_EDITOR_CANVAS: &str = "editor:canvas";
|
||||
const SCOPE_EDITOR_IMAGE_GENERATE: &str = "editor:image-generate";
|
||||
const OPENAPI_JSON: &str =
|
||||
include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json");
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectCreateRequest {
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorCanvasSaveRequest {
|
||||
viewport: EditorCanvasViewportPayload,
|
||||
layers: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorImageGenerationRequest {
|
||||
prompt: String,
|
||||
project_id: Option<String>,
|
||||
title: Option<String>,
|
||||
size: Option<String>,
|
||||
reference_image_srcs: Option<Vec<String>>,
|
||||
layers: Option<Value>,
|
||||
viewport: Option<EditorCanvasViewportPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorProjectResponse {
|
||||
project: EditorProjectPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalEditorImageGenerationResponse {
|
||||
image_src: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
prompt: String,
|
||||
actual_prompt: Option<String>,
|
||||
model: String,
|
||||
provider: &'static str,
|
||||
task_id: String,
|
||||
asset: EditorAssetPayload,
|
||||
resource: Option<EditorProjectResourcePayload>,
|
||||
project: Option<EditorProjectPayload>,
|
||||
}
|
||||
|
||||
pub async fn openapi_json() -> Response {
|
||||
(
|
||||
[(CONTENT_TYPE, "application/json; charset=utf-8")],
|
||||
OPENAPI_JSON,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn create_external_editor_project(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(principal): Extension<ExternalApiPrincipal>,
|
||||
Json(payload): Json<ExternalEditorProjectCreateRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
|
||||
let project = state
|
||||
.spacetime_client()
|
||||
.create_editor_project(EditorProjectCreateRecordInput {
|
||||
project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX),
|
||||
owner_user_id: principal.owner_user_id().to_string(),
|
||||
title: normalize_project_title(payload.title),
|
||||
now_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalEditorProjectResponse {
|
||||
project: editor_project_payload_from_record(project),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_external_editor_project(
|
||||
State(state): State<AppState>,
|
||||
Path(project_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(principal): Extension<ExternalApiPrincipal>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
require_scope(&principal, SCOPE_EDITOR_PROJECT)?;
|
||||
let project = state
|
||||
.spacetime_client()
|
||||
.get_editor_project(EditorProjectGetRecordInput {
|
||||
project_id,
|
||||
owner_user_id: principal.owner_user_id().to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalEditorProjectResponse {
|
||||
project: editor_project_payload_from_record(project),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn save_external_editor_canvas(
|
||||
State(state): State<AppState>,
|
||||
Path(project_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(principal): Extension<ExternalApiPrincipal>,
|
||||
Json(payload): Json<ExternalEditorCanvasSaveRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
require_scope(&principal, SCOPE_EDITOR_CANVAS)?;
|
||||
let project = state
|
||||
.spacetime_client()
|
||||
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
|
||||
project_id,
|
||||
owner_user_id: principal.owner_user_id().to_string(),
|
||||
viewport: payload.viewport.into_record(),
|
||||
layers_json: serialize_editor_layers(payload.layers)?,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalEditorProjectResponse {
|
||||
project: editor_project_payload_from_record(project),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_external_editor_image(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(principal): Extension<ExternalApiPrincipal>,
|
||||
Json(payload): Json<ExternalEditorImageGenerationRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
||||
let prompt = payload.prompt.trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": EXTERNAL_EDITOR_PROVIDER,
|
||||
"message": "生成提示词不能为空",
|
||||
})),
|
||||
);
|
||||
}
|
||||
let project_id = normalize_optional_string(payload.project_id);
|
||||
let asset_title = normalize_asset_title(payload.title.as_deref(), prompt.as_str());
|
||||
let size = normalize_external_image_size(payload.size.as_deref());
|
||||
let reference_images = payload
|
||||
.reference_image_srcs
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|source| {
|
||||
let trimmed = source.trim().to_string();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
})
|
||||
.take(5)
|
||||
.map(|source| crate::editor_project::parse_editor_reference_image(source.as_str()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||
&request_context,
|
||||
Some(principal.owner_user_id().to_string()),
|
||||
project_id.clone(),
|
||||
);
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = if reference_images.is_empty() {
|
||||
create_openai_image_generation_with_model(
|
||||
&http_client,
|
||||
&settings,
|
||||
GPT_IMAGE_2_MODEL,
|
||||
prompt.as_str(),
|
||||
None,
|
||||
size.as_str(),
|
||||
1,
|
||||
&[],
|
||||
"外部 OpenAPI 编辑器美术生图",
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
create_openai_image_edit_with_references_and_model(
|
||||
&http_client,
|
||||
&settings,
|
||||
GPT_IMAGE_2_MODEL,
|
||||
prompt.as_str(),
|
||||
None,
|
||||
size.as_str(),
|
||||
1,
|
||||
reference_images.as_slice(),
|
||||
"外部 OpenAPI 编辑器参考图美术生图",
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "VectorEngine 未返回图片",
|
||||
}))
|
||||
})?;
|
||||
let (width, height) = image::load_from_memory(image.bytes.as_slice())
|
||||
.map(|image| (image.width(), image.height()))
|
||||
.unwrap_or((1024, 1024));
|
||||
let image_src = data_url_from_image_bytes(image.mime_type.as_str(), image.bytes.as_slice());
|
||||
let persisted = persist_editor_generated_image(
|
||||
&state,
|
||||
principal.owner_user_id(),
|
||||
generated.task_id.as_str(),
|
||||
&image,
|
||||
prompt.as_str(),
|
||||
generated.actual_prompt.as_deref(),
|
||||
EXTERNAL_EDITOR_IMAGE_ASSET_KIND,
|
||||
"external-images",
|
||||
EXTERNAL_EDITOR_IMAGE_SLOT,
|
||||
)
|
||||
.await?;
|
||||
let now_micros = current_utc_micros();
|
||||
let asset = state
|
||||
.spacetime_client()
|
||||
.create_editor_asset(EditorAssetCreateRecordInput {
|
||||
asset_id: build_prefixed_uuid_id(EDITOR_ASSET_ID_PREFIX),
|
||||
owner_user_id: principal.owner_user_id().to_string(),
|
||||
folder_id: default_asset_folder_id(principal.owner_user_id()),
|
||||
label: asset_title,
|
||||
asset_object_id: Some(persisted.asset_object_id.clone()),
|
||||
image_src: image_src.clone(),
|
||||
object_key: Some(persisted.object_key.clone()),
|
||||
width,
|
||||
height,
|
||||
source_type: EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE.to_string(),
|
||||
prompt: Some(prompt.clone()),
|
||||
actual_prompt: generated.actual_prompt.clone(),
|
||||
model: Some(GPT_IMAGE_2_MODEL.to_string()),
|
||||
provider: Some(EXTERNAL_EDITOR_IMAGE_PROVIDER.to_string()),
|
||||
task_id: Some(generated.task_id.clone()),
|
||||
now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
let resource = if let Some(project_id) = project_id.clone() {
|
||||
Some(
|
||||
state
|
||||
.spacetime_client()
|
||||
.create_editor_project_resource(EditorProjectResourceCreateRecordInput {
|
||||
resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX),
|
||||
project_id,
|
||||
owner_user_id: principal.owner_user_id().to_string(),
|
||||
asset_object_id: Some(persisted.asset_object_id.clone()),
|
||||
image_src: image_src.clone(),
|
||||
object_key: Some(persisted.object_key.clone()),
|
||||
width,
|
||||
height,
|
||||
source_type: EXTERNAL_EDITOR_IMAGE_SOURCE_TYPE.to_string(),
|
||||
prompt: Some(prompt.clone()),
|
||||
actual_prompt: generated.actual_prompt.clone(),
|
||||
model: Some(GPT_IMAGE_2_MODEL.to_string()),
|
||||
provider: Some(EXTERNAL_EDITOR_IMAGE_PROVIDER.to_string()),
|
||||
task_id: Some(generated.task_id.clone()),
|
||||
source_resource_id: None,
|
||||
updated_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let project = if let (Some(project_id), Some(layers), Some(viewport)) =
|
||||
(project_id, payload.layers, payload.viewport)
|
||||
{
|
||||
Some(
|
||||
state
|
||||
.spacetime_client()
|
||||
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
|
||||
project_id,
|
||||
owner_user_id: principal.owner_user_id().to_string(),
|
||||
viewport: viewport.into_record(),
|
||||
layers_json: serialize_editor_layers(layers)?,
|
||||
updated_at_micros: now_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalEditorImageGenerationResponse {
|
||||
image_src,
|
||||
width,
|
||||
height,
|
||||
prompt,
|
||||
actual_prompt: generated.actual_prompt,
|
||||
model: GPT_IMAGE_2_MODEL.to_string(),
|
||||
provider: EXTERNAL_EDITOR_IMAGE_PROVIDER,
|
||||
task_id: generated.task_id,
|
||||
asset: editor_asset_payload_from_record(asset),
|
||||
resource: resource.map(editor_project_resource_payload_from_record),
|
||||
project: project.map(editor_project_payload_from_record),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
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_EDITOR_PROVIDER,
|
||||
"keyId": principal.key_id(),
|
||||
"scope": scope,
|
||||
"message": "API Key 缺少所需权限",
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_project_title(title: Option<String>) -> String {
|
||||
title
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string())
|
||||
}
|
||||
|
||||
fn normalize_asset_title(title: Option<&str>, prompt: &str) -> String {
|
||||
title
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| prompt.trim().split('\n').next())
|
||||
.map(|value| value.chars().take(40).collect())
|
||||
.unwrap_or_else(|| "外部生成图片".to_string())
|
||||
}
|
||||
|
||||
fn normalize_external_image_size(size: Option<&str>) -> String {
|
||||
match size.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(value @ ("1024x1024" | "1536x1024" | "1024x1536" | "2048x1152" | "2048x2048")) => {
|
||||
value.to_string()
|
||||
}
|
||||
_ => EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_asset_folder_id(owner_user_id: &str) -> String {
|
||||
format!("{owner_user_id}:asset-folder:project")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exported_openapi_json_contains_external_editor_routes_and_security() {
|
||||
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
|
||||
|
||||
assert_eq!(parsed["openapi"], "3.1.0");
|
||||
assert!(
|
||||
parsed["paths"]
|
||||
.get("/api/external/v1/editor/projects")
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
parsed["paths"]
|
||||
.get("/api/external/v1/editor/images/generations")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["components"]["securitySchemes"]["ExternalApiKey"]["scheme"],
|
||||
"bearer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_image_size_falls_back_to_default_for_unknown_values() {
|
||||
assert_eq!(
|
||||
normalize_external_image_size(Some("1536x1024")),
|
||||
"1536x1024"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_external_image_size(Some("4096x4096")),
|
||||
EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_external_image_size(None),
|
||||
EXTERNAL_EDITOR_IMAGE_DEFAULT_SIZE
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,9 @@ mod edutainment_baby_drawing;
|
||||
mod edutainment_baby_object;
|
||||
mod error_middleware;
|
||||
mod external_api_audit;
|
||||
mod external_api_auth;
|
||||
mod external_api_keys;
|
||||
mod external_editor_api;
|
||||
mod external_generation;
|
||||
mod external_generation_worker;
|
||||
mod external_generation_worker_controller;
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod big_fish;
|
||||
pub mod custom_world;
|
||||
pub mod editor_project;
|
||||
pub mod edutainment;
|
||||
pub mod external_api;
|
||||
pub mod external_generation;
|
||||
pub mod health;
|
||||
pub mod internal;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{get, patch, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
external_api_auth::require_external_api_key,
|
||||
external_editor_api::{
|
||||
create_external_editor_project, generate_external_editor_image,
|
||||
get_external_editor_project, openapi_json, save_external_editor_canvas,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/external/v1/openapi.json", get(openapi_json))
|
||||
.route(
|
||||
"/api/external/v1/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/{project_id}",
|
||||
get(get_external_editor_project).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_external_api_key,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/external/v1/editor/projects/{project_id}/canvas",
|
||||
patch(save_external_editor_canvas).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)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
EXTERNAL_EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state,
|
||||
require_external_api_key,
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, patch, post},
|
||||
routing::{delete, get, patch, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
external_api_keys::{create_external_api_key, list_external_api_keys, revoke_external_api_key},
|
||||
profile_identity::update_profile_identity,
|
||||
runtime_profile::{
|
||||
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
|
||||
@@ -32,6 +33,22 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/api-keys",
|
||||
get(list_external_api_keys)
|
||||
.post(create_external_api_key)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/api-keys/{key_id}",
|
||||
delete(revoke_external_api_key).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/wallet-ledger",
|
||||
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
use super::*;
|
||||
|
||||
impl SpacetimeClient {
|
||||
pub async fn create_external_api_key(
|
||||
&self,
|
||||
input: ExternalApiKeyCreateRecordInput,
|
||||
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"create_external_api_key_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.create_external_api_key_and_return_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_external_api_key_single_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_external_api_keys(
|
||||
&self,
|
||||
owner_user_id: String,
|
||||
) -> Result<Vec<ExternalApiKeyRecord>, SpacetimeClientError> {
|
||||
let procedure_input = ExternalApiKeyListInput { owner_user_id };
|
||||
|
||||
self.call_after_connect(
|
||||
"list_external_api_keys_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.list_external_api_keys_and_return_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_external_api_key_list_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn revoke_external_api_key(
|
||||
&self,
|
||||
input: ExternalApiKeyRevokeRecordInput,
|
||||
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"revoke_external_api_key_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.revoke_external_api_key_and_return_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_external_api_key_single_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn authenticate_external_api_key(
|
||||
&self,
|
||||
input: ExternalApiKeyAuthenticateRecordInput,
|
||||
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
|
||||
let procedure_input = input.into();
|
||||
|
||||
self.call_after_connect(
|
||||
"authenticate_external_api_key_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.authenticate_external_api_key_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_external_api_key_single_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -37,16 +37,18 @@ pub use mapper::{
|
||||
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
|
||||
EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord,
|
||||
EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput,
|
||||
EditorProjectResourceRecord, ExternalGenerationJobClaimRecordInput,
|
||||
ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput,
|
||||
ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput,
|
||||
ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput,
|
||||
ExternalGenerationQueueStatsRecord, JumpHopActionRequest, JumpHopActionResponse,
|
||||
JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse,
|
||||
JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse,
|
||||
JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult,
|
||||
JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse,
|
||||
JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
|
||||
EditorProjectResourceRecord, ExternalApiKeyAuthenticateRecordInput,
|
||||
ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput,
|
||||
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
|
||||
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput,
|
||||
ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord,
|
||||
ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord,
|
||||
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
|
||||
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
|
||||
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath,
|
||||
JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
|
||||
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
|
||||
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
|
||||
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
|
||||
JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse,
|
||||
@@ -125,6 +127,7 @@ pub mod big_fish;
|
||||
pub mod combat;
|
||||
pub mod custom_world;
|
||||
pub mod editor_project;
|
||||
pub mod external_api_key;
|
||||
pub mod external_generation;
|
||||
|
||||
pub mod inventory;
|
||||
|
||||
@@ -9,6 +9,7 @@ mod combat;
|
||||
mod common;
|
||||
mod custom_world;
|
||||
mod editor_project;
|
||||
mod external_api_key;
|
||||
mod external_generation;
|
||||
|
||||
mod inventory;
|
||||
@@ -80,6 +81,10 @@ pub use self::editor_project::{
|
||||
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord,
|
||||
};
|
||||
pub use self::external_api_key::{
|
||||
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord,
|
||||
ExternalApiKeyRevokeRecordInput,
|
||||
};
|
||||
pub use self::external_generation::{
|
||||
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
|
||||
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput,
|
||||
@@ -203,6 +208,9 @@ pub(crate) use self::editor_project::{
|
||||
map_editor_project_optional_procedure_result, map_editor_project_required_procedure_result,
|
||||
map_editor_project_resource_procedure_result,
|
||||
};
|
||||
pub(crate) use self::external_api_key::{
|
||||
map_external_api_key_list_procedure_result, map_external_api_key_single_procedure_result,
|
||||
};
|
||||
pub(crate) use self::external_generation::{
|
||||
map_external_generation_job_claim_result, map_external_generation_job_procedure_result,
|
||||
map_external_generation_queue_stats_result,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ExternalApiKeyRecord {
|
||||
pub key_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub name: String,
|
||||
pub key_prefix: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub revoked_at: Option<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalApiKeyCreateRecordInput {
|
||||
pub key_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub name: String,
|
||||
pub key_prefix: String,
|
||||
pub key_hash: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub now_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalApiKeyRevokeRecordInput {
|
||||
pub key_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub revoked_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalApiKeyAuthenticateRecordInput {
|
||||
pub key_hash: String,
|
||||
pub used_at_micros: i64,
|
||||
}
|
||||
|
||||
impl From<ExternalApiKeyCreateRecordInput> for crate::module_bindings::ExternalApiKeyCreateInput {
|
||||
fn from(input: ExternalApiKeyCreateRecordInput) -> Self {
|
||||
Self {
|
||||
key_id: input.key_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
name: input.name,
|
||||
key_prefix: input.key_prefix,
|
||||
key_hash: input.key_hash,
|
||||
scopes_json: serde_json::to_string(&input.scopes).unwrap_or_else(|_| "[]".to_string()),
|
||||
now_micros: input.now_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExternalApiKeyRevokeRecordInput> for crate::module_bindings::ExternalApiKeyRevokeInput {
|
||||
fn from(input: ExternalApiKeyRevokeRecordInput) -> Self {
|
||||
Self {
|
||||
key_id: input.key_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
revoked_at_micros: input.revoked_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExternalApiKeyAuthenticateRecordInput>
|
||||
for crate::module_bindings::ExternalApiKeyAuthenticateInput
|
||||
{
|
||||
fn from(input: ExternalApiKeyAuthenticateRecordInput) -> Self {
|
||||
Self {
|
||||
key_hash: input.key_hash,
|
||||
used_at_micros: input.used_at_micros,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_external_api_key_single_procedure_result(
|
||||
result: ExternalApiKeyProcedureResult,
|
||||
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
result
|
||||
.key
|
||||
.map(map_external_api_key_snapshot)
|
||||
.transpose()?
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("外部 API Key 快照"))
|
||||
}
|
||||
|
||||
pub(crate) fn map_external_api_key_list_procedure_result(
|
||||
result: ExternalApiKeyProcedureResult,
|
||||
) -> Result<Vec<ExternalApiKeyRecord>, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
|
||||
result
|
||||
.keys
|
||||
.into_iter()
|
||||
.map(map_external_api_key_snapshot)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn map_external_api_key_snapshot(
|
||||
snapshot: ExternalApiKeySnapshot,
|
||||
) -> Result<ExternalApiKeyRecord, SpacetimeClientError> {
|
||||
Ok(ExternalApiKeyRecord {
|
||||
key_id: snapshot.key_id,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
name: snapshot.name,
|
||||
key_prefix: snapshot.key_prefix,
|
||||
scopes: serde_json::from_str::<Vec<String>>(snapshot.scopes_json.as_str())
|
||||
.map_err(SpacetimeClientError::validation_failed)?,
|
||||
created_at: format_timestamp_micros(snapshot.created_at_micros),
|
||||
last_used_at: snapshot.last_used_at_micros.map(format_timestamp_micros),
|
||||
revoked_at: snapshot.revoked_at_micros.map(format_timestamp_micros),
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
})
|
||||
}
|
||||
@@ -101,6 +101,7 @@ pub mod auth_store_snapshot_record_type;
|
||||
pub mod auth_store_snapshot_table;
|
||||
pub mod auth_store_snapshot_type;
|
||||
pub mod auth_store_snapshot_upsert_input_type;
|
||||
pub mod authenticate_external_api_key_and_return_procedure;
|
||||
pub mod authorize_database_migration_operator_procedure;
|
||||
pub mod bark_battle_draft_config_row_type;
|
||||
pub mod bark_battle_draft_config_snapshot_type;
|
||||
@@ -240,6 +241,7 @@ pub mod create_editor_asset_and_return_procedure;
|
||||
pub mod create_editor_asset_folder_and_return_procedure;
|
||||
pub mod create_editor_project_and_return_procedure;
|
||||
pub mod create_editor_project_resource_and_return_procedure;
|
||||
pub mod create_external_api_key_and_return_procedure;
|
||||
pub mod create_jump_hop_agent_session_procedure;
|
||||
pub mod create_match_3_d_agent_session_procedure;
|
||||
pub mod create_profile_recharge_order_and_return_procedure;
|
||||
@@ -397,6 +399,14 @@ pub mod equip_inventory_item_input_type;
|
||||
pub mod execute_custom_world_agent_action_procedure;
|
||||
pub mod export_auth_store_snapshot_from_tables_procedure;
|
||||
pub mod export_database_migration_to_file_procedure;
|
||||
pub mod external_api_key_authenticate_input_type;
|
||||
pub mod external_api_key_create_input_type;
|
||||
pub mod external_api_key_list_input_type;
|
||||
pub mod external_api_key_procedure_result_type;
|
||||
pub mod external_api_key_revoke_input_type;
|
||||
pub mod external_api_key_snapshot_type;
|
||||
pub mod external_api_key_table;
|
||||
pub mod external_api_key_type;
|
||||
pub mod external_generation_job_claim_input_type;
|
||||
pub mod external_generation_job_complete_input_type;
|
||||
pub mod external_generation_job_enqueue_input_type;
|
||||
@@ -550,6 +560,7 @@ pub mod list_custom_world_gallery_entries_procedure;
|
||||
pub mod list_custom_world_profiles_procedure;
|
||||
pub mod list_custom_world_works_procedure;
|
||||
pub mod list_editor_projects_and_return_procedure;
|
||||
pub mod list_external_api_keys_and_return_procedure;
|
||||
pub mod list_jump_hop_works_procedure;
|
||||
pub mod list_match_3_d_works_procedure;
|
||||
pub mod list_platform_browse_history_procedure;
|
||||
@@ -869,6 +880,7 @@ pub mod restart_square_hole_run_procedure;
|
||||
pub mod resume_profile_save_archive_and_return_procedure;
|
||||
pub mod retry_puzzle_clear_level_run_procedure;
|
||||
pub mod revoke_database_migration_operator_procedure;
|
||||
pub mod revoke_external_api_key_and_return_procedure;
|
||||
pub mod rpg_agent_draft_card_kind_type;
|
||||
pub mod rpg_agent_draft_card_status_type;
|
||||
pub mod rpg_agent_message_kind_type;
|
||||
@@ -1291,6 +1303,7 @@ pub use auth_store_snapshot_record_type::AuthStoreSnapshotRecord;
|
||||
pub use auth_store_snapshot_table::*;
|
||||
pub use auth_store_snapshot_type::AuthStoreSnapshot;
|
||||
pub use auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput;
|
||||
pub use authenticate_external_api_key_and_return_procedure::authenticate_external_api_key_and_return;
|
||||
pub use authorize_database_migration_operator_procedure::authorize_database_migration_operator;
|
||||
pub use bark_battle_draft_config_row_type::BarkBattleDraftConfigRow;
|
||||
pub use bark_battle_draft_config_snapshot_type::BarkBattleDraftConfigSnapshot;
|
||||
@@ -1430,6 +1443,7 @@ pub use create_editor_asset_and_return_procedure::create_editor_asset_and_return
|
||||
pub use create_editor_asset_folder_and_return_procedure::create_editor_asset_folder_and_return;
|
||||
pub use create_editor_project_and_return_procedure::create_editor_project_and_return;
|
||||
pub use create_editor_project_resource_and_return_procedure::create_editor_project_resource_and_return;
|
||||
pub use create_external_api_key_and_return_procedure::create_external_api_key_and_return;
|
||||
pub use create_jump_hop_agent_session_procedure::create_jump_hop_agent_session;
|
||||
pub use create_match_3_d_agent_session_procedure::create_match_3_d_agent_session;
|
||||
pub use create_profile_recharge_order_and_return_procedure::create_profile_recharge_order_and_return;
|
||||
@@ -1587,6 +1601,14 @@ pub use equip_inventory_item_input_type::EquipInventoryItemInput;
|
||||
pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action;
|
||||
pub use export_auth_store_snapshot_from_tables_procedure::export_auth_store_snapshot_from_tables;
|
||||
pub use export_database_migration_to_file_procedure::export_database_migration_to_file;
|
||||
pub use external_api_key_authenticate_input_type::ExternalApiKeyAuthenticateInput;
|
||||
pub use external_api_key_create_input_type::ExternalApiKeyCreateInput;
|
||||
pub use external_api_key_list_input_type::ExternalApiKeyListInput;
|
||||
pub use external_api_key_procedure_result_type::ExternalApiKeyProcedureResult;
|
||||
pub use external_api_key_revoke_input_type::ExternalApiKeyRevokeInput;
|
||||
pub use external_api_key_snapshot_type::ExternalApiKeySnapshot;
|
||||
pub use external_api_key_table::*;
|
||||
pub use external_api_key_type::ExternalApiKey;
|
||||
pub use external_generation_job_claim_input_type::ExternalGenerationJobClaimInput;
|
||||
pub use external_generation_job_complete_input_type::ExternalGenerationJobCompleteInput;
|
||||
pub use external_generation_job_enqueue_input_type::ExternalGenerationJobEnqueueInput;
|
||||
@@ -1740,6 +1762,7 @@ pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_e
|
||||
pub use list_custom_world_profiles_procedure::list_custom_world_profiles;
|
||||
pub use list_custom_world_works_procedure::list_custom_world_works;
|
||||
pub use list_editor_projects_and_return_procedure::list_editor_projects_and_return;
|
||||
pub use list_external_api_keys_and_return_procedure::list_external_api_keys_and_return;
|
||||
pub use list_jump_hop_works_procedure::list_jump_hop_works;
|
||||
pub use list_match_3_d_works_procedure::list_match_3_d_works;
|
||||
pub use list_platform_browse_history_procedure::list_platform_browse_history;
|
||||
@@ -2059,6 +2082,7 @@ pub use restart_square_hole_run_procedure::restart_square_hole_run;
|
||||
pub use resume_profile_save_archive_and_return_procedure::resume_profile_save_archive_and_return;
|
||||
pub use retry_puzzle_clear_level_run_procedure::retry_puzzle_clear_level_run;
|
||||
pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator;
|
||||
pub use revoke_external_api_key_and_return_procedure::revoke_external_api_key_and_return;
|
||||
pub use rpg_agent_draft_card_kind_type::RpgAgentDraftCardKind;
|
||||
pub use rpg_agent_draft_card_status_type::RpgAgentDraftCardStatus;
|
||||
pub use rpg_agent_message_kind_type::RpgAgentMessageKind;
|
||||
@@ -2702,6 +2726,7 @@ pub struct DbUpdate {
|
||||
editor_canvas: __sdk::TableUpdate<EditorCanvas>,
|
||||
editor_project: __sdk::TableUpdate<EditorProject>,
|
||||
editor_project_resource: __sdk::TableUpdate<EditorProjectResource>,
|
||||
external_api_key: __sdk::TableUpdate<ExternalApiKey>,
|
||||
external_generation_job: __sdk::TableUpdate<ExternalGenerationJob>,
|
||||
inventory_slot: __sdk::TableUpdate<InventorySlot>,
|
||||
jump_hop_agent_session: __sdk::TableUpdate<JumpHopAgentSessionRow>,
|
||||
@@ -2930,6 +2955,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
|
||||
"editor_project_resource" => db_update.editor_project_resource.append(
|
||||
editor_project_resource_table::parse_table_update(table_update)?,
|
||||
),
|
||||
"external_api_key" => db_update
|
||||
.external_api_key
|
||||
.append(external_api_key_table::parse_table_update(table_update)?),
|
||||
"external_generation_job" => db_update.external_generation_job.append(
|
||||
external_generation_job_table::parse_table_update(table_update)?,
|
||||
),
|
||||
@@ -3414,6 +3442,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
&self.editor_project_resource,
|
||||
)
|
||||
.with_updates_by_pk(|row| &row.resource_id);
|
||||
diff.external_api_key = cache
|
||||
.apply_diff_to_table::<ExternalApiKey>("external_api_key", &self.external_api_key)
|
||||
.with_updates_by_pk(|row| &row.key_id);
|
||||
diff.external_generation_job = cache
|
||||
.apply_diff_to_table::<ExternalGenerationJob>(
|
||||
"external_generation_job",
|
||||
@@ -3962,6 +3993,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"editor_project_resource" => db_update
|
||||
.editor_project_resource
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"external_api_key" => db_update
|
||||
.external_api_key
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
"external_generation_job" => db_update
|
||||
.external_generation_job
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
@@ -4347,6 +4381,9 @@ impl __sdk::DbUpdate for DbUpdate {
|
||||
"editor_project_resource" => db_update
|
||||
.editor_project_resource
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"external_api_key" => db_update
|
||||
.external_api_key
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
"external_generation_job" => db_update
|
||||
.external_generation_job
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
@@ -4648,6 +4685,7 @@ pub struct AppliedDiff<'r> {
|
||||
editor_canvas: __sdk::TableAppliedDiff<'r, EditorCanvas>,
|
||||
editor_project: __sdk::TableAppliedDiff<'r, EditorProject>,
|
||||
editor_project_resource: __sdk::TableAppliedDiff<'r, EditorProjectResource>,
|
||||
external_api_key: __sdk::TableAppliedDiff<'r, ExternalApiKey>,
|
||||
external_generation_job: __sdk::TableAppliedDiff<'r, ExternalGenerationJob>,
|
||||
inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>,
|
||||
jump_hop_agent_session: __sdk::TableAppliedDiff<'r, JumpHopAgentSessionRow>,
|
||||
@@ -4954,6 +4992,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
|
||||
&self.editor_project_resource,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<ExternalApiKey>(
|
||||
"external_api_key",
|
||||
&self.external_api_key,
|
||||
event,
|
||||
);
|
||||
callbacks.invoke_table_row_callbacks::<ExternalGenerationJob>(
|
||||
"external_generation_job",
|
||||
&self.external_generation_job,
|
||||
@@ -6046,6 +6089,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
editor_canvas_table::register_table(client_cache);
|
||||
editor_project_table::register_table(client_cache);
|
||||
editor_project_resource_table::register_table(client_cache);
|
||||
external_api_key_table::register_table(client_cache);
|
||||
external_generation_job_table::register_table(client_cache);
|
||||
inventory_slot_table::register_table(client_cache);
|
||||
jump_hop_agent_session_table::register_table(client_cache);
|
||||
@@ -6172,6 +6216,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
|
||||
"editor_canvas",
|
||||
"editor_project",
|
||||
"editor_project_resource",
|
||||
"external_api_key",
|
||||
"external_generation_job",
|
||||
"inventory_slot",
|
||||
"jump_hop_agent_session",
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::external_api_key_authenticate_input_type::ExternalApiKeyAuthenticateInput;
|
||||
use super::external_api_key_procedure_result_type::ExternalApiKeyProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct AuthenticateExternalApiKeyAndReturnArgs {
|
||||
pub input: ExternalApiKeyAuthenticateInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AuthenticateExternalApiKeyAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `authenticate_external_api_key_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait authenticate_external_api_key_and_return {
|
||||
fn authenticate_external_api_key_and_return(&self, input: ExternalApiKeyAuthenticateInput) {
|
||||
self.authenticate_external_api_key_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn authenticate_external_api_key_and_return_then(
|
||||
&self,
|
||||
input: ExternalApiKeyAuthenticateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalApiKeyProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl authenticate_external_api_key_and_return for super::RemoteProcedures {
|
||||
fn authenticate_external_api_key_and_return_then(
|
||||
&self,
|
||||
input: ExternalApiKeyAuthenticateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalApiKeyProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>(
|
||||
"authenticate_external_api_key_and_return",
|
||||
AuthenticateExternalApiKeyAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::external_api_key_create_input_type::ExternalApiKeyCreateInput;
|
||||
use super::external_api_key_procedure_result_type::ExternalApiKeyProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct CreateExternalApiKeyAndReturnArgs {
|
||||
pub input: ExternalApiKeyCreateInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CreateExternalApiKeyAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `create_external_api_key_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait create_external_api_key_and_return {
|
||||
fn create_external_api_key_and_return(&self, input: ExternalApiKeyCreateInput) {
|
||||
self.create_external_api_key_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn create_external_api_key_and_return_then(
|
||||
&self,
|
||||
input: ExternalApiKeyCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalApiKeyProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl create_external_api_key_and_return for super::RemoteProcedures {
|
||||
fn create_external_api_key_and_return_then(
|
||||
&self,
|
||||
input: ExternalApiKeyCreateInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<ExternalApiKeyProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>(
|
||||
"create_external_api_key_and_return",
|
||||
CreateExternalApiKeyAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct ExternalApiKeyAuthenticateInput {
|
||||
pub key_hash: String,
|
||||
pub used_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalApiKeyAuthenticateInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct ExternalApiKeyCreateInput {
|
||||
pub key_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub name: String,
|
||||
pub key_prefix: String,
|
||||
pub key_hash: String,
|
||||
pub scopes_json: String,
|
||||
pub now_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalApiKeyCreateInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct ExternalApiKeyListInput {
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalApiKeyListInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::external_api_key_snapshot_type::ExternalApiKeySnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct ExternalApiKeyProcedureResult {
|
||||
pub ok: bool,
|
||||
pub key: Option<ExternalApiKeySnapshot>,
|
||||
pub keys: Vec<ExternalApiKeySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalApiKeyProcedureResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user