Merge remote-tracking branch 'origin/master' into fix/test-concurrent-problem
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m44s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m48s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m15s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / Repository checks (pull_request) Failing after 22s
Project CI / Frontend tests (pull_request) Successful in 2m31s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m24s
Project CI / Native shell tests (pull_request) Successful in 6m53s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m44s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m48s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m15s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / Repository checks (pull_request) Failing after 22s
Project CI / Frontend tests (pull_request) Successful in 2m31s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m24s
Project CI / Native shell tests (pull_request) Successful in 6m53s
# Conflicts: # docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
@@ -5,12 +5,16 @@ import {
|
||||
executeAdminRechargeRefund,
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminGameReview,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminAgcTrackingEvents,
|
||||
listAdminGameDistributionGames,
|
||||
listAdminGameDistributionReviews,
|
||||
listAdminGameReviewGames,
|
||||
listAdminGameReviews,
|
||||
listAdminRechargeOrders,
|
||||
moderateAdminGameReview,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
restoreAdminGameDistributionGame,
|
||||
@@ -27,6 +31,52 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('用户评价查询编码筛选和目标,管理请求携带原创建时间及幂等key', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: {} }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await listAdminGameReviews('token', {
|
||||
gameId: 'game+1',
|
||||
userId: 'user/1',
|
||||
keyword: '中文 广告',
|
||||
status: 'hidden',
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
});
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-reviews?gameId=game%2B1&userId=user%2F1&keyword=%E4%B8%AD%E6%96%87+%E5%B9%BF%E5%91%8A&status=hidden&page=2&pageSize=20',
|
||||
);
|
||||
await listAdminGameReviewGames('token', { query: ' 中文 ', page: 1 });
|
||||
expect(fetchMock.mock.calls[1]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-review-games?query=%E4%B8%AD%E6%96%87&page=1',
|
||||
);
|
||||
await getAdminGameReview('token', '6:game/1user');
|
||||
expect(fetchMock.mock.calls[2]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-reviews/6%3Agame%2F1user',
|
||||
);
|
||||
const body = {
|
||||
action: 'delete' as const,
|
||||
expectedCreatedAt: '2026-10-01T00:00:00Z',
|
||||
reason: '广告',
|
||||
};
|
||||
await moderateAdminGameReview('token', '6:game/1user', 'same-attempt', body);
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
'/admin/api/game-distribution/user-reviews/6%3Agame%2F1user/moderation',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer token',
|
||||
'Idempotency-Key': 'same-attempt',
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
|
||||
const payload = { entries: [], nextCursor: null };
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
|
||||
@@ -38,6 +38,13 @@ import type {
|
||||
AdminGameDistributionReviewListResponse,
|
||||
AdminGameDistributionReviewRequest,
|
||||
AdminGameDistributionReviewResponse,
|
||||
AdminGameReviewDetailResponse,
|
||||
AdminGameReviewGamesQuery,
|
||||
AdminGameReviewGamesResponse,
|
||||
AdminGameReviewModerationRequest,
|
||||
AdminGameReviewModerationResponse,
|
||||
AdminGameReviewsQuery,
|
||||
AdminGameReviewsResponse,
|
||||
AdminImportAgcTemplatesResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
@@ -1243,6 +1250,61 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
||||
);
|
||||
}
|
||||
|
||||
function gameReviewQuery(
|
||||
query: AdminGameReviewGamesQuery | AdminGameReviewsQuery,
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && String(value).trim())
|
||||
params.set(key, String(value).trim());
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function listAdminGameReviewGames(
|
||||
token: string,
|
||||
query: AdminGameReviewGamesQuery = {},
|
||||
) {
|
||||
return request<AdminGameReviewGamesResponse>(
|
||||
`/admin/api/game-distribution/user-review-games?${gameReviewQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminGameReviews(
|
||||
token: string,
|
||||
query: AdminGameReviewsQuery = {},
|
||||
) {
|
||||
return request<AdminGameReviewsResponse>(
|
||||
`/admin/api/game-distribution/user-reviews?${gameReviewQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminGameReview(token: string, reviewId: string) {
|
||||
return request<AdminGameReviewDetailResponse>(
|
||||
`/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function moderateAdminGameReview(
|
||||
token: string,
|
||||
reviewId: string,
|
||||
idempotencyKey: string,
|
||||
payload: AdminGameReviewModerationRequest,
|
||||
) {
|
||||
return request<AdminGameReviewModerationResponse>(
|
||||
`/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}/moderation`,
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminGameDistributionGames(
|
||||
token: string,
|
||||
options: { limit?: number } = {},
|
||||
|
||||
@@ -1186,6 +1186,90 @@ export interface AdminGameDistributionRestoreResponse {
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
/** 游戏用户评价管理,与发行版本审核分开。 */
|
||||
export interface AdminGameReviewGamesQuery {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGame {
|
||||
gameId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGamesResponse {
|
||||
games: AdminGameReviewGame[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewsQuery {
|
||||
gameId?: string;
|
||||
userId?: string;
|
||||
keyword?: string;
|
||||
status?: 'all' | 'visible' | 'hidden';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGameInfo {
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReview {
|
||||
id: string;
|
||||
gameId: string;
|
||||
game: AdminGameReviewGameInfo;
|
||||
author: { id: string; name: string; avatarUrl?: string | null };
|
||||
score: number;
|
||||
comment: string;
|
||||
isHidden: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewOperation {
|
||||
id: string;
|
||||
reviewId: string;
|
||||
gameId: string;
|
||||
userId: string;
|
||||
reviewCreatedAt: string;
|
||||
action: string;
|
||||
adminUserId: string;
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewsResponse {
|
||||
reviews: AdminGameReview[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewDetailResponse {
|
||||
review: AdminGameReview;
|
||||
operations: AdminGameReviewOperation[];
|
||||
}
|
||||
|
||||
export interface AdminGameReviewModerationRequest {
|
||||
action: 'hide' | 'restore' | 'delete';
|
||||
expectedCreatedAt: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewModerationResponse {
|
||||
review: AdminGameReview | null;
|
||||
operation: AdminGameReviewOperation;
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplatePayload {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseRevie
|
||||
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
||||
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
|
||||
import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage';
|
||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
@@ -141,7 +142,7 @@ export function AdminApp() {
|
||||
|
||||
setRouteId(nextRouteId);
|
||||
const nextHash = routeHash(nextRouteId);
|
||||
if (window.location.hash !== nextHash) {
|
||||
if ((window.location.hash.split('?')[0] ?? '').toLowerCase() !== nextHash) {
|
||||
window.history.replaceState(null, '', nextHash);
|
||||
}
|
||||
}, [accessibleRoutes, admin, routeId, status]);
|
||||
@@ -325,6 +326,15 @@ export function AdminApp() {
|
||||
) : null}
|
||||
{activeRouteId === 'game-management' ? (
|
||||
<AdminGameManagementPage
|
||||
token={token}
|
||||
canManageReviews={accessibleRoutes.some(
|
||||
(route) => route.id === 'game-reviews',
|
||||
)}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'game-reviews' ? (
|
||||
<AdminGameReviewsPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
|
||||
@@ -55,6 +55,7 @@ const routeIcons = {
|
||||
'editor-showcase': Star,
|
||||
'game-distribution': Gamepad2,
|
||||
'game-management': Swords,
|
||||
'game-reviews': Star,
|
||||
'editor-assets': Images,
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
|
||||
@@ -8,6 +8,22 @@ import {
|
||||
routeHash,
|
||||
} from './adminRoutes';
|
||||
|
||||
test('游戏评价路由独立授权并支持游戏参数', () => {
|
||||
expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews');
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['game-reviews'],
|
||||
}).map((route) => route.id),
|
||||
).toEqual(['game-reviews']);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['game-management'],
|
||||
}).some((route) => route.id === 'game-reviews'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('客户端埋点路由遵守成员页签权限', () => {
|
||||
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
|
||||
expect(
|
||||
|
||||
@@ -18,6 +18,7 @@ export type AdminRouteId =
|
||||
| 'editor-showcase'
|
||||
| 'game-distribution'
|
||||
| 'game-management'
|
||||
| 'game-reviews'
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
@@ -62,6 +63,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
|
||||
{ id: 'game-reviews', label: '游戏评价', hash: '#game-reviews' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
|
||||
@@ -117,6 +117,20 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('有评价权限时游戏管理提供带gameId的评价入口', async () => {
|
||||
window.history.replaceState(null, '', '#game-management');
|
||||
render(
|
||||
<AdminGameManagementPage
|
||||
token="token"
|
||||
canManageReviews
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看评价' }));
|
||||
expect(window.location.hash).toBe('#game-reviews?gameId=game_1');
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -28,6 +28,7 @@ import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminGameManagementPageProps {
|
||||
token: string;
|
||||
canManageReviews?: boolean;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
@@ -86,6 +87,7 @@ function createGameActionIdempotencyKey(
|
||||
|
||||
export function AdminGameManagementPage({
|
||||
token,
|
||||
canManageReviews = false,
|
||||
onUnauthorized,
|
||||
}: AdminGameManagementPageProps) {
|
||||
const [games, setGames] = useState<AdminGameDistributionGameEntry[]>([]);
|
||||
@@ -327,6 +329,17 @@ export function AdminGameManagementPage({
|
||||
>
|
||||
版本历史
|
||||
</AdminButton>
|
||||
{canManageReviews ? (
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.hash = `#game-reviews?gameId=${encodeURIComponent(entry.gameId)}`;
|
||||
}}
|
||||
>
|
||||
查看评价
|
||||
</AdminButton>
|
||||
) : null}
|
||||
</AdminActionRow>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminGameReview,
|
||||
listAdminGameReviewGames,
|
||||
listAdminGameReviews,
|
||||
moderateAdminGameReview,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameReview,
|
||||
AdminGameReviewDetailResponse,
|
||||
AdminGameReviewsResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminGameReviewsPage } from './AdminGameReviewsPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
listAdminGameReviews: vi.fn(),
|
||||
listAdminGameReviewGames: vi.fn(),
|
||||
getAdminGameReview: vi.fn(),
|
||||
moderateAdminGameReview: vi.fn(),
|
||||
isAdminApiError: (error: unknown) =>
|
||||
typeof error === 'object' && error !== null && 'status' in error,
|
||||
formatAdminApiError: (error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
}));
|
||||
|
||||
const review: AdminGameReview = {
|
||||
id: '6:game_1user_1',
|
||||
gameId: 'game_1',
|
||||
game: { title: '测试游戏', status: 'published' },
|
||||
author: { id: 'user_1', name: '用户甲' },
|
||||
score: 8,
|
||||
comment: '<b>中文😀</b>\n完整正文',
|
||||
isHidden: false,
|
||||
createdAt: '2026-10-01T01:00:00Z',
|
||||
updatedAt: '2026-10-01T01:00:00Z',
|
||||
};
|
||||
function list(
|
||||
overrides: Partial<AdminGameReviewsResponse> = {},
|
||||
): AdminGameReviewsResponse {
|
||||
return {
|
||||
reviews: [review],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const unauthorized = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.history.replaceState(null, '', '#game-reviews');
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(list());
|
||||
vi.mocked(listAdminGameReviewGames).mockResolvedValue({
|
||||
games: [{ gameId: 'game_2', title: '第二个游戏', status: 'suspended' }],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
vi.mocked(getAdminGameReview).mockResolvedValue({ review, operations: [] });
|
||||
vi.mocked(moderateAdminGameReview).mockResolvedValue({
|
||||
review: { ...review, isHidden: true },
|
||||
operation: {
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin',
|
||||
reason: '广告',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
function show() {
|
||||
return render(
|
||||
<AdminGameReviewsPage token="token" onUnauthorized={unauthorized} />,
|
||||
);
|
||||
}
|
||||
async function row() {
|
||||
return (await screen.findByText('用户甲')).closest('tr')!;
|
||||
}
|
||||
|
||||
test('入口游戏参数和hash变化生效,组合筛选分页在服务端查询,重置回第一页', async () => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_1');
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ total: 21, totalPages: 2 }),
|
||||
);
|
||||
show();
|
||||
await row();
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_1', page: 1 }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('评价用户 ID'), {
|
||||
target: { value: 'user_1' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('评论关键词'), {
|
||||
target: { value: '中文' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('评价状态'), {
|
||||
target: { value: 'hidden' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', {
|
||||
gameId: 'game_1',
|
||||
userId: 'user_1',
|
||||
keyword: '中文',
|
||||
status: 'hidden',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
await row();
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('navigation', { name: '评价分页' })).getByRole(
|
||||
'button',
|
||||
{ name: '下一页' },
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 2, keyword: '中文' }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', {
|
||||
gameId: '',
|
||||
userId: '',
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_2');
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2', page: 1 }),
|
||||
),
|
||||
);
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
});
|
||||
|
||||
test('名称查找包含下架游戏,选择后按游戏ID筛选', async () => {
|
||||
show();
|
||||
await row();
|
||||
fireEvent.click(screen.getByRole('button', { name: '按名称查找游戏' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '查找游戏' });
|
||||
await within(dialog).findByText('第二个游戏');
|
||||
fireEvent.change(within(dialog).getByLabelText('游戏名称或 ID'), {
|
||||
target: { value: '第二个' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '搜索' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviewGames).toHaveBeenLastCalledWith('token', {
|
||||
query: '第二个',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
await within(dialog).findByText('第二个游戏');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '选择' }));
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('隐藏原因必填,失败保留草稿,同次重试复用幂等key,取消不写入', async () => {
|
||||
vi.mocked(moderateAdminGameReview).mockRejectedValueOnce(
|
||||
new Error('网络失败'),
|
||||
);
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' }));
|
||||
let dialog = screen.getByRole('dialog', { name: '隐藏评价' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
expect(
|
||||
await within(dialog).findByText('原因必须为 1–4000 个字符'),
|
||||
).toBeTruthy();
|
||||
expect(moderateAdminGameReview).not.toHaveBeenCalled();
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: ' 广告😀 ' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
await within(dialog).findByText('网络失败');
|
||||
expect(
|
||||
(within(dialog).getByLabelText('操作原因(必填)') as HTMLTextAreaElement)
|
||||
.value,
|
||||
).toBe(' 广告😀 ');
|
||||
const first = vi.mocked(moderateAdminGameReview).mock.calls[0]!;
|
||||
expect(first[3]).toEqual({
|
||||
action: 'hide',
|
||||
expectedCreatedAt: review.createdAt,
|
||||
reason: '广告😀',
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
await screen.findByText('评价已隐藏');
|
||||
expect(vi.mocked(moderateAdminGameReview).mock.calls[1]![2]).toBe(first[2]);
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
dialog = screen.getByRole('dialog', { name: '删除评价' });
|
||||
expect(
|
||||
within(dialog).getByText('删除后不可恢复,用户可以重新评价'),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
||||
expect(moderateAdminGameReview).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('4001码点原因不能提交,恢复复用确认且不要求原因', async () => {
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ reviews: [{ ...review, isHidden: true }] }),
|
||||
);
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
const deletion = screen.getByRole('dialog', { name: '删除评价' });
|
||||
fireEvent.change(within(deletion).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '😀'.repeat(4001) },
|
||||
});
|
||||
expect(within(deletion).getByText('4001/4000')).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
within(deletion).getByRole('button', {
|
||||
name: '确认删除',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
fireEvent.click(within(deletion).getByRole('button', { name: '取消' }));
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '恢复' }));
|
||||
const confirmation = screen.getByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirmation).getByRole('button', { name: '确认' }));
|
||||
await waitFor(() =>
|
||||
expect(moderateAdminGameReview).toHaveBeenCalledWith(
|
||||
'token',
|
||||
review.id,
|
||||
expect.any(String),
|
||||
{ action: 'restore', expectedCreatedAt: review.createdAt },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('删除使当前页越界时回到最后一页并保留条件', async () => {
|
||||
vi.mocked(listAdminGameReviews).mockImplementation(async (_token, query) =>
|
||||
list({ page: query?.page ?? 1, total: 21, totalPages: 2 }),
|
||||
);
|
||||
show();
|
||||
await row();
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('navigation', { name: '评价分页' })).getByRole(
|
||||
'button',
|
||||
{ name: '下一页' },
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 2 }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '删除评价' });
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '重复内容' },
|
||||
});
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ total: 20, totalPages: 1 }),
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 1 }),
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('评价已删除')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('详情完整纯文本只读并展示后台操作原因', async () => {
|
||||
vi.mocked(getAdminGameReview).mockResolvedValue({
|
||||
review,
|
||||
operations: [
|
||||
{
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin_1',
|
||||
reason: '广告内容',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
show();
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
const dialog = screen.getByRole('dialog', { name: '评价详情' });
|
||||
expect(await within(dialog).findByText('广告内容')).toBeTruthy();
|
||||
expect(dialog.textContent).toContain(review.comment);
|
||||
expect(dialog.querySelector('b')).toBeNull();
|
||||
expect(within(dialog).getByText('admin_1')).toBeTruthy();
|
||||
expect(within(dialog).queryByRole('textbox')).toBeNull();
|
||||
});
|
||||
|
||||
test('详情关闭后旧响应不会覆盖新目标', async () => {
|
||||
const second = {
|
||||
...review,
|
||||
id: 'review_2',
|
||||
author: { id: 'user_2', name: '用户乙' },
|
||||
comment: '第二条正文',
|
||||
};
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ reviews: [review, second], total: 2 }),
|
||||
);
|
||||
let resolveOld!: (value: { review: AdminGameReview; operations: [] }) => void;
|
||||
vi.mocked(getAdminGameReview)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AdminGameReviewDetailResponse>((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({ review: second, operations: [] });
|
||||
show();
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog')).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByText('用户乙').closest('tr')!).getByRole('button', {
|
||||
name: '查看详情',
|
||||
}),
|
||||
);
|
||||
await within(screen.getByRole('dialog')).findByText('第二条正文');
|
||||
await act(async () => {
|
||||
resolveOld({ review, operations: [] });
|
||||
});
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText('第二条正文'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('dialog').textContent).not.toContain(review.comment);
|
||||
});
|
||||
|
||||
test('管理提交中切换游戏,成功后刷新当前游戏而不恢复旧筛选', async () => {
|
||||
let finish!: (
|
||||
value: Awaited<ReturnType<typeof moderateAdminGameReview>>,
|
||||
) => void;
|
||||
vi.mocked(moderateAdminGameReview).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Awaited<ReturnType<typeof moderateAdminGameReview>>>(
|
||||
(resolve) => {
|
||||
finish = resolve;
|
||||
},
|
||||
),
|
||||
);
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_1');
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '隐藏评价' });
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '广告' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
act(() => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_2');
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
await within(screen.getByRole('dialog', { name: '评价详情' })).findByText(
|
||||
'评价 ID',
|
||||
);
|
||||
await act(async () => {
|
||||
finish({
|
||||
review: null,
|
||||
operation: {
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin',
|
||||
reason: '广告',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
});
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
);
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
expect(screen.getByRole('dialog', { name: '评价详情' })).toBeTruthy();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -766,6 +766,33 @@ button:disabled {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-review-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-review-comment {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-table .admin-review-comment {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-review-comment-summary {
|
||||
min-width: 160px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.admin-review-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-table-query-grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
|
||||
@@ -212,7 +212,7 @@ async fn run_sidecar_turn(
|
||||
.take()
|
||||
.ok_or_else(|| "Claude Agent SDK sidecar stderr 未建立".to_string())?;
|
||||
let stderr_task = tokio::spawn(read_capped(stderr, CLAUDE_CODE_OUTPUT_MAX_BYTES));
|
||||
let result = tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
|
||||
let result = match tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
let mut events = Vec::new();
|
||||
let mut terminal = None;
|
||||
@@ -257,7 +257,21 @@ async fn run_sidecar_turn(
|
||||
Ok(SidecarTurnResult { events, result })
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Agent SDK sidecar 回合超时".to_string())??;
|
||||
{
|
||||
Ok(result) => result?,
|
||||
Err(_) => {
|
||||
let detail = stderr_task
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.map(|value| value.trim().chars().take(512).collect::<String>())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!(":{value}"))
|
||||
.unwrap_or_default();
|
||||
return Err(format!("Claude Agent SDK sidecar 回合超时{detail}"));
|
||||
}
|
||||
};
|
||||
let _ = stderr_task.await;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -318,6 +332,14 @@ pub(crate) fn game_creator_claude_code_cli_route_error(
|
||||
if model.is_empty() || model.chars().count() > CLAUDE_CODE_MODEL_MAX_CHARS {
|
||||
return Some("cc 模式需要有效的 Claude Code 模型标识".to_string());
|
||||
}
|
||||
if llm.api_key.trim().is_empty()
|
||||
&& !llm.custom_enabled
|
||||
&& std::env::var_os("ANTHROPIC_API_KEY").is_none()
|
||||
&& std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none()
|
||||
&& crate::platform_session::current_platform_session().is_none()
|
||||
{
|
||||
return Some("cc 模式需要先登录陶泥儿账号".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -386,12 +408,44 @@ fn configure_claude_code_environment(
|
||||
{
|
||||
command.env("ANTHROPIC_AUTH_TOKEN", llm.api_key.trim());
|
||||
}
|
||||
// Official AGC model entries use the account session rather than a
|
||||
// user-supplied API key. The Claude SDK only reads Anthropic auth
|
||||
// from its child-process environment, so bridge the already-held
|
||||
// platform token without persisting or exposing it in argv/logs.
|
||||
if llm.api_key.trim().is_empty()
|
||||
&& !llm.custom_enabled
|
||||
&& std::env::var_os("ANTHROPIC_API_KEY").is_none()
|
||||
&& std::env::var_os("ANTHROPIC_AUTH_TOKEN").is_none()
|
||||
{
|
||||
if let Some(session) = crate::platform_session::current_platform_session() {
|
||||
command.env("ANTHROPIC_AUTH_TOKEN", session.access_token);
|
||||
}
|
||||
}
|
||||
if !llm.model.trim().is_empty() {
|
||||
command.env("ANTHROPIC_MODEL", llm.model.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_result_error_detail(value: &serde_json::Value) -> Option<String> {
|
||||
let candidates = [
|
||||
value.get("result").and_then(serde_json::Value::as_str),
|
||||
value.get("error").and_then(serde_json::Value::as_str),
|
||||
value.get("message").and_then(serde_json::Value::as_str),
|
||||
value
|
||||
.get("errors")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|errors| errors.first())
|
||||
.and_then(serde_json::Value::as_str),
|
||||
];
|
||||
candidates
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(str::trim)
|
||||
.find(|detail| !detail.is_empty())
|
||||
.map(|detail| detail.chars().take(512).collect())
|
||||
}
|
||||
|
||||
fn configure_claude_code_process(command: &mut tokio::process::Command) {
|
||||
super::codex_cli::configure_game_creator_codex_cli_process(command);
|
||||
}
|
||||
@@ -496,9 +550,12 @@ fn parse_claude_code_result(
|
||||
platform_llm::LlmError::Deserialize("Claude Code JSON 输出无效".to_string())
|
||||
})?;
|
||||
if value.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
|
||||
return Err(platform_llm::LlmError::Transport(
|
||||
"Claude Code 返回失败终态".to_string(),
|
||||
));
|
||||
let detail = claude_result_error_detail(&value)
|
||||
.map(|detail| format!(":{detail}"))
|
||||
.unwrap_or_default();
|
||||
return Err(platform_llm::LlmError::Transport(format!(
|
||||
"Claude Code 返回失败终态{detail}"
|
||||
)));
|
||||
}
|
||||
let response_id = value
|
||||
.get("session_id")
|
||||
@@ -749,7 +806,10 @@ fn parse_direct_stream_result(
|
||||
}
|
||||
} else if event_type == Some("result") {
|
||||
if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
|
||||
return Err("Claude Code 返回失败终态".to_string());
|
||||
let detail = claude_result_error_detail(&event)
|
||||
.map(|detail| format!(":{detail}"))
|
||||
.unwrap_or_default();
|
||||
return Err(format!("Claude Code 返回失败终态{detail}"));
|
||||
}
|
||||
result = event
|
||||
.get("result")
|
||||
@@ -804,6 +864,16 @@ mod tests {
|
||||
assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_claude_terminal_error_detail() {
|
||||
let error = parse_claude_code_result(
|
||||
br#"{"is_error":true,"result":"Authentication failed"}"#,
|
||||
&request(false),
|
||||
)
|
||||
.expect_err("failed terminal must be rejected");
|
||||
assert!(error.to_string().contains("Authentication failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_direct_stream_result_and_final_text() {
|
||||
let mut observed = Vec::new();
|
||||
@@ -822,4 +892,16 @@ mod tests {
|
||||
[DirectCodexTurnObservation::AgentMessageSegment(_)]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_direct_stream_terminal_error_detail() {
|
||||
let error = parse_direct_stream_result(
|
||||
br#"{"type":"result","is_error":true,"result":"Authentication failed"}
|
||||
"#,
|
||||
"turn-1",
|
||||
None,
|
||||
)
|
||||
.expect_err("failed terminal must be rejected");
|
||||
assert!(error.contains("Authentication failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,66 @@ import type { HomeCreationType } from '../../view/home/useHomeDraftStore';
|
||||
export const HOME_WEB_PREFLIGHT_FAILURE =
|
||||
'Web 游戏环境预检未通过,请检查 Node/npm 或浏览器后重试;尚未开始生成。';
|
||||
|
||||
export const HOME_WEB_PREFLIGHT_IPC_FAILURE =
|
||||
'Web 游戏环境预检调用失败,客户端连接异常;请重启客户端后重试,尚未开始生成。';
|
||||
|
||||
type HomeWebPreflightReport = {
|
||||
status?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
function knownPreflightCode(value: unknown): string | undefined {
|
||||
const text = value instanceof Error ? value.message : value;
|
||||
if (typeof text !== 'string') return undefined;
|
||||
const match = text.match(
|
||||
/(?:预检未通过:|预检未通过(|code[=:])([a-z0-9-]+)/i,
|
||||
);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export function homeWebPreflightErrorMessage(value: unknown): string {
|
||||
if (value instanceof Error && value.message === HOME_WEB_PREFLIGHT_FAILURE) {
|
||||
return HOME_WEB_PREFLIGHT_FAILURE;
|
||||
}
|
||||
const code = knownPreflightCode(value);
|
||||
if (code) return `Web 游戏环境预检未通过(${code});尚未开始生成。`;
|
||||
return HOME_WEB_PREFLIGHT_IPC_FAILURE;
|
||||
}
|
||||
|
||||
function isHostPreflightError(value: unknown): boolean {
|
||||
if (!(value instanceof Error)) return false;
|
||||
return (
|
||||
value.message === HOME_WEB_PREFLIGHT_FAILURE ||
|
||||
Boolean(knownPreflightCode(value.message))
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureHomeWebCreationEnvironment(
|
||||
invoke: TauriInvoke,
|
||||
creationType: HomeCreationType,
|
||||
startMode: ProjectStartMode,
|
||||
): Promise<void> {
|
||||
if (creationType !== 'game' || startMode !== 'direct-build') return;
|
||||
try {
|
||||
const report = await invoke<{ status: string }>(
|
||||
'preflight_web_game_creation',
|
||||
);
|
||||
if (report?.status !== 'ready') throw new Error(HOME_WEB_PREFLIGHT_FAILURE);
|
||||
} catch {
|
||||
// 不把子进程、Provider 或宿主路径的原始错误注入首页状态栏。
|
||||
throw new Error(HOME_WEB_PREFLIGHT_FAILURE);
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const report = await invoke<HomeWebPreflightReport>(
|
||||
'preflight_web_game_creation',
|
||||
);
|
||||
if (report?.status === 'ready') return;
|
||||
throw new Error(
|
||||
report?.code
|
||||
? `Web 游戏环境预检未通过:${report.code}`
|
||||
: HOME_WEB_PREFLIGHT_FAILURE,
|
||||
);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const code = knownPreflightCode(error);
|
||||
if (isHostPreflightError(error) || code || attempt === 1) {
|
||||
throw new Error(homeWebPreflightErrorMessage(error));
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
throw new Error(homeWebPreflightErrorMessage(lastError));
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ import { importDesignFiles } from '../project-workspace/importDesignFiles';
|
||||
import { clearAclElevationDenials } from './aclElevation';
|
||||
import {
|
||||
ensureHomeWebCreationEnvironment,
|
||||
HOME_WEB_PREFLIGHT_FAILURE,
|
||||
homeWebPreflightErrorMessage,
|
||||
} from './homeWebPreflight';
|
||||
import type { ShowTransientNotice } from './model';
|
||||
import { resolveSessionPreviewOnProjectOpen } from './sessionPreview';
|
||||
@@ -458,9 +458,10 @@ export function useHomeProjectCreation({
|
||||
}
|
||||
try {
|
||||
await ensureHomeWebCreationEnvironment(invoke, creationType, startMode);
|
||||
} catch {
|
||||
setStatus(HOME_WEB_PREFLIGHT_FAILURE);
|
||||
return HOME_WEB_PREFLIGHT_FAILURE;
|
||||
} catch (error) {
|
||||
const message = homeWebPreflightErrorMessage(error);
|
||||
setStatus(message);
|
||||
return message;
|
||||
}
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'init_local_game_project',
|
||||
@@ -894,7 +895,8 @@ export function useHomeProjectCreation({
|
||||
draft.creationType,
|
||||
startMode,
|
||||
);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const message = homeWebPreflightErrorMessage(error);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(
|
||||
operation,
|
||||
@@ -902,7 +904,7 @@ export function useHomeProjectCreation({
|
||||
operationScope(),
|
||||
),
|
||||
);
|
||||
return HOME_WEB_PREFLIGHT_FAILURE;
|
||||
return message;
|
||||
}
|
||||
const suggestedName = options.suggestName
|
||||
? await suggestAutomaticProjectName(invoke, draft)
|
||||
|
||||
@@ -3,7 +3,10 @@ import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { HOME_WEB_PREFLIGHT_FAILURE } from '../src/features/app-shell/homeWebPreflight';
|
||||
import {
|
||||
HOME_WEB_PREFLIGHT_FAILURE,
|
||||
HOME_WEB_PREFLIGHT_IPC_FAILURE,
|
||||
} from '../src/features/app-shell/homeWebPreflight';
|
||||
import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -67,26 +70,58 @@ it('awaits the real home preflight before naming or creating a game', async () =
|
||||
expect(result.current.currentProjectContext?.creationType).toBe('game');
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'blocks naming, creation, and first generation when preflight fails (throws=%s)',
|
||||
async (throws) => {
|
||||
const { result, calls } = mount(async () => {
|
||||
if (throws) throw new Error('private host path');
|
||||
return { status: 'blocked' };
|
||||
});
|
||||
let status = '';
|
||||
await act(async () => {
|
||||
status = await result.current.createHomeDraftAutomatically(
|
||||
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
|
||||
'direct-build',
|
||||
);
|
||||
});
|
||||
expect(status).toBe(HOME_WEB_PREFLIGHT_FAILURE);
|
||||
expect(calls).toEqual(['preflight_web_game_creation']);
|
||||
expect(result.current.currentProjectContext).toBeNull();
|
||||
expect(status).not.toContain('private');
|
||||
},
|
||||
);
|
||||
it('blocks naming, creation, and first generation with the host error code', async () => {
|
||||
const { result, calls } = mount(async () => {
|
||||
throw new Error(
|
||||
'Web 游戏环境预检未通过:web-preflight-browser-failed;尚未启动生成',
|
||||
);
|
||||
});
|
||||
let status = '';
|
||||
await act(async () => {
|
||||
status = await result.current.createHomeDraftAutomatically(
|
||||
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
|
||||
'direct-build',
|
||||
);
|
||||
});
|
||||
expect(status).toBe(
|
||||
'Web 游戏环境预检未通过(web-preflight-browser-failed);尚未开始生成。',
|
||||
);
|
||||
expect(calls).toEqual(['preflight_web_game_creation']);
|
||||
expect(result.current.currentProjectContext).toBeNull();
|
||||
});
|
||||
|
||||
it('distinguishes an IPC failure from a host preflight failure', async () => {
|
||||
const { result, calls } = mount(async () => {
|
||||
throw new Error('Failed to fetch');
|
||||
});
|
||||
let status = '';
|
||||
await act(async () => {
|
||||
status = await result.current.createHomeDraftAutomatically(
|
||||
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
|
||||
'direct-build',
|
||||
);
|
||||
});
|
||||
expect(status).toBe(HOME_WEB_PREFLIGHT_IPC_FAILURE);
|
||||
expect(calls).toEqual([
|
||||
'preflight_web_game_creation',
|
||||
'preflight_web_game_creation',
|
||||
]);
|
||||
expect(result.current.currentProjectContext).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the legacy fallback for a blocked report without a code', async () => {
|
||||
const { result, calls } = mount(async () => ({ status: 'blocked' }));
|
||||
let status = '';
|
||||
await act(async () => {
|
||||
status = await result.current.createHomeDraftAutomatically(
|
||||
{ creationType: 'game', prompt: '做一个小游戏', attachments: [] },
|
||||
'direct-build',
|
||||
);
|
||||
});
|
||||
expect(status).toBe(HOME_WEB_PREFLIGHT_FAILURE);
|
||||
expect(calls).toEqual(['preflight_web_game_creation']);
|
||||
expect(result.current.currentProjectContext).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['game', 'doc'] as const)(
|
||||
'does not require the Web preflight for %s planning',
|
||||
|
||||
@@ -12,6 +12,9 @@ upstream genarrative_api {
|
||||
}
|
||||
|
||||
limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m;
|
||||
# 中文注释:公开作品架 / 自定义世界画廊的读取 QPS 远高于普通创作接口,单独使用高阈值限流 zone;
|
||||
# 连接数上限仍复用 genarrative_api_conn,避免绕过 API 侧的整体连接保护。
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s;
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s;
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s;
|
||||
|
||||
@@ -135,6 +138,11 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 中文注释:以下为生产机 host-only 覆盖(内部工具页 / 官网页 / 公开画廊读取限流)。
|
||||
# 这些路径不属于平台共享发布产物,也不在 Pingora 路由矩阵覆盖范围内,
|
||||
# 因此独立成 snippet:新增平台路由仍必须走矩阵 + Pingora 门禁。
|
||||
include /etc/nginx/snippets/genarrative-host-extras.conf;
|
||||
|
||||
|
||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||
location ~ ^/api(?:/|$) {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# 生产机 host-only 覆盖:内部工具页、主站域名上的官网页入口、公开画廊读取限流。
|
||||
# 中文注释:这些路径依赖生产机本地静态根(/srv/genarrative/tools、/srv/genarrative/corporate-site-current),
|
||||
# 不属于平台共享发布产物,因此不进入 deploy/pingora/nginx-route-parity.matrix.json 的 Pingora 路由矩阵。
|
||||
# 新增平台路由仍必须在主模板内声明并同步矩阵与 Pingora 网关;只有确认与网关无关的生产机专属路径才放这里。
|
||||
# Pingora 接公网 443 前,必须为这里的路径单独确认处理方式,否则这些页面与限流会在切换后丢失。
|
||||
|
||||
# 中文注释:内部工具静态页与主站 Web 根分离,页面产物放在 /srv/genarrative/tools/<页面>/;
|
||||
# 这里只声明精确入口,且不接入维护窗口,避免把内部工具页并入公网维护开关。
|
||||
location = /finance-forecast {
|
||||
return 301 /finance-forecast/;
|
||||
}
|
||||
|
||||
location ^~ /finance-forecast/ {
|
||||
root /srv/genarrative/tools;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /finance-forecast/index.html;
|
||||
}
|
||||
|
||||
location = /medical-science {
|
||||
return 301 /medical-science/;
|
||||
}
|
||||
|
||||
location ^~ /medical-science/ {
|
||||
root /srv/genarrative/tools;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /medical-science/index.html;
|
||||
}
|
||||
|
||||
# 中文注释:公开作品架 / 自定义世界画廊读取接口使用专用高阈值限流(zone 在主模板 http 段声明);
|
||||
# 其余 /api 请求继续走主模板的通用 location,保持默认保护强度。
|
||||
location = /api/runtime/puzzle/gallery {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 320;
|
||||
limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
location = /api/runtime/custom-world-gallery {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 320;
|
||||
limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
location ~ ^/api/runtime/puzzle/gallery/[^/]+$ {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 32;
|
||||
limit_req zone=genarrative_api_rps burst=32 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
location ~ ^/api/runtime/custom-world-gallery/[^/]+/[^/]+$ {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 32;
|
||||
limit_req zone=genarrative_api_rps burst=32 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
# BEGIN CORPORATE SITE HOME ROUTE
|
||||
# 中文注释:主站域名保留清智创游官网首页入口,静态根与官网独立 vhost
|
||||
# (/etc/nginx/conf.d/tsingnova-games.conf)共用同一份官网构建;只有 /home 与 /home/ 生效。
|
||||
# 该入口是 2026-08-11 官网拆分前的历史遗留,是否退役需要与官网侧一起确认;
|
||||
# 退役时必须同时删除本块与线上对应 location,避免两侧再次漂移。
|
||||
location = /home {
|
||||
error_page 503 /maintenance.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
return 301 /home/;
|
||||
}
|
||||
|
||||
location ^~ /home/ {
|
||||
error_page 503 /maintenance.html;
|
||||
root /srv/genarrative/corporate-site-current;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
try_files $uri $uri/ /home/index.html;
|
||||
}
|
||||
# END CORPORATE SITE HOME ROUTE
|
||||
+5
-1
@@ -19,7 +19,11 @@
|
||||
## 当前产品与平台
|
||||
|
||||
- [当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md):现役入口、账号钱包、UI 和后端分层。
|
||||
- [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):只描述现役平台壳与图片画布编辑器链路。
|
||||
- [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):平台壳、图片画布、游戏分发与在线游玩合同;网站游戏评分与评价已实现并通过本地验证,待用户验收,未部署。
|
||||
- [网站游戏评分与评价里程碑](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md):唯一评价、编辑预填、4000 字符、公共分页与平均分/人数的验收边界与本地证据。
|
||||
- [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。
|
||||
- [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。
|
||||
- [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。
|
||||
- [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)
|
||||
- [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。
|
||||
- [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 【实施计划】后台游戏评价管理
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Milestone | [后台游戏评价管理](./【里程碑】后台游戏评价管理-2026-10-01.md) |
|
||||
| Status | implemented(本地验证通过,待用户验收;未部署) |
|
||||
| Owner | Codex |
|
||||
|
||||
## 修改边界
|
||||
|
||||
- 主规范:[后台游戏评价管理合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同);不扩大其四类筛选与单条管理范围。
|
||||
- 领域:`server-rs/crates/module-game-distribution/src/reviews.rs` 的管理动作/原因校验与有效评价统计规则。
|
||||
- 数据:`spacetime-module/src/game_distribution.rs` 追加隐藏字段、用户索引、新私有管理记录及读写事务;同步 migration、表目录与生成绑定。
|
||||
- 后端:`spacetime-client` facade/mapper、`shared-contracts`、`api-server/src/modules/game_distribution.rs` 路由与 handler,`admin.rs` 页面权限映射;不得使用新直接数据库访问路径。
|
||||
- 前端:admin-web 的路由/导航/账号权限/DTO/client/评价管理页及游戏管理跳转;网站评价 DTO、个人提示、摘要/列表读取。复用共享列表、表单、弹窗和后台写确认。
|
||||
- 文档:主规范、后端数据契约、决策记录和索引;不改 AGC、游戏发行审核或外部 OpenAPI。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 技术评审后将方案转为 ready;冻结原因校验、四类筛选、DTO及错误码。
|
||||
2. 追加默认 false 字段与索引、新管理记录表;事务实现隐藏/恢复/删除、创建时间目标核对及操作 key 重放,公共所有统计统一过滤,个人写保留隐藏状态。
|
||||
3. 同步迁移/绑定和 facade;新增后台列表、游戏选择、详情与管理 POST,接入独立 game-reviews 权限;普通用户投影不包含原因。
|
||||
4. 接入后台页、游戏入口、分页筛选、原因确认和详情内记录;网站个人状态加精确提示。请求重试复用同 key,不用旧响应覆盖新目标。
|
||||
5. 定向测试及真实运行时验证,包括旧 schema 带评价升级、删除后主键复用和后台/网站联动;回写证据和限制,交付验收。
|
||||
|
||||
## 验证命令与操作
|
||||
|
||||
- `cargo test --manifest-path server-rs/Cargo.toml --locked -p module-game-distribution`
|
||||
- `cargo test --manifest-path server-rs/Cargo.toml --locked -p api-server game_distribution`,另运行 admin 权限映射相关定向测试。
|
||||
- `cargo check --manifest-path server-rs/Cargo.toml --locked -p spacetime-module`
|
||||
- `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:game-distribution-dto-parity`
|
||||
- Vitest 定向运行后台新评价页、路由/权限/client与网站 `GameReviews` 测试;以仓库 include 配置核对实际执行名单。
|
||||
- `npm run admin-web:typecheck`、`npm run typecheck`,受影响文件 ESLint/Prettier。
|
||||
- `npm run check:doc-index`、`npm run check:encoding`、`git diff --check`
|
||||
- 通过项目 dev 脚本启动隔离数据库及 `npm run dev:api-server` 并检查 `/healthz`;端口按运行状态核实,不使用人工 `spacetime --root-dir`。
|
||||
- 在隔离数据库先发布旧 schema 并写入评价,再兼容升级;核对旧评价全部公开且内容/时间不变。真实 HTTP 验证跨页过滤、隐藏/统计/恢复/删除、重新评价、旧 key 重试和权限。
|
||||
- 桌面与移动视口真实浏览器验证游戏管理跳转、长评论弹窗、原因必填/取消/失败,以及网站回读。缺失证据如实标记未验证。
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- 删除是不可恢复操作;复用确认交互,事务校验原创建时间和幂等操作 key,禁止旧请求作用于新评价。
|
||||
- schema 只兼容追加和新增私有表,不删字段/表或清空数据。发布冲突时停止,不通过 delete-data 绕过迁移。
|
||||
- 隐藏后的旧后端会重新暴露评价;产生隐藏记录后只能回滚到仍过滤隐藏状态的兼容实现,保留全部数据和操作记录。
|
||||
- 不引入前端内容版本锁、搜索服务或统计缓存;当前规模采用完整筛选后分页,不在有限首批数据上筛选造成漏查。
|
||||
|
||||
## 当前状态
|
||||
|
||||
已按上述边界完成实现;定向自动化、旧 schema 带 21 条评价无损升级、真实 HTTP 管理链路和桌面/375×812 移动视口验证通过。完整证据及未验证项见主规范“迁移与验收”。保留计划供用户验收;未部署。
|
||||
@@ -0,0 +1,66 @@
|
||||
# 【实施计划】游戏广场评分展示
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Milestone | [游戏广场评分展示](./【里程碑】游戏广场评分展示-2026-10-01.md) |
|
||||
| Status | implemented(本地验证通过,待用户验收;未部署) |
|
||||
| Date | 2026-10-01 |
|
||||
| Owner | Codex |
|
||||
|
||||
## 修改边界
|
||||
|
||||
- 主规范:[游戏广场评分展示合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同);仅实施本里程碑。
|
||||
- 数据读取:`server-rs/crates/spacetime-module/src/game_distribution.rs` 的公开快照、列表/详情读取及 procedure 返回类型;复用已有评分 helper。现有持久化表、索引、评价写入与 migration 登记不变。
|
||||
- 后端:`spacetime-client/src/active/mapper/game_distribution.rs` 公开 record 和 mapper、`shared-contracts/src/game_distribution.rs`、`api-server/src/modules/game_distribution.rs` 公开响应与 no-store;通过既有 facade,不新增数据库访问通道。
|
||||
- 前端:`packages/shared/src/contracts/gameDistribution.ts`、共享评分文本组件及导出,网站 `GameGalleryPage.tsx`、`GameReviews.tsx` 和对应 CSS/定向测试;API client 只按必要调整类型,不新增逐卡读取。
|
||||
- 契约检查:`scripts/check-game-distribution-dto-parity.mjs` 与生成绑定;运行时复用现有隔离数据,不新增 E2E 脚本或扩大评分/后台管理回归矩阵。
|
||||
- 文档:主规范、本文、里程碑、文档索引及稳定决策。后台 UI、AGC、外部 OpenAPI、发行包链路不在修改范围。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 扩展 `GameDistributionPublicGameSnapshot` 返回类型,追加 `average_score:Option<f64>` 与 `rating_count:u64`;复用 `game_distribution_user_rating_summary` 及领域 `visible_review_summary` 的隐藏过滤、一位小数和空态。列表先核对公开可见性与有效版本、按原规则排序并限量,再只为返回项构建含摘要的快照;不能让无效版本占用限量名额。详情共用该公开快照。
|
||||
2. 同步生成绑定与公开 record/mapper,将摘要映射为既有 `GameDistributionRatingSummaryRecord`。浮点数进入公开快照/record 后,移除其及引用结果类型中不适用的 `Eq`,保留 `PartialEq`;record 嵌入摘要时按现有序列化用途补齐必要 derives。
|
||||
3. Rust/TS 共用游戏 DTO 追加可选 `ratingSummary`,Rust 允许旧字段缺失;公开 `public_game_payload` 必须输出摘要对象,复用既有摘要转换。同步列表/详情 no-store、DTO parity 的公开构建器 `mustEmit` 和必要嵌套约束;作者 `game_payload` 可省略摘要,不造零值。
|
||||
4. 将纯评分文本展示抽到 `packages/shared`,只接收摘要数据,复用一位小数及人数表现。现有 `GameRatingSummary` 保留详情评价的加载/错误/重试职责,已加载文本使用共享组件;广场卡片使用列表摘要,无额外评价请求,缺字段显示“评分暂不可用”。卡片简介下增摘要行,采用现有视觉变量并允许移动换行。
|
||||
5. 在现有广场页面测试中补有评分、零评价、缺摘要三种展示断言;复用现有公开响应/DTO 测试核对新增字段。有效评价统计、管理动作、筛选、滚动恢复和迟到响应沿用已有测试,不另加重复用例;纯文本共享组件由页面测试覆盖即可。
|
||||
6. 执行定向工程检查,以现有隔离数据做一次公开 API smoke 和桌面/移动页面检查,按主规范回写证据和限制,交付用户验收;验收通过后融合持久结论并清理临时计划。
|
||||
|
||||
## 必要测试范围
|
||||
|
||||
- 前端:在已有页面测试中覆盖正常评分、一位小数/人数、无人评分与缺字段提示;运行受影响的广场及详情评价测试。
|
||||
- 后端:复用已有响应/契约测试验证摘要字段,沿用有效评价统计测试确认隐藏过滤。相同映射不在 mapper、handler、DTO 每层重复添加一套测试。
|
||||
- 联调:复用现有两款游戏及少量评价,一次核对列表/详情/评价接口摘要一致、隐藏记录不计入且游戏间统计不串联;浏览器检查布局与详情返回后的更新。
|
||||
- 不新增测试框架、专用 E2E 脚本、大批量账号/分页 fixture、故障注入或完整管理动作矩阵。仅在实现确实改动已有行为且现有测试未覆盖时补相应回归。
|
||||
|
||||
## 验证命令与操作
|
||||
|
||||
- 运行 `module-game-distribution` 已有有效评价统计定向测试;领域逻辑未改时不新增同类测试。
|
||||
- `cargo test --manifest-path server-rs/Cargo.toml --locked -p shared-contracts game_distribution`
|
||||
- `cargo test --manifest-path server-rs/Cargo.toml --locked -p api-server game_distribution`
|
||||
- `cargo check --manifest-path server-rs/Cargo.toml --locked -p spacetime-module -p spacetime-client`
|
||||
- `npm run spacetime:generate`、`npm run check:generated-bindings`、`npm run check:spacetime-schema`、`npm run check:game-distribution-dto-parity`
|
||||
- `npm run test -- src/components/game-distribution/GameDistributionPages.test.tsx src/components/game-distribution/GameReviews.test.tsx`;只有 API client 实现发生变化时追加其已有测试,不单独新增纯文本共享组件测试。
|
||||
- `npm run typecheck`,对受影响代码运行仓库现有格式/静态检查。
|
||||
- `npm run check:doc-index`、`npm run check:encoding`、`git diff --check`。
|
||||
- 按现有 dev 脚本启动显式隔离数据库和 `npm run dev:api-server`,读取 `.app/dev-stack.json` 核对目标/端口,检查 `/healthz`;复用现有隔离数据做一次列表/详情/评价摘要对照,不为本增量重建整套 ratings/moderation E2E 流程。
|
||||
- 真实浏览器桌面与 375px 移动视口检查评分行、换行及详情改分后返回;确认摘要随列表返回。筛选/滚动恢复、错误/重试及迟到响应沿用已有定向测试,不重复建立浏览器矩阵。
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- 公开 procedure 返回类型属于配套运行时合同;module、生成绑定和后端先在隔离环境一起验证,再按现有运维流程发布,网站随后更新。无需持久化迁移或数据回填,不能用删除数据处理类型不匹配。
|
||||
- 评分聚合仅作用于返回项,并使用既有按游戏索引;保留有效版本过滤与排序,避免额外扫描全部匹配游戏的评价。当前规模不增加缓存、统计表或预计算任务。
|
||||
- 缺字段只表示不可用,不能默认成 `null/0`;详情评价状态继续由现有读取/保存驱动,避免公开投影摘要覆盖提交后的最新值。
|
||||
- 回滚网站展示可保留新增公开字段;回滚 module/后端必须恢复互相匹配的绑定,并继续过滤隐藏评价。不得退回不识别管理隐藏状态的基础评价旧实现。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 已完成上述代码与绑定接入;公开列表先过滤有效版本、排序并限量,只为返回游戏聚合评分。持久化表与评价写入规则未变。
|
||||
- 前端两文件 36 项、API game_distribution 34 项、shared-contracts 4 项、领域 summary 2 项通过;module/client 编译、typecheck、受影响代码静态/格式检查通过。
|
||||
- `spacetime:generate` 使用固定 2.8.3 完成;保留新增快照字段,清除无关生成格式 diff。生成绑定、schema(87 表)、DTO parity(31 类型/4 构建器)、编码、文档索引和 diff 检查通过。
|
||||
- `dev:api-server` 启动后 `/healthz` 通过;本机显式隔离库没有既有评分 fixture,复用现有 helper 创建两个账号、两款游戏、两条评价。游客/登录公开列表摘要一致,列表/详情/评价摘要 `8/2`,隐藏一条后 `7/1`,另一游戏始终 `null/0`;列表/详情 no-store 通过。临时联调代码与数据仅留在 ignored 本地产物,未新增专用仓库 E2E 脚本。
|
||||
- 1280px/375px 浏览器评分行无横向溢出,卡片可进入详情;详情期间通过 API 将可见评分从 7 改为 6,返回广场显示 `6.0/10 · 1 人评分`。
|
||||
- 前端实现与后端只读审查由两个 `gpt-6.1-sol` subagent 协助,后端审查未发现可执行问题。
|
||||
|
||||
## 未验证与待验收
|
||||
|
||||
用户最终验收与生产部署待完成,保留本计划。未跑完整仓库测试、完整评价/后台管理 E2E、真实手机、长标题/极大人数浏览器矩阵、浏览器登录改分和实际发行包游玩。返回刷新通过 API 改分验证,登录公开读取通过 HTTP smoke 验证;未增加复杂测试矩阵。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 【实施计划】网站游戏评分与评价
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Milestone | [网站游戏评分与评价](./【里程碑】网站游戏评分与评价-2026-09-30.md) |
|
||||
| Status | implemented(本地验证通过,待用户验收) |
|
||||
| Owner | Codex |
|
||||
|
||||
## 修改边界
|
||||
|
||||
- 按用户确认的最新主规范实施网站详情评价、评分统计、公共分页与个人编辑。
|
||||
- 后端限定现有 game-distribution 领域、私有评价表、spacetime-client、共享 DTO 与 API 路由;新增表不破坏现有表。
|
||||
- 前端限定网站详情、评价组件与 API client,复用现有登录、按钮和状态组件。
|
||||
- 不扩展 AGC、广场、外部 API,不新增删除、审核、回复、点赞、排行榜或统计缓存。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 补齐评分、码点长度、归一化、排序统计领域规则与 Rust/TS DTO。
|
||||
2. 新增 game_distribution_review 私有表,以游戏/用户组合为唯一主键,按游戏建立索引;同步 migration、表目录、生成绑定。在事务内检查公开可玩状态并保存,读取分页与统计使用一致快照。
|
||||
3. facade 和 HTTP 接入公共分页、个人读取、唯一 PUT,身份由认证主体取得,响应 no-store。
|
||||
4. 网站实现个人表单/记录/编辑、公共分页与平均分人数,输入冻结及读取请求序号隔离;补定向交互测试。
|
||||
5. 定向 Rust/Vitest、typecheck、DTO/schema/编码/索引检查及真实数据库/API/桌面移动浏览器 smoke;记录逐条证据和限制。
|
||||
|
||||
## 验证命令
|
||||
|
||||
- `cargo test --manifest-path server-rs/Cargo.toml -p module-game-distribution`
|
||||
- `cargo test --manifest-path server-rs/Cargo.toml -p api-server game_distribution`
|
||||
- `cargo test --manifest-path server-rs/Cargo.toml -p spacetime-module game_distribution`
|
||||
- `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:game-distribution-dto-parity`
|
||||
- `npx vitest run src/components/game-distribution src/services/gameDistributionClient.test.ts`
|
||||
- `npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`
|
||||
- `npm run dev:api-server` 检查 `/healthz`,真实本地 SpacetimeDB/API 验证两账号、唯一并发与至少 21 条评价;浏览器验证桌面、移动、编辑取消与键盘评分。
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- 当前是新增私有表,无旧数据回填。回滚页面/API 时保留评价数据,不删除数据库记录。
|
||||
- 中文/emoji 统一按换行归一化后的 Unicode 码点计数,不依赖 textarea 原生 UTF-16 maxLength。
|
||||
- 保存成功与账号/游戏切换使旧读取失效,旧成功、失败、finally 均不得覆盖当前状态。
|
||||
- 若环境不支持真实 smoke,明确记录已通过的自动化证据和未验证项,不标记已上线或已验收。
|
||||
|
||||
## 验证结果
|
||||
|
||||
本计划步骤已实施;各条行为和验证证据已回写[主规范](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#网站游戏评分与评价合同)。真实数据库/API 49 项及桌面/移动视口浏览器 smoke 通过,未部署。用户验收后与里程碑一起清理。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 【里程碑】后台游戏评价管理
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | implemented(本地验证通过,待用户验收;未部署) |
|
||||
| Date | 2026-10-01 |
|
||||
| Parent Spec | [后台游戏评价管理合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同) Version 1.0 |
|
||||
|
||||
## 目标
|
||||
|
||||
交付后台查找评价、隐藏/恢复/删除、原因与操作记录,并同步网站公共分页/统计和个人隐藏状态,保持每账号每游戏唯一评价。
|
||||
|
||||
## 范围
|
||||
|
||||
- 独立“游戏评价”后台页及页面权限;从游戏管理带游戏 ID 进入,完整正文和评分只读。
|
||||
- 游戏名称搜索选择/ID定位、评价用户 ID、正文关键词和公开/隐藏状态组合筛选;完整数据过滤后分页。
|
||||
- 隐藏/删除必填原因,恢复无须原因;管理操作原子保存,失败与取消不产生部分写入。
|
||||
- 隐藏排除整条评价及评分,个人固定提示“已被管理员隐藏”;用户编辑保持隐藏,恢复重新参与统计。
|
||||
- 删除不可恢复,允许用户重新评价;旧管理目标和重复请求不能误处理重新创建的记录。
|
||||
- 存量无损升级、管理员权限、私有原因隔离、真实数据库/API和桌面/移动后台及网站联动。
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 管理员修改正文/分数、删除恢复、再次评价封禁、批量、导出、举报、自动审核、申诉、通知/轮询/推送。
|
||||
- 评分/时间范围等额外筛选、独立操作日志页、AGC 评价、外部 API、统计缓存或清理任务。
|
||||
|
||||
## 依赖与前置条件
|
||||
|
||||
- 用户已确认主规范与本里程碑按方案实施;实施计划进入执行,范围仍限本里程碑。
|
||||
- 基础网站评价已实现且本地验证通过,其用户验收/生产发布状态独立;本次不推定该功能已验收。
|
||||
- 现有后台认证/账号授权、游戏/账号读取、评价唯一写入、受信服务身份及 SpacetimeDB 2.8.3 可用。
|
||||
- 有隔离数据库可验证带真实存量评价的旧 schema 升级,以及超过 20 条评价的筛选/分页。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 游戏管理入口按权限显示,ID 参数首次及切换生效;四条件组合查询、重置、刷新、跨页匹配、空态正确。
|
||||
- [ ] 列表与详情显示游戏/用户/评分/完整纯文本;仅评分记录可管理,管理员没有编辑内容/分数入口。
|
||||
- [ ] 原因 1–4000 码点,隐藏/删除空原因被拒,恢复不要求;取消无写入,失败保留草稿,删除明确不可恢复。
|
||||
- [ ] 隐藏后公共列表/人数/均分排除,全部隐藏为空态;个人提示精确、仍可编辑且不解除隐藏。
|
||||
- [ ] 恢复后参与公共统计,创建和用户修改时间不变;游戏恢复公开不自动解除隐藏。
|
||||
- [ ] 删除后个人读取为空并可重新评价,记录仍唯一;删除后总数减少、越界页正确回退。
|
||||
- [ ] 原因和管理员身份仅后台可见;匿名 401、无权限 403、伪造身份/隐藏状态无效,下架游戏后台可管理。
|
||||
- [ ] 管理变更与记录原子保存;同 key 重试不重复动作或日志,不同请求 409;旧页面/旧 key 不删除重新提交的新评价。
|
||||
- [ ] 旧 schema 有数据升级后内容/时间/唯一性不变,默认公开;schema/绑定/DTO 和定向检查通过。
|
||||
- [ ] 真实数据库/API及桌面/移动浏览器覆盖后台操作、前台回读、分页、长评论和失败;证据与未验证项逐条记录。
|
||||
|
||||
## 证据要求
|
||||
|
||||
- 自动化:领域原因校验/统计过滤,API鉴权/错误/DTO,后台筛选及操作交互、网站隐藏个人状态。
|
||||
- 运行时:存量 schema 升级,21 条以上评价的筛选分页、隐藏/恢复/删除、重新提交与重试;浏览器后台和网站联动。
|
||||
- 边界:401/403、管理原因泄漏、原子失败、状态并发、删除后主键复用、全部隐藏和页码越界。
|
||||
- 本里程碑的自动化、存量升级、真实 HTTP 及桌面/移动视口证据已回写[主规范](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#迁移与验收);上方复选框保留供用户验收,不以既有评价功能证据替代。
|
||||
@@ -36,7 +36,7 @@
|
||||
- 正式状态来自后端,素材仍复用平台上传与归属能力;前端和 AGC 不另建公开游戏状态、owner 事实或审核结果。
|
||||
- 真实发行包具有不可变版本和 SHA-256,AGC dist 归一化为发行根 `index.html`;网页 ZIP 与 AGC 共用一条服务管道。
|
||||
- 不恢复退役玩法 API、公开作品表或专属 runtime;不把私有项目源码镜像公开。
|
||||
- 首版不包含原生/Wasm 游戏、任意外网依赖、服务端进程、多人联机、云存档、评论/评分/关注、排行榜、推荐算法和收益结算。
|
||||
- 本发行里程碑不包含原生/Wasm 游戏、任意外网依赖、服务端进程、多人联机、云存档、评论/评分/关注、排行榜、推荐算法和收益结算。网站评分与评价作为[独立里程碑](./【里程碑】网站游戏评分与评价-2026-09-30.md)推进,不借用本文件的发行验收结论。
|
||||
- 本文件只协调本业务;不顺带改造图片编辑器、Agent Runtime 执行模型或无关项目数据。
|
||||
|
||||
## 阶段 A:真实包、身份与可恢复发行
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 【里程碑】游戏广场评分展示
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | implemented(本地验证通过,待用户验收;未部署) |
|
||||
| Date | 2026-10-01 |
|
||||
| Parent Spec | [游戏广场评分展示合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同) Version 1.0 |
|
||||
|
||||
## 目标
|
||||
|
||||
在网站游戏广场交付真实评分摘要展示,保持与详情评价及后台隐藏/恢复/删除后的统计一致,并保留原有目录交互。
|
||||
|
||||
## 范围
|
||||
|
||||
- 游客与登录用户均可在卡片查看 10 分制一位小数均分和评分人数,无有效评价显示“暂无评分”。
|
||||
- 公开列表和详情返回同形状摘要,统计来自后端全部有效评价,与公开游戏资料采用一致读取快照。
|
||||
- 空评论计人数,编辑不增加人数;隐藏/删除不计入,恢复重新参与,新版本保留评分。
|
||||
- 列表一次取得摘要,详情返回广场重新读取,并保留既有筛选与滚动位置。
|
||||
- 缺少摘要与无人评分明确区分,列表故障沿用重试;桌面和移动卡片均可正常展示和操作。
|
||||
- 共享契约、生成绑定和配套运行时验证,不改变持久化表或既有数据。
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 卡片输入评分、评论预览、评分排序、排行、推荐、AGC 或作者管理页评分展示。
|
||||
- 统计持久化/缓存/重算任务、推送/轮询、评价写入和管理规则变更、外部 API。
|
||||
- 原网站评价与后台管理的用户验收、生产部署或游戏发行包验收。
|
||||
|
||||
## 依赖与前置条件
|
||||
|
||||
- 用户已确认方案、提交文档,并后续授权工程实现;本里程碑代码与本地定向验证已完成。
|
||||
- 当前基础评价、后台管理与有效评价统计已实现并通过本地验证,其用户验收和生产发布状态独立。
|
||||
- 现有公开游戏目录、详情、平台认证、数据库服务身份与后端访问链路可用。
|
||||
- 可复用现有隔离测试游戏和少量评价,完成统计对照及桌面/移动视口检查;不要求新建大批量账号或分页数据。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 游客和登录用户均显示真实 `8.2/10 · 26 人评分` 形状,均分固定一位小数。
|
||||
- [ ] 无评价及全部隐藏/删除为 `null/0` 和“暂无评分”,缺摘要为“评分暂不可用”,不显示虚构分数。
|
||||
- [ ] 公开列表、公开详情、评价接口在稳定数据集上的统计一致;多游戏不串数据,统计覆盖全部有效评价而非当前评价页。
|
||||
- [ ] 空评论计人数,改分只改均分,改文字不改均分;隐藏/恢复/删除后下一次读取正确变化。
|
||||
- [ ] 下架/封禁或没有有效公开版本的游戏不进入目录;摘要不泄漏评论、管理原因、管理员或个人状态。
|
||||
- [ ] 广场不逐卡请求评价;列表失败显示原错误/重试,旧响应不覆盖新筛选结果。
|
||||
- [ ] 详情修改评分后返回广场读取最新值;筛选、封面、卡片点击和滚动恢复行为保留。
|
||||
- [ ] 桌面和 375px 移动视口下长标题、大人数及评分换行不溢出、不阻挡操作。
|
||||
- [ ] 新公开响应保证带摘要,作者/旧响应可省略;DTO/绑定/schema 与定向检查通过,持久化数据保持。
|
||||
- [ ] 以现有数据完成一次隔离 API 摘要对照和桌面/移动页面检查,缺失验证如实标明;无需重跑完整评价/后台管理 E2E。
|
||||
|
||||
## 证据要求
|
||||
|
||||
- 新增自动化仅覆盖:已有广场页面测试中的正常/零评价/缺摘要展示,以及已有公开响应/契约测试中的新增摘要字段。
|
||||
- 复用已有测试:有效评价统计、隐藏/恢复/删除、改分、可见性及目录/详情交互,不为每层映射或本增量再复制完整矩阵。
|
||||
- 运行时:现有两款游戏和少量评价的一次列表/详情/评价摘要对照,确认隐藏过滤与统计不串联;桌面/移动检查评分布局及详情返回后的更新。
|
||||
- 不新增测试框架、专用 E2E 脚本或大批量分页 fixture;纯文本共享组件由页面测试覆盖。只有新增行为或实际改动的旧行为缺少覆盖时补测试。
|
||||
|
||||
## 实现与验证证据
|
||||
|
||||
- 公开列表/详情快照同事务携带摘要,限量后聚合;共享 DTO、mapper、2.8.3 生成绑定与公开 no-store 已接入。网站卡片与详情复用纯文本组件,缺字段不会造零值。
|
||||
- 前端受影响两文件 36 项、API game_distribution 34 项、shared-contracts 4 项、领域 summary 2 项通过。仅补评分文案参数化断言并扩展已有响应测试,未新增测试基础设施。
|
||||
- module/client 编译、TypeScript typecheck、受影响文件静态/格式检查、DTO parity、生成绑定/schema、编码、文档索引与 diff 检查通过。
|
||||
- 本机隔离库没有既有评分数据,复用既有 helper 建两个账号、两款游戏和两条空评论评价;三接口摘要 `8/2` 一致,隐藏一条后为 `7/1`,另一游戏 `null/0`。游客/登录列表一致,公开列表/详情 no-store、健康检查通过。
|
||||
- 1280px 桌面及 375px 移动浏览器显示正常且评分行无横向溢出;详情期间经真实 API 改分,返回广场更新为 `6.0/10 · 1 人评分`。
|
||||
|
||||
## 未验证与待验收
|
||||
|
||||
验收勾选保留给用户最终确认,临时计划尚未清理。未执行完整仓库测试、完整评价/后台管理 E2E、真实手机、长标题/极大人数浏览器矩阵、浏览器登录改分、实际发行包游玩或生产部署;本次没有重建管理动作/分页矩阵。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user