Compare commits

...

11 Commits

Author SHA1 Message Date
kdletters 5ac7f65c92 合并 master 并保留游戏分发功能
- 合并 origin/master(含 429f991bd 定向回滚、Godot 原生绑定迁移与策划 Agent 模型控件)。
- 回滚提交删除的游戏分发代码按功能面补回:SpacetimeDB 模块注册与迁移表、api-server 路由与权限映射、后台审核页、AGC 发布命令与 payload 类型、SelectionStage 的 games 阶段、ProjectSupervisorView 的 overlay 挂载点、主站路由与标题、Cargo 依赖与锁、vite/vitest 代理与 tailwind source。
- 保留 master 的 Godot C++ 迁移(移除旧 vendored GDExtension、新的 composer 控件布局)与其它 master 侧改动。
- 修正 master 自带的红灯断言:/api/creation-entry 已被 api-server 列入退役路由,dev 中间件按退役路径 404,测试改为断言不进入代理。
- 验证:cargo check(api-server / spacetime-module / spacetime-client / AGC 壳)、cargo test(api-server 游戏分发 17、module-game-distribution 11、AGC 本地导出 7)、全量 vitest 4557 passed、两端 typecheck、eslint、rustfmt、编码、文档索引与 SpacetimeDB schema guard。
2026-09-21 00:20:45 +08:00
kdletters 2f0e2a48a3 把游戏分发客户端纳入 ESLint 边界
- `.eslintrc.cjs`:`src/services/gameDistributionClient.ts` 及其测试加入活跃服务白名单,不再被遗留忽略列表吞掉。
- 客户端:按仓库规则修掉排序与未使用类型导入(顺带移除两个只在 re-export 中出现的多余导入)。
- 修复 `scripts/vite-retired-css-plugin.test.ts` 的边界门禁:活跃模块不得停留在 ESLint 忽略名单里。
2026-09-20 22:56:43 +08:00
kdletters 00b96f244d 补齐 AGC 素材直传的错误文案与能力作用域验证
- AGC:直传对象存储抛错(能力作用域拒绝、网络不可达、请求被取消)时统一转成中文可操作文案,不再把英文插件错误原样暴露给作者。
- 测试:新增直传抛错的单测,确认失败时不再登记素材。
- 文档:记录用 tauri-plugin-http 同一套 urlpattern 逻辑验证 `https://*.aliyuncs.com/*` 命中 dev/生产 OSS 桶且拒绝无关主机,并说明桌面端真实执行仍需在装有 AGC 的机器上补跑。
2026-09-20 22:49:56 +08:00
kdletters 0dde6ee501 打磨封面与截图的文件选择交互
- 网页发布页:封面与截图改用透明 input 覆盖自定义胶囊按钮,点击命中原生控件;上传后不再出现「未选择任何文件」与「已选择」互相矛盾的状态。
- AGC 发布面板:同一处理,按钮文案随状态在「选择封面图片 / 更换封面图片」「添加截图 / 截图已满 6 张」之间切换。
- 文档:实施计划补充真实浏览器发布链路证据(网页上传封面 → my-games 待审 → 版本 frozenMetadata 指向本次上传素材)与交互打磨记录。
2026-09-20 22:45:50 +08:00
kdletters 4a91e5865a 创建游戏同步校验封面与截图素材归属
- 服务端:抽出 `resolve_owned_game_media`,创建游戏与创建版本复用同一素材校验;不存在的素材返回 400,借用他人素材返回 403,避免游戏行先落一个无效素材 ID。
- 脚本:新增 `npm run check:game-distribution-media-e2e`(`scripts/check-game-distribution-media-e2e.mjs`),在本地 dev 栈上跑真实素材直传、冻结、审核生效、匿名换签读与发行网关的可重复检查。
- 文档:后端数据契约补充创建游戏的素材复核口径,实施计划记录真实本地栈 27 项媒体验证与真实浏览器封面/截图展示证据。
2026-09-20 22:34:33 +08:00
kdletters 4a2b270714 补齐游戏分发封面与截图的冻结、上传与展示
- 服务端:游戏表追加可空 cover_object_key / screenshots_json,版本表追加可空 metadata_json,创建版本时校验必需封面、最多 6 张截图与图片素材归属,并从素材记录派生对象键生成冻结资料。
- 服务端:审核通过时把冻结资料整体生效到游戏行;只有已公开且存在有效活动版本的游戏,其封面/截图素材才在 /api/assets/read-url 获得匿名读授权。
- 服务端:作者与管理员回读版本时追加 frozenMetadata,带回封面与截图素材 ID 供续发复用,公开投影仍只暴露对象键。
- 网页:/games/publish 新增「封面与截图」区,复用平台图片直传与 confirm 通道,缺封面或截图超量在发请求前拦截,更新版本时按冻结资料回填且不要求重新上传。
- 网页:游戏广场卡片与详情页 hero 换签展示真实封面,详情页新增可点击的截图缩略图条,无素材或换签失败时静默回退原有渐变占位。
- AGC:发布面板支持封面必选与截图最多 6 张,新增素材直传服务并在创建游戏前拦截缺封面,同一文件重复提交复用素材 ID。
- AGC:http 能力作用域新增阿里云 OSS 直传地址,客户端同时校验直传目标必须是平台素材存储。
- 契约与文档:同步 shared-contracts、packages/shared 契约,更新 SpacetimeDB 迁移注释、后端架构、玩法链路与实施计划文档,并补齐网页端与 AGC 定向测试。
2026-09-20 22:19:50 +08:00
kdletters 789aef4ce5 补齐游戏分发结构化可观测事件
- api-server 游戏分发模块新增 package_confirmed/package_rejected/version_submitted/version_cancelled 事件,带 request_id、game_id、version_id、耗时与体积/文件数(不记录凭据、URL 与文件内容)
- 新增 review_backlog_listed(审核积压)、review_decided(审核结论与公开修订号)、game_unpublished/game_suspended(撤销传播)与 publish_switch_blocked/unavailable(开关拦截)事件
- 发行网关拒绝路径补 debug 级 release_rejected(cookie、扩展名、未公开、非公开版本)便于排查发行 4xx
- 运维文档给出可直接 grep 的事件名与关键字段,实施计划记录真实栈复跑证据
2026-09-20 21:30:32 +08:00
kdletters 7ff6e46774 修复分支上的格式与导入排序门禁问题
- 用 rustfmt 修正 AGC Tauri 导出包测试的断言排版
- 按 simple-import-sort 修正 AGC App.tsx 导入顺序与 packages/shared 导出顺序
2026-09-20 21:17:27 +08:00
kdletters 2254831db5 补充游戏内真实触屏输入的运行时证据
- 用 CDP Input.dispatchTouchEvent 向游玩页 iframe 派发真实 touchStart/touchEnd,沙箱内探测包收到 touchstart/touchend/pointerdown(pointerType=touch)/click 并命中目标元素
- 记录触摸模拟必须在 iframe 文档创建前启用的踩坑口径
2026-09-20 21:11:37 +08:00
kdletters 64425e8d1a Merge commit 'a2acfa9a6' into codex/game-distribution 2026-09-20 20:59:58 +08:00
kdletters 328ac31844 恢复游戏分发完整实现(特性分支)
- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务
- 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关
- 后台:游戏审核页(待审列表、通过/拒绝、安全下架)
- AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试
- 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试
- 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
2026-09-20 20:49:42 +08:00
152 changed files with 22115 additions and 27 deletions
+2
View File
@@ -213,6 +213,8 @@ module.exports = {
'!src/services/clipboard.test.ts',
'!src/services/frontendRuntimeConfigService.ts',
'!src/services/frontendRuntimeConfigService.test.ts',
'!src/services/gameDistributionClient.ts',
'!src/services/gameDistributionClient.test.ts',
'!src/services/sseStream.ts',
'!src/services/sseStream.test.ts',
'src/AdventurePanel.tsx',
@@ -5,9 +5,12 @@ import {
executeAdminRechargeRefund,
getAdminFeatureGateConfig,
getAdminUserDetail,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
updateAdminAccount,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
@@ -364,3 +367,134 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => {
}),
);
});
test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ entries: [], nextCursor: null }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await listAdminGameDistributionReviews('admin-token');
await reviewAdminGameDistributionVersion(
'admin-token',
'gamever/1',
'game-review-key-1',
{
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
},
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/reviews?limit=48',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/admin/api/game-distribution/versions/gamever%2F1/review',
);
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-review-key-1',
}),
body: JSON.stringify({
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
}),
}),
);
});
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ game: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await suspendAdminGameDistributionGame(
'admin-token',
'game/1',
'game-suspend-key-1',
{ expectedPublicationRevision: 7, reason: '版权投诉' },
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games/game%2F1/suspend',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-suspend-key-1',
}),
body: JSON.stringify({
expectedPublicationRevision: 7,
reason: '版权投诉',
}),
}),
);
expect(() =>
suspendAdminGameDistributionGame('admin-token', ' ', 'key', {
expectedPublicationRevision: 1,
}),
).toThrow('缺少游戏 ID');
expect(() =>
suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', {
expectedPublicationRevision: 1,
}),
).toThrow('下架幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ version: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await reviewAdminGameDistributionVersion(
'admin-token',
'version-1',
'game-review-key-2',
{
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
},
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
body: JSON.stringify({
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
}),
}),
);
expect(() =>
reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', {
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: 'x',
}),
).toThrow('审核幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
+72
View File
@@ -29,6 +29,9 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
@@ -1176,3 +1179,72 @@ export function saveAgcModelCatalog(
{ token, method: 'PUT', body },
);
}
export function listAdminGameDistributionReviews(token: string, limit = 48) {
const normalizedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 48)
: 48;
return request<AdminGameDistributionReviewListResponse>(
`/admin/api/game-distribution/reviews?limit=${normalizedLimit}`,
{ token },
);
}
/**
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
* 两条审核结论。
*/
/**
* 安全下架整个游戏。管理员下架同样要求 CAS 修订号与幂等键,避免并发审核互相覆盖。
*/
export function suspendAdminGameDistributionGame(
token: string,
gameId: string,
idempotencyKey: string,
payload: import('./adminApiTypes').AdminGameDistributionSuspendRequest,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) {
throw new Error('缺少游戏 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('下架幂等键必须是 1 到 128 个字符');
}
return request<
import('./adminApiTypes').AdminGameDistributionSuspendResponse
>(
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/suspend`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
export function reviewAdminGameDistributionVersion(
token: string,
versionId: string,
idempotencyKey: string,
payload: AdminGameDistributionReviewRequest,
) {
const normalizedVersionId = versionId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedVersionId) {
throw new Error('缺少发行版本 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('审核幂等键必须是 1 到 128 个字符');
}
return request<AdminGameDistributionReviewResponse>(
`/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
+45
View File
@@ -1031,3 +1031,48 @@ export interface AdminAgcModelCatalog {
defaultModelId: string;
models: AdminAgcModel[];
}
export interface AdminGameDistributionReviewEntry {
versionId: string;
gameId: string;
versionNumber: number;
packageSha256: string;
packageBytes: number;
status: string;
publicationRevision: number;
reviewReason: string | null;
createdAt: string;
updatedAt: string;
}
export interface AdminGameDistributionReviewListResponse {
entries: AdminGameDistributionReviewEntry[];
nextCursor: string | null;
}
export interface AdminGameDistributionReviewRequest {
decision: 'approve' | 'reject';
expectedPublicationRevision: number;
reviewReason?: string;
entryUrl?: string;
}
export interface AdminGameDistributionReviewResponse {
version: AdminGameDistributionReviewEntry;
replayed: boolean;
}
export interface AdminGameDistributionSuspendRequest {
expectedPublicationRevision: number;
reason?: string;
}
export interface AdminGameDistributionSuspendResponse {
game: {
id: string;
title: string;
status: string;
publicationRevision: number;
};
replayed: boolean;
}
+7
View File
@@ -26,6 +26,7 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -300,6 +301,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-distribution' ? (
<AdminGameDistributionReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
+2
View File
@@ -5,6 +5,7 @@ import {
Coins,
Database,
FolderArchive,
Gamepad2,
GitBranch,
Images,
LayoutDashboard,
@@ -49,6 +50,7 @@ const routeIcons = {
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
@@ -147,3 +147,24 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
}),
).not.toContainEqual(route);
});
test('后台游戏审核路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'game-distribution',
label: '游戏审核',
hash: '#game-distribution',
});
expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution');
expect(routeHash('game-distribution')).toBe('#game-distribution');
});
test('member 可单独获得游戏审核 Tab 权限', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['game-distribution'],
});
expect(routes.map((route) => route.id)).toEqual(['game-distribution']);
expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe(
'game-distribution',
);
});
+2
View File
@@ -15,6 +15,7 @@ export type AdminRouteId =
| 'recharge-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
@@ -54,6 +55,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
},
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -0,0 +1,186 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import {
AdminGameDistributionReviewPage,
resolveGameReleaseEntryUrlError,
} from './AdminGameDistributionReviewPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
listAdminGameDistributionReviews: vi.fn(),
reviewAdminGameDistributionVersion: vi.fn(),
suspendAdminGameDistributionGame: vi.fn(),
}));
const entry: AdminGameDistributionReviewEntry = {
versionId: 'version-1',
gameId: 'game_1',
versionNumber: 2,
packageSha256: 'a'.repeat(64),
packageBytes: 2048,
status: 'pending_review',
publicationRevision: 4,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T08:00:00Z',
};
beforeEach(() => {
vi.mocked(listAdminGameDistributionReviews).mockReset();
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
vi.mocked(suspendAdminGameDistributionGame).mockReset();
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
entries: [entry],
nextCursor: null,
});
});
test('发行入口必须是带完整来源的 HTTPS 地址', () => {
expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口');
expect(
resolveGameReleaseEntryUrlError('http://games.test/a/index.html'),
).toBe('发行入口必须以 https:// 开头');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'),
).toBe('发行入口不能包含 query 或 fragment');
expect(
resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'),
).toBe('发行入口不能包含凭据');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html'),
).toBe('');
});
test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => {
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
version: { ...entry, status: 'published' },
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('发行入口'), {
target: { value: 'https://games.test/releases/game_1/index.html' },
});
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() =>
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1),
);
const [token, versionId, idempotencyKey, payload] =
vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(versionId).toBe('version-1');
expect(String(idempotencyKey)).toContain('version-1');
expect(payload).toEqual({
decision: 'approve',
expectedPublicationRevision: 4,
entryUrl: 'https://games.test/releases/game_1/index.html',
});
await waitFor(() =>
expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes(
2,
),
);
});
test('缺少拒绝理由时不调用审核接口', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy();
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
});
test('安全下架需要二次确认,并携带公开修订号与原因', async () => {
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
game: {
id: 'game_1',
title: '测试游戏',
status: 'suspended',
publicationRevision: 5,
},
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '盗用素材' },
});
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
// 第一次点击只弹出确认面板,不直接调用后端。
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
expect(await screen.findByRole('dialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_1');
expect(String(idempotencyKey)).toContain('game_1');
expect(payload).toEqual({
expectedPublicationRevision: 4,
reason: '盗用素材',
});
expect(await screen.findByText(//u)).toBeTruthy();
});
test('取消确认时不下架', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
});
@@ -0,0 +1,365 @@
import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminGameDistributionReviewPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KiB`;
}
return `${value} B`;
}
function formatTime(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString('zh-CN', { hour12: false });
}
function createSuspendIdempotencyKey(gameId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `game-suspend-${gameId}-${random}`.slice(0, 128);
}
function createReviewIdempotencyKey(versionId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `game-review-${versionId}-${random}`.slice(0, 128);
}
export function resolveGameReleaseEntryUrlError(value: string) {
const normalized = value.trim();
if (!normalized) return '请填写发行入口';
if (!normalized.startsWith('https://')) {
return '发行入口必须以 https:// 开头';
}
if (normalized.includes('?') || normalized.includes('#')) {
return '发行入口不能包含 query 或 fragment';
}
try {
const parsed = new URL(normalized);
if (parsed.username || parsed.password) {
return '发行入口不能包含凭据';
}
} catch {
return '发行入口不是合法 URL';
}
return '';
}
export function AdminGameDistributionReviewPage({
token,
onUnauthorized,
}: AdminGameDistributionReviewPageProps) {
const [entries, setEntries] = useState<AdminGameDistributionReviewEntry[]>(
[],
);
const [isLoading, setIsLoading] = useState(false);
const [busyVersionId, setBusyVersionId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [entryUrlByVersion, setEntryUrlByVersion] = useState<
Record<string, string>
>({});
const [reasonByVersion, setReasonByVersion] = useState<
Record<string, string>
>({});
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [busyGameId, setBusyGameId] = useState('');
const writeConfirm = useAdminWriteConfirm();
const loadReviews = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminGameDistributionReviews(token);
setEntries(response.entries);
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [token, onUnauthorized]);
useEffect(() => {
void loadReviews();
}, [loadReviews]);
async function submitReview(
entry: AdminGameDistributionReviewEntry,
decision: 'approve' | 'reject',
) {
const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim();
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
if (decision === 'approve') {
const invalid = resolveGameReleaseEntryUrlError(entryUrl);
if (invalid) {
setErrorMessage(invalid);
return;
}
} else if (!reason) {
setErrorMessage('拒绝审核必须填写理由');
return;
}
setBusyVersionId(entry.versionId);
setErrorMessage('');
setStatusMessage('');
try {
await reviewAdminGameDistributionVersion(
token,
entry.versionId,
createReviewIdempotencyKey(entry.versionId),
decision === 'approve'
? {
decision,
expectedPublicationRevision: entry.publicationRevision,
entryUrl,
}
: {
decision,
expectedPublicationRevision: entry.publicationRevision,
reviewReason: reason,
},
);
setStatusMessage(
decision === 'approve'
? `版本 v${entry.versionNumber} 已通过审核`
: `版本 v${entry.versionNumber} 已拒绝`,
);
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyVersionId('');
}
}
/**
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
* 由服务端返回冲突,前端只提示刷新,不静默重试。
*/
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
const confirmed = await writeConfirm.confirmWrite({
action: '安全下架游戏',
target: `${entry.gameId}(版本 v${entry.versionNumber}`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await suspendAdminGameDistributionGame(
token,
entry.gameId,
createSuspendIdempotencyKey(entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
...(reason ? { reason } : {}),
},
);
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h1></h1>
<button
type="button"
className="admin-secondary-button"
onClick={() => void loadReviews()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
</button>
</div>
{errorMessage ? (
<div className="admin-alert admin-alert-warning" role="alert">
{errorMessage}
</div>
) : null}
{statusMessage ? (
<div className="admin-alert admin-alert-success" role="status">
{statusMessage}
</div>
) : null}
<div className="admin-panel">
<div className="admin-panel-heading">
<h2></h2>
<span className="admin-muted-text"> {entries.length} </span>
</div>
{isLoading ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && entries.length === 0 ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && entries.length > 0 ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const busy = busyVersionId === entry.versionId;
return (
<tr key={entry.versionId}>
<td>
<code>{entry.gameId}</code>
</td>
<td>
v{entry.versionNumber}
<div className="admin-muted-text">{entry.status}</div>
{entry.reviewReason ? (
<div className="admin-muted-text">
{entry.reviewReason}
</div>
) : null}
</td>
<td>
{formatBytes(entry.packageBytes)}
<div className="admin-muted-text">
<code>{entry.packageSha256.slice(0, 12)}</code>
</div>
</td>
<td>{formatTime(entry.createdAt)}</td>
<td>
<div className="admin-action-row">
<div className="admin-field">
<label
htmlFor={`game-release-url-${entry.versionId}`}
>
</label>
<input
id={`game-release-url-${entry.versionId}`}
value={entryUrlByVersion[entry.versionId] ?? ''}
placeholder="https://"
onChange={(event) =>
setEntryUrlByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-primary-button"
disabled={busy}
onClick={() => void submitReview(entry, 'approve')}
>
</button>
<div className="admin-field">
<label
htmlFor={`game-reject-reason-${entry.versionId}`}
>
</label>
<input
id={`game-reject-reason-${entry.versionId}`}
value={reasonByVersion[entry.versionId] ?? ''}
onChange={(event) =>
setReasonByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-ghost-button"
disabled={busy}
onClick={() => void submitReview(entry, 'reject')}
>
</button>
<div className="admin-field">
<label
htmlFor={`game-suspend-reason-${entry.versionId}`}
>
</label>
<input
id={`game-suspend-reason-${entry.versionId}`}
value={suspendReasonByGame[entry.gameId] ?? ''}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-danger-button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => void suspendGame(entry)}
>
{busyGameId === entry.gameId
? '正在下架…'
: '安全下架'}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</div>
{writeConfirm.confirmDialog}
</section>
);
}
@@ -16,7 +16,8 @@
{ "url": "https://www.genarrative.world/api/*" },
{ "url": "https://*/api/*" },
{ "url": "http://localhost:*/*" },
{ "url": "http://127.0.0.1:*/*" }
{ "url": "http://127.0.0.1:*/*" },
{ "url": "https://*.aliyuncs.com/*" }
]
},
"opener:default",
@@ -5933,6 +5933,16 @@ pub(crate) fn export_local_project_package(
export_local_project_package_at(root)
}
#[tauri::command]
pub(crate) fn read_local_project_export_package(
project_path: String,
package_relative_path: String,
) -> Result<LocalProjectExportPackagePayload, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
read_local_project_export_package_at(root, package_relative_path.trim())
}
#[tauri::command]
pub(crate) fn list_local_project_export_packages(
project_path: String,
@@ -2692,6 +2692,7 @@ fn main() {
build_local_project_index,
create_local_project_checkpoint,
export_local_project_package,
read_local_project_export_package,
list_local_project_export_packages,
diff_local_project_checkpoint,
restore_local_project_checkpoint,
@@ -1,5 +1,27 @@
use super::*;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::io::{Cursor, Read};
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectExportPackageFileDigest {
pub(crate) path: String,
pub(crate) size_bytes: u64,
pub(crate) sha256: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectExportPackagePayload {
pub(crate) package_relative_path: String,
pub(crate) package_bytes: Vec<u8>,
pub(crate) package_sha256: String,
pub(crate) package_size_bytes: u64,
pub(crate) files: Vec<LocalProjectExportPackageFileDigest>,
}
pub(crate) fn export_local_project_package_at(
root: &Path,
) -> Result<LocalProjectExportPackageResult, String> {
@@ -110,6 +132,114 @@ pub(crate) fn export_local_project_package_at(
})
}
/// Read a previously exported package for the explicit AGC publish flow.
///
/// The caller receives the package bytes and a deterministic file manifest, but
/// never receives a filesystem path that it could accidentally send to the API.
pub(crate) fn read_local_project_export_package_at(
root: &Path,
package_relative_path: &str,
) -> Result<LocalProjectExportPackagePayload, String> {
validate_project_root(root)?;
let normalized = normalize_export_package_entry_path(package_relative_path)?;
if !normalized.starts_with("exports/playtest-package-")
|| !normalized.ends_with(".zip")
|| normalized.contains('/') && normalized.split('/').count() != 2
{
return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string());
}
let package_path = resolve_local_project_path(root, &normalized)?;
prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?;
let metadata = checked_export_package_metadata(&package_path, &normalized)?;
if !metadata.is_file() {
return Err("发行包必须是普通文件".to_string());
}
if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err("发行包大小超出本地发布上限".to_string());
}
let package_bytes =
fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?;
if package_bytes.len() as u64 != metadata.len() {
return Err("发行包在读取期间发生变化,请重新导出".to_string());
}
let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes))
.map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?;
let mut entries = Vec::with_capacity(archive.len());
let mut seen = BTreeSet::new();
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("读取发行包条目失败:{error}"))?;
if entry.is_dir() {
continue;
}
let source_path = normalize_export_package_entry_path(entry.name())?;
// 本地试玩包以 game/index.html 为入口,而平台发行合同要求根
// index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改
// 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。
let path = source_path
.strip_prefix("game/")
.unwrap_or(source_path.as_str())
.to_string();
let path = normalize_export_package_entry_path(&path)?;
if !seen.insert(path.clone()) {
return Err(format!("发行包包含重复条目:{path}"));
}
let expected_size = entry.size();
let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize);
entry
.read_to_end(&mut content)
.map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?;
if content.len() as u64 != expected_size {
return Err(format!("发行包条目长度不一致:{path}"));
}
entries.push((path, content));
}
entries.sort_by(|left, right| left.0.cmp(&right.0));
if entries.is_empty() {
return Err("发行包没有可上传文件".to_string());
}
let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (path, content) in &entries {
normalized_writer
.start_file(path, options)
.map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?;
normalized_writer
.write_all(content)
.map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?;
}
let normalized_cursor = normalized_writer
.finish()
.map_err(|error| format!("完成发行包失败:{error}"))?;
let package_bytes = normalized_cursor.into_inner();
if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err("归一化发行包大小超出本地发布上限".to_string());
}
let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes));
let files = entries
.into_iter()
.map(|(path, content)| LocalProjectExportPackageFileDigest {
size_bytes: content.len() as u64,
sha256: format!("{:x}", Sha256::digest(&content)),
path,
})
.collect::<Vec<_>>();
if !files.iter().any(|file| file.path == "index.html") {
return Err("归一化发行包缺少根 index.html".to_string());
}
Ok(LocalProjectExportPackagePayload {
package_relative_path: normalized,
package_size_bytes: package_bytes.len() as u64,
package_bytes,
package_sha256,
files,
})
}
pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> {
let seed = unix_millis();
for suffix in 0..1000 {
@@ -3906,6 +3906,54 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() {
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-publish", "在线试玩项目").expect("project init");
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
.expect("write playable html");
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
let exported = export_local_project_package_at(&root).expect("export package");
let payload = read_local_project_export_package_at(&root, &exported.package_relative_path)
.expect("read publish payload");
assert_eq!(
payload.package_relative_path,
exported.package_relative_path
);
assert_eq!(
payload.package_size_bytes,
payload.package_bytes.len() as u64
);
assert_eq!(payload.files.len(), 2);
assert!(payload.files.iter().any(|file| file.path == "index.html"));
assert!(payload
.files
.iter()
.any(|file| file.path == "exports/README.md"));
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&payload.package_bytes))
.expect("read normalized package");
let names = (0..archive.len())
.map(|index| {
archive
.by_index(index)
.expect("normalized entry")
.name()
.to_string()
})
.collect::<Vec<_>>();
assert!(names.iter().any(|name| name == "index.html"));
assert!(!names.iter().any(|name| name.starts_with("game/")));
assert_eq!(payload.package_sha256.len(), 64);
assert!(payload
.package_sha256
.chars()
.all(|value| value.is_ascii_hexdigit()));
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_export_package_list_only_returns_recent_playtest_zips() {
let root = unique_project_path();
+32
View File
@@ -97,6 +97,7 @@ import type {
TauriInvoke,
UploadLocalAssetResult,
} from './app/types';
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
import { useWindowChrome } from './components/windowChromeContext';
import {
agentConversationId,
@@ -1070,6 +1071,9 @@ export function App({
const [filePath, setFilePath] = useState('game/index.html');
const [fileDraft, setFileDraft] = useState('');
const [fileStatus, setFileStatus] = useState('未读取');
const [publishPackageResult, setPublishPackageResult] =
useState<LocalProjectExportPackageResult | null>(null);
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
const [agentRunTrace, setAgentRunTrace] =
useState<GameCreationAgentRunTrace | null>(null);
const [agentRunHistory, setAgentRunHistory] = useState<AgentRunHistoryItem[]>(
@@ -7483,6 +7487,8 @@ export function App({
{ projectPath: nextProjectPath },
);
setFileStatus(`已导出本地试玩包:${result.packageRelativePath}`);
setPublishPackageResult(result);
setPublishPanelOpen(true);
setCommandLog((current) => [...current, 'project.export_package']);
void refreshManifest(nextProjectPath);
if (announceToChat) {
@@ -11785,6 +11791,15 @@ export function App({
workspaceStatus={workspaceStatus}
expectedRunId={projectSupervisorExpectedRunId}
versions={chatProjectVersions}
overlay={
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={supervisorProjectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
}
/>
);
}
@@ -11970,6 +11985,15 @@ export function App({
onProfessionalToolAction={handleProjectProfessionalAgentToolAction}
onProfessionalRetry={handleProjectProfessionalAgentRetry}
onUserInput={handleProjectSupervisorUserInput}
overlay={
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
}
/>
);
}
@@ -12077,6 +12101,14 @@ export function App({
workspaceStatus={workspaceStatus}
/>
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
{runtimeConfigOpen ? (
<RuntimeConfigDialog
projectPath={localProject?.projectPath}
@@ -794,6 +794,20 @@ export interface LocalProjectExportPackageResult {
totalBytes: number;
}
export interface LocalProjectExportPackageFileDigest {
path: string;
sizeBytes: number;
sha256: string;
}
export interface LocalProjectExportPackagePayload {
packageRelativePath: string;
packageBytes: number[];
packageSha256: string;
packageSizeBytes: number;
files: LocalProjectExportPackageFileDigest[];
}
export interface LocalProjectExportPackageSummary {
packagePath: string;
packageRelativePath: string;
@@ -150,6 +150,8 @@ function AgentReasoning({
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
type ProjectSupervisorViewProps = RuntimePanelProps & {
/** 面板层:发布等业务面板以 portal 形式挂到 supervisor 视图内部。 */
overlay?: ReactNode;
activeVersionId?: string | null;
/** 输入盒待发送附件:随下次提交进入回合(direct-codex 才渲染)。 */
attachments?: DirectCodexTurnAttachment[];
@@ -215,6 +217,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
};
export function ProjectSupervisorView({
overlay,
activeVersionId = null,
attachments = [],
attachmentNotice = '',
@@ -895,6 +898,7 @@ export function ProjectSupervisorView({
closeOnEscape={false}
/>
) : null}
{overlay}
</section>
);
}
@@ -2,6 +2,7 @@ import { Send, Settings } from 'lucide-react';
import type {
ComponentProps,
FormEventHandler,
ReactNode,
Ref,
RefObject,
UIEvent,
@@ -42,6 +43,7 @@ type RuntimeControlProps = ComponentProps<
const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
type SupervisorChatOnlyViewProps = {
overlay?: ReactNode;
activeVersionId?: string | null;
chatAgentBusy: boolean;
chatInput: string;
@@ -80,6 +82,7 @@ type SupervisorChatOnlyViewProps = {
};
export function SupervisorChatOnlyView({
overlay,
activeVersionId = null,
chatAgentBusy,
chatInput,
@@ -351,6 +354,7 @@ export function SupervisorChatOnlyView({
onClose={onCloseRuntimeConfig}
/>
) : null}
{overlay}
</>
);
}
@@ -0,0 +1,167 @@
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
import { requestClientApi } from './clientApi';
/** 直传凭证里的对象存储目标;字段口径与平台 `/api/assets/direct-upload-tickets` 一致。 */
type DirectUploadTicketResponse = {
upload: {
bucket: string;
host: string;
objectKey: string;
legacyPublicPath: string;
formFields: Record<string, string | null | undefined>;
};
};
type ConfirmAssetObjectResponse = {
assetObject: {
assetObjectId: string;
objectKey: string;
assetKind: string;
};
};
export type UploadedPlatformAsset = {
assetObjectId: string;
objectKey: string;
};
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
/**
* 平台直传凭证支持的 OSS 主机白名单前缀。
*
* Tauri 的 http 插件只允许访问 `capabilities/main.json` 里声明的地址,这里再做一次校验,
* 保证即使能力配置放宽到 `*.aliyuncs.com`,客户端也只把文件发到平台素材存储,而不是任意主机。
*/
const PLATFORM_UPLOAD_HOST_SUFFIXES = ['.aliyuncs.com'];
function isLocalUploadHost(parsed: URL) {
return (
parsed.protocol === 'http:' &&
(parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost')
);
}
/** 校验直传地址;不是平台素材存储时直接失败关闭,避免把本地文件发给第三方主机。 */
export function resolvePlatformAssetUploadUrl(host: string) {
const trimmedHost = host.trim();
if (!trimmedHost) {
throw new Error('素材上传地址为空,请稍后重试');
}
let parsed: URL;
try {
parsed = new URL(trimmedHost);
} catch {
throw new Error('素材上传地址无效,请稍后重试');
}
const isOssHost =
parsed.protocol === 'https:' &&
PLATFORM_UPLOAD_HOST_SUFFIXES.some((suffix) =>
parsed.hostname.endsWith(suffix),
);
if (!isOssHost && !isLocalUploadHost(parsed)) {
throw new Error('素材上传地址不属于平台素材存储,已终止上传');
}
return parsed.toString();
}
function buildDirectUploadFormData(
upload: DirectUploadTicketResponse['upload'],
file: File,
) {
const formData = new FormData();
Object.entries(upload.formFields ?? {}).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
// OSS 要求 file 字段位于表单末尾,否则签名校验会失败。
formData.append('file', file, file.name);
return formData;
}
/**
* 把本地文件上传成平台素材对象并返回素材标识。
*
* 三步与网页端一致:申请直传凭证 → 直传对象存储 → confirm 登记素材。AGC 无法在浏览器里
* 直接跨域直传 OSS,所以直传这一步固定走 Tauri HTTP 插件(Rust 侧发起请求)。
*/
export async function uploadPlatformMediaAsset(args: {
file: File;
assetKind: string;
pathSegments: string[];
entityId: string;
metadata?: Record<string, string>;
/** 单测注入的直传实现;正式运行时固定使用 Tauri HTTP。 */
fetchImpl?: FetchLike;
}): Promise<UploadedPlatformAsset> {
const fileName = args.file.name.trim() || 'cover.png';
const contentType = args.file.type.trim() || 'application/octet-stream';
const ticket = await requestClientApi<DirectUploadTicketResponse>(
'/api/assets/direct-upload-tickets',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
legacyPrefix: 'generated-character-drafts',
pathSegments: args.pathSegments,
fileName,
contentType,
access: 'private',
maxSizeBytes: args.file.size,
metadata: {
asset_kind: args.assetKind,
...args.metadata,
},
}),
},
'创建素材上传凭证失败',
);
const uploadHost = resolvePlatformAssetUploadUrl(ticket.upload.host);
const uploadFetch = args.fetchImpl ?? tauriHttpFetch;
let uploadResponse: Response;
try {
uploadResponse = await uploadFetch(uploadHost, {
method: 'POST',
body: buildDirectUploadFormData(ticket.upload, args.file),
});
} catch (error) {
// Tauri http 插件在目标不在能力作用域、网络不可达或请求被取消时直接抛错;
// 统一转成可操作文案,避免把英文插件错误原样暴露给作者。
const detail = error instanceof Error ? error.message.trim() : '';
throw new Error(
`上传素材失败:无法访问素材存储,请检查网络后重试${
detail ? `${detail.slice(0, 120)}` : ''
}`,
);
}
if (!uploadResponse.ok) {
throw new Error(
`上传素材到对象存储失败(HTTP ${uploadResponse.status}),请重试`,
);
}
const confirmed = await requestClientApi<ConfirmAssetObjectResponse>(
'/api/assets/objects/confirm',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bucket: ticket.upload.bucket,
objectKey: ticket.upload.objectKey,
contentType,
contentLength: args.file.size,
assetKind: args.assetKind,
accessPolicy: 'private',
entityId: args.entityId,
}),
},
'确认素材资产失败',
);
return {
assetObjectId: confirmed.assetObject.assetObjectId,
objectKey: confirmed.assetObject.objectKey,
};
}
@@ -0,0 +1,247 @@
import type { GameCreationAppManifest } from '../../../../packages/shared/src/contracts/gameCreationApp';
import type {
GameDistributionCategory,
GameDistributionCreateGameRequest,
GameDistributionCreateVersionRequest,
GameDistributionInputMode,
GameDistributionOrientation,
} from '../../../../packages/shared/src/contracts/gameDistribution';
import type {
LocalProjectExportPackagePayload,
TauriInvoke,
} from '../app/types';
import { requestClientApi } from './clientApi';
export type GameDistributionPublishMetadata = {
title: string;
summary: string;
description: string;
category: GameDistributionCategory;
tags: string[];
/** 平台素材库里的封面素材 ID;服务端要求发布必须带封面。 */
coverAssetId: string;
/** 平台素材库里的截图素材 ID,最多 6 张。 */
screenshots?: string[];
deviceSupport: {
desktop: boolean;
mobile: boolean;
touch: boolean;
};
inputModes: GameDistributionInputMode[];
orientation: GameDistributionOrientation;
};
/** 游戏截图上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */
export const MAX_AGC_GAME_SCREENSHOTS = 6;
export type GameDistributionPublishResult = {
gameId: string;
versionId: string;
versionNumber: number;
status: string;
packageSha256: string;
packageSizeBytes: number;
fileCount: number;
};
type CreatedGame = { id: string; publicationRevision?: number };
type CreatedVersion = {
gameId: string;
versionId: string;
versionNumber: number;
status: string;
};
export function createGameDistributionPublishKey() {
const randomUuid =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `agc-publish-${randomUuid}`;
}
function normalizeMetadata(
manifest: GameCreationAppManifest,
metadata?: Partial<GameDistributionPublishMetadata>,
): GameDistributionPublishMetadata {
const title = (metadata?.title ?? manifest.name).trim();
const summary = (
metadata?.summary ??
manifest.goal ??
'由陶泥儿创作的可在线游玩游戏'
).trim();
if (!title || title.length > 40) {
throw new Error('游戏标题必须为 1 到 40 个字符');
}
if (!summary || summary.length > 120) {
throw new Error('游戏简介必须为 1 到 120 个字符');
}
const coverAssetId = (metadata?.coverAssetId ?? '').trim();
if (!coverAssetId) {
// 服务端会拒绝没有封面的发布;在创建游戏前失败关闭,避免留下无资料的半成品。
throw new Error('请先选择游戏封面(JPG/PNG/WebP),再发布到游戏广场');
}
const screenshots = (metadata?.screenshots ?? [])
.map((screenshot) => screenshot.trim())
.filter(Boolean);
if (screenshots.length > MAX_AGC_GAME_SCREENSHOTS) {
throw new Error('游戏截图最多 6 张');
}
return {
title,
summary,
description: (metadata?.description ?? summary).trim().slice(0, 2_000),
category: metadata?.category ?? '其他',
tags: metadata?.tags ?? [],
coverAssetId,
screenshots,
deviceSupport: metadata?.deviceSupport ?? {
desktop: true,
mobile: true,
touch: true,
},
inputModes: metadata?.inputModes ?? ['keyboard', 'mouse', 'touch'],
orientation: metadata?.orientation ?? 'responsive',
};
}
function toCreateGameRequest(
metadata: GameDistributionPublishMetadata,
localProjectId: string,
): GameDistributionCreateGameRequest {
return {
localProjectId,
title: metadata.title,
summary: metadata.summary,
description: metadata.description,
category: metadata.category,
tags: metadata.tags,
coverAssetId: metadata.coverAssetId,
screenshots: metadata.screenshots ?? [],
deviceSupport: metadata.deviceSupport,
inputModes: metadata.inputModes,
orientation: metadata.orientation,
};
}
export async function publishLocalProjectGame(args: {
invoke: TauriInvoke;
projectPath: string;
packageRelativePath: string;
manifest: GameCreationAppManifest;
metadata?: Partial<GameDistributionPublishMetadata>;
idempotencyKey?: string;
}): Promise<GameDistributionPublishResult> {
const projectPath = args.projectPath.trim();
const packageRelativePath = args.packageRelativePath.trim();
if (!projectPath || !packageRelativePath) {
throw new Error('发布需要绑定本地项目和试玩包');
}
const payload = await args.invoke<LocalProjectExportPackagePayload>(
'read_local_project_export_package',
{ projectPath, packageRelativePath },
);
if (
!payload.packageBytes.length ||
payload.packageSizeBytes !== payload.packageBytes.length ||
payload.files.length === 0
) {
throw new Error('本地发行包摘要无效,请重新导出试玩包');
}
if (payload.packageRelativePath !== packageRelativePath) {
throw new Error('本地发行包路径已变化,请重新导出试玩包');
}
const metadata = normalizeMetadata(args.manifest, args.metadata);
const localProjectId = args.manifest.projectId.trim();
if (!localProjectId) {
throw new Error('发布需要本地项目标识,请重新打开项目后再试');
}
const gameMetadata = toCreateGameRequest(metadata, localProjectId);
const rootKey =
args.idempotencyKey?.trim() || createGameDistributionPublishKey();
const game = await requestClientApi<CreatedGame>(
'/api/game-distribution/games',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': `${rootKey}:game`,
},
body: JSON.stringify(gameMetadata),
},
'创建平台游戏失败',
);
if (!game?.id?.trim()) {
throw new Error('创建平台游戏未返回游戏 ID');
}
const versionRequest: GameDistributionCreateVersionRequest = {
localProjectId,
packageSha256: payload.packageSha256,
packageBytes: payload.packageSizeBytes,
packageFileCount: payload.files.length,
packageEntryPath: 'index.html',
gameMetadata,
};
const version = await requestClientApi<CreatedVersion>(
`/api/game-distribution/games/${encodeURIComponent(game.id)}/versions`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': `${rootKey}:version`,
},
body: JSON.stringify(versionRequest),
},
'创建游戏发行版本失败',
);
if (!version?.versionId?.trim()) {
throw new Error('创建发行版本未返回版本 ID');
}
const packageBody = new Blob([new Uint8Array(payload.packageBytes)], {
type: 'application/zip',
});
const uploaded = await requestClientApi<{
versionId: string;
status: string;
}>(
`/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/package`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/zip',
'Idempotency-Key': `${rootKey}:upload`,
},
body: packageBody,
},
'上传游戏发行包失败',
);
const submitted = await requestClientApi<{
game?: { publicationRevision?: number };
version?: { status?: string };
}>(
`/api/game-distribution/versions/${encodeURIComponent(version.versionId)}/submit`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': `${rootKey}:submit`,
},
body: JSON.stringify({
expectedPublicationRevision: game.publicationRevision ?? 0,
}),
},
'提交审核失败',
);
return {
gameId: game.id,
versionId: version.versionId,
versionNumber: version.versionNumber,
status: submitted?.version?.status ?? uploaded?.status ?? 'pending_review',
packageSha256: payload.packageSha256,
packageSizeBytes: payload.packageSizeBytes,
fileCount: payload.files.length,
};
}
+365
View File
@@ -3768,6 +3768,371 @@ textarea {
color: #fff;
}
.game-distribution-publish-panel {
width: min(520px, 100%);
max-height: min(760px, calc(100vh - 48px));
overflow-y: auto;
padding: 24px;
border: 1px solid rgb(104 77 57 / 16%);
border-radius: 20px;
box-shadow: 0 24px 80px rgb(74 48 33 / 20%);
}
.game-distribution-publish-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.game-distribution-publish-panel__header h2 {
margin: 5px 0 0;
color: var(--platform-text-strong);
font-size: 24px;
letter-spacing: -0.03em;
}
.game-distribution-publish-panel__header > button {
width: 32px;
height: 32px;
border: 1px solid rgb(104 77 57 / 16%);
border-radius: 50%;
background: transparent;
color: var(--platform-text-muted);
font-size: 22px;
line-height: 1;
}
.game-distribution-publish-panel__eyebrow {
color: #a8663d;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.game-distribution-publish-panel__intro {
margin: 18px 0;
color: var(--platform-text-muted);
font-size: 13px;
line-height: 1.6;
}
.game-distribution-publish-panel__package {
display: grid;
gap: 5px;
margin-bottom: 18px;
padding: 12px 14px;
border: 1px solid rgb(168 102 61 / 16%);
border-radius: 12px;
background: rgb(168 102 61 / 6%);
color: var(--platform-text-muted);
font-size: 12px;
}
.game-distribution-publish-panel__package span:first-child {
overflow: hidden;
color: var(--platform-text-strong);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-distribution-publish-panel__fields {
display: grid;
gap: 14px;
}
.game-distribution-publish-panel__fields label {
display: grid;
gap: 6px;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 700;
}
.game-distribution-publish-panel__fields input,
.game-distribution-publish-panel__fields textarea,
.game-distribution-publish-panel__fields select {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 10px 12px;
border: 1px solid rgb(104 77 57 / 18%);
border-radius: 10px;
background: rgb(255 255 255 / 76%);
color: var(--platform-text-strong);
font: inherit;
line-height: 1.45;
}
.game-distribution-publish-panel__fields textarea {
resize: vertical;
}
.game-distribution-publish-panel__fields input:focus-visible,
.game-distribution-publish-panel__fields textarea:focus-visible,
.game-distribution-publish-panel__fields select:focus-visible {
border-color: #a8663d;
outline: 2px solid rgb(168 102 61 / 20%);
outline-offset: 1px;
}
.game-distribution-publish-panel__media {
display: grid;
gap: 14px;
margin-top: 18px;
padding-top: 16px;
border-top: 1px dashed rgb(104 77 57 / 18%);
}
.game-distribution-publish-panel__media > label,
.game-distribution-publish-panel__cover label {
display: grid;
gap: 6px;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 700;
}
.game-distribution-publish-panel__media input[type='file'] {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 10px 12px;
border: 1px solid rgb(104 77 57 / 18%);
border-radius: 10px;
background: rgb(255 255 255 / 76%);
color: var(--platform-text-strong);
font: inherit;
}
.game-distribution-publish-panel__picker {
position: relative;
display: inline-flex;
align-items: center;
}
/* 透明 input 覆盖在触发按钮上:真实点击永远命中原生控件,自定义文案只负责显示。 */
.game-distribution-publish-panel__picker input[type='file'] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0;
opacity: 0;
cursor: pointer;
}
.game-distribution-publish-panel__pick {
display: inline-flex;
pointer-events: none;
align-items: center;
padding: 6px 14px;
border: 1px solid rgb(104 77 57 / 18%);
border-radius: 999px;
background: rgb(255 255 255 / 76%);
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: 160ms ease;
}
.game-distribution-publish-panel__picker:hover
.game-distribution-publish-panel__pick {
border-color: #a8663d;
}
.game-distribution-publish-panel__picker
input[type='file']:focus-visible
+ .game-distribution-publish-panel__pick {
outline: 2px solid rgb(168 102 61 / 24%);
outline-offset: 2px;
}
.game-distribution-publish-panel__picker
input[type='file']:disabled
+ .game-distribution-publish-panel__pick {
opacity: 0.55;
cursor: not-allowed;
}
.game-distribution-publish-panel__cover {
display: grid;
grid-template-columns: minmax(0, 168px) minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.game-distribution-publish-panel__cover-preview {
display: grid;
aspect-ratio: 16 / 9;
place-items: center;
overflow: hidden;
border: 1px solid rgb(104 77 57 / 18%);
border-radius: 12px;
background: rgb(168 102 61 / 8%);
color: var(--platform-text-muted);
font-size: 12px;
}
.game-distribution-publish-panel__cover-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.game-distribution-publish-panel__hint {
color: var(--platform-text-muted);
font-size: 11px;
font-weight: 500;
line-height: 1.5;
}
.game-distribution-publish-panel__cover > button,
.game-distribution-publish-panel__shots button {
justify-self: start;
width: fit-content;
padding: 5px 12px;
border: 1px solid rgb(104 77 57 / 18%);
border-radius: 999px;
background: transparent;
color: var(--platform-text-muted);
font: inherit;
font-size: 11px;
cursor: pointer;
}
/* 移除封面按钮固定落在文字列,避免占掉预览列的第二行。 */
.game-distribution-publish-panel__cover > button {
grid-column: 2;
}
.game-distribution-publish-panel__shots {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.game-distribution-publish-panel__shots li {
display: grid;
gap: 6px;
width: 124px;
}
.game-distribution-publish-panel__shots img,
.game-distribution-publish-panel__shots li > span {
width: 100%;
aspect-ratio: 16 / 9;
border: 1px solid rgb(104 77 57 / 18%);
border-radius: 10px;
}
.game-distribution-publish-panel__shots img {
object-fit: cover;
}
.game-distribution-publish-panel__shots li > span {
display: grid;
place-items: center;
border-style: dashed;
color: var(--platform-text-muted);
font-size: 11px;
}
@media (max-width: 560px) {
.game-distribution-publish-panel__cover {
grid-template-columns: minmax(0, 1fr);
}
.game-distribution-publish-panel__cover > button {
grid-column: 1;
}
}
.game-distribution-publish-panel__error {
margin: 14px 0 0;
color: #b42318;
font-size: 12px;
line-height: 1.5;
}
.game-distribution-publish-panel__success {
margin-top: 18px;
padding: 16px;
border-radius: 14px;
background: rgb(51 125 87 / 9%);
color: var(--platform-text-strong);
}
.game-distribution-publish-panel__success p {
margin: 8px 0 0;
color: var(--platform-text-muted);
font-size: 13px;
line-height: 1.55;
}
.game-distribution-publish-panel__mono {
font-variant-numeric: tabular-nums;
}
.game-distribution-publish-panel__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
.game-distribution-publish-panel__actions button {
min-height: 38px;
padding: 0 16px;
border: 1px solid rgb(104 77 57 / 20%);
border-radius: 10px;
background: transparent;
color: var(--platform-text-strong);
cursor: pointer;
font: inherit;
font-weight: 700;
}
.game-distribution-publish-panel__actions button:last-child {
border-color: #a8663d;
background: #a8663d;
color: #fff;
}
.game-distribution-publish-panel__actions button:disabled,
.game-distribution-publish-panel__header > button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@media (max-width: 560px) {
.game-distribution-publish-panel {
padding: 18px;
border-radius: 16px;
}
.game-distribution-publish-panel__header h2 {
font-size: 21px;
}
.game-distribution-publish-panel__actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.game-distribution-publish-panel__actions button {
width: 100%;
}
}
.app-shell {
min-height: 100vh;
background: #f5f7fb;
@@ -849,6 +849,14 @@ export function registerProjectToolsAndPreviewTests() {
/已导出本地试玩包:exports\/playtest-package-unit\.zip/,
),
).not.toBeNull();
// 导出成功后会直接打开发布到游戏广场的面板;它是模态,先确认出现再关闭,
// 然后回到预览快捷操作的会话流。
const publishPanel = await screen.findByRole('dialog', {
name: '发布到游戏广场',
});
expect(publishPanel).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '关闭发布面板' }));
const exportRevealButtons = screen.getAllByRole('button', {
name: '显示目录',
});
@@ -0,0 +1,199 @@
// @vitest-environment jsdom
/**
* AGC 素材直传(封面 / 截图)的三步链路边界。
*
* 这里只替换平台 API 调用与对象存储直传实现,验证「凭证 → 直传 → confirm」的字段口径、
* 地址白名单与错误文案,不触达真实 Tauri HTTP 插件或 OSS。
*/
import { beforeEach, expect, test, vi } from 'vitest';
const requestClientApiMock = vi.hoisted(() => vi.fn());
vi.mock('../src/services/clientApi', () => ({
requestClientApi: (...args: unknown[]) => requestClientApiMock(...args),
}));
import {
resolvePlatformAssetUploadUrl,
uploadPlatformMediaAsset,
} from '../src/services/assetDirectUpload';
const TICKET = {
upload: {
bucket: 'genarrative-assets',
host: 'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/',
objectKey:
'generated-character-drafts/game-distribution/cover/42/cover.png',
legacyPublicPath: '/generated-character-drafts/game-distribution/cover/42',
formFields: {
key: 'generated-character-drafts/game-distribution/cover/42/cover.png',
policy: 'policy-value',
OSSAccessKeyId: 'ak-value',
signature: 'signature-value',
success_action_status: '204',
'x-oss-meta-asset_kind': 'game_distribution_cover',
},
},
};
const CONFIRMED = {
assetObject: {
assetObjectId: 'asset_cover_1',
objectKey: TICKET.upload.objectKey,
assetKind: 'game_distribution_cover',
},
};
function buildCoverFile() {
return new File(['cover-bytes'], 'cover.png', { type: 'image/png' });
}
beforeEach(() => {
requestClientApiMock.mockReset();
requestClientApiMock
.mockResolvedValueOnce(TICKET)
.mockResolvedValueOnce(CONFIRMED);
});
test('封面按凭证、直传、confirm 三步上传并返回素材标识', async () => {
const uploadFetch = vi.fn(async () => new Response(null, { status: 204 }));
const file = buildCoverFile();
const uploaded = await uploadPlatformMediaAsset({
file,
assetKind: 'game_distribution_cover',
pathSegments: ['game-distribution', 'cover', '42'],
entityId: 'game-distribution-cover',
metadata: { game_distribution_media: 'cover' },
fetchImpl: uploadFetch,
});
expect(uploaded).toEqual({
assetObjectId: 'asset_cover_1',
objectKey: TICKET.upload.objectKey,
});
const ticketCall = requestClientApiMock.mock.calls[0];
expect(ticketCall?.[0]).toBe('/api/assets/direct-upload-tickets');
const ticketBody = JSON.parse(String((ticketCall?.[1] as RequestInit).body));
expect(ticketBody).toEqual(
expect.objectContaining({
legacyPrefix: 'generated-character-drafts',
pathSegments: ['game-distribution', 'cover', '42'],
fileName: 'cover.png',
contentType: 'image/png',
access: 'private',
maxSizeBytes: file.size,
metadata: {
asset_kind: 'game_distribution_cover',
game_distribution_media: 'cover',
},
}),
);
const uploadCall = uploadFetch.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(uploadCall[0]).toBe(TICKET.upload.host);
expect(uploadCall[1].method).toBe('POST');
const formData = uploadCall[1].body as FormData;
expect(formData.get('policy')).toBe('policy-value');
expect(formData.get('signature')).toBe('signature-value');
expect(formData.get('success_action_status')).toBe('204');
const uploadedFile = formData.get('file');
expect(uploadedFile).toBeInstanceOf(File);
expect((uploadedFile as File).name).toBe('cover.png');
const confirmCall = requestClientApiMock.mock.calls[1];
expect(confirmCall?.[0]).toBe('/api/assets/objects/confirm');
expect(JSON.parse(String((confirmCall?.[1] as RequestInit).body))).toEqual({
bucket: 'genarrative-assets',
objectKey: TICKET.upload.objectKey,
contentType: 'image/png',
contentLength: file.size,
assetKind: 'game_distribution_cover',
accessPolicy: 'private',
entityId: 'game-distribution-cover',
});
});
test('直传地址不属于平台素材存储时失败关闭,且不发送文件', async () => {
requestClientApiMock.mockReset();
requestClientApiMock
.mockResolvedValueOnce({
upload: { ...TICKET.upload, host: 'https://evil.example.com/upload' },
})
.mockResolvedValueOnce(CONFIRMED);
const uploadFetch = vi.fn();
await expect(
uploadPlatformMediaAsset({
file: buildCoverFile(),
assetKind: 'game_distribution_cover',
pathSegments: ['game-distribution', 'cover', '42'],
entityId: 'game-distribution-cover',
fetchImpl: uploadFetch as never,
}),
).rejects.toThrow('素材上传地址不属于平台素材存储,已终止上传');
expect(uploadFetch).not.toHaveBeenCalled();
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
});
test('对象存储拒绝上传时给出可重试文案', async () => {
const uploadFetch = vi.fn(
async () => new Response('denied', { status: 403 }),
);
await expect(
uploadPlatformMediaAsset({
file: buildCoverFile(),
assetKind: 'game_distribution_screenshot',
pathSegments: ['game-distribution', 'screenshot', '42'],
entityId: 'game-distribution-screenshot',
fetchImpl: uploadFetch,
}),
).rejects.toThrow('上传素材到对象存储失败(HTTP 403),请重试');
// 直传失败时不得再登记素材,避免留下没有实体的素材记录。
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
});
test('直传网络/作用域错误转成可操作文案', async () => {
const uploadFetch = vi.fn(async () => {
throw new Error('url not allowed on the configured scope');
});
await expect(
uploadPlatformMediaAsset({
file: buildCoverFile(),
assetKind: 'game_distribution_cover',
pathSegments: ['game-distribution', 'cover', '42'],
entityId: 'game-distribution-cover',
fetchImpl: uploadFetch,
}),
).rejects.toThrow(
'上传素材失败:无法访问素材存储,请检查网络后重试(url not allowed on the configured scope',
);
// 直传抛错时不得继续登记素材。
expect(requestClientApiMock).toHaveBeenCalledTimes(1);
});
test('本地回环与阿里云 OSS 之外的主机一律拒绝', () => {
expect(resolvePlatformAssetUploadUrl('http://127.0.0.1:9000/bucket')).toBe(
'http://127.0.0.1:9000/bucket',
);
expect(
resolvePlatformAssetUploadUrl(
'https://genarrative-assets.oss-cn-beijing.aliyuncs.com/',
),
).toBe('https://genarrative-assets.oss-cn-beijing.aliyuncs.com/');
expect(() =>
resolvePlatformAssetUploadUrl('http://oss.example.com/'),
).toThrow('素材上传地址不属于平台素材存储,已终止上传');
expect(() => resolvePlatformAssetUploadUrl(' ')).toThrow(
'素材上传地址为空,请稍后重试',
);
});

Some files were not shown because too many files have changed in this diff Show More