平台 web 作者预览切到 owner 媒体读
- gameDistributionClient 新增 getOwnerGameDistributionMediaReadUrl,参数与换签解析与公开读共用 - useGameDistributionMediaReadUrl 支持 gameId;GameCover 增 owner 开关,MyGamesPage 封面走 owner - resolveGamePublishImagePreview 接 gameId,发布/编辑回填未发布作品的封面与截图也能换签 - 补 client 路由/鉴权测试与 MyGamesPage owner 换签测试
This commit is contained in:
@@ -15,15 +15,20 @@ export function GameCover({
|
||||
compact = false,
|
||||
priority = false,
|
||||
objectKey,
|
||||
owner = false,
|
||||
}: {
|
||||
game: GameDistributionGame;
|
||||
compact?: boolean;
|
||||
/** 首屏 hero 用的封面立即加载,列表卡片保持延迟加载。 */
|
||||
priority?: boolean;
|
||||
objectKey?: string | null;
|
||||
/** 作者自己的作品列表传 `owner`:未发布 / 待审 / 被驳回的封面也能换签预览。 */
|
||||
owner?: boolean;
|
||||
}) {
|
||||
const targetKey = (objectKey ?? game.coverObjectKey ?? '').trim();
|
||||
const { resolvedUrl } = useGameDistributionMediaReadUrl(targetKey);
|
||||
const { resolvedUrl } = useGameDistributionMediaReadUrl(targetKey, {
|
||||
gameId: owner ? game.id : undefined,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={`game-cover ${compact ? 'game-cover--compact' : ''}`}
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock('../../services/gameDistributionClient', () => ({
|
||||
createGame: vi.fn(),
|
||||
createGameVersion: vi.fn(),
|
||||
getGameDistributionMediaReadUrl: vi.fn().mockResolvedValue(''),
|
||||
getOwnerGameDistributionMediaReadUrl: vi.fn().mockResolvedValue(''),
|
||||
getGameVersion: vi.fn(),
|
||||
listMyGames: vi.fn(),
|
||||
submitGameVersion: vi.fn(),
|
||||
@@ -699,6 +700,7 @@ test('更新模式在既有 gameId 下创建新版本并沿用公开修订号',
|
||||
await waitFor(() =>
|
||||
expect(resolveGamePublishImagePreview).toHaveBeenCalledWith(
|
||||
'game-distribution/cover/old.png',
|
||||
'game-1',
|
||||
),
|
||||
);
|
||||
expect(screen.queryByText(/本次发布需要重新选择一次封面图片/u)).toBeNull();
|
||||
@@ -791,6 +793,7 @@ test('线上封面没有冻结快照时沿用游戏行封面对象键', async ()
|
||||
await waitFor(() =>
|
||||
expect(resolveGamePublishImagePreview).toHaveBeenCalledWith(
|
||||
'game-distribution/cover/legacy.png',
|
||||
'game-1',
|
||||
),
|
||||
);
|
||||
const file = await buildZipFile();
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
cancelGameVersion,
|
||||
deleteMyGame,
|
||||
type GameDistributionMyGame,
|
||||
getGameDistributionMediaReadUrl,
|
||||
getGameVersion,
|
||||
getOwnerGameDistributionMediaReadUrl,
|
||||
listMyGames,
|
||||
loadVersionRecoveryActions,
|
||||
submitGameVersion,
|
||||
@@ -23,6 +25,7 @@ vi.mock('../../services/gameDistributionClient', () => ({
|
||||
cancelGameVersion: vi.fn(),
|
||||
deleteMyGame: vi.fn(),
|
||||
getGameDistributionMediaReadUrl: vi.fn().mockResolvedValue(''),
|
||||
getOwnerGameDistributionMediaReadUrl: vi.fn().mockResolvedValue(''),
|
||||
getGameVersion: vi.fn(),
|
||||
listMyGames: vi.fn(),
|
||||
loadVersionRecoveryActions: vi.fn(),
|
||||
@@ -123,6 +126,8 @@ beforeEach(() => {
|
||||
vi.mocked(cancelGameVersion).mockReset();
|
||||
vi.mocked(deleteMyGame).mockReset();
|
||||
vi.mocked(getGameVersion).mockReset();
|
||||
vi.mocked(getOwnerGameDistributionMediaReadUrl).mockReset();
|
||||
vi.mocked(getOwnerGameDistributionMediaReadUrl).mockResolvedValue('');
|
||||
vi.mocked(listMyGames).mockReset();
|
||||
vi.mocked(loadVersionRecoveryActions).mockReset();
|
||||
vi.mocked(submitGameVersion).mockReset();
|
||||
@@ -172,6 +177,23 @@ test('展示审核中状态与驳回理由', async () => {
|
||||
expect(screen.getByText('驳回理由:封面需要替换')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('作者自有作品封面走 owner 作用域换签,未发布也能预览', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([
|
||||
createGame({ coverObjectKey: 'game-distribution/cover/mine.png' }),
|
||||
]);
|
||||
|
||||
renderPage(createAuthValue());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getOwnerGameDistributionMediaReadUrl).toHaveBeenCalledWith(
|
||||
'game-1',
|
||||
'game-distribution/cover/mine.png',
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
expect(getGameDistributionMediaReadUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('下架已公开游戏会带上当前 publicationRevision', async () => {
|
||||
const game = createGame({
|
||||
status: 'published',
|
||||
|
||||
@@ -494,7 +494,7 @@ export function MyGamesPage({
|
||||
const isHistoryOpen = expandedGameId === game.id;
|
||||
return (
|
||||
<article key={game.id} className="my-game-card">
|
||||
<GameCover game={game} compact />
|
||||
<GameCover game={game} compact owner />
|
||||
<div className="my-game-card__body">
|
||||
<div className="my-game-card__heading">
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getGameDistributionMediaReadUrl } from '../../services/gameDistributionClient';
|
||||
import {
|
||||
getGameDistributionMediaReadUrl,
|
||||
getOwnerGameDistributionMediaReadUrl,
|
||||
} from '../../services/gameDistributionClient';
|
||||
|
||||
/** 游戏截图数量上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */
|
||||
export const MAX_GAME_SCREENSHOTS = 6;
|
||||
@@ -89,15 +92,25 @@ export function revokeGamePublishImagePreview(
|
||||
}
|
||||
}
|
||||
|
||||
/** 作者续发/编辑时按对象键换签预览;失败返回空字符串,由调用方回退占位。 */
|
||||
export async function resolveGamePublishImagePreview(objectKey: string) {
|
||||
/** 作者续发/编辑时按对象键换签预览;传 gameId 走 owner 路由,未发布作品也能预览。 */
|
||||
export async function resolveGamePublishImagePreview(
|
||||
objectKey: string,
|
||||
gameId?: string,
|
||||
) {
|
||||
const normalizedKey = objectKey.trim();
|
||||
if (!normalizedKey) return '';
|
||||
const normalizedGameId = gameId?.trim() ?? '';
|
||||
try {
|
||||
return await getGameDistributionMediaReadUrl(
|
||||
normalizedKey,
|
||||
GAME_PUBLISH_IMAGE_PREVIEW_EXPIRE_SECONDS,
|
||||
);
|
||||
return normalizedGameId
|
||||
? await getOwnerGameDistributionMediaReadUrl(
|
||||
normalizedGameId,
|
||||
normalizedKey,
|
||||
GAME_PUBLISH_IMAGE_PREVIEW_EXPIRE_SECONDS,
|
||||
)
|
||||
: await getGameDistributionMediaReadUrl(
|
||||
normalizedKey,
|
||||
GAME_PUBLISH_IMAGE_PREVIEW_EXPIRE_SECONDS,
|
||||
);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -53,7 +53,10 @@ export async function applyVersionMediaDefaults(
|
||||
})),
|
||||
);
|
||||
|
||||
const coverPreview = await resolveGamePublishImagePreview(coverObjectKey);
|
||||
const coverPreview = await resolveGamePublishImagePreview(
|
||||
coverObjectKey,
|
||||
detail.game.id,
|
||||
);
|
||||
if (apply.isCancelled()) return;
|
||||
if (coverPreview) {
|
||||
// 只在作者还没换图、且预览仍为空时补上签名地址,避免覆盖作者的新选择。
|
||||
@@ -64,7 +67,10 @@ export async function applyVersionMediaDefaults(
|
||||
);
|
||||
}
|
||||
for (const [index, objectKey] of screenshotObjectKeys.entries()) {
|
||||
const preview = await resolveGamePublishImagePreview(objectKey);
|
||||
const preview = await resolveGamePublishImagePreview(
|
||||
objectKey,
|
||||
detail.game.id,
|
||||
);
|
||||
if (apply.isCancelled() || !preview) continue;
|
||||
apply.setScreenshots((current) =>
|
||||
current.map((item, itemIndex) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('缺少游戏媒体标识');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<GameDistributionMediaReadUrlResponse>(
|
||||
`/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<GameDistributionMediaReadUrlResponse>(
|
||||
`/my-games/${encodeURIComponent(normalizedGameId)}/media/read-url?${params.toString()}`,
|
||||
{ method: 'GET', cache: 'no-store' },
|
||||
'读取作品媒体地址失败',
|
||||
);
|
||||
return readGameDistributionMediaSignedUrl(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user