From 95d6bc2ae7d3568556125c08d21f536a003e7e4a Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 15:29:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=A4=96=E9=83=A8=20OpenAPI?= =?UTF-8?q?=20=E4=B8=8E=20API=20Key=20=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增外部编辑器 OpenAPI 路由与 openapi.json 导出 新增账号级 API Key 表、鉴权、创建、列表和撤销链路 新增个人中心开发者 API Key 管理弹窗 补充前端契约、客户端方法、测试与项目文档 --- .../genarrative-external-v1.openapi.json | 822 ++++++++++++++++++ .../shared-memory/decision-log.md | 8 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 7 + ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 101 +++ packages/shared/src/contracts/runtime.ts | 24 + server-rs/crates/api-server/src/app.rs | 1 + .../crates/api-server/src/editor_project.rs | 62 +- .../api-server/src/external_api_auth.rs | 91 ++ .../api-server/src/external_api_keys.rs | 243 ++++++ .../api-server/src/external_editor_api.rs | 439 ++++++++++ server-rs/crates/api-server/src/main.rs | 3 + server-rs/crates/api-server/src/modules.rs | 1 + .../api-server/src/modules/external_api.rs | 54 ++ .../crates/api-server/src/modules/profile.rs | 19 +- .../spacetime-client/src/external_api_key.rs | 94 ++ server-rs/crates/spacetime-client/src/lib.rs | 23 +- .../crates/spacetime-client/src/mapper.rs | 8 + .../src/mapper/external_api_key.rs | 118 +++ .../spacetime-client/src/module_bindings.rs | 45 + ...e_external_api_key_and_return_procedure.rs | 59 ++ ...e_external_api_key_and_return_procedure.rs | 59 ++ ...xternal_api_key_authenticate_input_type.rs | 16 + .../external_api_key_create_input_type.rs | 21 + .../external_api_key_list_input_type.rs | 15 + .../external_api_key_procedure_result_type.rs | 20 + .../external_api_key_revoke_input_type.rs | 17 + .../external_api_key_snapshot_type.rs | 23 + .../module_bindings/external_api_key_table.rs | 190 ++++ .../module_bindings/external_api_key_type.rs | 80 ++ ..._external_api_keys_and_return_procedure.rs | 59 ++ ...e_external_api_key_and_return_procedure.rs | 59 ++ .../src/external_api_key_storage.rs | 312 +++++++ server-rs/crates/spacetime-module/src/lib.rs | 2 + .../crates/spacetime-module/src/migration.rs | 1 + .../ImageCanvasWorldView.test.tsx | 35 +- .../PlatformProfileApiKeysModal.tsx | 305 +++++++ .../RpgEntryHomeView.recharge.test.tsx | 96 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 15 + .../rpg-entry/rpgProfileClient.test.ts | 61 ++ src/services/rpg-entry/rpgProfileClient.ts | 42 + 40 files changed, 3593 insertions(+), 57 deletions(-) create mode 100644 docs/openapi/genarrative-external-v1.openapi.json create mode 100644 docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md create mode 100644 server-rs/crates/api-server/src/external_api_auth.rs create mode 100644 server-rs/crates/api-server/src/external_api_keys.rs create mode 100644 server-rs/crates/api-server/src/external_editor_api.rs create mode 100644 server-rs/crates/api-server/src/modules/external_api.rs create mode 100644 server-rs/crates/spacetime-client/src/external_api_key.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/external_api_key.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/authenticate_external_api_key_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_external_api_key_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_api_key_authenticate_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_api_key_create_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_api_key_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_api_key_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_api_key_revoke_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_api_key_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_api_key_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_api_key_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_external_api_keys_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/revoke_external_api_key_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-module/src/external_api_key_storage.rs create mode 100644 src/components/platform-entry/PlatformProfileApiKeysModal.tsx diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json new file mode 100644 index 000000000..a733e57a4 --- /dev/null +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -0,0 +1,822 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "陶泥儿外部编辑器 OpenAPI", + "version": "1.0.0", + "description": "外部系统调用陶泥儿图片画布项目、画布布局保存和编辑器美术生图能力的 v1 契约。" + }, + "servers": [ + { + "url": "/", + "description": "当前部署环境" + } + ], + "tags": [ + { + "name": "Editor Projects", + "description": "图片画布项目" + }, + { + "name": "Editor Canvas", + "description": "图片画布布局" + }, + { + "name": "Editor Images", + "description": "编辑器美术生图" + }, + { + "name": "API Keys", + "description": "登录态 API Key 管理接口,路径位于 /api/profile" + } + ], + "paths": { + "/api/external/v1/openapi.json": { + "get": { + "tags": ["Editor Projects"], + "operationId": "getExternalOpenApiJson", + "summary": "读取外部 API OpenAPI JSON", + "security": [], + "responses": { + "200": { + "description": "OpenAPI 3.1 JSON", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/external/v1/editor/projects": { + "post": { + "tags": ["Editor Projects"], + "operationId": "createEditorProject", + "summary": "创建图片画布项目", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "已创建项目", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/external/v1/editor/projects/{projectId}": { + "get": { + "tags": ["Editor Projects"], + "operationId": "getEditorProject", + "summary": "读取图片画布项目", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "responses": { + "200": { + "description": "项目快照", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/external/v1/editor/projects/{projectId}/canvas": { + "patch": { + "tags": ["Editor Canvas"], + "operationId": "saveEditorProjectCanvas", + "summary": "保存默认画布布局", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/ProjectId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorCanvasSaveRequest" + } + } + } + }, + "responses": { + "200": { + "description": "保存后的项目快照", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorProjectResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/external/v1/editor/images/generations": { + "post": { + "tags": ["Editor Images"], + "operationId": "generateEditorImage", + "summary": "生成编辑器美术图片", + "security": [ + { + "ExternalApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorImageGenerationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "生成结果与落库资源", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorImageGenerationResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "502": { + "$ref": "#/components/responses/UpstreamError" + } + } + } + }, + "/api/profile/api-keys": { + "get": { + "tags": ["API Keys"], + "operationId": "listExternalApiKeys", + "summary": "列出当前登录账号的外部 API Key", + "description": "实际路径为 /api/profile/api-keys,使用登录态 Bearer JWT,不使用外部 API Key。", + "security": [ + { + "UserAccessToken": [] + } + ], + "responses": { + "200": { + "description": "API Key 列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalApiKeyListResponse" + } + } + } + } + } + }, + "post": { + "tags": ["API Keys"], + "operationId": "createExternalApiKey", + "summary": "创建外部 API Key", + "description": "实际路径为 /api/profile/api-keys,明文 apiKey 只在本响应返回一次。", + "security": [ + { + "UserAccessToken": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalApiKeyCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "新建 API Key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalApiKeyCreateResponse" + } + } + } + } + } + } + }, + "/api/profile/api-keys/{keyId}": { + "delete": { + "tags": ["API Keys"], + "operationId": "revokeExternalApiKey", + "summary": "撤销外部 API Key", + "description": "实际路径为 /api/profile/api-keys/{keyId},使用登录态 Bearer JWT。", + "security": [ + { + "UserAccessToken": [] + } + ], + "parameters": [ + { + "name": "keyId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "已撤销 API Key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalApiKeyResponse" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "ExternalApiKey": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "tnr_sk" + }, + "UserAccessToken": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "parameters": { + "ProjectId": { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + }, + "responses": { + "BadRequest": { + "description": "请求参数不合法", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "Unauthorized": { + "description": "未授权访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "Forbidden": { + "description": "禁止访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "NotFound": { + "description": "资源不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "UpstreamError": { + "description": "上游生成服务失败", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "schemas": { + "ExternalEditorProjectCreateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 80 + } + }, + "additionalProperties": false + }, + "ExternalEditorCanvasSaveRequest": { + "type": "object", + "required": ["viewport", "layers"], + "properties": { + "viewport": { + "$ref": "#/components/schemas/EditorCanvasViewport" + }, + "layers": { + "type": "object", + "description": "画布图层 JSON,最大约 256KB。" + } + }, + "additionalProperties": false + }, + "ExternalEditorImageGenerationRequest": { + "type": "object", + "required": ["prompt"], + "properties": { + "prompt": { + "type": "string", + "minLength": 1 + }, + "projectId": { + "type": "string" + }, + "title": { + "type": "string", + "description": "写入素材库时使用的素材名称。" + }, + "size": { + "type": "string", + "enum": ["1024x1024", "1536x1024", "1024x1536", "2048x1152", "2048x2048"], + "default": "1024x1024" + }, + "referenceImageSrcs": { + "type": "array", + "maxItems": 5, + "items": { + "type": "string", + "description": "图片 Data URL。" + } + }, + "layers": { + "type": "object", + "description": "可选。带 projectId 时,生图完成后顺带保存画布图层 JSON。" + }, + "viewport": { + "$ref": "#/components/schemas/EditorCanvasViewport" + } + }, + "additionalProperties": false + }, + "ExternalEditorProjectResponse": { + "type": "object", + "required": ["project"], + "properties": { + "project": { + "$ref": "#/components/schemas/EditorProject" + } + } + }, + "ExternalEditorImageGenerationResponse": { + "type": "object", + "required": [ + "imageSrc", + "width", + "height", + "prompt", + "model", + "provider", + "taskId", + "asset" + ], + "properties": { + "imageSrc": { + "type": "string", + "description": "生成图片 Data URL。" + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "prompt": { + "type": "string" + }, + "actualPrompt": { + "type": ["string", "null"] + }, + "model": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "asset": { + "$ref": "#/components/schemas/EditorAsset" + }, + "resource": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorProjectResource" + }, + { + "type": "null" + } + ] + }, + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorProject" + }, + { + "type": "null" + } + ] + } + } + }, + "EditorProject": { + "type": "object", + "required": ["projectId", "title", "canvas", "viewport", "layers", "resources", "updatedAt"], + "properties": { + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "canvas": { + "$ref": "#/components/schemas/EditorCanvas" + }, + "viewport": { + "$ref": "#/components/schemas/EditorCanvasViewport" + }, + "layers": { + "type": "object" + }, + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EditorProjectResource" + } + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "EditorCanvas": { + "type": "object", + "required": ["canvasId", "projectId", "title", "viewport", "layers", "createdAt", "updatedAt"], + "properties": { + "canvasId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "viewport": { + "$ref": "#/components/schemas/EditorCanvasViewport" + }, + "layers": { + "type": "object" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "EditorCanvasViewport": { + "type": "object", + "required": ["x", "y", "scale"], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "scale": { + "type": "number", + "minimum": 0.01, + "maximum": 8 + } + }, + "additionalProperties": false + }, + "EditorProjectResource": { + "type": "object", + "required": ["resourceId", "projectId", "imageSrc", "width", "height", "sourceType", "createdAt", "updatedAt"], + "properties": { + "resourceId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "imageSrc": { + "type": "string" + }, + "objectKey": { + "type": ["string", "null"] + }, + "assetObjectId": { + "type": ["string", "null"] + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "sourceType": { + "type": "string" + }, + "prompt": { + "type": ["string", "null"] + }, + "actualPrompt": { + "type": ["string", "null"] + }, + "model": { + "type": ["string", "null"] + }, + "provider": { + "type": ["string", "null"] + }, + "taskId": { + "type": ["string", "null"] + }, + "sourceResourceId": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "EditorAsset": { + "type": "object", + "required": ["assetId", "folderId", "label", "imageSrc", "width", "height", "sourceType", "createdAt", "updatedAt"], + "properties": { + "assetId": { + "type": "string" + }, + "folderId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "imageSrc": { + "type": "string" + }, + "objectKey": { + "type": ["string", "null"] + }, + "assetObjectId": { + "type": ["string", "null"] + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "sourceType": { + "type": "string" + }, + "prompt": { + "type": ["string", "null"] + }, + "actualPrompt": { + "type": ["string", "null"] + }, + "model": { + "type": ["string", "null"] + }, + "provider": { + "type": ["string", "null"] + }, + "taskId": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ExternalApiKeyCreateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 80 + } + }, + "additionalProperties": false + }, + "ExternalApiKeyCreateResponse": { + "type": "object", + "required": ["apiKey", "key"], + "properties": { + "apiKey": { + "type": "string", + "description": "仅创建时返回一次。" + }, + "key": { + "$ref": "#/components/schemas/ExternalApiKey" + } + } + }, + "ExternalApiKeyListResponse": { + "type": "object", + "required": ["keys"], + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExternalApiKey" + } + } + } + }, + "ExternalApiKeyResponse": { + "type": "object", + "required": ["key"], + "properties": { + "key": { + "$ref": "#/components/schemas/ExternalApiKey" + } + } + }, + "ExternalApiKey": { + "type": "object", + "required": ["keyId", "name", "keyPrefix", "scopes", "createdAt", "updatedAt"], + "properties": { + "keyId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "keyPrefix": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "lastUsedAt": { + "type": ["string", "null"], + "format": "date-time" + }, + "revokedAt": { + "type": ["string", "null"], + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": ["object", "null"] + } + } + } + } + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4b0f9e708..f30856498 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 6d6df13ec..553c2f3bd 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md new file mode 100644 index 000000000..34a4b8daf --- /dev/null +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -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`。 diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index b0d5c74ca..3e76215b4 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -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'; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index b4a404abb..e4fa80070 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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())) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 37a5a06c6..e0d8eed19 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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 { +pub(crate) fn serialize_editor_layers(layers: Value) -> Result { 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 { Ok(payload) } -fn normalize_optional_string(value: Option) -> Option { +pub(crate) fn normalize_optional_string(value: Option) -> Option { 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 { 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 { +pub(crate) fn parse_editor_reference_image(source: &str) -> Result { 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 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() diff --git a/server-rs/crates/api-server/src/external_api_auth.rs b/server-rs/crates/api-server/src/external_api_auth.rs new file mode 100644 index 000000000..e7ee85b29 --- /dev/null +++ b/server-rs/crates/api-server/src/external_api_auth.rs @@ -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, +} + +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, + mut request: Request, + next: Next, +) -> Result { + let request_id = request + .extensions() + .get::() + .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 { + 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)) +} diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs new file mode 100644 index 000000000..8b9c99ebf --- /dev/null +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -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, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalApiKeyPayload { + key_id: String, + name: String, + key_prefix: String, + scopes: Vec, + created_at: String, + last_used_at: Option, + revoked_at: Option, + 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, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalApiKeyResponse { + key: ExternalApiKeyPayload, +} + +pub async fn list_external_api_keys( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, 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, + Path(key_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, 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 { + 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(), + ] + ); + } +} diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs new file mode 100644 index 000000000..e47f36dcc --- /dev/null +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -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, +} + +#[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, + title: Option, + size: Option, + reference_image_srcs: Option>, + layers: Option, + viewport: Option, +} + +#[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, + model: String, + provider: &'static str, + task_id: String, + asset: EditorAssetPayload, + resource: Option, + project: Option, +} + +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, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, 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, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, +) -> Result, 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, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, 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, + Extension(request_context): Extension, + Extension(principal): Extension, + Json(payload): Json, +) -> Result, 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::, _>>()?; + 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 { + 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 + ); + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 7f51e5a64..18666212c 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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; diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index c2c71344e..ee51d7682 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -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; diff --git a/server-rs/crates/api-server/src/modules/external_api.rs b/server-rs/crates/api-server/src/modules/external_api.rs new file mode 100644 index 000000000..9c09e1d7d --- /dev/null +++ b/server-rs/crates/api-server/src/modules/external_api.rs @@ -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 { + 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, + )), + ) +} diff --git a/server-rs/crates/api-server/src/modules/profile.rs b/server-rs/crates/api-server/src/modules/profile.rs index 54b17f8b9..58b7c1b9e 100644 --- a/server-rs/crates/api-server/src/modules/profile.rs +++ b/server-rs/crates/api-server/src/modules/profile.rs @@ -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 { 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( diff --git a/server-rs/crates/spacetime-client/src/external_api_key.rs b/server-rs/crates/spacetime-client/src/external_api_key.rs new file mode 100644 index 000000000..3524f12c0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/external_api_key.rs @@ -0,0 +1,94 @@ +use super::*; + +impl SpacetimeClient { + pub async fn create_external_api_key( + &self, + input: ExternalApiKeyCreateRecordInput, + ) -> Result { + 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, 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 { + 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 { + 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 + } +} diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f698df627..08d1f0371 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -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; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index f17a6937b..e2dc114d0 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/mapper/external_api_key.rs b/server-rs/crates/spacetime-client/src/mapper/external_api_key.rs new file mode 100644 index 000000000..1092c1f50 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/external_api_key.rs @@ -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, + pub created_at: String, + pub last_used_at: Option, + pub revoked_at: Option, + 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, + 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 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 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 + 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 { + 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, 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 { + 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::>(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), + }) +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 9f964c90f..8d6885175 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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, editor_project: __sdk::TableUpdate, editor_project_resource: __sdk::TableUpdate, + external_api_key: __sdk::TableUpdate, external_generation_job: __sdk::TableUpdate, inventory_slot: __sdk::TableUpdate, jump_hop_agent_session: __sdk::TableUpdate, @@ -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::("external_api_key", &self.external_api_key) + .with_updates_by_pk(|row| &row.key_id); diff.external_generation_job = cache .apply_diff_to_table::( "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::( + "external_api_key", + &self.external_api_key, + event, + ); callbacks.invoke_table_row_callbacks::( "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", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/authenticate_external_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/authenticate_external_api_key_and_return_procedure.rs new file mode 100644 index 000000000..4c3aec34c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/authenticate_external_api_key_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>( + "authenticate_external_api_key_and_return", + AuthenticateExternalApiKeyAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_external_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_external_api_key_and_return_procedure.rs new file mode 100644 index 000000000..daf4a676f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_external_api_key_and_return_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>( + "create_external_api_key_and_return", + CreateExternalApiKeyAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_authenticate_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_authenticate_input_type.rs new file mode 100644 index 000000000..1fec84c0e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_authenticate_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_create_input_type.rs new file mode 100644 index 000000000..7a3081292 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_create_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_list_input_type.rs new file mode 100644 index 000000000..52c6cc740 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_list_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_procedure_result_type.rs new file mode 100644 index 000000000..18feb9522 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_procedure_result_type.rs @@ -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, + pub keys: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for ExternalApiKeyProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_revoke_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_revoke_input_type.rs new file mode 100644 index 000000000..530a2acac --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_revoke_input_type.rs @@ -0,0 +1,17 @@ +// 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 ExternalApiKeyRevokeInput { + pub key_id: String, + pub owner_user_id: String, + pub revoked_at_micros: i64, +} + +impl __sdk::InModule for ExternalApiKeyRevokeInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_snapshot_type.rs new file mode 100644 index 000000000..5e9dc0c4b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_snapshot_type.rs @@ -0,0 +1,23 @@ +// 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 ExternalApiKeySnapshot { + pub key_id: String, + pub owner_user_id: String, + pub name: String, + pub key_prefix: String, + pub scopes_json: String, + pub created_at_micros: i64, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for ExternalApiKeySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_table.rs new file mode 100644 index 000000000..b07fbdfdd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_table.rs @@ -0,0 +1,190 @@ +// 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 super::external_api_key_type::ExternalApiKey; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `external_api_key`. +/// +/// Obtain a handle from the [`ExternalApiKeyTableAccess::external_api_key`] method on [`super::RemoteTables`], +/// like `ctx.db.external_api_key()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.external_api_key().on_insert(...)`. +pub struct ExternalApiKeyTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `external_api_key`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ExternalApiKeyTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ExternalApiKeyTableHandle`], which mediates access to the table `external_api_key`. + fn external_api_key(&self) -> ExternalApiKeyTableHandle<'_>; +} + +impl ExternalApiKeyTableAccess for super::RemoteTables { + fn external_api_key(&self) -> ExternalApiKeyTableHandle<'_> { + ExternalApiKeyTableHandle { + imp: self.imp.get_table::("external_api_key"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ExternalApiKeyInsertCallbackId(__sdk::CallbackId); +pub struct ExternalApiKeyDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ExternalApiKeyTableHandle<'ctx> { + type Row = ExternalApiKey; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ExternalApiKeyInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ExternalApiKeyInsertCallbackId { + ExternalApiKeyInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ExternalApiKeyInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ExternalApiKeyDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ExternalApiKeyDeleteCallbackId { + ExternalApiKeyDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ExternalApiKeyDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ExternalApiKeyUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ExternalApiKeyTableHandle<'ctx> { + type UpdateCallbackId = ExternalApiKeyUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ExternalApiKeyUpdateCallbackId { + ExternalApiKeyUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ExternalApiKeyUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `key_id` unique index on the table `external_api_key`, +/// which allows point queries on the field of the same name +/// via the [`ExternalApiKeyKeyIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.external_api_key().key_id().find(...)`. +pub struct ExternalApiKeyKeyIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ExternalApiKeyTableHandle<'ctx> { + /// Get a handle on the `key_id` unique index on the table `external_api_key`. + pub fn key_id(&self) -> ExternalApiKeyKeyIdUnique<'ctx> { + ExternalApiKeyKeyIdUnique { + imp: self.imp.get_unique_constraint::("key_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ExternalApiKeyKeyIdUnique<'ctx> { + /// Find the subscribed row whose `key_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +/// Access to the `key_hash` unique index on the table `external_api_key`, +/// which allows point queries on the field of the same name +/// via the [`ExternalApiKeyKeyHashUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.external_api_key().key_hash().find(...)`. +pub struct ExternalApiKeyKeyHashUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ExternalApiKeyTableHandle<'ctx> { + /// Get a handle on the `key_hash` unique index on the table `external_api_key`. + pub fn key_hash(&self) -> ExternalApiKeyKeyHashUnique<'ctx> { + ExternalApiKeyKeyHashUnique { + imp: self.imp.get_unique_constraint::("key_hash"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ExternalApiKeyKeyHashUnique<'ctx> { + /// Find the subscribed row whose `key_hash` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("external_api_key"); + _table.add_unique_constraint::("key_id", |row| &row.key_id); + _table.add_unique_constraint::("key_hash", |row| &row.key_hash); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ExternalApiKey`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait external_api_keyQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ExternalApiKey`. + fn external_api_key(&self) -> __sdk::__query_builder::Table; +} + +impl external_api_keyQueryTableAccess for __sdk::QueryTableAccessor { + fn external_api_key(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("external_api_key") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_type.rs new file mode 100644 index 000000000..b0064ea90 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_api_key_type.rs @@ -0,0 +1,80 @@ +// 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 ExternalApiKey { + 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 created_at: __sdk::Timestamp, + pub last_used_at: Option<__sdk::Timestamp>, + pub revoked_at: Option<__sdk::Timestamp>, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ExternalApiKey { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ExternalApiKey`. +/// +/// Provides typed access to columns for query building. +pub struct ExternalApiKeyCols { + pub key_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub name: __sdk::__query_builder::Col, + pub key_prefix: __sdk::__query_builder::Col, + pub key_hash: __sdk::__query_builder::Col, + pub scopes_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub last_used_at: __sdk::__query_builder::Col>, + pub revoked_at: __sdk::__query_builder::Col>, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ExternalApiKey { + type Cols = ExternalApiKeyCols; + fn cols(table_name: &'static str) -> Self::Cols { + ExternalApiKeyCols { + key_id: __sdk::__query_builder::Col::new(table_name, "key_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + name: __sdk::__query_builder::Col::new(table_name, "name"), + key_prefix: __sdk::__query_builder::Col::new(table_name, "key_prefix"), + key_hash: __sdk::__query_builder::Col::new(table_name, "key_hash"), + scopes_json: __sdk::__query_builder::Col::new(table_name, "scopes_json"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + last_used_at: __sdk::__query_builder::Col::new(table_name, "last_used_at"), + revoked_at: __sdk::__query_builder::Col::new(table_name, "revoked_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `ExternalApiKey`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ExternalApiKeyIxCols { + pub key_hash: __sdk::__query_builder::IxCol, + pub key_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ExternalApiKey { + type IxCols = ExternalApiKeyIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ExternalApiKeyIxCols { + key_hash: __sdk::__query_builder::IxCol::new(table_name, "key_hash"), + key_id: __sdk::__query_builder::IxCol::new(table_name, "key_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ExternalApiKey {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_external_api_keys_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_external_api_keys_and_return_procedure.rs new file mode 100644 index 000000000..ca827a562 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_external_api_keys_and_return_procedure.rs @@ -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_list_input_type::ExternalApiKeyListInput; +use super::external_api_key_procedure_result_type::ExternalApiKeyProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListExternalApiKeysAndReturnArgs { + pub input: ExternalApiKeyListInput, +} + +impl __sdk::InModule for ListExternalApiKeysAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_external_api_keys_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_external_api_keys_and_return { + fn list_external_api_keys_and_return(&self, input: ExternalApiKeyListInput) { + self.list_external_api_keys_and_return_then(input, |_, _| {}); + } + + fn list_external_api_keys_and_return_then( + &self, + input: ExternalApiKeyListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_external_api_keys_and_return for super::RemoteProcedures { + fn list_external_api_keys_and_return_then( + &self, + input: ExternalApiKeyListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>( + "list_external_api_keys_and_return", + ListExternalApiKeysAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/revoke_external_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/revoke_external_api_key_and_return_procedure.rs new file mode 100644 index 000000000..d893b2180 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/revoke_external_api_key_and_return_procedure.rs @@ -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_procedure_result_type::ExternalApiKeyProcedureResult; +use super::external_api_key_revoke_input_type::ExternalApiKeyRevokeInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RevokeExternalApiKeyAndReturnArgs { + pub input: ExternalApiKeyRevokeInput, +} + +impl __sdk::InModule for RevokeExternalApiKeyAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `revoke_external_api_key_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait revoke_external_api_key_and_return { + fn revoke_external_api_key_and_return(&self, input: ExternalApiKeyRevokeInput) { + self.revoke_external_api_key_and_return_then(input, |_, _| {}); + } + + fn revoke_external_api_key_and_return_then( + &self, + input: ExternalApiKeyRevokeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl revoke_external_api_key_and_return for super::RemoteProcedures { + fn revoke_external_api_key_and_return_then( + &self, + input: ExternalApiKeyRevokeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalApiKeyProcedureResult>( + "revoke_external_api_key_and_return", + RevokeExternalApiKeyAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/external_api_key_storage.rs b/server-rs/crates/spacetime-module/src/external_api_key_storage.rs new file mode 100644 index 000000000..3b54d2bb0 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/external_api_key_storage.rs @@ -0,0 +1,312 @@ +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\"]"; + +#[spacetimedb::table( + accessor = external_api_key, + index(accessor = by_external_api_key_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct ExternalApiKey { + #[primary_key] + pub key_id: String, + pub owner_user_id: String, + pub name: String, + pub key_prefix: String, + #[unique] + pub key_hash: String, + pub scopes_json: String, + pub created_at: Timestamp, + pub last_used_at: Option, + pub revoked_at: Option, + pub updated_at: Timestamp, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +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, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalApiKeyListInput { + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalApiKeyRevokeInput { + pub key_id: String, + pub owner_user_id: String, + pub revoked_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalApiKeyAuthenticateInput { + pub key_hash: String, + pub used_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalApiKeySnapshot { + pub key_id: String, + pub owner_user_id: String, + pub name: String, + pub key_prefix: String, + pub scopes_json: String, + pub created_at_micros: i64, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalApiKeyProcedureResult { + pub ok: bool, + pub key: Option, + pub keys: Vec, + pub error_message: Option, +} + +#[spacetimedb::procedure] +pub fn create_external_api_key_and_return( + ctx: &mut ProcedureContext, + input: ExternalApiKeyCreateInput, +) -> ExternalApiKeyProcedureResult { + match ctx.try_with_tx(|tx| create_external_api_key(tx, input.clone())) { + Ok(key) => external_api_key_single_ok(key), + Err(message) => external_api_key_error(message), + } +} + +#[spacetimedb::procedure] +pub fn list_external_api_keys_and_return( + ctx: &mut ProcedureContext, + input: ExternalApiKeyListInput, +) -> ExternalApiKeyProcedureResult { + match ctx.try_with_tx(|tx| list_external_api_keys(tx, input.clone())) { + Ok(keys) => external_api_key_list_ok(keys), + Err(message) => external_api_key_error(message), + } +} + +#[spacetimedb::procedure] +pub fn revoke_external_api_key_and_return( + ctx: &mut ProcedureContext, + input: ExternalApiKeyRevokeInput, +) -> ExternalApiKeyProcedureResult { + match ctx.try_with_tx(|tx| revoke_external_api_key(tx, input.clone())) { + Ok(key) => external_api_key_single_ok(key), + Err(message) => external_api_key_error(message), + } +} + +#[spacetimedb::procedure] +pub fn authenticate_external_api_key_and_return( + ctx: &mut ProcedureContext, + input: ExternalApiKeyAuthenticateInput, +) -> ExternalApiKeyProcedureResult { + match ctx.try_with_tx(|tx| authenticate_external_api_key(tx, input.clone())) { + Ok(key) => external_api_key_single_ok(key), + Err(message) => external_api_key_error(message), + } +} + +fn create_external_api_key( + ctx: &ReducerContext, + input: ExternalApiKeyCreateInput, +) -> Result { + let key_id = normalize_required(&input.key_id, "external_api_key.key_id")?; + let owner_user_id = normalize_required(&input.owner_user_id, "external_api_key.owner_user_id")?; + let key_prefix = normalize_required(&input.key_prefix, "external_api_key.key_prefix")?; + let key_hash = normalize_required(&input.key_hash, "external_api_key.key_hash")?; + let scopes_json = normalize_scopes_json(input.scopes_json)?; + if ctx.db.external_api_key().key_id().find(&key_id).is_some() { + return Err("API Key 已存在".to_string()); + } + if ctx + .db + .external_api_key() + .key_hash() + .find(&key_hash) + .is_some() + { + return Err("API Key 摘要已存在".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db.external_api_key().insert(ExternalApiKey { + key_id: key_id.clone(), + owner_user_id, + name: normalize_key_name(input.name.as_str()), + key_prefix, + key_hash, + scopes_json, + created_at: now, + last_used_at: None, + revoked_at: None, + updated_at: now, + }); + ctx.db + .external_api_key() + .key_id() + .find(&key_id) + .map(external_api_key_snapshot_from_row) + .ok_or_else(|| "API Key 创建失败".to_string()) +} + +fn list_external_api_keys( + ctx: &ReducerContext, + input: ExternalApiKeyListInput, +) -> Result, String> { + let owner_user_id = normalize_required(&input.owner_user_id, "external_api_key.owner_user_id")?; + let mut keys = ctx + .db + .external_api_key() + .by_external_api_key_owner_user_id() + .filter(&owner_user_id) + .map(external_api_key_snapshot_from_row) + .collect::>(); + keys.sort_by(|left, right| { + right + .created_at_micros + .cmp(&left.created_at_micros) + .then_with(|| right.key_id.cmp(&left.key_id)) + }); + Ok(keys) +} + +fn revoke_external_api_key( + ctx: &ReducerContext, + input: ExternalApiKeyRevokeInput, +) -> Result { + let key_id = normalize_required(&input.key_id, "external_api_key.key_id")?; + let owner_user_id = normalize_required(&input.owner_user_id, "external_api_key.owner_user_id")?; + let row = ctx + .db + .external_api_key() + .key_id() + .find(&key_id) + .ok_or_else(|| "API Key 不存在".to_string())?; + if row.owner_user_id != owner_user_id { + return Err("无权访问该 API Key".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.revoked_at_micros); + ctx.db.external_api_key().key_id().delete(&key_id); + ctx.db.external_api_key().insert(ExternalApiKey { + revoked_at: row.revoked_at.or(Some(now)), + updated_at: now, + ..row + }); + ctx.db + .external_api_key() + .key_id() + .find(&key_id) + .map(external_api_key_snapshot_from_row) + .ok_or_else(|| "API Key 撤销失败".to_string()) +} + +fn authenticate_external_api_key( + ctx: &ReducerContext, + input: ExternalApiKeyAuthenticateInput, +) -> Result { + let key_hash = normalize_required(&input.key_hash, "external_api_key.key_hash")?; + let row = ctx + .db + .external_api_key() + .key_hash() + .find(&key_hash) + .ok_or_else(|| "API Key 不存在或已失效".to_string())?; + if row.revoked_at.is_some() { + return Err("API Key 不存在或已失效".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.used_at_micros); + ctx.db.external_api_key().key_id().delete(&row.key_id); + ctx.db.external_api_key().insert(ExternalApiKey { + last_used_at: Some(now), + updated_at: now, + ..row + }); + ctx.db + .external_api_key() + .key_hash() + .find(&key_hash) + .map(external_api_key_snapshot_from_row) + .ok_or_else(|| "API Key 校验失败".to_string()) +} + +fn external_api_key_snapshot_from_row(row: ExternalApiKey) -> ExternalApiKeySnapshot { + ExternalApiKeySnapshot { + key_id: row.key_id, + owner_user_id: row.owner_user_id, + name: row.name, + key_prefix: row.key_prefix, + scopes_json: row.scopes_json, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + last_used_at_micros: row + .last_used_at + .map(|value| value.to_micros_since_unix_epoch()), + revoked_at_micros: row + .revoked_at + .map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn normalize_required(value: &str, field: &str) -> Result { + let normalized = value.trim(); + if normalized.is_empty() { + return Err(format!("{field} 不能为空")); + } + Ok(normalized.to_string()) +} + +fn normalize_key_name(value: &str) -> String { + let name = value.trim(); + if name.is_empty() { + return EXTERNAL_API_KEY_DEFAULT_NAME.to_string(); + } + name.chars().take(EXTERNAL_API_KEY_MAX_NAME_CHARS).collect() +} + +fn normalize_scopes_json(value: String) -> Result { + let normalized = value.trim(); + if normalized.is_empty() { + return Ok(EXTERNAL_API_KEY_DEFAULT_SCOPES_JSON.to_string()); + } + serde_json::from_str::(normalized) + .map_err(|_| "API Key scopes 必须是合法 JSON".to_string())?; + Ok(normalized.to_string()) +} + +fn external_api_key_single_ok(key: ExternalApiKeySnapshot) -> ExternalApiKeyProcedureResult { + ExternalApiKeyProcedureResult { + ok: true, + key: Some(key), + keys: Vec::new(), + error_message: None, + } +} + +fn external_api_key_list_ok(keys: Vec) -> ExternalApiKeyProcedureResult { + ExternalApiKeyProcedureResult { + ok: true, + key: None, + keys, + error_message: None, + } +} + +fn external_api_key_error(message: String) -> ExternalApiKeyProcedureResult { + ExternalApiKeyProcedureResult { + ok: false, + key: None, + keys: Vec::new(), + error_message: Some(message), + } +} diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 9a839f332..a6fe765b5 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -32,6 +32,7 @@ mod custom_world; mod domain_types; mod editor_project_storage; mod entry; +mod external_api_key_storage; mod external_generation; mod gameplay; mod jump_hop; @@ -54,6 +55,7 @@ pub use custom_world::*; pub use domain_types::*; pub use editor_project_storage::*; pub use entry::*; +pub use external_api_key_storage::*; pub use external_generation::*; pub use gameplay::*; pub use jump_hop::*; diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 4f1927027..19eb41724 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -229,6 +229,7 @@ macro_rules! migration_tables { asset_object, asset_entity_binding, asset_event, + external_api_key, editor_project, editor_canvas, editor_project_resource, diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 52b5ec752..9ba108644 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -69,7 +69,6 @@ function renderWorldView( onLayerMouseEnter: vi.fn(), onLayerMouseLeave: vi.fn(), onOpenLayerMetadata: vi.fn(), - onUpdateLayerAssetKind: vi.fn(), onGenerationFramePointerDown: vi.fn(), onActivateGenerationDialog: vi.fn(), onCropExpandHandlePointerDown: vi.fn(), @@ -146,27 +145,27 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByText('角色')).toBeTruthy(); }); - it('shows unknown tag for untagged layers and lets users choose a tag', () => { + it('shows kind tag for tagged layers and omits the tag for untagged layers', () => { const layer = createLayer({ assetKind: null }); - const { props } = renderWorldView({ layers: [layer] }); - - const layerButton = screen.getByRole('button', { name: '选择角色主图' }); - const unknownTag = within(layerButton).getByRole('button', { - name: '素材标签:未知', + renderWorldView({ + layers: [ + layer, + createLayer({ + id: 'layer-character', + resourceId: 'resource-character', + title: '角色标签图层', + assetKind: 'character', + }), + ], }); - expect(unknownTag.textContent).toBe('未知'); - expect(unknownTag.style.getPropertyValue('--image-canvas-editor-inverse-scale')).toBe( - `${1 / props.viewport.scale}`, - ); + const layerButton = screen.getByRole('button', { name: '选择角色主图' }); + const characterButton = screen.getByRole('button', { + name: '选择角色标签图层', + }); - fireEvent.click(unknownTag); - - const menu = screen.getByRole('menu', { name: '选择素材标签' }); - fireEvent.click(within(menu).getByRole('menuitem', { name: '角色' })); - - expect(props.onUpdateLayerAssetKind).toHaveBeenCalledWith(layer.id, 'character'); - expect(props.onLayerClick).not.toHaveBeenCalled(); + expect(within(layerButton).queryByText('未知')).toBeNull(); + expect(within(characterButton).getByText('角色')).toBeTruthy(); }); it('renders snap guides, marquee and floating generation status', () => { diff --git a/src/components/platform-entry/PlatformProfileApiKeysModal.tsx b/src/components/platform-entry/PlatformProfileApiKeysModal.tsx new file mode 100644 index 000000000..05a8063a6 --- /dev/null +++ b/src/components/platform-entry/PlatformProfileApiKeysModal.tsx @@ -0,0 +1,305 @@ +import { KeyRound, ShieldCheck, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { + ExternalApiKeyCreateResponse, + ExternalApiKeyProfile, +} from '../../../packages/shared/src/contracts/runtime'; +import { + createRpgProfileExternalApiKey, + listRpgProfileExternalApiKeys, + revokeRpgProfileExternalApiKey, +} from '../../services/rpg-entry/rpgProfileClient'; +import { CopyFeedbackButton } from '../common/CopyFeedbackButton'; +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel'; +import { PlatformEmptyState } from '../common/PlatformEmptyState'; +import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow'; +import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList'; +import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { PlatformTextField } from '../common/PlatformTextField'; +import { useCopyFeedback } from '../common/useCopyFeedback'; +import { formatPlatformWorldTime } from '../rpg-entry/rpgEntryWorldPresentation'; +import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell'; + +type PlatformProfileApiKeysModalProps = { + onClose: () => void; +}; + +function isActiveExternalApiKey(key: ExternalApiKeyProfile) { + return !key.revokedAt; +} + +function buildApiKeyTimeLabel(value: string | null) { + return value ? formatPlatformWorldTime(value) : '尚未使用'; +} + +function buildApiKeyScopeLabel(scopes: string[]) { + return scopes.length > 0 ? scopes.join(' / ') : '默认权限'; +} + +/** + * 开发者 API Key 管理弹窗。 + * 明文 Key 只保留在本次创建后的组件状态里,关闭弹窗后即丢弃。 + */ +export function PlatformProfileApiKeysModal({ + onClose, +}: PlatformProfileApiKeysModalProps) { + const [keys, setKeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [nameInput, setNameInput] = useState('外部 API Key'); + const [createdKey, setCreatedKey] = + useState(null); + const [isCreating, setIsCreating] = useState(false); + const [revokingKeyId, setRevokingKeyId] = useState(null); + const { copyState, copyText } = useCopyFeedback(); + + const activeKeys = useMemo( + () => keys.filter(isActiveExternalApiKey), + [keys], + ); + const shouldShowBlockingError = Boolean(error && !isLoading && keys.length === 0); + + const loadKeys = useCallback(() => { + setIsLoading(true); + setError(null); + void listRpgProfileExternalApiKeys() + .then((response) => { + setKeys(response.keys); + }) + .catch((loadError: unknown) => { + setError(loadError instanceof Error ? loadError.message : '读取 API Key 失败'); + }) + .finally(() => setIsLoading(false)); + }, []); + + useEffect(() => { + loadKeys(); + }, [loadKeys]); + + const createKey = useCallback(() => { + if (isCreating) { + return; + } + setIsCreating(true); + setError(null); + setCreatedKey(null); + void createRpgProfileExternalApiKey(nameInput) + .then((response) => { + setCreatedKey(response); + setKeys((current) => [ + response.key, + ...current.filter((key) => key.keyId !== response.key.keyId), + ]); + setNameInput('外部 API Key'); + }) + .catch((createError: unknown) => { + setError( + createError instanceof Error ? createError.message : '创建 API Key 失败', + ); + }) + .finally(() => setIsCreating(false)); + }, [isCreating, nameInput]); + + const revokeKey = useCallback((keyId: string) => { + setRevokingKeyId(keyId); + setError(null); + void revokeRpgProfileExternalApiKey(keyId) + .then((response) => { + setKeys((current) => + current.map((key) => + key.keyId === response.key.keyId ? response.key : key, + ), + ); + setCreatedKey((current) => + current?.key.keyId === response.key.keyId ? null : current, + ); + }) + .catch((revokeError: unknown) => { + setError( + revokeError instanceof Error ? revokeError.message : '撤销 API Key 失败', + ); + }) + .finally(() => setRevokingKeyId(null)); + }, []); + + return ( + + + + {activeKeys.length} 个可用 + + } + /> + +
+
+ + + + {isCreating ? '创建中' : '创建'} + +
+
+ + {createdKey ? ( + +
新 Key
+
+ {createdKey.apiKey} +
+ void copyText(createdKey.apiKey)} + /> +
+ ) : null} + + +
{error}
+ + 重新加载 + + + ) : null + } + isLoading={isLoading} + loadingState={ + + } + isEmpty={keys.length === 0} + emptyState={ + + 暂无 API Key + + } + > + {error ? ( + + {error} + + ) : null} +
+ {keys.map((key) => { + const isActive = isActiveExternalApiKey(key); + const isRevoking = revokingKeyId === key.keyId; + return ( + +
+
+ + {key.name} + + + {isActive ? '可用' : '已撤销'} + +
+
+ {key.keyPrefix}... +
+
+ {buildApiKeyScopeLabel(key.scopes)} +
+
+ 创建 {buildApiKeyTimeLabel(key.createdAt)} · 最近使用{' '} + {buildApiKeyTimeLabel(key.lastUsedAt)} +
+
+ {isActive ? ( + revokeKey(key.keyId)} + > + + {isRevoking ? '撤销中' : '撤销'} + + ) : null} +
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index d98027908..b5ef1a8d5 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -52,6 +52,9 @@ const { mockGetRpgProfileRechargeCenter, mockGetRpgProfileTasks, mockGetRpgProfileWalletLedger, + mockListRpgProfileExternalApiKeys, + mockCreateRpgProfileExternalApiKey, + mockRevokeRpgProfileExternalApiKey, mockRedeemRpgProfileReferralInviteCode, mockWatchWechatRpgProfileRechargeOrder, } = vi.hoisted(() => { @@ -327,6 +330,46 @@ const { }, ], })), + mockListRpgProfileExternalApiKeys: vi.fn(async () => ({ + keys: [ + { + keyId: 'api-key-1', + name: '默认 Key', + keyPrefix: 'tnr_sk_existing_1', + scopes: ['editor:project', 'editor:canvas', 'editor:image-generate'], + createdAt: '2026-06-19T08:00:00Z', + lastUsedAt: null, + revokedAt: null, + updatedAt: '2026-06-19T08:00:00Z', + }, + ], + })), + mockCreateRpgProfileExternalApiKey: vi.fn(async () => ({ + apiKey: 'tnr_sk_created.secret', + key: { + keyId: 'api-key-created', + name: '测试 Key', + keyPrefix: 'tnr_sk_created', + scopes: ['editor:project', 'editor:canvas', 'editor:image-generate'], + createdAt: '2026-06-19T09:00:00Z', + lastUsedAt: null, + revokedAt: null, + updatedAt: '2026-06-19T09:00:00Z', + }, + })), + mockRevokeRpgProfileExternalApiKey: vi.fn(async (keyId: string) => ({ + key: { + keyId, + name: keyId === 'api-key-created' ? '测试 Key' : '默认 Key', + keyPrefix: + keyId === 'api-key-created' ? 'tnr_sk_created' : 'tnr_sk_existing_1', + scopes: ['editor:project', 'editor:canvas', 'editor:image-generate'], + createdAt: '2026-06-19T08:00:00Z', + lastUsedAt: null, + revokedAt: '2026-06-19T10:00:00Z', + updatedAt: '2026-06-19T10:00:00Z', + }, + })), mockWatchWechatRpgProfileRechargeOrder: vi.fn(async () => null), }; }); @@ -406,6 +449,9 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger, claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward, redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode, + listRpgProfileExternalApiKeys: mockListRpgProfileExternalApiKeys, + createRpgProfileExternalApiKey: mockCreateRpgProfileExternalApiKey, + revokeRpgProfileExternalApiKey: mockRevokeRpgProfileExternalApiKey, getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter, createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder, confirmWechatRpgProfileRechargeOrder: @@ -3112,7 +3158,10 @@ test('mobile profile page matches the reference layout sections', async () => { ).toBeNull(); expect( settingsRegion.querySelectorAll('.platform-profile-settings-row'), - ).toHaveLength(1); + ).toHaveLength(2); + expect( + within(settingsRegion).getByRole('button', { name: /开发者 API Key/u }), + ).toBeTruthy(); expect( within(settingsRegion).queryByRole('button', { name: /存档/u }), ).toBeNull(); @@ -3575,6 +3624,9 @@ test('profile page shows legal entries and hides archive shortcuts', async () => expect( within(settingsRegion).getByRole('button', { name: /通用设置/u }), ).toBeTruthy(); + expect( + within(settingsRegion).getByRole('button', { name: /开发者 API Key/u }), + ).toBeTruthy(); expect( within(settingsRegion).queryByRole('button', { name: /主题设置/u }), ).toBeNull(); @@ -3610,6 +3662,48 @@ test('profile page shows legal entries and hides archive shortcuts', async () => expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy(); }); +test('profile developer api key entry opens management modal and supports create and revoke', async () => { + const user = userEvent.setup(); + + renderProfileView(); + + await user.click(screen.getByRole('button', { name: /开发者 API Key/u })); + + const dialog = await screen.findByRole('dialog', { + name: '开发者 API Key', + }); + + expect(mockListRpgProfileExternalApiKeys).toHaveBeenCalledTimes(1); + expect(within(dialog).getByText('默认 Key')).toBeTruthy(); + expect(within(dialog).getByText('tnr_sk_existing_1...')).toBeTruthy(); + expect(within(dialog).queryByText('tnr_sk_created.secret')).toBeNull(); + + const nameInput = within(dialog).getByLabelText('名称'); + await user.clear(nameInput); + await user.type(nameInput, '测试 Key'); + await user.click(within(dialog).getByRole('button', { name: /创建/u })); + + await waitFor(() => { + expect(mockCreateRpgProfileExternalApiKey).toHaveBeenCalledWith('测试 Key'); + }); + expect(await within(dialog).findByText('tnr_sk_created.secret')).toBeTruthy(); + expect(within(dialog).getByText('测试 Key')).toBeTruthy(); + + const revokeButtons = within(dialog).getAllByRole('button', { + name: /撤销/u, + }); + expect(revokeButtons.length).toBeGreaterThan(0); + const revokeButton = revokeButtons[0]; + if (!revokeButton) { + throw new Error('撤销按钮不存在'); + } + await user.click(revokeButton); + await waitFor(() => { + expect(mockRevokeRpgProfileExternalApiKey).toHaveBeenCalled(); + }); + expect(within(dialog).getByText('已撤销')).toBeTruthy(); +}); + test('shows a reachable login entry outside mobile recommend tab', async () => { const user = userEvent.setup(); const openLoginModal = vi.fn(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 06408b9f5..5a42448ac 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -12,6 +12,7 @@ import { Gamepad2, GitFork, Heart, + KeyRound, LogIn, MessageCircle, Palette, @@ -127,6 +128,7 @@ import { } from '../platform-entry/PlatformProfilePrimitives'; import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell'; import { PlatformProfileGenerationQueueCard } from '../platform-entry/PlatformProfileGenerationQueueCard'; +import { PlatformProfileApiKeysModal } from '../platform-entry/PlatformProfileApiKeysModal'; import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal'; import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal'; import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal'; @@ -2651,6 +2653,7 @@ export function RpgEntryHomeView({ }); const [avatarError, setAvatarError] = useState(null); const [isSavingAvatar, setIsSavingAvatar] = useState(false); + const [isApiKeysModalOpen, setIsApiKeysModalOpen] = useState(false); const currentUser = authUi?.user ?? null; const isAuthenticated = Boolean(currentUser); const { @@ -4456,6 +4459,11 @@ export function RpgEntryHomeView({ icon={Settings} onClick={() => authUi.openSettingsModal()} /> + setIsApiKeysModalOpen(true)} + /> @@ -4913,6 +4921,11 @@ export function RpgEntryHomeView({ }} /> ) : null; + const apiKeysModal: ReactNode = isApiKeysModalOpen ? ( + setIsApiKeysModalOpen(false)} + /> + ) : null; if (!isDesktopLayout) { const isMobileRecommendTab = activeTab === 'home'; @@ -5021,6 +5034,7 @@ export function RpgEntryHomeView({ ) : null} {rewardCodeModal} {rechargeModal} + {apiKeysModal} {rechargePaymentResultModal} {qrScannerModal} {categoryFilterDialog} @@ -5178,6 +5192,7 @@ export function RpgEntryHomeView({ {rewardCodeModal} {rechargeModal} + {apiKeysModal} {rechargePaymentResultModal} {categoryFilterDialog} {isTaskCenterOpen ? ( diff --git a/src/services/rpg-entry/rpgProfileClient.test.ts b/src/services/rpg-entry/rpgProfileClient.test.ts index 68d292b21..5b46128fe 100644 --- a/src/services/rpg-entry/rpgProfileClient.test.ts +++ b/src/services/rpg-entry/rpgProfileClient.test.ts @@ -7,8 +7,11 @@ const { fetchWithApiAuthMock, requestJsonMock } = vi.hoisted(() => ({ import { clearRpgProfileBrowseHistory, + createRpgProfileExternalApiKey, listRpgProfileBrowseHistory, + listRpgProfileExternalApiKeys, listRpgProfileSaveArchives, + revokeRpgProfileExternalApiKey, resumeRpgProfileSaveArchive, submitRpgProfileFeedback, syncRpgProfileBrowseHistory, @@ -199,6 +202,64 @@ describe('rpgProfileClient save archive routes', () => { }); }); +describe('rpgProfileClient external api key routes', () => { + beforeEach(() => { + requestJsonMock.mockReset(); + requestJsonMock.mockResolvedValue({ keys: [] }); + }); + + it('lists profile api keys from the profile route', async () => { + await listRpgProfileExternalApiKeys(); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/profile/api-keys', + expect.objectContaining({ method: 'GET' }), + '读取 API Key 失败', + expect.objectContaining({ + retry: expect.objectContaining({ maxRetries: 1 }), + }), + ); + }); + + it('creates profile api keys through the profile route', async () => { + await createRpgProfileExternalApiKey('测试 Key'); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/profile/api-keys', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + '创建 API Key 失败', + expect.objectContaining({ + retry: expect.objectContaining({ + maxRetries: 1, + retryUnsafeMethods: true, + }), + }), + ); + expect(JSON.parse(requestJsonMock.mock.calls[0][1].body)).toEqual({ + name: '测试 Key', + }); + }); + + it('revokes profile api keys through the profile route', async () => { + await revokeRpgProfileExternalApiKey('key:1'); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/profile/api-keys/key%3A1', + expect.objectContaining({ method: 'DELETE' }), + '撤销 API Key 失败', + expect.objectContaining({ + retry: expect.objectContaining({ + maxRetries: 1, + retryUnsafeMethods: true, + }), + }), + ); + }); +}); + describe('rpgProfileClient feedback routes', () => { beforeEach(() => { requestJsonMock.mockReset(); diff --git a/src/services/rpg-entry/rpgProfileClient.ts b/src/services/rpg-entry/rpgProfileClient.ts index 8aab97bc9..53f3facad 100644 --- a/src/services/rpg-entry/rpgProfileClient.ts +++ b/src/services/rpg-entry/rpgProfileClient.ts @@ -2,6 +2,9 @@ import type { ClaimProfileTaskRewardResponse, ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, + ExternalApiKeyCreateResponse, + ExternalApiKeyListResponse, + ExternalApiKeyMutationResponse, PlatformBrowseHistoryBatchSyncRequest, PlatformBrowseHistoryResponse, PlatformBrowseHistoryWriteEntry, @@ -79,6 +82,45 @@ export function getRpgProfileWalletLedger(options: RuntimeRequestOptions = {}) { ); } +export function listRpgProfileExternalApiKeys( + options: RuntimeRequestOptions = {}, +) { + return requestRpgRuntimeJson( + '/profile/api-keys', + { method: 'GET' }, + '读取 API Key 失败', + options, + ); +} + +export function createRpgProfileExternalApiKey( + name: string, + options: RuntimeRequestOptions = {}, +) { + return requestRpgRuntimeJson( + '/profile/api-keys', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }, + '创建 API Key 失败', + options, + ); +} + +export function revokeRpgProfileExternalApiKey( + keyId: string, + options: RuntimeRequestOptions = {}, +) { + return requestRpgRuntimeJson( + `/profile/api-keys/${encodeURIComponent(keyId)}`, + { method: 'DELETE' }, + '撤销 API Key 失败', + options, + ); +} + export function getRpgProfileRechargeCenter( options: RuntimeRequestOptions = {}, ) {