平台 web 作者预览切到 owner 媒体读

- gameDistributionClient 新增 getOwnerGameDistributionMediaReadUrl,参数与换签解析与公开读共用
- useGameDistributionMediaReadUrl 支持 gameId;GameCover 增 owner 开关,MyGamesPage 封面走 owner
- resolveGamePublishImagePreview 接 gameId,发布/编辑回填未发布作品的封面与截图也能换签
- 补 client 路由/鉴权测试与 MyGamesPage owner 换签测试
This commit is contained in:
2026-10-07 10:27:14 +08:00
parent 4cd7843134
commit 89567d94a9
9 changed files with 164 additions and 28 deletions
@@ -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('缺少游戏媒体标识');
});
});
+48 -12
View File
@@ -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);
}
/**