- + current.map((item, itemIndex) => diff --git a/src/components/game-distribution/useGameDistributionMediaReadUrl.ts b/src/components/game-distribution/useGameDistributionMediaReadUrl.ts index b3f6ea476..68b2b1137 100644 --- a/src/components/game-distribution/useGameDistributionMediaReadUrl.ts +++ b/src/components/game-distribution/useGameDistributionMediaReadUrl.ts @@ -1,17 +1,26 @@ import { useEffect, useState } from 'react'; -import { getGameDistributionMediaReadUrl } from '../../services/gameDistributionClient'; +import { + getGameDistributionMediaReadUrl, + getOwnerGameDistributionMediaReadUrl, +} from '../../services/gameDistributionClient'; type GameDistributionMediaReadUrlOptions = { enabled?: boolean; expireSeconds?: number; + /** + * 作者自己的作品:传 gameId 后走 owner 作用域 `my-games/{gameId}/media/read-url`, + * 未发布 / 待审 / 被驳回的封面与截图也能换签;不传则走匿名公开读。 + */ + gameId?: string | null; }; /** * 把游戏分发的封面/截图 objectKey 换成签名读地址。 * - * 游戏媒体落在项目快照桶、走 `GET /api/game-distribution/media/read-url`,与素材库 - * `/api/assets/read-url` 的 ACL 解耦;换签失败时保持空地址,由调用方回退占位。 + * 游戏媒体落在项目快照桶、走 `GET /api/game-distribution/media/read-url`(作者自有走 + * `my-games/{gameId}/media/read-url`),与素材库 `/api/assets/read-url` 的 ACL 解耦; + * 换签失败时保持空地址,由调用方回退占位。 */ export function useGameDistributionMediaReadUrl( objectKey: string | null | undefined, @@ -19,6 +28,7 @@ export function useGameDistributionMediaReadUrl( ) { const enabled = options.enabled !== false; const normalizedKey = objectKey?.trim() ?? ''; + const resolvedGameId = options.gameId?.trim() ?? ''; const shouldResolve = enabled && Boolean(normalizedKey); const [resolvedUrl, setResolvedUrl] = useState(''); const [isResolving, setIsResolving] = useState(shouldResolve); @@ -33,7 +43,14 @@ export function useGameDistributionMediaReadUrl( let cancelled = false; setResolvedUrl(''); setIsResolving(true); - getGameDistributionMediaReadUrl(normalizedKey, options.expireSeconds) + const resolve = resolvedGameId + ? getOwnerGameDistributionMediaReadUrl( + resolvedGameId, + normalizedKey, + options.expireSeconds, + ) + : getGameDistributionMediaReadUrl(normalizedKey, options.expireSeconds); + resolve .then((nextUrl) => { if (!cancelled) setResolvedUrl(nextUrl); }) @@ -47,7 +64,7 @@ export function useGameDistributionMediaReadUrl( return () => { cancelled = true; }; - }, [normalizedKey, options.expireSeconds, shouldResolve]); + }, [normalizedKey, resolvedGameId, options.expireSeconds, shouldResolve]); return { resolvedUrl, diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts index f26ec2ac3..f3bffe330 100644 --- a/src/services/gameDistributionClient.test.ts +++ b/src/services/gameDistributionClient.test.ts @@ -15,6 +15,7 @@ import { getGameDistributionMediaReadUrl, getGameVersion, getMyGameReview, + getOwnerGameDistributionMediaReadUrl, listGameReviews, listGames, listMyGames, @@ -785,4 +786,37 @@ describe('gameDistributionClient 发布媒体 multipart', () => { '缺少游戏媒体标识', ); }); + + it('作者自有作品媒体换签走 owner 路由并带 bearer', async () => { + setStoredAccessToken('owner-token', { emit: false }); + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + read: { signedUrl: 'https://oss.example.com/signed-owner' }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const signedUrl = await getOwnerGameDistributionMediaReadUrl( + 'game-1', + 'game-distribution/cover/alpha.png', + 3600, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/api/game-distribution/my-games/game-1/media/read-url?objectKey=game-distribution%2Fcover%2Falpha.png&expireSeconds=3600', + ); + expect(fetchMock.mock.calls[0]?.[1]?.headers?.Authorization).toBe( + 'Bearer owner-token', + ); + expect(signedUrl).toBe('https://oss.example.com/signed-owner'); + await expect( + getOwnerGameDistributionMediaReadUrl(' ', 'cover.png'), + ).rejects.toThrow('缺少作品标识'); + await expect( + getOwnerGameDistributionMediaReadUrl('game-1', ' '), + ).rejects.toThrow('缺少游戏媒体标识'); + }); }); diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index 1afa61b9a..fd9ab82b9 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -385,13 +385,7 @@ export async function getGame(gameId: string) { ); } -/** - * 为公开的游戏封面/截图 objectKey 换取签名读地址。 - * - * 游戏媒体落在项目快照桶、不经素材库 ACL,匿名可读;与 `/api/assets/read-url` 分离。 - * 换签失败不在这里吞掉,由调用方决定占位或回退。 - */ -export async function getGameDistributionMediaReadUrl( +function buildGameDistributionMediaReadParams( objectKey: string, expireSeconds?: number, ) { @@ -405,17 +399,59 @@ export async function getGameDistributionMediaReadUrl( ) { params.set('expireSeconds', String(Math.floor(expireSeconds))); } + return params; +} + +function readGameDistributionMediaSignedUrl( + payload: GameDistributionMediaReadUrlResponse, +) { + const read = payload.read ?? payload; + const signedUrl = + typeof read.signedUrl === 'string' ? read.signedUrl.trim() : ''; + if (!signedUrl) throw new Error('游戏媒体访问地址缺失'); + return signedUrl; +} + +/** + * 为公开的游戏封面/截图 objectKey 换取签名读地址。 + * + * 游戏媒体落在项目快照桶、不经素材库 ACL,匿名可读;与 `/api/assets/read-url` 分离。 + * 换签失败不在这里吞掉,由调用方决定占位或回退。 + */ +export async function getGameDistributionMediaReadUrl( + objectKey: string, + expireSeconds?: number, +) { + const params = buildGameDistributionMediaReadParams(objectKey, expireSeconds); const payload = await requestJson( `/media/read-url?${params.toString()}`, { method: 'GET', cache: 'no-store' }, '读取游戏媒体地址失败', PUBLIC_GAME_REQUEST_OPTIONS, ); - const read = payload.read ?? payload; - const signedUrl = - typeof read.signedUrl === 'string' ? read.signedUrl.trim() : ''; - if (!signedUrl) throw new Error('游戏媒体访问地址缺失'); - return signedUrl; + return readGameDistributionMediaSignedUrl(payload); +} + +/** + * 为作者自己作品的封面/截图 objectKey 换取签名读地址。 + * + * 走 owner 作用域 `GET /my-games/{gameId}/media/read-url`:未发布 / 待审 / 被驳回的作品也能 + * 预览换签,判定只认「objectKey 命中本人作品当前行的媒体」;非本人或作品不存在返回 404。 + */ +export async function getOwnerGameDistributionMediaReadUrl( + gameId: string, + objectKey: string, + expireSeconds?: number, +) { + const normalizedGameId = gameId.trim(); + if (!normalizedGameId) throw new Error('缺少作品标识'); + const params = buildGameDistributionMediaReadParams(objectKey, expireSeconds); + const payload = await requestJson( + `/my-games/${encodeURIComponent(normalizedGameId)}/media/read-url?${params.toString()}`, + { method: 'GET', cache: 'no-store' }, + '读取作品媒体地址失败', + ); + return readGameDistributionMediaSignedUrl(payload); } /**