From 187d7735b986e27fdf6acb7f3afb7339ef3eb888 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 10 Jul 2026 18:10:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=AE=9A=E4=BB=B7=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将模型定价改为 SpacetimeDB 强类型持久化并兼容旧配置种子迁移 修复快速编辑支付弹窗期间框选显示和交互冻结 补齐后台图片放大以及视频音频资源预览 收紧主站和 External 资源换签授权并记录管理员跨用户审计 固化外部生成入队价格、attempt 钱包结算和最终 lease 失败收口 加固运行时身份轮换、bootstrap secret 与生产构建发布门禁 同步生成绑定、定向测试、运维脚本和项目文档 --- apps/admin-web/src/api/adminApiClient.ts | 8 +- .../pages/AdminEditorAssetQueryPage.test.tsx | 294 ++++++- .../src/pages/AdminEditorAssetQueryPage.tsx | 265 +++++- .../AdminEditorShowcaseReviewPage.test.tsx | 4 + .../pages/AdminEditorShowcaseReviewPage.tsx | 16 +- apps/admin-web/src/styles/admin.css | 54 +- apps/admin-web/vite.config.ts | 3 +- deploy/env/api-server.env.example | 2 + .../external-generation-worker.env.example | 2 + docs/README.md | 2 +- .../shared-memory/decision-log.md | 19 +- docs/project-memory/shared-memory/pitfalls.md | 31 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 38 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 13 +- ...编辑器】模型定价配置管理方案-2026-06-22.md | 30 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 5 +- jenkins/Jenkinsfile.production-api-deploy | 9 +- ...nkinsfile.production-full-build-and-deploy | 12 +- .../Jenkinsfile.production-stdb-module-build | 34 +- ...Jenkinsfile.production-stdb-module-publish | 32 +- scripts/build-production-release.sh | 71 +- scripts/check-production-ops-guardrails.mjs | 298 +++++++ scripts/deploy-rust-remote.sh | 111 ++- scripts/deploy/production-api-deploy.sh | 47 ++ ...duction-runtime-writer-identity-rotate.mjs | 163 ++++ scripts/deploy/production-stdb-publish.sh | 349 +++++++- scripts/dev.mjs | 409 +++++++++- scripts/dev.test.ts | 522 +++++++++++- scripts/jenkins-server-provision.sh | 39 +- ...spacetime-authorize-migration-operator.mjs | 3 +- scripts/spacetime-export-migration-json.mjs | 3 +- scripts/spacetime-import-migration-json.mjs | 3 +- scripts/spacetime-migration-common.mjs | 28 +- server-rs/crates/api-server/src/admin.rs | 84 +- server-rs/crates/api-server/src/app.rs | 19 +- .../crates/api-server/src/asset_billing.rs | 474 +++++++++-- server-rs/crates/api-server/src/assets.rs | 289 ++++++- .../src/character_animation_assets.rs | 8 +- server-rs/crates/api-server/src/config.rs | 66 +- .../src/editor_generation_config.rs | 175 +++- .../crates/api-server/src/editor_project.rs | 85 +- .../api-server/src/external_assets_api.rs | 12 +- .../src/external_generation_worker.rs | 75 +- server-rs/crates/api-server/src/main.rs | 23 +- .../crates/api-server/src/modules/admin.rs | 19 +- server-rs/crates/api-server/src/state.rs | 222 ++++- .../generation.rs | 6 +- .../spacetime-client/src/editor_project.rs | 75 ++ server-rs/crates/spacetime-client/src/lib.rs | 4 +- .../crates/spacetime-client/src/mapper.rs | 13 +- .../src/mapper/editor_project.rs | 111 +++ .../spacetime-client/src/module_bindings.rs | 114 +++ ...asset_operation_wallet_settlement_table.rs | 171 ++++ .../asset_operation_wallet_settlement_type.rs | 61 ++ .../editor_generation_model_pricing_type.rs | 20 + ...on_pricing_config_procedure_result_type.rs | 19 + ...generation_pricing_config_snapshot_type.rs | 21 + .../editor_generation_pricing_config_table.rs | 170 ++++ .../editor_generation_pricing_config_type.rs | 71 ++ ...ration_pricing_config_upsert_input_type.rs | 20 + .../editor_generation_pricing_tier_type.rs | 16 + ...tion_runtime_identity_rotate_input_type.rs | 17 + ...eration_runtime_identity_rotation_table.rs | 182 +++++ ...neration_runtime_identity_rotation_type.rs | 81 ++ ...ion_pricing_config_and_return_procedure.rs | 54 ++ ..._config_if_missing_and_return_procedure.rs | 65 ++ ...e_service_identity_and_return_procedure.rs | 62 ++ ...ion_pricing_config_and_return_procedure.rs | 62 ++ .../src/asset_metadata/objects.rs | 35 + .../src/editor_project_storage.rs | 760 ++++++++++++++++++ .../src/external_generation.rs | 161 +++- .../crates/spacetime-module/src/migration.rs | 125 ++- .../spacetime-module/src/runtime/profile.rs | 570 ++++++++++++- .../ImageCanvasEditorView.test.tsx | 156 +++- .../image-editor/ImageCanvasEditorView.tsx | 49 +- .../image-editor/ImageCanvasStageView.tsx | 24 +- .../useImageCanvasGenerationWorkflow.ts | 45 +- .../useImageCanvasKeyboardShortcuts.test.tsx | 98 ++- .../useImageCanvasKeyboardShortcuts.ts | 17 + 79 files changed, 7457 insertions(+), 468 deletions(-) create mode 100644 scripts/deploy/production-runtime-writer-identity-rotate.mjs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/asset_operation_wallet_settlement_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/asset_operation_wallet_settlement_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_model_pricing_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_tier_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_runtime_identity_rotate_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_runtime_identity_rotation_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_generation_runtime_identity_rotation_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_editor_generation_pricing_config_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/initialize_editor_generation_pricing_config_if_missing_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/rotate_editor_generation_runtime_service_identity_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_editor_generation_pricing_config_and_return_procedure.rs diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index e3087cfe3..89cb391db 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -342,9 +342,13 @@ export function updateAdminWorkVisibility( ); } -export function getAdminAssetReadUrl(query: AdminAssetReadUrlQuery) { +export function getAdminAssetReadUrl( + token: string, + query: AdminAssetReadUrlQuery, +) { return request( - `/api/assets/read-url${buildAssetReadUrlQuery(query)}`, + `/admin/api/assets/read-url${buildAssetReadUrlQuery(query)}`, + { token }, ); } diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index 346a7335d..5786bc43c 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -7,6 +7,7 @@ import { waitFor, within, } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { beforeEach, expect, test, vi } from 'vitest'; import { @@ -127,12 +128,115 @@ test('后台素材查询缩略图使用 objectKey 换签后展示', async () => 'https://signed.example.com/generated-character-drafts/editor/spec.png', ); }); - expect(getAdminAssetReadUrl).toHaveBeenCalledWith({ + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { objectKey: 'generated-character-drafts/editor/spec.png', expireSeconds: 300, }); }); +test('后台素材查询将无 objectKey 的绝对 OSS 图片地址换签后展示', async () => { + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + imageSrc: + 'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-character-drafts/editor/absolute.png?x-oss-process=image/resize,w_320', + objectKey: null, + }, + ], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockResolvedValue({ + read: { + objectKey: 'generated-character-drafts/editor/absolute.png', + signedUrl: 'https://signed.example.com/absolute.png', + expiresAt: '2026-07-04T11:00:00Z', + }, + }); + + render( + , + ); + + const image = await screen.findByRole('img', { name: '素材:角色形象 1' }); + await waitFor(() => { + expect(image.getAttribute('src')).toBe( + 'https://signed.example.com/absolute.png', + ); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + legacyPublicPath: '/generated-character-drafts/editor/absolute.png', + expireSeconds: 300, + }); +}); + +test('后台素材查询点击图片缩略图可打开放大预览', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + await screen.findByRole('button', { name: '素材:角色形象 1' }), + ); + + const dialog = await screen.findByRole('dialog', { name: '素材预览' }); + const image = within(dialog).getByRole('img', { + name: '图片预览:角色形象 1', + }); + await waitFor(() => { + expect(image.getAttribute('src')).toBe( + 'https://signed.example.com/generated-character-drafts/editor/spec.png', + ); + }); +}); + +test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () => { + const user = userEvent.setup(); + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + assetId: 'asset-character-animation-1', + label: '角色动作首帧', + imageSrc: '/generated-animations/editor/source-1/task-1/frame00.png', + objectKey: 'generated-animations/editor/source-1/task-1/frame00.png', + assetKind: 'character-animation', + thumbnailSrc: + '/generated-animations/editor/source-1/task-1/frame00.png', + }, + ], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockResolvedValue({ + read: { + objectKey: 'generated-animations/editor/source-1/task-1/frame00.png', + signedUrl: 'https://signed.example.com/character-animation-frame00.png', + expiresAt: '2026-07-04T11:00:00Z', + }, + }); + + render( + , + ); + + await user.click( + await screen.findByRole('button', { name: '素材:角色动作首帧' }), + ); + + const dialog = await screen.findByRole('dialog', { name: '素材预览' }); + const image = within(dialog).getByRole('img', { + name: '图片预览:角色动作首帧', + }); + await waitFor(() => { + expect(image.getAttribute('src')).toBe( + 'https://signed.example.com/character-animation-frame00.png', + ); + }); + expect(within(dialog).queryByLabelText('视频预览:角色动作首帧')).toBeNull(); +}); + test('后台素材查询音频素材使用统一封面缩略图', async () => { vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ entries: [ @@ -159,6 +263,194 @@ test('后台素材查询音频素材使用统一封面缩略图', async () => { expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); }); +test('后台素材查询点击音频缩略图后签名音频并展示播放器', async () => { + const user = userEvent.setup(); + + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + assetId: 'asset-audio-1', + label: '胜利音效', + imageSrc: '/generated-editor-audios/sfx.mp3', + objectKey: 'generated-editor-audios/sfx.mp3', + assetKind: 'sound-effect', + }, + ], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockResolvedValue({ + read: { + objectKey: 'generated-editor-audios/sfx.mp3', + signedUrl: 'https://signed.example.com/generated-editor-audios/sfx.mp3', + expiresAt: '2026-07-04T11:00:00Z', + }, + }); + + render( + , + ); + + await user.click( + await screen.findByRole('button', { name: '素材:胜利音效' }), + ); + + const player = await screen.findByLabelText('音频预览:胜利音效'); + await waitFor(() => { + expect(player.getAttribute('src')).toBe( + 'https://signed.example.com/generated-editor-audios/sfx.mp3', + ); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-editor-audios/sfx.mp3', + expireSeconds: 300, + }); +}); + +test('后台素材查询视频缩略图使用封面并在预览中播放视频', async () => { + const user = userEvent.setup(); + + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + assetId: 'asset-video-1', + label: '生成视频 1', + imageSrc: '/generated-editor-videos/task-1/preview.mp4', + objectKey: + 'generated-character-drafts/editor-videos/task-1/preview.mp4', + assetKind: 'editor_video', + thumbnailSrc: '/generated-editor-videos/task-1/cover.png', + }, + ], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => { + if ('legacyPublicPath' in request) { + return { + read: { + objectKey: 'generated-editor-videos/task-1/cover.png', + signedUrl: 'https://signed.example.com/video-cover.png', + expiresAt: '2026-07-04T11:00:00Z', + }, + }; + } + const objectKey = request.objectKey ?? ''; + return { + read: { + objectKey, + signedUrl: objectKey.endsWith('.mp4') + ? 'https://signed.example.com/video-preview.mp4' + : 'https://signed.example.com/video-cover-by-key.png', + expiresAt: '2026-07-04T11:00:00Z', + }, + }; + }); + + render( + , + ); + + const image = await screen.findByRole('img', { name: '素材:生成视频 1' }); + await waitFor(() => { + expect(image.getAttribute('src')).toBe( + 'https://signed.example.com/video-cover.png', + ); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + legacyPublicPath: '/generated-editor-videos/task-1/cover.png', + expireSeconds: 300, + }); + expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-character-drafts/editor-videos/task-1/preview.mp4', + expireSeconds: 300, + }); + + vi.mocked(getAdminAssetReadUrl).mockClear(); + await user.click(screen.getByRole('button', { name: '素材:生成视频 1' })); + + const video = await screen.findByLabelText('视频预览:生成视频 1'); + await waitFor(() => { + expect(video.getAttribute('src')).toBe( + 'https://signed.example.com/video-preview.mp4', + ); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-character-drafts/editor-videos/task-1/preview.mp4', + expireSeconds: 300, + }); +}); + +test('后台素材查询将无 objectKey 的绝对 OSS 视频和封面分别换签', async () => { + const user = userEvent.setup(); + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + assetId: 'asset-video-2', + label: '生成视频 2', + imageSrc: + 'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-editor-videos/task-2/preview.mp4?versionId=video', + objectKey: null, + assetKind: 'editor_video', + thumbnailSrc: + 'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-editor-videos/task-2/cover.png?versionId=poster', + }, + ], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => { + const legacyPublicPath = request.legacyPublicPath ?? ''; + return { + read: { + objectKey: legacyPublicPath.replace(/^\//u, ''), + signedUrl: legacyPublicPath.endsWith('/cover.png') + ? 'https://signed.example.com/video-2-cover.png' + : 'https://signed.example.com/video-2-preview.mp4', + expiresAt: '2026-07-04T11:00:00Z', + }, + }; + }); + + render( + , + ); + + const thumbnail = await screen.findByRole('img', { + name: '素材:生成视频 2', + }); + await waitFor(() => { + expect(thumbnail.getAttribute('src')).toBe( + 'https://signed.example.com/video-2-cover.png', + ); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + legacyPublicPath: '/generated-editor-videos/task-2/cover.png', + expireSeconds: 300, + }); + + vi.mocked(getAdminAssetReadUrl).mockClear(); + await user.click(screen.getByRole('button', { name: '素材:生成视频 2' })); + + const video = await screen.findByLabelText('视频预览:生成视频 2'); + await waitFor(() => { + expect(video.getAttribute('src')).toBe( + 'https://signed.example.com/video-2-preview.mp4', + ); + expect(video.getAttribute('poster')).toBe( + 'https://signed.example.com/video-2-cover.png', + ); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + legacyPublicPath: '/generated-editor-videos/task-2/preview.mp4', + expireSeconds: 300, + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + legacyPublicPath: '/generated-editor-videos/task-2/cover.png', + expireSeconds: 300, + }); +}); + test('后台素材查询格式化微秒时间文本', async () => { vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ entries: [ diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index 177865f24..25b9e17bc 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -19,7 +19,7 @@ interface AdminEditorAssetQueryPageProps { } const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; -const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; +const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`; export function AdminEditorAssetQueryPage({ token, @@ -36,6 +36,8 @@ export function AdminEditorAssetQueryPage({ const [errorMessage, setErrorMessage] = useState(''); const [detailEntry, setDetailEntry] = useState(null); + const [previewEntry, setPreviewEntry] = + useState(null); const [promptPreview, setPromptPreview] = useState<{ title: string; prompt: string; @@ -168,11 +170,11 @@ export function AdminEditorAssetQueryPage({ {entry.label || '-'} @@ -233,7 +235,9 @@ export function AdminEditorAssetQueryPage({ {detailEntry ? ( setDetailEntry(null)} + onPreview={(entry) => setPreviewEntry(entry)} onPromptPreview={(entry, prompt) => setPromptPreview({ title: entry.label || entry.assetId, @@ -243,6 +247,14 @@ export function AdminEditorAssetQueryPage({ /> ) : null} + {previewEntry ? ( + setPreviewEntry(null)} + /> + ) : null} + {promptPreview ? (
void; + onPreview: (entry: AdminEditorAssetPayload) => void; onPromptPreview: (entry: AdminEditorAssetPayload, prompt: string) => void; }) { const promptText = entry.prompt || entry.actualPrompt || ''; @@ -332,7 +417,14 @@ function AdminAssetDetailDialog({
- +
{authorDisplayName(entry)} @@ -385,6 +477,114 @@ function AdminAssetDetailDialog({ ); } +function AdminAssetPreviewDialog({ + entry, + token, + onClose, +}: { + entry: AdminEditorAssetPayload; + token: string; + onClose: () => void; +}) { + return ( +
+
+
+
+

{entry.label || entry.assetId}

+ {entry.assetId} +
+ +
+ +
+
+ ); +} + +function AdminAssetPreviewMedia({ + entry, + token, +}: { + entry: AdminEditorAssetPayload; + token: string; +}) { + const mediaKind = resolveAdminAssetMediaKind(entry); + const isAudio = mediaKind === 'audio'; + const isVideo = mediaKind === 'video'; + const mediaSrc = useAdminResolvedAssetUrl( + token, + entry.imageSrc, + entry.objectKey, + ); + const posterSrc = useAdminResolvedAssetUrl( + token, + isVideo ? (entry.thumbnailSrc ?? '') : '', + null, + ); + const label = entry.label || entry.assetId; + + if (isAudio) { + return ( +
+ {`音频封面:${label}`} + {mediaSrc ? ( +