feat(游戏共创): 共创列表补上真实封面(有界 data URL,失败回退首字占位)
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- 新命令 `read_public_game_cover_preview(objectKey)`:复用发布面板封面那套链路
  (`read-url` 换签 → `resolve_canvas_resource_download_with_limit_route_and_fence` 有界下载 →
  `cover_preview_data_url`),返回**有界 data URL**。
  为什么不返回签名地址让渲染层 `<img src>`:AGC 既有约定是「渲染层不自行拼 read-url、不接触对象键
  与 bearer token、也不让 WebView 直连签名地址」(`commands.rs` 里同一口径的注释),因此沿用
  data URL 方案——顺带免掉签名过期与 CSP 白名单两个问题。
  对象键做最小校验(非空、≤512、不含 `..`);失败如实返回 `Err`,由调用方回退占位。
- 渲染层 `platformForkCover.ts`:会话内缓存 + 并发去重。
  · 成功:会话内一直复用(data URL 自身不过期);
  · 失败:缓存 60 秒(沿用网页端失败缓存窗口),避免同一张坏图在每张卡片上反复重试;
  · 提供 `clearCoverPreviewCache()` 供换号时清空(换号后旧封面可能已无权访问)。
- `usePlatformForkCovers` 钩子按当前列表批量解析;卡片上有封面就 `<img>`(`object-cover`),
  没有封面 / 换签失败 / 超限一律**保留原来的首字占位 + `coverColor` 底色**(降级逐条测到)。

测试:新增一条覆盖三种情形——有封面(断言 `src` 是命令返回的 data URL)、换签失败(保留占位、
不出现第二张 `<img>`)、没有 `coverObjectKey`(不发换签请求)。
This commit is contained in:
2026-10-06 11:56:34 +08:00
parent 7b8c3153b1
commit 5798551b21
6 changed files with 233 additions and 3 deletions
@@ -623,6 +623,7 @@ pub(super) fn run() {
read_game_cover_generation_price,
generate_game_distribution_cover,
list_platform_game_catalog,
read_public_game_cover_preview,
read_game_distribution_publication,
publish_local_project_game,
read_game_creator_app_config,
@@ -1126,6 +1126,39 @@ pub(crate) async fn list_platform_game_catalog(
serde_json::from_value(value).map_err(|error| format!("共创目录响应无效:{error}"))
}
/// 公开作品封面预览:把公开目录里的 `coverObjectKey` 换成**有界 data URL**。
///
/// 为什么不让渲染层直接拿签名地址去 `<img src>`:AGC 既有约定是「渲染层不自行拼 read-url、
/// 不接触对象键与 bearer token、也不让 WebView 直连签名地址」(见 `commands.rs` 里同一口径的注释)。
/// 因此这里走与发布面板封面同一套链路:`read-url` 换签 + 有界下载 + data URL。
///
/// 失败与「没有封面」都由调用方按同一个分支处理(回落到首字占位),所以这里把失败如实返回成
/// `Err`,不吞成空串。
#[tauri::command]
pub(crate) async fn read_public_game_cover_preview(object_key: String) -> Result<String, String> {
let object_key = object_key.trim().to_string();
if object_key.is_empty() || object_key.len() > 512 || object_key.contains("..") {
return Err("封面对象键无效".to_string());
}
let snapshot = require_platform_session()?;
current_scoped_session(&snapshot)?;
let client = build_client()?;
let source = json!({ "objectKey": object_key });
let download = crate::assets::resolve_canvas_resource_download_with_limit_route_and_fence(
&client,
snapshot.api_base_url.as_str(),
snapshot.access_token.as_str(),
&source,
COVER_PREVIEW_MAX_BYTES,
"/api/assets/read-url",
|| Ok(()),
)
.await?
.ok_or_else(|| "封面内容为空".to_string())?;
current_scoped_session(&snapshot)?;
cover_preview_data_url(&download.media_type, &download.bytes)
}
/// 发布面板打开时的绑定恢复。
///
/// 只有「本地绑定(且属于当前账号/origin)+ 线上作品」或「无本地绑定时按作者 + localProjectId
@@ -0,0 +1,81 @@
/**
* 公开作品封面:把 `coverObjectKey` 换成可显示的地址,带会话内缓存与失败短路。
*
* AGC 的约定是「渲染层不接触签名地址、不让 WebView 直连外部图片」(见 Rust 侧命令注释),因此
* 这里调用 `read_public_game_cover_preview` 拿**有界 data URL**——没有过期时间,也就不需要 TTL;
* 代价是每张封面要过一次 IPC,所以按对象键缓存:
* - **成功**:会话内一直复用(data URL 自身不会过期);
* - **失败**:缓存 60 秒,避免同一张坏图在每个卡片上反复重试(沿用网页端同一条失败缓存窗口);
* - **并发**:同一对象键的并发请求去重(列表一次渲染多张卡时只发一次)。
*
* 拿不到封面(没有对象键 / 请求失败 / 超限)时返回 `null`——调用方回落到首字占位 + `coverColor`。
*/
import type { GameDistributionGame } from '../../../../../packages/shared/src/contracts/gameDistribution';
const COVER_COMMAND = 'read_public_game_cover_preview';
const FAILURE_CACHE_WINDOW_MS = 60 * 1000;
type CoverEntry = { url: string | null; at: number };
const coverCache = new Map<string, CoverEntry>();
const inflight = new Map<string, Promise<string | null>>();
type CoverInvoke = <T>(
command: string,
args?: Record<string, unknown>,
) => Promise<T>;
/** 当前登录账号切换时清空缓存:换号后旧封面可能已无权访问。 */
export function clearCoverPreviewCache() {
coverCache.clear();
inflight.clear();
}
function cacheKey(objectKey: string, accountScope: string) {
return `${accountScope}\u0000${objectKey}`;
}
/**
* 解析一张封面;失败一律 `null`(调用方保留占位),不抛给渲染层。
*
* `accountScope` 一般是当前账号 id:换号后同一对象键的结果不能复用。
*/
export async function resolveCoverPreview(
invoke: CoverInvoke,
game: Pick<GameDistributionGame, 'coverObjectKey'>,
accountScope = '',
): Promise<string | null> {
const objectKey = (game.coverObjectKey ?? '').trim();
if (!objectKey) {
return null;
}
const key = cacheKey(objectKey, accountScope);
const cached = coverCache.get(key);
if (
cached &&
(cached.url !== null || Date.now() - cached.at < FAILURE_CACHE_WINDOW_MS)
) {
return cached.url;
}
const pending = inflight.get(key);
if (pending) {
return pending;
}
const request = (async () => {
try {
const url = await invoke<string>(COVER_COMMAND, { objectKey });
const normalized =
typeof url === 'string' && url.startsWith('data:') ? url : null;
coverCache.set(key, { url: normalized, at: Date.now() });
return normalized;
} catch {
// 失败同样进缓存(短窗口):列表里同一张坏图不重复打请求。
coverCache.set(key, { url: null, at: Date.now() });
return null;
} finally {
inflight.delete(key);
}
})();
inflight.set(key, request);
return request;
}
@@ -0,0 +1,45 @@
/**
* 列表封面:一次性解析当前列表里每张卡片的封面(有界 data URL),失败/缺封面就不返回该键。
*
* 解析本身走 `resolveCoverPreview` 的会话内缓存与并发去重,所以翻页、重渲染都不会重复拉同一张图。
*/
import { useEffect, useState } from 'react';
import type { GameDistributionGame } from '../../../../../packages/shared/src/contracts/gameDistribution';
import { resolveTauriInvoke } from '../../app/tauri';
import { resolveCoverPreview } from './platformForkCover';
export function usePlatformForkCovers(
games: readonly GameDistributionGame[],
accountScope = '',
): Record<string, string> {
const [coverUrls, setCoverUrls] = useState<Record<string, string>>({});
useEffect(() => {
const invoke = resolveTauriInvoke();
if (!invoke || games.length === 0) {
return;
}
let cancelled = false;
void (async () => {
const resolved: Record<string, string> = {};
await Promise.all(
games.map(async (game) => {
const url = await resolveCoverPreview(invoke, game, accountScope);
if (url) {
resolved[game.id] = url;
}
}),
);
if (cancelled) {
return;
}
setCoverUrls((current) => ({ ...current, ...resolved }));
})();
return () => {
cancelled = true;
};
}, [games, accountScope]);
return coverUrls;
}
@@ -24,6 +24,7 @@ import {
PLATFORM_FORK_INPUT_ERROR,
} from '../../features/platform-fork/platformGameForkModel';
import { usePlatformFork } from '../../features/platform-fork/usePlatformFork';
import { usePlatformForkCovers } from '../../features/platform-fork/usePlatformForkCovers';
import type { ProjectOpenAnalytics } from '../../services/clientAnalytics';
type CoCreationViewProps = {
@@ -140,6 +141,7 @@ export default function CoCreationView({
}
const games = catalog?.games ?? [];
const coverUrls = usePlatformForkCovers(games);
const scannedHint = catalog
? `已检查最新 ${catalog.scannedGames} 个作品(${catalog.scannedPages} 页${
catalog.cappedByPageLimit ? `,最多 ${FORK_CATALOG_MAX_PAGES} 页` : ''
@@ -285,15 +287,29 @@ export default function CoCreationView({
<div className="grid grid-cols-3 gap-4 max-[900px]:grid-cols-2 max-[760px]:grid-cols-1">
{games.map((game) => {
const active = fork.activeGameId === game.id;
const coverUrl = coverUrls[game.id];
return (
<article className="grid min-w-0" key={game.id}>
<div className="grid min-h-34 w-full grid-cols-[96px_minmax(0,1fr)] items-stretch gap-3 overflow-hidden rounded-xl border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-2 text-left shadow-(--platform-panel-shadow)">
<span
className="grid min-h-29 place-items-center rounded-lg text-[26px] font-bold text-white shadow-inner"
className="grid min-h-29 place-items-center overflow-hidden rounded-lg text-[26px] font-bold text-white shadow-inner"
style={{ backgroundColor: game.coverColor || '#c2653d' }}
aria-hidden="true"
>
{(game.title || game.id).slice(0, 1)}
{/*
封面:优先用有界 data URL(AGC 不让渲染层直连签名地址)。没有封面、换签失败
或超限时保持首字占位——**降级必须保留**,否则列表会出现破图。
*/}
{coverUrl ? (
<img
className="h-full w-full object-cover"
src={coverUrl}
alt={`${game.title || game.id} 封面`}
/>
) : (
<span aria-hidden="true">
{(game.title || game.id).slice(0, 1)}
</span>
)}
</span>
<span className="grid min-w-0 content-start gap-1.5 py-1">
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[14px] font-semibold leading-4.5 text-(--platform-text-strong)">
@@ -272,6 +272,60 @@ describe('共创页', () => {
expect(await screen.findByText('开放共创的作品')).not.toBeNull();
});
it('有封面时用有界 data URL 渲染,换签失败或没有封面则保留首字占位', async () => {
const calls = installInvoke((command, args) => {
if (command === CATALOG_COMMAND) {
return {
games: [
{
...catalogGame('game_with_cover', '有封面的作品', 'full'),
coverObjectKey: 'game-distribution/cover/open.png',
},
{
...catalogGame('game_broken_cover', '换签失败的作品', 'full'),
coverObjectKey: 'game-distribution/cover/broken.png',
},
catalogGame('game_no_cover', '没有封面的作品', 'full'),
],
nextCursor: null,
};
}
if (command === 'read_public_game_cover_preview') {
if ((args as { objectKey?: string })?.objectKey?.includes('broken')) {
throw new Error('换签失败');
}
return 'data:image/png;base64,AAAA';
}
throw new Error(`unexpected invoke ${command}`);
});
const { container } = render(<CoCreationView onProjectCreated={vi.fn()} />);
// 有封面的那张走 <img>,src 是命令返回的 data URL。
const image = await screen.findByRole('img', {
name: '有封面的作品 封面',
});
expect(image.getAttribute('src')).toBe('data:image/png;base64,AAAA');
// 失败与缺封面都保留首字占位:不出现破图(即没有第二张 <img>)。
expect(container.querySelectorAll('img')).toHaveLength(1);
expect(
screen.queryByRole('img', { name: '换签失败的作品 封面' }),
).toBeNull();
expect(
screen.queryByRole('img', { name: '没有封面的作品 封面' }),
).toBeNull();
// 没有对象键的那张根本不该发换签请求。
expect(
calls.filter(
(call) =>
call.command === 'read_public_game_cover_preview' &&
(call.args as { objectKey?: string })?.objectKey === undefined,
),
).toHaveLength(0);
expect(
calls.filter((call) => call.command === 'read_public_game_cover_preview'),
).toHaveLength(2);
});
it('深链只预填输入框,不自动开始下载', async () => {
const calls = installInvoke((command) => {
if (command === CATALOG_COMMAND) {