From 2c134d87dac49e5021d9c204e5d6c742a228ea67 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 22 Jul 2026 18:23:57 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B2=BE=E9=80=89?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E5=8D=A1=E5=9B=BE=E7=89=87=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐后台活动卡图片上传确认与私有资产登记链路 为启用活动卡当前对象键派生受限匿名读取授权 覆盖禁用换图越目录及历史未登记对象回归场景 同步后端架构创作主页与项目排障文档 --- apps/admin-web/src/api/adminApiClient.test.ts | 109 ++++++++++-- apps/admin-web/src/api/adminApiClient.ts | 14 ++ apps/admin-web/src/api/adminApiTypes.ts | 7 + docs/project-memory/shared-memory/pitfalls.md | 12 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- ...作】创作主页与项目入口改版计划-2026-06-18.md | 2 +- server-rs/crates/api-server/src/admin.rs | 163 +++++++++++++++++- server-rs/crates/api-server/src/assets.rs | 56 +++++- .../crates/api-server/src/modules/admin.rs | 7 + .../crates/shared-contracts/src/admin.rs | 34 +++- .../src/asset_metadata/objects.rs | 10 +- .../src/editor_project_storage.rs | 160 +++++++++++++++++ .../CreationLandingView.test.tsx | 25 +++ 13 files changed, 566 insertions(+), 37 deletions(-) diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 85f029e37..68fbc4a54 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -7,6 +7,7 @@ import { listAdminRechargeOrders, resolveAdminRechargeRefundManualReview, updateAdminAccount, + uploadAdminEditorShowcaseCampaignImage, } from './adminApiClient'; afterEach(() => { @@ -16,7 +17,7 @@ afterEach(() => { test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( - new Response(JSON.stringify({account: {accountId: 'member-1'}}), { + new Response(JSON.stringify({ account: { accountId: 'member-1' } }), { status: 200, }), ), @@ -40,7 +41,7 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => expect(fetchMock.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ method: 'POST', - headers: expect.objectContaining({Authorization: 'Bearer owner-token'}), + headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }), }), ); expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1'); @@ -56,6 +57,86 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => ); }); +test('活动卡图片上传成功后先确认正式私有对象再返回图片引用', async () => { + const closeBitmap = vi.fn(); + vi.stubGlobal( + 'createImageBitmap', + vi + .fn() + .mockResolvedValue({ width: 1024, height: 1536, close: closeBitmap }), + ); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + upload: { + bucket: 'genarrative-release', + host: 'https://genarrative-release.oss.example.com', + objectKey: + 'generated-character-drafts/editor/showcase-campaign/current/card.png', + legacyPublicPath: + '/generated-character-drafts/editor/showcase-campaign/current/card.png', + contentType: 'image/png', + formFields: { key: 'campaign-key', policy: 'signed-policy' }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce(new Response('', { status: 200 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ assetObject: { assetObjectId: 'assetobj-1' } }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + const file = new File(['image-bytes'], 'card.png', { type: 'image/png' }); + + const uploaded = await uploadAdminEditorShowcaseCampaignImage( + 'admin-token', + file, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/editor-showcase/campaign/image-upload-ticket', + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://genarrative-release.oss.example.com', + ); + expect(fetchMock.mock.calls[2]?.[0]).toBe( + '/admin/api/editor-showcase/campaign/image-upload-confirm', + ); + expect(fetchMock.mock.calls[2]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), + body: JSON.stringify({ + bucket: 'genarrative-release', + objectKey: + 'generated-character-drafts/editor/showcase-campaign/current/card.png', + contentType: 'image/png', + contentLength: file.size, + }), + }), + ); + expect(uploaded).toEqual({ + imageSrc: + '/generated-character-drafts/editor/showcase-campaign/current/card.png', + imageObjectKey: + 'generated-character-drafts/editor/showcase-campaign/current/card.png', + imageWidth: 1024, + imageHeight: 1536, + legacyPublicPath: + '/generated-character-drafts/editor/showcase-campaign/current/card.png', + }); + expect(closeBitmap).toHaveBeenCalledOnce(); +}); + test('充值订单查询按后台契约序列化筛选参数', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ entries: [] }), { @@ -110,13 +191,11 @@ test('用户详情只发送实际提供的用户定位字段', async () => { }); test('退款执行使用独立 execute 管理员路由', async () => { - const fetchMock = vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { - status: 200, - }), - ); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { + status: 200, + }), + ); vi.stubGlobal('fetch', fetchMock); await executeAdminRechargeRefund('token-1', { @@ -143,13 +222,11 @@ test('退款执行使用独立 execute 管理员路由', async () => { }); test('退款人工复核使用独立 resolve 管理员路由', async () => { - const fetchMock = vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { - status: 200, - }), - ); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { + status: 200, + }), + ); vi.stubGlobal('fetch', fetchMock); await resolveAdminRechargeRefundManualReview('token-1', { diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 17e3ba244..c5f17d5b5 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -1,5 +1,6 @@ import type { AdminAccountListResponse, + AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminCreateAccountRequest, AdminCreateAccountResponse, AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, @@ -388,6 +389,19 @@ export async function uploadAdminEditorShowcaseCampaignImage( ); await postAdminDirectUploadFile(response.upload, file); const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, ''); + await request( + '/admin/api/editor-showcase/campaign/image-upload-confirm', + { + method: 'POST', + token, + body: { + bucket: response.upload.bucket, + objectKey, + contentType, + contentLength: file.size, + } satisfies AdminConfirmEditorShowcaseCampaignImageUploadRequest, + }, + ); return { imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath, imageObjectKey: objectKey, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 65c416d50..62de75fc8 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -459,6 +459,13 @@ export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse { upload: AdminDirectUploadTicketPayload; } +export interface AdminConfirmEditorShowcaseCampaignImageUploadRequest { + bucket: string; + objectKey: string; + contentType: string; + contentLength: number; +} + export interface AdminUploadedEditorShowcaseCampaignImage { imageSrc: string; imageObjectKey: string; diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a63733655..21488cf3c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -155,10 +155,18 @@ - 现象:主站或 External API 只要拿到另一个账号的 generated `objectKey` 就能换签,已登记的私有对象因为 key 同时命中 legacy 前缀而被匿名读取,或者 `/api/assets/read-url` 已拒绝但 `/api/assets/read-bytes` 仍能读出原始字节;后台资源预览为解决跨账号读取又误把主站入口整体放开。 - 原因:`legacyPublicPath` 与 `objectKey` 代表两种不同信任边界。前者仅用于未登记历史公开作品兼容,后者是正式对象引用;只检查 generated 前缀、或在查询 `asset_object` metadata 前直接接受 legacy 白名单,都不能证明对象公开或属于调用方。签名 URL 和 bytes proxy 如果各写一套判断也容易漂移。 -- 处理:`read-url` 与 `read-bytes` 必须共用 `authorize_asset_read_target`,先按配置 bucket / 精确 key 查询 `asset_object`;metadata 一旦存在,即使 key 命中 legacy 前缀,也严格执行 `PublicRead` / owner ACL。只有 metadata 不存在且显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` 时才允许匿名兼容;任意 `objectKey` 必须登记。External read-url 使用 API Key owner。主站和 External 的 object confirm owner 必须来自认证主体,同 bucket / key 已登记后不能改变 owner,不能让请求体 owner 接管对象。后台跨 owner 换签只用于管理员资源预览,成功后以 `admin_asset_read_url` 持久化管理员 subject、请求对象和有效期,且不得记录 signed URL;不能为此把 admin 能力下沉到主站入口。无权访问统一返回不存在,避免泄露对象是否存在。 -- 验证:覆盖未登记 curated legacy public path、命中 legacy 前缀但已有私有 metadata、未登记 objectKey、公开对象、本人私有对象、跨 owner、匿名私有、External owner、confirm owner 不可变和 admin-only endpoint;对 `read-url` 与 `read-bytes` 使用同一组授权矩阵,并断言 Admin 成功换签会生成不含 signed URL 的管理员主体审计事件。 +- 处理:`read-url` 与 `read-bytes` 必须共用 `authorize_asset_read_target`,先按配置 bucket / 精确 key 查询 `asset_object`;metadata 一旦存在,即使 key 命中 legacy 前缀,也严格执行 `PublicRead` / owner ACL。只有 metadata 不存在且显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` 时才允许匿名兼容;普通 `objectKey` 必须登记。唯一窄例外是历史精选活动卡:`global` 配置已启用、请求 key 位于 `generated-character-drafts/editor/showcase-campaign/` 且与当前 `image_object_key` 精确匹配时,可在 metadata 缺失期间派生公开读取,禁用或换图后旧 key 立即失效;新上传活动卡仍必须 confirm。External read-url 使用 API Key owner。主站和 External 的 object confirm owner 必须来自认证主体,同 bucket / key 已登记后不能改变 owner,不能让请求体 owner 接管对象。后台跨 owner 换签只用于管理员资源预览,成功后以 `admin_asset_read_url` 持久化管理员 subject、请求对象和有效期,且不得记录 signed URL;不能为此把 admin 能力下沉到主站入口。无权访问统一返回不存在,避免泄露对象是否存在。 +- 验证:覆盖未登记 curated legacy public path、命中 legacy 前缀但已有私有 metadata、未登记普通 objectKey、活动卡当前/禁用/替换/越目录 exact key、公开对象、本人私有对象、跨 owner、匿名私有、External owner、confirm owner 不可变和 admin-only endpoint;对 `read-url` 与 `read-bytes` 使用同一组授权矩阵,并断言 Admin 成功换签会生成不含 signed URL 的管理员主体审计事件。 - 关联:`server-rs/crates/api-server/src/assets.rs`、`server-rs/crates/api-server/src/external_assets_api.rs`、`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/api-server/src/modules/admin.rs`。 +## 精选活动卡上传成功但网站空图先查对象确认和 exact grant + +- 现象:后台精选活动卡能保存标题、作者、尺寸和图片地址,`GET /api/editor/showcase/resources` 也返回已启用 campaign,但网站卡片只有占位区域,没有 ``;Network 中活动卡的 `/api/assets/read-url?objectKey=...` 返回 `404 资源不存在或无权访问`。 +- 原因:活动卡旧上传链路只完成 signed POST 并保存 `imageSrc + imageObjectKey`,没有调用 object confirm;同时公开授权只扫描普通 `editor_showcase_asset`,没有识别当前活动卡。前端见到 `imageObjectKey` 后会优先走正式 objectKey 换签,失败时按安全规则保持空 src,不回退裸 private 路径。 +- 处理:后台上传必须按 ticket -> OSS POST -> `/admin/api/editor-showcase/campaign/image-upload-confirm` -> 写回表单执行,confirm 复用统一 OSS HEAD、bucket/长度校验和 `asset_object` upsert,并由管理员会话绑定 owner、强制 private/固定 asset kind/活动卡专用目录。读取 procedure 在同一事务中对当前 enabled global campaign 的专用目录 exact key 派生授权,使历史未登记当前卡无需重新上传即可恢复;api-server 仅接受 procedure 明确返回的这一 grant,其他未登记 objectKey 继续 404。 +- 验证:SpacetimeDB 测试覆盖 current key、disabled、missing key、replaced old key、越目录 key 和普通精选 grant;api-server 测试覆盖 metadata 缺失时 exact grant 可读、无 grant 仍 404、confirm 路径/MIME/大小/固定 private 约束;admin-web 测试锁定 ticket -> OSS -> confirm 顺序。真实浏览器应看到活动卡图片,Network 中 objectKey 换签返回 200,禁用或换图后旧 key 返回 404。 +- 关联:`apps/admin-web/src/api/adminApiClient.ts`、`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/api-server/src/assets.rs`、`server-rs/crates/spacetime-module/src/asset_metadata/objects.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`。 + ## 编辑器生成按钮显示泥点后仍要查真实钱包预扣 - 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index c631b90c2..2dd6144e1 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -328,7 +328,7 @@ npm run check:server-rs-ddd - Rust 结构体:`AssetObject` - 源码:`server-rs/crates/spacetime-module/src/asset_metadata/objects.rs` -- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table;资产读取 ACL 使用 `get_asset_read_access_by_location_and_return` 在同一事务快照内同时返回位置查询与现役 `editor_showcase_asset` 精选素材派生授权,procedure 只允许 runtime service identity 调用。旧创作模板作品授权 view 和逐玩法资产采集器已经退出 module;`spacetime-client` 不订阅全量 `asset_object`,也不把连接级 cache 当成安全判断。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。只有权威位置查询返回不存在的历史对象才能进入 curated legacy 白名单兼容。 +- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话、后台管理员会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table;资产读取 ACL 使用 `get_asset_read_access_by_location_and_return` 在同一事务快照内同时返回位置查询、现役 `editor_showcase_asset` 精选素材派生授权和当前已启用 `editor_showcase_campaign_config` 的活动卡专用目录 exact-key 派生授权,procedure 只允许 runtime service identity 调用。旧创作模板作品授权 view 和逐玩法资产采集器已经退出 module;`spacetime-client` 不订阅全量 `asset_object`,也不把连接级 cache 当成安全判断。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。普通对象只有权威位置查询返回不存在且显式使用 curated `legacyPublicPath` 时才能进入白名单兼容;历史活动卡上传缺少 metadata 时,可由“`global` 配置已启用 + `image_object_key` 精确匹配 + key 位于 `generated-character-drafts/editor/showcase-campaign/`”这一窄授权继续换签,禁用或替换活动卡后旧 key 立即失效。新上传活动卡仍必须完成 `asset_object` confirm,不能把该历史兼容当作跳过正式登记的常规路径。 ### SpacetimeDB view:`public_work_asset_read_grant` @@ -614,7 +614,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorShowcaseCampaignConfig` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = global`。后台可配置启用状态、标题、图片地址、提示词、作者和成本文案;上传按钮通过后台受控上传票据把图片写入 OSS,保存时同时落 `image_src`、内部图片 OSS `image_object_key`、`image_width` 和 `image_height`。公开精选接口只在启用时返回该配置,前端优先用 `image_object_key` 走签名读地址展示,并按记录的图片宽高决定活动卡比例。 +- 说明:`陶泥儿精选` 首位固定活动卡配置表,当前使用固定 `config_id = global`。后台可配置启用状态、标题、图片地址、提示词、作者和成本文案;上传按钮先通过后台受控上传票据把 private 图片写入 OSS,再调用 `/admin/api/editor-showcase/campaign/image-upload-confirm` 复用统一 OSS HEAD 校验并登记正式 `asset_object`,确认成功后才把 `image_src`、内部图片 OSS `image_object_key`、`image_width` 和 `image_height` 写回表单。公开精选接口只在启用时返回该配置,前端优先用 `image_object_key` 走签名读地址展示,并按记录的图片宽高决定活动卡比例。匿名读权限只对当前启用配置中、位于活动卡专用目录且精确匹配的 key 派生;禁用或换图后旧 key 自动失效,不能公开整个 generated 前缀。 - 索引:主键 `config_id`。 ### `editor_generation_pricing_config` diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index 971f24bc4..f32c214aa 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -79,7 +79,7 @@ 精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等;批准状态、钱包返还流水和 `refund_completed_at` 必须在同一个 SpacetimeDB 事务中完成,任一步失败都保持待审核,历史已通过但未返还记录重复批准时按同一流水补齐且不得重复入账。已通过、已开启展示且返还完成的精选快照必须按同 owner 的精确 `assetObjectId` 或 `objectKey` 派生匿名读取授权,不能放开整个 `generated-*` 前缀。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。 -瀑布流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选。 +瀑布流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条;响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有瀑布流,而不是固定只展示首屏数量。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选。活动卡图片继续保持 private:后台直传 OSS 后必须先确认正式 `asset_object`,公开读取只由已启用 `global` 配置对活动卡专用目录中的当前 exact `imageObjectKey` 派生,禁用或替换图片后旧 key 不再可读;历史缺 metadata 的当前活动卡可通过同一 exact 配置授权恢复,但新上传不能跳过 confirm。 Tab: diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index f7d444371..359834cb4 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -21,7 +21,7 @@ use reqwest::Client; use serde::Deserialize; use serde_json::{Map, Value}; use shared_contracts::admin::{ - ADMIN_TAB_PERMISSIONS, AdminAccountRole, + ADMIN_TAB_PERMISSIONS, AdminAccountRole, AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, AdminDashboardBreakdownRowPayload, AdminDashboardChartBucketPayload, AdminDashboardChartPayload, AdminDashboardMetricsPayload, @@ -50,7 +50,8 @@ use shared_contracts::admin::{ AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse, }; use shared_contracts::assets::{ - CreateDirectUploadTicketRequest, DirectUploadObjectAccess, GetReadUrlQuery, + ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, CreateDirectUploadTicketRequest, + DirectUploadObjectAccess, GetReadUrlQuery, }; #[cfg(any())] use shared_contracts::creation_entry_config::{ @@ -71,8 +72,8 @@ use crate::{ admin_accounts::normalize_admin_account_username, api_response::json_success_body, assets::{ - AssetReadAuthorization, create_direct_upload_ticket_for_owner, - get_asset_read_url_with_query, + AssetReadAuthorization, confirm_asset_object_for_owner, + create_direct_upload_ticket_for_owner, get_asset_read_url_with_query, }, editor_generation_config::EditorGenerationPricingConfig, editor_project::current_utc_micros, @@ -113,6 +114,9 @@ const ADMIN_DASHBOARD_MAX_PERIOD_DAYS: i64 = 366; const ADMIN_EDITOR_ASSET_DEFAULT_LIMIT: u32 = 80; const ADMIN_EDITOR_ASSET_MAX_LIMIT: u32 = 200; const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024; +const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX: &str = + "generated-character-drafts/editor/showcase-campaign/"; +const ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_ASSET_KIND: &str = "editor_showcase_campaign_image"; #[derive(Clone, Debug)] pub struct AuthenticatedAdmin { @@ -903,6 +907,69 @@ pub async fn admin_create_editor_showcase_campaign_image_upload_ticket( )) } +pub async fn admin_confirm_editor_showcase_campaign_image_upload( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, AppError> { + let confirm_request = build_admin_editor_showcase_campaign_image_confirm_request(payload)?; + confirm_asset_object_for_owner( + &state, + &request_context, + admin.session().subject.as_str(), + confirm_request, + ) + .await +} + +fn build_admin_editor_showcase_campaign_image_confirm_request( + payload: AdminConfirmEditorShowcaseCampaignImageUploadRequest, +) -> Result { + let bucket = payload.bucket.trim(); + if bucket.is_empty() { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("活动卡图片 bucket 不能为空")); + } + let object_key = payload.object_key.trim().trim_start_matches('/'); + if !object_key.starts_with(ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX) + || object_key.len() == ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_OBJECT_KEY_PREFIX.len() + { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("活动卡图片 Object Key 不属于后台活动卡目录")); + } + let content_type = payload.content_type.trim(); + if !content_type.starts_with("image/") { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡只能确认图片对象") + ); + } + if payload.content_length == 0 { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡图片不能为空") + ); + } + if payload.content_length > ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("活动卡图片不能超过 20MB") + ); + } + + Ok(ConfirmAssetObjectRequest { + bucket: Some(bucket.to_string()), + object_key: object_key.to_string(), + content_type: Some(content_type.to_string()), + content_length: Some(payload.content_length), + content_hash: None, + asset_kind: ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_ASSET_KIND.to_string(), + access_policy: Some(ConfirmAssetObjectAccessPolicy::Private), + source_job_id: None, + owner_user_id: None, + profile_id: None, + entity_id: None, + }) +} + #[cfg(any())] fn build_admin_creation_entry_config_response( config: shared_contracts::creation_entry_config::CreationEntryConfigResponse, @@ -3940,9 +4007,9 @@ mod tests { admin_editor_asset_group_payload, admin_editor_showcase_asset_payload_from_record, append_spacetime_sql_response_chunk, apply_admin_database_table_filters, build_admin_asset_read_url_audit, build_admin_dashboard_chart, - build_admin_database_table_row, build_admin_tracking_event_keys_sql, - build_admin_tracking_events_sql, build_body_preview, build_debug_base_url, - build_spacetime_schema_url, clamp_admin_database_table_limit, + build_admin_database_table_row, build_admin_editor_showcase_campaign_image_confirm_request, + build_admin_tracking_event_keys_sql, build_admin_tracking_events_sql, build_body_preview, + build_debug_base_url, build_spacetime_schema_url, clamp_admin_database_table_limit, clamp_admin_tracking_event_limit, enforce_admin_request_permission, finalize_admin_database_table_rows_response, group_admin_editor_asset_records, hash_admin_password, is_admin_account_not_found, is_safe_spacetime_table_name, @@ -3961,9 +4028,10 @@ mod tests { }; use serde_json::json; use shared_contracts::admin::{ - AdminDashboardQuery, AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery, + AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminDashboardQuery, + AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery, }; - use shared_contracts::assets::GetReadUrlQuery; + use shared_contracts::assets::{ConfirmAssetObjectAccessPolicy, GetReadUrlQuery}; use spacetime_client::{ AdminDashboardBreakdownRecord, AdminDashboardDailyCountRecord, AdminDashboardPaymentConversionRecord, AdminDashboardRetentionRecord, @@ -4070,6 +4138,83 @@ mod tests { assert!(draft.metadata.get("signedUrl").is_none()); } + #[test] + fn campaign_image_confirm_is_scoped_to_private_activity_card_objects() { + let request = build_admin_editor_showcase_campaign_image_confirm_request( + AdminConfirmEditorShowcaseCampaignImageUploadRequest { + bucket: " genarrative-release ".to_string(), + object_key: + " /generated-character-drafts/editor/showcase-campaign/current/card.png " + .to_string(), + content_type: " image/png ".to_string(), + content_length: 1024, + }, + ) + .expect("valid activity card upload should build a confirm request"); + + assert_eq!(request.bucket.as_deref(), Some("genarrative-release")); + assert_eq!( + request.object_key, + "generated-character-drafts/editor/showcase-campaign/current/card.png" + ); + assert_eq!(request.content_type.as_deref(), Some("image/png")); + assert_eq!(request.content_length, Some(1024)); + assert_eq!(request.asset_kind, "editor_showcase_campaign_image"); + assert_eq!( + request.access_policy, + Some(ConfirmAssetObjectAccessPolicy::Private) + ); + assert!(request.owner_user_id.is_none()); + } + + #[test] + fn campaign_image_confirm_rejects_objects_outside_activity_card_directory() { + let error = build_admin_editor_showcase_campaign_image_confirm_request( + AdminConfirmEditorShowcaseCampaignImageUploadRequest { + bucket: "genarrative-release".to_string(), + object_key: "generated-character-drafts/editor/asset-library/private.png" + .to_string(), + content_type: "image/png".to_string(), + content_length: 1024, + }, + ) + .expect_err("unrelated editor objects must not use the activity card confirm route"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + } + + #[test] + fn campaign_image_confirm_rejects_invalid_media_and_size() { + for payload in [ + AdminConfirmEditorShowcaseCampaignImageUploadRequest { + bucket: "genarrative-release".to_string(), + object_key: "generated-character-drafts/editor/showcase-campaign/current/card.txt" + .to_string(), + content_type: "text/plain".to_string(), + content_length: 1024, + }, + AdminConfirmEditorShowcaseCampaignImageUploadRequest { + bucket: "genarrative-release".to_string(), + object_key: "generated-character-drafts/editor/showcase-campaign/current/empty.png" + .to_string(), + content_type: "image/png".to_string(), + content_length: 0, + }, + AdminConfirmEditorShowcaseCampaignImageUploadRequest { + bucket: "genarrative-release".to_string(), + object_key: + "generated-character-drafts/editor/showcase-campaign/current/oversized.png" + .to_string(), + content_type: "image/png".to_string(), + content_length: super::ADMIN_EDITOR_SHOWCASE_CAMPAIGN_IMAGE_MAX_BYTES + 1, + }, + ] { + let error = build_admin_editor_showcase_campaign_image_confirm_request(payload) + .expect_err("invalid activity card uploads must fail before OSS HEAD"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + } + } + #[test] fn admin_editor_showcase_asset_payload_resolves_author_profile() { let state = AppState::new(AppConfig::default()).expect("state should build"); diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 16113daaf..d990de7d2 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -686,7 +686,23 @@ async fn authorize_asset_read_target( }) .await .map_err(map_asset_read_authorization_error)?; - if let Some(asset_object) = asset_object.as_ref() { + resolve_asset_read_access( + configured_bucket, + target, + authorization, + asset_object.as_ref(), + public_work_granted, + ) +} + +fn resolve_asset_read_access( + configured_bucket: &str, + target: &AssetReadTarget, + authorization: &AssetReadAuthorization, + asset_object: Option<&module_assets::AssetObjectRecord>, + public_work_granted: bool, +) -> Result { + if let Some(asset_object) = asset_object { return require_asset_object_read_access( asset_object, configured_bucket, @@ -696,6 +712,12 @@ async fn authorize_asset_read_target( ); } + // 当前启用的活动卡可能来自历史上传,尚无 asset_object metadata; + // SpacetimeDB 只会对配置中的 exact object key 派生该授权,禁用或换图后立即失效。 + if public_work_granted { + return Ok(AssetReadAccessScope::Public); + } + // 已登记对象始终服从 metadata ACL;只有没有 metadata 的历史资源才走公开前缀兼容。 if target.is_legacy_public_path && is_supported_legacy_public_object_key(&target.object_key) { return Ok(AssetReadAccessScope::Public); @@ -1120,6 +1142,38 @@ mod tests { ); } + #[test] + fn current_campaign_grant_allows_exact_object_key_without_legacy_metadata() { + let target = super::AssetReadTarget { + object_key: "generated-character-drafts/editor/showcase-campaign/current/image.png" + .to_string(), + is_legacy_public_path: false, + }; + + assert!(matches!( + super::resolve_asset_read_access( + "genarrative-assets", + &target, + &super::AssetReadAuthorization::Anonymous, + None, + true, + ), + Ok(super::AssetReadAccessScope::Public) + )); + assert_eq!( + super::resolve_asset_read_access( + "genarrative-assets", + &target, + &super::AssetReadAuthorization::Anonymous, + None, + false, + ) + .expect_err("unregistered object key without an exact grant should stay hidden") + .status_code(), + StatusCode::NOT_FOUND + ); + } + #[test] fn public_asset_read_url_expiry_is_capped_but_privileged_expiry_is_preserved() { assert_eq!( diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index b57c0f760..c3ad31e50 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -5,6 +5,7 @@ use axum::{ use crate::{ admin::{ + admin_confirm_editor_showcase_campaign_image_upload, admin_create_editor_showcase_campaign_image_upload_ticket, admin_dashboard, admin_debug_http, admin_get_asset_read_url, admin_get_editor_generation_pricing, admin_get_editor_showcase_campaign, admin_get_feature_gate_config, @@ -174,6 +175,12 @@ pub fn router(state: AppState) -> Router { middleware::from_fn_with_state(state.clone(), require_admin_auth), ), ) + .route( + "/admin/api/editor-showcase/campaign/image-upload-confirm", + post(admin_confirm_editor_showcase_campaign_image_upload).route_layer( + middleware::from_fn_with_state(state.clone(), require_admin_auth), + ), + ) .route( "/admin/api/profile/redeem-codes", get(admin_list_profile_redeem_codes) diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 0f29ced55..25e954e31 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -374,6 +374,15 @@ pub struct AdminCreateEditorShowcaseCampaignImageUploadTicketResponse { pub upload: AdminDirectUploadTicketPayload, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminConfirmEditorShowcaseCampaignImageUploadRequest { + pub bucket: String, + pub object_key: String, + pub content_type: String, + pub content_length: u64, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AdminDirectUploadTicketPayload { @@ -989,7 +998,10 @@ pub struct AdminWalletRestrictionResponse { mod tests { use serde_json::json; - use super::{AdminEditorShowcaseAssetPayload, AdminRechargeRefundManualReviewResolveRequest}; + use super::{ + AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminEditorShowcaseAssetPayload, + AdminRechargeRefundManualReviewResolveRequest, + }; #[test] fn refund_manual_review_resolution_request_uses_camel_case_contract() { @@ -1008,6 +1020,26 @@ mod tests { assert!(value.get("out_refund_no").is_none()); } + #[test] + fn editor_showcase_campaign_image_confirm_uses_camel_case_contract() { + let value = serde_json::to_value(AdminConfirmEditorShowcaseCampaignImageUploadRequest { + bucket: "genarrative-release".to_string(), + object_key: "generated-character-drafts/editor/showcase-campaign/current/card.png" + .to_string(), + content_type: "image/png".to_string(), + content_length: 1024, + }) + .expect("request should serialize"); + + assert_eq!( + value["objectKey"], + json!("generated-character-drafts/editor/showcase-campaign/current/card.png") + ); + assert_eq!(value["contentType"], json!("image/png")); + assert_eq!(value["contentLength"], json!(1024)); + assert!(value.get("object_key").is_none()); + } + #[test] fn editor_showcase_asset_payload_serializes_author_fields_as_camel_case() { let payload = AdminEditorShowcaseAssetPayload { diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs index f33c55359..c0ba703af 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs @@ -134,12 +134,12 @@ pub fn get_asset_read_access_by_location_and_return( tx, caller, )?; let record = find_asset_object_by_location(tx, &input)?; - let public_work_granted = record.as_ref().is_some_and(|asset_object| { - crate::editor_project_storage::asset_object_has_public_showcase_read_grant( + let public_work_granted = + crate::editor_project_storage::asset_location_has_public_showcase_read_grant( tx, - asset_object, - ) - }); + input.object_key.as_str(), + record.as_ref(), + ); Ok((record, public_work_granted)) }) { Ok((record, public_work_granted)) => AssetObjectReadAccessProcedureResult { diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 199d57e8d..ccd6e6f0d 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -36,6 +36,8 @@ const EDITOR_SHOWCASE_STATUS_PENDING: &str = "pending"; const EDITOR_SHOWCASE_STATUS_APPROVED: &str = "approved"; const EDITOR_SHOWCASE_STATUS_REJECTED: &str = "rejected"; const EDITOR_SHOWCASE_CAMPAIGN_CONFIG_ID: &str = "global"; +const EDITOR_SHOWCASE_CAMPAIGN_OBJECT_KEY_PREFIX: &str = + "generated-character-drafts/editor/showcase-campaign/"; const EDITOR_GENERATION_PRICING_CONFIG_ID: &str = "global"; const EDITOR_SHOWCASE_CATEGORIES: [&str; 4] = ["characters", "ui", "music", "marketing"]; const EDITOR_GENERATION_PRICING_UNIT_PER_GENERATION: &str = "perGeneration"; @@ -2926,6 +2928,58 @@ pub(crate) fn asset_object_has_public_showcase_read_grant( }) } +pub(crate) fn asset_location_has_public_showcase_read_grant( + ctx: &ReducerContext, + object_key: &str, + asset_object: Option<&module_assets::AssetObjectUpsertSnapshot>, +) -> bool { + let asset_object_granted = asset_object + .is_some_and(|asset_object| asset_object_has_public_showcase_read_grant(ctx, asset_object)); + let campaign_config = ctx + .db + .editor_showcase_campaign_config() + .config_id() + .find(&EDITOR_SHOWCASE_CAMPAIGN_CONFIG_ID.to_string()); + + location_has_public_showcase_read_grant( + campaign_config.as_ref(), + object_key, + asset_object_granted, + ) +} + +fn location_has_public_showcase_read_grant( + campaign_config: Option<&EditorShowcaseCampaignConfig>, + object_key: &str, + asset_object_granted: bool, +) -> bool { + if asset_object_granted { + return true; + } + let Some(object_key) = normalized_showcase_object_key(Some(object_key)) else { + return false; + }; + if !object_key + .strip_prefix(EDITOR_SHOWCASE_CAMPAIGN_OBJECT_KEY_PREFIX) + .is_some_and(|suffix| !suffix.is_empty()) + { + return false; + } + + campaign_config.is_some_and(|config| { + config.enabled + && normalized_showcase_object_key(config.image_object_key.as_deref()) + == Some(object_key) + }) +} + +fn normalized_showcase_object_key(value: Option<&str>) -> Option<&str> { + value + .map(str::trim) + .map(|value| value.trim_start_matches('/')) + .filter(|value| !value.is_empty()) +} + fn is_public_showcase_asset(asset: &EditorShowcaseAsset) -> bool { asset.review_status == EDITOR_SHOWCASE_STATUS_APPROVED && asset.display_enabled @@ -9093,6 +9147,27 @@ mod tests { } } + fn showcase_campaign_config( + enabled: bool, + image_object_key: Option<&str>, + ) -> EditorShowcaseCampaignConfig { + EditorShowcaseCampaignConfig { + config_id: EDITOR_SHOWCASE_CAMPAIGN_CONFIG_ID.to_string(), + enabled, + title: "活动卡".to_string(), + image_src: "/generated-character-drafts/editor/showcase-campaign/current.png" + .to_string(), + prompt: String::new(), + author: String::new(), + cost_text: String::new(), + updated_by_admin_user_id: Some("admin".to_string()), + updated_at: Timestamp::from_micros_since_unix_epoch(1_000_000), + image_object_key: image_object_key.map(str::to_string), + image_width: Some(1200), + image_height: Some(800), + } + } + fn tiered_pricing(model: &str, unit: &str, tiers: &[&str]) -> EditorGenerationModelPricing { EditorGenerationModelPricing { model: model.to_string(), @@ -9522,6 +9597,91 @@ mod tests { })); } + #[test] + fn campaign_read_grant_matches_only_enabled_current_object_key_without_metadata() { + let campaign = showcase_campaign_config( + true, + Some(" /generated-character-drafts/editor/showcase-campaign/current.png "), + ); + + assert!(location_has_public_showcase_read_grant( + Some(&campaign), + "generated-character-drafts/editor/showcase-campaign/current.png", + false, + )); + assert!(!location_has_public_showcase_read_grant( + Some(&campaign), + "generated-character-drafts/editor/showcase-campaign/previous.png", + false, + )); + assert!(!location_has_public_showcase_read_grant( + Some(&campaign), + "generated-character-drafts/editor/showcase-campaign/current.png.backup", + false, + )); + assert!(!location_has_public_showcase_read_grant( + Some(&campaign), + "generated-character-drafts/editor/asset-library/current.png", + false, + )); + } + + #[test] + fn campaign_read_grant_is_revoked_when_disabled_or_key_is_missing() { + let disabled = showcase_campaign_config( + false, + Some("generated-character-drafts/editor/showcase-campaign/current.png"), + ); + let missing_key = showcase_campaign_config(true, None); + + assert!(!location_has_public_showcase_read_grant( + Some(&disabled), + "generated-character-drafts/editor/showcase-campaign/current.png", + false, + )); + assert!(!location_has_public_showcase_read_grant( + Some(&missing_key), + "generated-character-drafts/editor/showcase-campaign/current.png", + false, + )); + } + + #[test] + fn campaign_key_replacement_revokes_the_previous_key() { + let current = showcase_campaign_config( + true, + Some("generated-character-drafts/editor/showcase-campaign/current.png"), + ); + + assert!(!location_has_public_showcase_read_grant( + Some(¤t), + "generated-character-drafts/editor/showcase-campaign/previous.png", + false, + )); + assert!(location_has_public_showcase_read_grant( + Some(¤t), + "generated-character-drafts/editor/showcase-campaign/current.png", + false, + )); + } + + #[test] + fn ordinary_showcase_asset_grant_remains_effective_without_campaign_match() { + assert!(location_has_public_showcase_read_grant( + None, + "generated-characters/editor/asset-1.png", + true, + )); + assert!(location_has_public_showcase_read_grant( + Some(&showcase_campaign_config( + true, + Some("generated-character-drafts/editor/showcase-campaign/current.png"), + )), + "generated-characters/editor/asset-1.png", + true, + )); + } + #[test] fn editor_asset_task_group_uses_final_output_as_primary() { let assets = vec![ diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 4f15e5210..fdd3acb0c 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -751,6 +751,22 @@ describe('CreationLandingView', () => { }); it('renders campaign card previews with centered contain media', async () => { + const campaignObjectKey = + 'generated-character-drafts/editor/showcase-campaign/current/campaign.png'; + const signedCampaignUrl = 'data:image/png;base64,Y2FtcGFpZ24='; + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + read: { + objectKey: campaignObjectKey, + signedUrl: signedCampaignUrl, + expiresAt: '2099-01-01T00:00:00Z', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); listEditorProjectsMock.mockResolvedValue(projectItems); listPublicEditorProjectResourcesMock.mockResolvedValue({ resources: [], @@ -759,6 +775,7 @@ describe('CreationLandingView', () => { enabled: true, title: '活动精选', imageSrc: '/campaign.png', + imageObjectKey: campaignObjectKey, imageWidth: 900, imageHeight: 1200, prompt: '活动提示词', @@ -777,6 +794,14 @@ describe('CreationLandingView', () => { 'creation-landing__asset-preview--campaign', ); expect(campaignPreview?.style.aspectRatio).toBe('900 / 1200'); + const campaignImage = await screen.findByRole('img', { + name: '活动精选', + }); + expect((campaignImage as HTMLImageElement).src).toBe(signedCampaignUrl); + expect(fetchMock).toHaveBeenCalledWith( + `/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`, + expect.objectContaining({ method: 'GET' }), + ); }); it('loads more featured resources when the waterfall reaches the end', async () => { From 5d6a4260138144cce38d4f8e80c6daf571569c5a Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 22 Jul 2026 18:49:09 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=B2=BE=E9=80=89=E5=AE=A1=E6=A0=B8=E7=B4=A0=E6=9D=90=E9=A2=84?= =?UTF-8?q?=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽取后台素材媒体共享组件并统一懒换签与限流重试 为精选审核补充列表和详情中的素材预览交互 透传精选素材缩略图字段并覆盖历史 OSS 地址回归 同步后台预览数据契约与排障文档 --- apps/admin-web/src/api/adminApiTypes.ts | 1 + .../src/components/AdminEditorAssetMedia.tsx | 436 +++++++++++++++++ .../src/pages/AdminEditorAssetQueryPage.tsx | 458 +----------------- .../AdminEditorShowcaseReviewPage.test.tsx | 201 +++++++- .../pages/AdminEditorShowcaseReviewPage.tsx | 152 ++---- docs/project-memory/shared-memory/pitfalls.md | 10 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + ...作】创作主页与项目入口改版计划-2026-06-18.md | 1 + server-rs/crates/api-server/src/admin.rs | 13 +- .../crates/shared-contracts/src/admin.rs | 7 + 10 files changed, 722 insertions(+), 558 deletions(-) create mode 100644 apps/admin-web/src/components/AdminEditorAssetMedia.tsx diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 62de75fc8..879af5c51 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -372,6 +372,7 @@ export interface AdminEditorShowcaseAssetPayload { taskId?: string | null; assetKind?: string | null; generationInputs?: Record | null; + thumbnailSrc?: string | null; generationCostMudPoints: number; refundMudPoints: number; reviewStatus: 'pending' | 'approved' | 'rejected' | string; diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx new file mode 100644 index 000000000..9e93be572 --- /dev/null +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -0,0 +1,436 @@ +import { X } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; +import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient'; + +const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; +const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40; +const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const; +const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px'; +const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`; +let adminAssetReadDispatchTail = Promise.resolve(); + +export interface AdminPreviewableEditorAsset { + assetId: string; + label: string; + imageSrc: string; + objectKey?: string | null; + assetKind?: string | null; + thumbnailSrc?: string | null; +} + +export function AdminEditorAssetThumbnail({ + entry, + token, + altPrefix = '素材', +}: { + entry: AdminPreviewableEditorAsset; + token: string; + altPrefix?: string; +}) { + const thumbnailSource = resolveAdminAssetThumbnailSource(entry); + const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility(); + const imageSrc = useAdminResolvedAssetUrl( + token, + thumbnailSource.src, + thumbnailSource.objectKey, + shouldLoad, + ); + const alt = `${altPrefix}:${entry.label || entry.assetId}`; + + return imageSrc ? ( + {alt} + ) : ( +
+ ); +} + +export function AdminEditorAssetPreviewDialog({ + entry, + token, + onClose, +}: { + entry: AdminPreviewableEditorAsset; + token: string; + onClose: () => void; +}) { + return ( +
+
+
+
+

{entry.label || entry.assetId}

+ {entry.assetId} +
+ +
+ +
+
+ ); +} + +function AdminEditorAssetPreviewMedia({ + entry, + token, +}: { + entry: AdminPreviewableEditorAsset; + 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 ? ( +