Merge branch 'master' into fix/issue-549
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 3m2s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 2m8s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m26s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m51s
Project CI / Frontend tests (pull_request) Successful in 2m45s
Project CI / Repository checks (pull_request) Successful in 3m1s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m0s
Project CI / Backend tests (pull_request) Successful in 6m26s
Project CI / Native shell tests (pull_request) Successful in 7m12s

This commit is contained in:
2026-10-01 19:20:29 +08:00
81 changed files with 8594 additions and 60 deletions
@@ -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(
+62
View File
@@ -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 } = {},
+84
View File
@@ -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;
+11 -1
View File
@@ -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}
/>
+1
View File
@@ -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(
+2
View File
@@ -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
+27
View File
@@ -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:
@@ -1670,11 +1670,13 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) {
);
}
if (!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)) {
if (
!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)
) {
throw new Error(
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
);
@@ -269,7 +269,7 @@ async function prepareFrontendDev(endpoint, { onChild, signal }) {
function isDirectModuleExecution() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);
}
+5 -1
View File
@@ -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、真实手机、长标题/极大人数浏览器矩阵、浏览器登录改分、实际发行包游玩或生产部署;本次没有重建管理动作/分页矩阵。
@@ -0,0 +1,64 @@
# 【里程碑】网站游戏评分与评价
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | implemented(本地验证通过,待用户验收;未部署) |
| Date | 2026-09-30 |
| Parent Spec | [平台入口与玩法链路](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#网站游戏评分与评价合同)中的“网站游戏评分与评价合同”Version 1.0 |
## 目标
在网站公开游戏详情页交付“一用户一游戏一条评分与可选评论 → 查看个人评价 → 编辑与取消/提交 → 公共分页浏览 → 平均分与评分人数更新”的完整行为闭环。
## 范围
- 复用现有平台登录态,游客可浏览公开评价与统计,登录后可保存和修改自己的唯一评价。
- 评分为 1–10 整数且必选,评论可空、最多 4000 Unicode 码点;实时显示字数与上限。
- 已评价默认显示个人记录和编辑按钮,编辑时预填;取消不保存修改,成功提交收起输入模块。
- 公共列表默认每页 20 条,按首次提交时间倒序;包含当前用户和空评论评价,不因编辑改变位置。
- 详情显示平均分与评分人数,无人评分为“暂无评分”;人数不因修改增加,均分覆盖全部评价。
- 评价随游戏身份保留,新版本发布不清空;下架与封禁关闭评价读写,重新公开恢复读取。
- 覆盖并发唯一性、账号切换、失败与重试、移动端和键盘可操作性。
## 不在范围内
- AGC 客户端评价、独立评价页面、游戏广场评分卡片、评论回复/点赞/附件/Markdown。
- 评价删除、评价审核、评分排行/推荐、必须游玩或消费才能评价的门槛。
- 通用评论框架、统计缓存、评分任务或外部 API;不补做游戏发行原里程碑的生产上线验收。
## 依赖与前置条件
- 主规范与本里程碑评审通过,整数评分、分页与取消行为等默认规则已确认;均分/人数已由用户确认纳入范围。
- 当前游戏详情、公开可见性、平台认证、账号公开资料读取与受信服务身份链路在验收环境可用。
- 能建立独立测试账号及公开游戏,运行真实 SpacetimeDB/API 和网站浏览器验收;不得把本地通过当成生产上线。
- 新增评价 schema、共享 DTO 与既有绑定变更在实现前写入本里程碑的单独实施计划,不对现有表进行破坏性修改。
## 验收标准
下列条款已完成本地验证,证据与限制见主规范;保留勾选供用户验收确认。
- [ ] 游客可读公开游戏评价与摘要;未登录不能保存或读取个人评价。
- [ ] 1/10 分和空评论成功,未选/越界/非整数分数与超长评论均被拒。
- [ ] 中文、英文、emoji、空格及换行计数一致,4000/4001 边界、粘贴和中文输入法符合主规范。
- [ ] 已评价先显示自己的记录,编辑预填;取消恢复旧记录,提交中禁用评分选择、评论输入及取消/提交,成功隐藏表单,失败恢复编辑并保留草稿。
- [ ] 首次取消不落库;同一账号/游戏的重复与并发保存只有一条;不能指定他人身份写入。
- [ ] 至少 21 条真实记录验证分页、顺序和总数,自己的评价允许在个人区与公共区各出现一次。
- [ ] 空评论也展示分数并计人数;编辑不增加人数、不改变创建时间或排序位置。
- [ ] 无评价显示“暂无评分”;改分正确更新全部评价均分,改评论不改变均分,分页不影响统计。
- [ ] 切换账号或游戏不残留个人草稿、不接受旧响应覆盖;读取失败不伪装为未评价。
- [ ] 保存成功后此前的个人评价与公共列表读取失效,不覆盖新记录、摘要或列表;快速翻页/重复刷新只接受各区域最新请求的结果,旧请求不改写错误及加载状态。
- [ ] 游戏下架/封禁后读写不可见,恢复公开后仍能读取已有评价,新版本不清空评价。
- [ ] 评价读取或保存故障不阻断既有游戏资料与游玩;桌面/移动/键盘操作均可用。
- [ ] 定向测试、网站类型、DTO、schema/生成绑定、真实 API/SpacetimeDB/browser smoke 及编码/文档索引/diff 检查有可复核结果。
## 证据要求
- 自动化:评分/字数/唯一性与修改规则、统计、分页、页面交互及账号切换定向测试。
- 运行时:真实 SpacetimeDB/API 上的两账号写入、并发首次保存与分页统计;真实浏览器编辑/取消/提交、移动端字数提示与键盘评分。
- 边界:未登录、伪造身份、不可见游戏、4000/4001 字符、0/11/非整数分数、响应丢失与迟到响应。
- 证据按主规范条款逐项记录环境、操作、结果与未验证原因,原游戏发行证据不自动代替本次验收。
## 实施与验证状态
实现与本地验收已完成,详细证据已回写主规范:前端 77 项、领域 18 项、API 31 项、DTO 4 项、真实数据库/API 49 项(21 条评价)与桌面/移动视口浏览器操作通过。网站类型、定向严格类型/ESLint、DTO/schema/绑定、DDD/runtime access、编码/索引/diff 检查通过。未部署,未验证物理手机/真实系统输入法或发行资源游玩;用户验收后清理本里程碑和实施计划。
@@ -1,11 +1,38 @@
# 决策记录
## 2026-10-01 游戏广场评分展示边界
- 用户确认广场卡片增加一位小数的 10 分制平均分与评分人数,无有效评价显示“暂无评分”;保留现有排序、筛选、卡片打开详情及返回上下文。
- 公开游戏列表和详情共用投影,随现有请求返回 ratingSummary;复用后端有效评价统计并排除隐藏记录,不逐卡请求评价,不增加统计表、缓存或重算任务。
- 共用 DTO 可选字段兼容作者及旧响应,当前公开列表/详情保证返回;缺字段显示“评分暂不可用”,不能伪装为无人评分。读取与后台管理后的下一次刷新一致,不增加推送/轮询。
- 本增量不改变持久化表或评价写入,仅同步读取投影、DTO 与生成绑定;不扩展评分排序、推荐、AGC 或外部 API。
- 用户要求测试保持简单:仅补卡片正常/零评价/缺摘要及公开摘要契约断言,复用已有统计/管理/目录测试和少量隔离数据 smoke;不新增专用 E2E、分页 fixture 或逐层重复测试。
- 权威入口:[游戏广场评分展示合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)。已完成工程实现及本地定向验证,待用户验收,未部署;持久化表未变,公开 procedure 返回类型与后端绑定需配套发布。
## 2026-10-01 后台游戏评价管理规则
- 用户确认新增管理员隐藏/删除评价,后台不能修改分数或正文;隐藏整条评价并从公共列表、平均分、人数和公共分页总数排除,恢复后重新参与。个人区域固定提示“已被管理员隐藏”,用户仍可编辑但不能自动恢复公开。
- 隐藏和删除必须填写原因,恢复不要求;原因仅后台展示。删除物理移除,不能恢复,用户可重新评价,唯一规则继续成立。
- 当前方案范围为游戏名称选择/ID定位、评价用户 ID、评论关键词、状态四类组合筛选,以及分页、详情、单条操作和持久操作记录。不增加批量、导出、举报、自动审核或评分/时间范围筛选。
- 实现边界:评价表末尾追加默认 false 的 is_hidden;新增私有管理记录,事务保存操作人/原因/时间,以创建时间区分删除后重建记录,同 key 重试不得再次操作新评价。不增加统计缓存;用户编辑保持隐藏状态,管理操作不改变用户内容时间。
- 状态:用户确认按方案实施;后台与网站联动已实现并通过本地存量升级、真实 HTTP 和浏览器验证,证据见主规范。待用户验收,未部署。产生隐藏记录后不得直接回退到未过滤隐藏状态的旧后端。
- 权威入口:[后台游戏评价管理合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同);活动[里程碑](../plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](../plans/【实施计划】后台游戏评价管理-2026-10-01.md)。
## 2026-09-30 网站游戏评价范围与状态边界
- 已确认需求:网站游戏详情支持每账号每游戏唯一一条 1–10 分评分和可空的评论(最多 4000 字符),可修改自己的评价;个人区默认展示已有评价并提供编辑预填,公共列表分页且不排除自己。
- 已确认扩展:详情显示真实平均分与评分人数,空评论计人数,修改不增加人数;当前先由后端评价记录计算,不增加统计缓存。
- 状态:用户已确认按最新方案实现;整数评分、默认每页 20 条、创建时间排序、取消行为与游戏维度保留作为现行合同。网站与后端实现已落地,本地验收证据见主规范;用户验收与生产发布单独确认。原发行 Version 0.2 的排除项不再被解释为永久禁止新增用户评价。
- 实现边界:新增私有 `game_distribution_review` 表和三条评价 API,游戏/账号组合主键保证唯一;可见性与写入同事务,分页与均分来自一致快照,作者资料读时关联账号。网站用游戏/账号上下文隔离草稿,以读取序号隔离旧成功、错误和加载结束,保存期间冻结输入。
- 权威入口:[平台入口与玩法链路](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#网站游戏评分与评价合同);执行验收使用[独立里程碑](../plans/【里程碑】网站游戏评分与评价-2026-09-30.md),不得把既有发行或本次文档检查当作评价功能上线证据。
## 2026-10-01 生产 Nginx 以模板为唯一来源:host-only 块独立成 snippet、退役路由不做显式 404
- 背景:线上主站 `genarrative.conf` 长期手工维护,`profile` 未进 SPA allowlist 导致 `/profile` 刷新 404,`client_max_body_size` 也停在 `64m`;同时线上存在 4 处仓库模板没有的 host-only 块(画廊读取限流、`/finance-forecast/`、`/medical-science/`、`/home/` 官网首页入口),直接用 `Genarrative-Server-Provision` 覆盖会静默删除它们。
- 决策:生产 vhost 以 `deploy/nginx/genarrative.conf` 为唯一来源;生产机专属路径收进 `deploy/nginx/snippets/genarrative-host-extras.conf`,由主模板 include、由 Server-Provision 安装,新增平台路由仍必须在主模板内声明并同步 `deploy/pingora/nginx-route-parity.matrix.json` 与 Pingora 网关。snippet 内的 location 有意不在矩阵覆盖范围,Pingora 接公网 443 前必须单独确认这些路径的处理方式。
- 边界:退役路由(`/match3d`、`/puzzle`、`/runtime/*`、`/gallery/*/detail`、`/works/detail`、`/worlds/detail`、`/bark-battle` 等)不再进 SPA allowlist,也不配置显式 404,统一落 `location /` 的 `error_page 404 /404.html`。`/home/` 是 2026-08-11 官网拆分前的历史入口,是否退役需与官网侧一起决定。
- 验证方式:`node scripts/check-nginx-spa-routes.mjs`、`node scripts/check-pingora-route-parity.mjs`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`、`bash -n scripts/jenkins-server-provision.sh`;线上改后按生效配置烟测 `/profile`、模板 12 条 SPA 路由、`/admin/`、`/home/`、`/finance-forecast/`、`/medical-science/`、画廊 API 与 `/games/game_<id>/…` 发行网关。
## 2026-10-01 DirectProject 审批拒绝原因留痕
- 决策:宿主拒绝 app-server 的审批 / 交互请求时,原因必须落 AppData `RUST` 日志。稳定键 `agent.direct_codex.approval.denied`,字段为 `thread_id` / `method` / `reason` / `distinct_reasons`;未绑定宿主执行器时另记 `agent.direct_codex.interaction.no_adapter`(`method` / `outcome`,`outcome` 区分 `decline` / `empty-permissions` / `unsupported-method`);回包未送达另记 `agent.direct_codex.approval.response_delivery_failed`,`cause` 区分 `response-write-failed` 与 `turn-bind-mismatch`。
@@ -461,8 +461,34 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。
### `game_distribution_review`
- Rust 结构体:`GameDistributionReview`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;私有表,仅通过受信 API 服务身份的 procedure 与 BFF 提供评价投影。
- 主键 `review_id` 使用游戏 ID 字节长度前缀与 `game_id`、`user_id` 无歧义组合,确保每账号每游戏只有一条;`by_game_distribution_review_game_id` 用于游戏评价读取。保存 `score`(1–10 整数)、`comment`(换行统一 LF 后最多 4000 Unicode 码点)、首次创建与最近实际修改时间,不绑定发行版本。
- 事务内复用公开详情可见性,创建或更新唯一评价;重复相同内容不改时间。公共分页按创建时间和主键倒序,作者资料从 `user_account` 读取;均分和人数与分页在同一事务快照内计算,不落额外统计或缓存。
- 网站 HTTP:`GET /api/game-distribution/games/{gameId}/reviews` 允许游客页码读取;`GET/PUT /api/game-distribution/games/{gameId}/my-review` 使用认证账号,均返回 `Cache-Control: no-store`。未公开、下架、暂停或无有效公开版本时统一 404;不受发布灰度影响。完整行为见玩法链路的“网站游戏评分与评价合同”。
### 后台游戏评价管理数据增量(2026-10-01)
状态:`implemented`,本地验证通过,待用户验收,未部署。下述字段/表已进入当前 schema,完整合同与验证证据见[后台游戏评价管理](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同)。
- `game_distribution_review` 末尾追加 `is_hidden: bool` 并设置 `#[default(false)]`,已有评价全部公开;新增 `user_id` 索引,保留现有主键和游戏索引。用户编辑只改变 score/comment/updated_at,保留隐藏状态;管理动作不改变用户内容时间。
- 新增私有 `game_distribution_review_moderation_log`:`operation_id: String` 主键、`review_id/game_id/user_id: String`、`review_created_at: Timestamp`、`action: String`(hide/restore/delete 白名单)、`admin_user_id: String`、`reason: Option<String>`、`created_at: Timestamp`;按 `review_id` 建索引。隐藏/删除原因必填、恢复为 None,只返回后台;不复制正文,不级联删除记录。
- `operation_id` 为认证管理员与幂等 key 的无歧义组合。事务核对记录中的目标、原评价创建时间、动作和原因识别重放;同 key 不同请求冲突,同请求不重做。请求携带 `expectedCreatedAt` 区分同一组合主键删除前后重建的评价,旧目标请求不得作用于新行。
- 受信服务 procedure 原子完成查询过滤/分页,或管理状态变更/物理删除与操作记录插入;后端访问继续经 typed facade。普通公开读取及所有评分摘要先过滤 `is_hidden=false`;个人读取仍返回隐藏状态,后台读取不受游戏公开状态限制。不增加统计缓存。
- 新后台 `/admin/api/game-distribution/user-review-games`、`/user-reviews`、`/user-reviews/{reviewId}`、`/user-reviews/{reviewId}/moderation` 与原发行 `/reviews` 分开;新增 Tab 权限 `game-reviews`,HTTP 认证与页面权限均校验,管理员 ID 来自会话。个人/公共评价 DTO 追加 isHidden,不暴露原因。
- 已同步 `migration.rs`、表目录、生成绑定并通过 schema 检查;隔离数据库的 21 条旧评价兼容升级后默认公开,既有记录内容/唯一性/时间不变。部署后若已隐藏评价,不直接回退到未过滤隐藏记录的旧后端。
### `game_distribution_review_moderation_log`
- Rust 结构体:`GameDistributionReviewModerationLog`,源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`;私有管理操作表,仅通过受信服务 procedure 提供后台投影。
- 保存 `operation_id` 主键、评价/游戏/用户 ID、目标创建时间、动作、管理员 ID、可空原因和操作时间;按 `review_id` 建索引,读取时同时匹配目标创建时间,记录不随评价删除。
- 管理员与幂等 key 的无歧义组合唯一定位请求;同请求重试不重做,同 key 不同请求冲突。变更与日志同事务,原因只供后台,完整合同见上方后台评价管理数据增量。
### `game_distribution_version`
- Rust 结构体:`GameDistributionVersion`
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
- 用途:不可变发行版本与真实包确认事实。创建后冻结 `package_sha256`、字节数、文件数、根入口和版本号;后续只推进上传、校验、审核、公开、撤回状态,并记录私有对象键、文件清单、入口 URL、审核者和阶段时间。
@@ -1,8 +1,8 @@
# 平台入口与玩法链路
> 更新时间:`2026-09-23`
> 更新时间:`2026-10-01`
>
> 本文只描述现役主站平台壳、图片画布编辑器和对应后端契约。未出现在现役路由树、shared-contracts 或 SpacetimeDB module / bindings 中的入口与数据对象不属于本文范围。
> 本文描述现役主站平台壳、图片画布编辑器和游戏分发合同;拟新增能力在各节单独标明状态。网站游戏评分与评价、后台游戏评价管理已实现并通过本地验证,待用户验收,未部署;游戏广场评分展示方案已确认,待实现。当前实现以路由树、shared-contracts 和 SpacetimeDB module / bindings 为准。
## 现役平台入口
@@ -64,7 +64,7 @@
交付“AGC 一键提交游戏 / 网页上传游戏 ZIP → 后端收取真实发行包 → 校验与人工审核 → 主站发现、详情、游客在线游玩 → 更新与下架”的完整闭环。验收必须使用真实上传、真实存储、真实审核状态和隔离发行域名;静态演示卡片、metadata-only 请求或前端本地发布状态不能作为完成证据。
首版支持可以离线运行的静态 Web 游戏:HTML、JavaScript、CSS、JSON 与图片、字体、音视频。AGC 首先支持现有 npm/Vite Web 工程;网页入口允许上传符合相同包合同的 ZIP,不以游戏引擎名称限制普通静态产物。Godot/Cocos 工程源码、原生可执行文件、Wasm、服务端进程、外部 API、多人联机、云存档和跨版本存档迁移不在首版内。暂不做评分、评论、关注、榜单、推荐算法、交易和创作者收入。
首版支持可以离线运行的静态 Web 游戏:HTML、JavaScript、CSS、JSON 与图片、字体、音视频。AGC 首先支持现有 npm/Vite Web 工程;网页入口允许上传符合相同包合同的 ZIP,不以游戏引擎名称限制普通静态产物。Godot/Cocos 工程源码、原生可执行文件、Wasm、服务端进程、外部 API、多人联机、云存档和跨版本存档迁移不在首版内。本发行合同 Version 0.2 不包含用户评分与评论,其扩展范围见下文“网站游戏评分与评价合同”;关注、榜单、推荐算法、交易和创作者收入仍不在范围内。
### 入口与产品体验
@@ -176,3 +176,287 @@
3. 是否接受首版离线静态包、无 Wasm/外网/持久存档的范围,以及建议包额度、7 天失败包保留和 60 秒缓存撤销窗口;公开/撤销版本及审核记录保留周期待定。
4. 是否接受平台同源路径发行(`/games/<gameId>/` + `sandbox="allow-scripts"` 的不透明来源隔离)替代独立发行域名:选择该方案后独立域名、通配 DNS/TLS 与 CDN 不再是上线门禁,存储区域与运营责任仍需确定。
5. 本节已提供页面行为、发行状态、真实上传、幂等/CAS 和 API 草案,足以评审完整业务;尚不足以直接实现持久化和部署,必须在对应里程碑评审前冻结表/索引/服务身份、额度/清理、最终响应 DTO 与发行基础设施配置。未经评审不建立 `ready` 实施计划,不把 proposed 标记为 accepted。
## 网站游戏评分与评价合同
本节记录已实现的基础评价合同;已实现的后台管理增量规则及其独立验证证据见下方“后台游戏评价管理合同”。
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | implemented(本地验证通过,待用户验收;未部署) |
| Date | 2026-09-30 |
| 入口 | 网站 `/games/detail?id=<gameId>` |
| 交付目标 | 登录用户对同一游戏保存并修改唯一一条评分与可选评论;所有访客分页浏览评价,并查看真实平均分与评分人数 |
| 里程碑 | [网站游戏评分与评价](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md) |
### 范围与默认规则
- 本次只扩展网站游戏详情页、现有游戏分发后端与共享契约;复用平台账号,评分与评论合并为一条“评价”。
- 用户已明确要求:评分范围 1–10、评论可空且最多 4000 字符、可修改自己的评价、每用户每游戏最多一条、个人评价展示与编辑预填、公共列表分页且包含自己的评价。
- 用户已确认加入详情页平均分和评分人数:无人评分显示“暂无评分”,空评论计入评分人数,修改不增加人数,当前规模先由后端读取评价记录计算。
- 本方案采用以下评审默认值:评分只接受整数;每页默认 20 条;按首次提交时间倒序;取消首次填写清空草稿,取消编辑恢复原评价;评价跟随游戏身份,不随发行版本更新清空。
- 登录用户均可评价,包括游戏作者;不增加必须先游玩、消费或具有发布权限的条件。评价权限不复用 `game-distribution:publish` 发布灰度。
- 基础评价功能不包含 AGC 客户端评价界面、独立评价页面、游戏目录评分展示、评论回复/点赞/图片/Markdown、评价删除、评价审核或推荐排行;后台管理与广场评分展示分别由下方独立合同扩展,不新建通用评论框架、评分缓存或独立统计任务。
### 评分、评论与统计口径
1. 评分必选,取值为整数 1–10;首次填写时不预选分数,不能仅提交文字而不评分。
2. 评论为空字符串或未提供时均允许提交。纯空白评论保存为空字符串;普通评论保留首尾空格与换行。
3. 输入与提交前统一把 `CRLF` / `CR` 换行为 `LF`,再按 Unicode 码点计数;空格、换行同样计入上限。前端与后端计数一致,不按 UTF-8 字节或 UTF-16 单元计数;组合 emoji 可能含多个码点。
4. 长度校验先于纯空白归一化,4000 字符允许,4001 字符拒绝;不静默截断后保存。输入框旁始终显示 `当前字数/4000`,超限显示可读错误并阻止提交;中文输入法与粘贴同样按该口径处理。
5. 每个评价对应一个已认证账号和一个稳定 `gameId`,不绑定 `versionId`。分数与评论一起原子保存;修改已有记录,`createdAt` 不变,有内容变化时更新 `updatedAt`。
6. 平均分等于所有有效评价分数之和除以评价人数,包含作者、当前用户和只评分的记录;每账号最多计一次。后端返回保留一位小数的平均分,页面显示如“8.2/10 · 26 人评分”。
7. 无评价时 `ratingCount=0`、`averageScore=null`,显示“暂无评分”;不能显示“0 分”或虚构默认分。修改评分重新计算平均分,人数保持不变;修改文字不改变均分。
8. 平均分与人数来自后端对该游戏评价的权威读取,不能由当前分页或前端累计计算。本次不持久化总分、人数或均分,不增加缓存及重算任务。
### 页面布局与交互
游戏详情保留“立即玩”和现有资料,在游戏介绍后增加“我的评价”“全部评价”两个区块;平均分与人数显示在详情资料区,并在评价区域共用同一份后端摘要。
| 状态 | 我的评价区域 | 操作结果 |
| --- | --- | --- |
| 未登录 | 显示“登录后评价”入口,不展示可提交表单 | 使用现有平台登录入口;公共列表和评分摘要仍可浏览 |
| 读取自己的评价中 | 显示加载状态 | 确认不存在已有评价前,不提前展示首次填写表单 |
| 已登录且没有评价 | 展示 1–10 分选择、可选评论输入框、字数统计、取消/提交按钮 | 首次输入的评分和文字仅为当前页面草稿 |
| 已登录且已有评价 | 展示自己的分数、评论、修改时间及“编辑评价”按钮,隐藏输入模块 | 空评论仍展示分数,正文不填造提示内容 |
| 编辑已有评价 | 展示输入模块,完整预填分数与评论 | 个人展示区域暂由编辑表单替代,公共列表仍保留该评价 |
| 提交中 | 保留表单及内容,显示提交状态并禁用评分选择、评论输入及取消/提交 | 冻结本次提交内容,防止重复点击和后续输入被成功响应收起表单时丢失;失败后恢复编辑,不把在途请求解释为已保存 |
| 提交成功 | 用后端权威记录显示自己的评价,隐藏输入模块 | 刷新平均分、人数与公共列表 |
| 读取或提交失败 | 给出错误与重试入口 | 读取失败不伪装成“没有评价”;提交失败保留草稿,不覆盖原评价 |
- 首次填写点击取消:清空评分与文字,保留首次填写表单,不产生后端写入;编辑点击取消:丢弃草稿,恢复上次成功读取/保存的个人评价。
- 首次提交成功后公共列表回到第一页;修改成功后刷新当前页,保留页码。提交响应中的个人记录与统计摘要先更新,分页读取失败单独提示重试,不回滚已成功保存的评价。
- 保存成功后,使此前发出的个人评价与公共列表读取失效;迟到响应不得覆盖已保存的个人记录、评分摘要或刷新后的列表。同一游戏内快速翻页或重复刷新时,各读取区域只接受其最新请求的结果,旧请求的成功、错误和加载结束均不得覆盖当前状态。
- 刷新页面重新读取后端保存结果,不自动保存或恢复未提交草稿;退出登录、切换账号或切换游戏时清空个人评价、草稿与旧错误,旧请求的迟到响应不得写入新上下文。
- 评论纯文本展示并保留换行,不执行 HTML、不解析 Markdown;正文与上方作者/评分行之间保留 8px 间距。移动端评分控件可换行,评论与按钮不被底部导航遮挡;控件支持键盘选择并提供可访问名称。
- 评分摘要、公共列表和个人评价分别承接加载/失败;评价区域故障不阻断已有游戏资料和游玩动作。摘要加载失败显示重试状态,不能误显示“暂无评分”。
- 优先复用共享动作按钮、状态提示、空态、输入与分页表现;存在跨页面重复时再抽取共享表现组件,领域规则仍留在后端。
### 公共评价列表与分页
- 游客和登录用户都能读取公开游戏的评价;列表展示作者公开昵称/头像、分数、评论、首次提交时间,编辑后标明修改时间。
- 只评分的评价仍进入列表与总数,不制造“无评论”正文;**不排除当前用户**。自己的评价可以在个人区域与公共列表各出现一次,公共列表内部只能出现一条。
- 使用服务端页码分页,`page` 从 1 开始,`pageSize` 默认 20、允许 1–50;页面提供上一页、下一页、当前页及总页数。前端不能一次取全后自行分页。
- 默认按 `createdAt` 倒序,同时间按稳定评价标识倒序;编辑不改变排序位置,不新增排序/筛选选项。
- 无记录时第一页返回空列表、`total=0`、`totalPages=0`,页面显示真实空态并禁用翻页;超出末页返回空列表,不把不存在的页作为服务端错误。
- 分页与统计在单次读取的一致快照中返回;不同翻页请求之间允许反映新提交,不承诺跨请求冻结整份评价集合。
### 数据、身份与持久化边界
游戏评价私有表 `game_distribution_review` 保存以下必要字段:
| 逻辑字段 | 约束 |
| --- | --- |
| `reviewId` | 稳定唯一标识;通过 `gameId + userId` 的无歧义组合标识保证每对游戏/账号只占一个主键 |
| `gameId` | 已存在的稳定游戏身份;建立按游戏查询索引 |
| `userId` | 仅来自平台认证主体,不接受请求指定或转移归属 |
| `score` | 整数 1–10 |
| `comment` | 归一化后的纯文本,最多 4000 Unicode 码点,可为空 |
| `createdAt` | 首次保存时间,修改时不变 |
| `updatedAt` | 最近一次实际内容修改时间 |
- 游戏必须与当前公开详情采用相同可见性判定,存在已公开可玩版本;未公开、作者下架或管理员封禁时,评价公开读取、个人读取和提交均返回 404。已有评价保留,游戏重新公开后可继续读取和编辑。
- `api-server` 先认证并取得用户 ID;SpacetimeDB 入口继续校验现有受信运行服务身份。数据库上下文中的调用者是 API 服务身份,不能把它误当成网页用户。
- 在同一数据库事务里检查游戏可见性并按唯一主键创建或更新;不得用 HTTP 层“先查是否存在,再生成随机 ID 新建”保证唯一性。并发首次提交、双击与重试最终都只有一条记录。
- 对同一条评价的并发修改采用后提交成功的事务覆盖,避免为该低风险编辑引入新版本锁或操作账本;重复提交相同内容返回已有记录,不变更人数和创建时间,也不制造修改时间。
- 公共作者资料由现有账号公开投影取得,不能由评论请求伪造昵称或头像;私有表不直接对浏览器订阅公开。日志不记录评论正文、Token 或私有账号字段。
- 新增表,不删除、改名、重排或修改现有游戏/版本字段;同步迁移登记、表目录、生成绑定与 schema 检查。现有评价数据不存在,无需旧数据回填;已有游戏无评价时自然返回空列表与“暂无评分”。
- 基础评价功能由新增评价接口提供评分摘要,不要求旧客户端或原游戏目录、详情 DTO 增加必填字段;广场评分展示对公开游戏投影的增量约束见下方独立合同。本次不扩展 `/api/external/v1`,不改外部 OpenAPI。
- 规则与校验进入 `module-game-distribution`,表与事务进入 `spacetime-module`,读取经 `spacetime-client` facade,HTTP/鉴权进入现有 `api-server` 分发路由,DTO 同步 `shared-contracts` / `packages/shared`;网站只持有展示和编辑草稿。
### HTTP 与 DTO
以下接口已实现,路径以 `/api/game-distribution` 为前缀,返回沿用平台 `ok/data/error/meta` envelope。用户评价与既有后台游戏发行“审核”是不同对象,`/admin/api/game-distribution/reviews` 继续只表示发行审核队列。
| 方法与路径 | 身份 | 请求与成功响应 |
| --- | --- | --- |
| `GET /games/{gameId}/reviews?page=1&pageSize=20` | 游客可读 | `data={reviews,page,pageSize,total,totalPages,ratingSummary}`;公共作者资料随每条评价返回 |
| `GET /games/{gameId}/my-review` | 登录用户 | `data={review}`;未评价为 `review:null`,不返回 404 |
| `PUT /games/{gameId}/my-review` | 登录用户 | `{score,comment}`;`comment` 可省略,按空字符串保存;首次保存与修改均返回 `200`、`data={review,ratingSummary}` |
- `review` 字段:`id`、`gameId`、`author:{id,name,avatarUrl}`、`score`、`comment`、`createdAt`、`updatedAt`;`avatarUrl` 可为空。个人与公共评价使用同一展示形状,时间为统一 UTC 格式。
- `ratingSummary={averageScore:number|null,ratingCount:number}`;公共列表 `total` 等于 `ratingCount`。`totalPages` 由总数与实际 `pageSize` 得出;均分与人数覆盖该游戏全部评价,不只覆盖当前页。
- PUT 是“保存我的唯一评价”,不能指定目标用户或改他人记录;首次创建和更新不拆成不同接口,不接收任意 `reviewId` 定位写入。
- `400` 表示 JSON/字段类型、非整数分数或分页参数错误;`401` 表示未登录/认证失效;`404` 表示游戏不可公开访问;`422` 表示整数评分越界或评论超长;复用现有限流的 `429` 及可读 `5xx` 失败。
- 网络中断或响应丢失时保存结果可能未知,前端不能宣布“已提交”或删除原评价;保留草稿并可通过个人读取核对结果后重试。PUT 重试不会追加第二条记录。
- 公共与个人响应均不缓存过期的评价/评分摘要,使用 `Cache-Control: no-store`;不混入发布灰度或扩大现有发行沙箱权限。
### 验收标准与证据
| 条款 | 必须获得的证据 |
| --- | --- |
| 首次评分与可选评论 | 1 分、10 分和空评论成功;未选择评分、0/11/非整数被拒,后端没有无效写入 |
| 字数与输入 | 中文、英文、emoji、空格与换行按统一口径计数;4000 成功、4001 拒绝,字数提示实时可见,粘贴/中文输入法不绕过上限 |
| 唯一与并发 | 同一用户同游戏的双击、重试与并发首次保存后仍只有一条;不同用户、不同游戏各自独立 |
| 修改与取消 | 已评价先显示个人卡片;编辑完整预填,取消保留旧值,提交更新同一记录;提交中评分、评论和操作按钮均禁用,失败恢复编辑且不丢草稿;首次取消不落库 |
| 公共分页与重复展示 | 至少 21 条真实评价验证翻页/总数/顺序/空态;自己的评价在个人区和所属公共页各出现一次;空评论评价可见,编辑不换位 |
| 均分与人数 | 无记录为 `null/0`;仅评分计人数;改分只改变均分、不加人数;改评论不改变均分;统计基于所有页 |
| 身份与可见性 | 匿名读成功、写/读个人为 401;伪造用户不能修改他人;下架与封禁后读写 404、恢复公开后记录仍在 |
| 生命周期与故障 | 切换账号/游戏后旧响应不覆盖新状态;保存成功后旧读取不覆盖新记录与摘要,快速翻页/刷新只接受各区域最新读取结果;个人读取失败不误展首次表单,摘要失败不误显示无评分;评价失败不阻断游玩 |
| 页面与工程 | 桌面/移动真实浏览器可操作,评分可键盘选择;相关前后端定向测试、网站类型检查、真实 SpacetimeDB/API smoke、DTO/schema/绑定、编码/文档索引/diff 检查 |
实现已覆盖本节合同,按独立里程碑进行本地验收;具体证据如下。生产发布与用户验收仍单独确认,不能把本地验证当成上线证据。
| 证据 | 当前结果 |
| --- | --- |
| 前端交互与 API client | 游戏详情/发布/作者页等相关 Vitest 共 77 项通过,其中新增评价交互 10 项;覆盖编辑取消、空评论、码点上限、提交冻结、失败保留、保存后旧读取、快速分页与账号/游戏切换 |
| 领域与 HTTP/DTO | `module-game-distribution` 18 项、`api-server game_distribution` 31 项、共享 DTO 4 项通过;SpacetimeDB module 编译与 wasm 构建通过。module 的 `game_distribution` 测试筛选为 0 项,事务行为由真实数据库验收补充 |
| 工程契约 | 网站 typecheck 与游戏组件测试定向严格类型检查、定向 ESLint、DTO parity(20 组)、2.8.3 绑定生成/schema guard(86 表)、DDD/runtime access、共享生成契约、编码/文档索引/diff 检查通过 |
| 真实数据库/API | 使用本机隔离 `genarrative-reviews-e2e` 数据库,`E2E_REVIEWS_DATABASE=genarrative-reviews-e2e node scripts/check-game-distribution-ratings-e2e.mjs` 49 项通过;含 21 个账号/评价、六个并发首次保存、唯一性/同内容重试、1/10/空评论、码点与空白 4000/4001、换行归一化、身份伪造、分页/全量均分、no-store、下架/封禁/恢复公开与新版本保留。使用 `npm run dev:api-server` 拉起 API,健康检查通过;fixture 经现有受信 procedure 建立公开游戏,不上传 OSS,不替代发行资源/游玩验收 |
| 浏览器 | 本地真实 Chromium:1280×900 桌面与 375×812 移动视口通过游客浏览、登录、自己的评价在个人区/公共第二页各展示一次、编辑预填、取消恢复、方向键评分、提交收起、页码保留、重载回读、移动端空评论/1 分提交。4001 个 emoji 输入显示 `4001/4000` 并禁用提交,评分控件换行且输入/操作按钮不被底部导航遮挡;评论中的 HTML/Markdown 按纯文本显示。桌面与移动截图保存在本地忽略目录供复核 |
| 验证限制 | 未使用物理手机或真实系统中文输入法;组合输入事件、超限和迟到响应/故障路径由定向交互测试覆盖。未重跑与评价无关的完整测试套件 |
| 发布边界 | 未部署 dev 或 production;未执行原游戏发行生产验收 |
## 后台游戏评价管理合同
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | implemented(本地验证通过,待用户验收;未部署) |
| Date | 2026-10-01 |
| 交付目标 | 管理员查找游戏评价并隐藏、恢复或删除;公共列表和评分统计同步遵循管理状态,用户仍可编辑自己的评价 |
| 入口 | 后台“游戏评价”页,以及“游戏管理”的“查看评价”入口 |
| 里程碑 | [后台游戏评价管理](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md) |
### 范围与已确认规则
- 评价是评分与可选评论组成的整条记录,包含仅评分记录;管理员只读分数与正文,不能修改两者。
- 隐藏整条评价,公共列表不展示,平均分、评分人数和公共分页总数均不计算该记录;恢复后重新参与。
- 用户自己的评价保留,固定标记“已被管理员隐藏”,仍可编辑;编辑不自动解除隐藏,只有管理员可恢复。
- 删除物理移除评价,不支持恢复;用户再次读取自己的评价时得到 `review:null`,可重新提交,仍是一账号一游戏最多一条。
- 隐藏和删除必须填写原因,恢复不要求填写;原因只在后台展示,不返回普通用户。用户提示不拼接管理原因。
- 保留管理员、时间、原因和操作目标的持久操作记录,删除后仍可追查;不复制完整评论正文,不提供删除内容恢复。
- 首版只增加下述四类筛选、分页、详情、单条操作和详情内操作记录。不增加评分范围/时间范围筛选、批量处理、导出、举报、自动审核、申诉、通知、回复或点赞;不扩展 AGC、目录评分卡片或外部 API。
- 基础评价合同中“评价删除/审核不在范围内”仅指原基础功能。本节新增后台管理范围,不引入评价发布前审批。
### 后台入口、查找与展示
- 新增 `#game-reviews`,独立 Tab 权限为 `game-reviews`。Owner 沿用全部权限;member 由现有账号管理授权。所有读取和写入接口都校验该权限。
- “游戏管理”仅对有评价管理权限的管理员提供“查看评价”,跳转 `#game-reviews?gameId=<编码后的游戏ID>` 并自动查询;首次进入与 hash 参数变化都要生效。
- 游戏筛选:输入名称搜索并选择游戏,也可粘贴游戏 ID 精确选择。搜索覆盖已公开、未公开、作者下架和管理员暂停的游戏,游戏选择后按 ID 查询评价。
- 用户筛选:输入评价用户 ID 精确匹配,不按昵称模糊匹配;评论关键词:trim 后做纯文本包含匹配,区分大小写,不执行正则,空条件不限制,空评论不命中非空关键词。
- 状态筛选:全部/公开/已隐藏,默认全部;公开指评价未被隐藏,不代表所属游戏可公开访问。各条件以 AND 组合。
- 提供查询、重置、刷新;查询和重置回第一页,重置清空筛选并恢复“全部”。刷新保留当前条件与页码。
- 服务端先对完整数据筛选,再按首次提交时间倒序、同时间按评价 ID 倒序排序分页;编辑、隐藏和恢复不改变原顺序。默认每页 20 条,`page>=1`、`pageSize=1–50`;返回总条数和总页数,空结果为 0 页并禁用翻页。
- 列表列为游戏名称/ID、评价用户昵称/ID、评分、评论摘要、状态、提交时间、修改时间和操作。空评论在后台显示“仅评分”;摘要最多 120 Unicode 码点,截断时加省略号。
- “查看详情”使用现有弹窗,展示完整纯文本评论、分数、游戏/用户/评价 ID、创建和修改时间,并展示该次评价的操作记录。名称与头像关联现有游戏/账号信息,不复制到评价表。
- 评价表格的评论与操作原因保留换行,清除单元格内段落的默认上下外边距,与其他列顶对齐。
- 行内提供“隐藏”或“恢复”和“删除”。隐藏、删除通过原因输入及确认弹窗提交;原因 trim 后必须为 1–4000 Unicode 码点,不静默截断;恢复沿用现有后台确认交互。删除明确提示“删除后不可恢复,用户可以重新评价”。
- 请求期间禁用对应操作,失败保留原因草稿并显示错误,不先移除记录;成功刷新当前筛选。删除或状态过滤导致当前页越界时回到最后一页,零记录回第 1 页并显示空态。详情打开期间切换目标,不接受旧目标的迟到响应覆盖。
- 管理列表/详情不受游戏公开可见性限制。移动端表格横向滚动、弹窗内正文可滚动,操作按钮可达。
### 网站读取与统计变更
- 公共评价读取及保存后的摘要只计算 `is_hidden=false` 的记录。统计与公共分页在同一事务快照内计算,公共 `total=ratingCount`,不建立统计缓存或统计表。
- 所有记录被隐藏/删除时,网站显示“暂无评分”“暂无评价”;个人隐藏评价仍展示分数、正文、编辑按钮和“已被管理员隐藏”。
- 个人 GET/PUT 评价投影追加 `isHidden:boolean`,现有字段不删不改;公共列表只返回未隐藏记录,也可带同一字段(恒为 false)。管理原因和管理员身份不进入普通用户 DTO。
- 编辑隐藏评价保留隐藏状态,保存后仍不计入公共摘要;仅隐藏/恢复不修改评价 `createdAt` 或 `updatedAt`,后者继续只表示用户实际修改内容的时间。
- 管理动作完成后的下一次读取或刷新反映新状态;不新增推送、轮询或自动通知。用户在删除后保存仍按现有唯一 PUT 语义创建一条新评价,不增加“禁止再次评价”的记录。
- 游戏下架/封禁时,网站个人/公共读写仍按现有规则 404;后台仍可管理。游戏恢复公开不会自动解除评价隐藏。
### 数据、事务、重试与权限
- 现有私有评价表末尾追加 `is_hidden:bool`,明确默认值 false;旧评价自动视为未隐藏,不改主键、已有字段顺序或类型。保留游戏索引,新增用户 ID 索引。
- 新增私有 `game_distribution_review_moderation_log`:`operation_id` 主键、`review_id`、`game_id`、`user_id`、`review_created_at`、`action`(hide/restore/delete)、`admin_user_id`、`reason`(可空)、`created_at`。按 `review_id` 建索引,查看历史时同时匹配 `review_created_at`,避免混合删除前后两次评价。
- `operation_id` 使用管理员身份与 `Idempotency-Key` 的无歧义组合,重复 key 必须核对目标、动作、目标创建时间和规范化原因;不同请求复用 key 返回 409。同 key 同请求返回已完成操作,不能重新执行,尤其不能删除后来重新提交的评价。
- 每次管理请求带目标 `expectedCreatedAt`,事务核对评价当前创建时间;旧页面指向已删除后重建记录时返回 409,要求刷新。目标不存在且没有同请求成功记录时返回 404。
- 事务内原子完成身份校验、重试核对、目标校验、状态变更/物理删除及操作记录插入。已隐藏再隐藏或已公开再恢复不改变评价,但新的操作 key 仍记录本次管理请求,确保该次请求重试不会在状态后来改变后重新执行;同 key 重试不重复记录。
- API 从管理员会话获取身份,不能接受请求指定管理员;数据库 procedure 只允许现有受信 API 服务身份。用户 PUT 只修改内容,不能设置或重置隐藏状态。用户编辑与管理并发由数据库事务顺序处理,不引入内容版本锁。
- 操作记录独立于评价行存续,删除评价不级联删除记录;本次不新增自动清理任务或单独日志管理页面。历史仍可通过已有受权限保护的表查询查看,详情只展示当前评价实例的记录。
- 分层沿用 `module-game-distribution` 规则、`spacetime-module` 表/事务、`spacetime-client` facade、`api-server` HTTP/权限、共享 DTO、admin-web/网站表现;不新增直接访问私有表的前端路径。
### HTTP 与 DTO
新增路径以 `/admin/api/game-distribution` 为前缀,沿用平台 envelope 和 `Cache-Control: no-store`。原 `/reviews` 保持发行审核队列含义;评价 ID 在路径中按组件编码。
| 方法与路径 | 请求 | 成功 `data` |
| --- | --- | --- |
| `GET /user-review-games` | `query` 可空,非空先精确匹配 ID,同时对游戏名称做包含查询;名称匹配区分大小写;`page=1&pageSize=20` | `{games:[{gameId,title,status}],page,pageSize,total,totalPages}`,按创建时间/ID 倒序,分页范围同评价列表 |
| `GET /user-reviews` | 可选 `gameId,userId,keyword,status=all\|visible\|hidden`;`page=1&pageSize=20` | `{reviews,page,pageSize,total,totalPages}` |
| `GET /user-reviews/{reviewId}` | 无 body | `{review,operations}`,操作记录按时间/操作 ID 倒序,只包含当前创建时间对应的记录 |
| `POST /user-reviews/{reviewId}/moderation` | `Idempotency-Key`(沿用已有 1–128 字符限制);`{action:"hide"\|"restore"\|"delete",expectedCreatedAt,reason?}`;恢复省略原因 | `{review,operation,replayed}`;删除 `review=null` |
- 后台 `review` 为 `{id,gameId,game:{title,status},author:{id,name,avatarUrl},score,comment,isHidden,createdAt,updatedAt}`。时间使用统一 UTC 格式;游戏/作者资料来自关联读取,关联缺失时保留 ID、名称显示占位,不影响定位和处理。
- `operation` 为 `{id,reviewId,gameId,userId,reviewCreatedAt,action,adminUserId,reason,createdAt}`;恢复 `reason=null`。`replayed` 表示同一次成功操作重放,新 key 首次成功为 false。
- 400 为字段类型、枚举、分页、时间格式或幂等头错误;401 未认证;403 无页面权限;404 目标评价不存在;409 目标重建或同 key 请求不一致;422 原因为空/超长。错误不产生部分写入。网络失败保留草稿,以原 key 重试。
- 操作响应的 `review` 是该次操作事务的结果;重放返回当前评价实例的状态(原实例已删除则为 null),不能把后续新评价当作旧操作结果。管理列表按后续读取更新。
### 迁移与验收
- 日常评价回归与旧库迁移验收独立运行。先用 `check-game-distribution-ratings-e2e.mjs` 在显式指定的本机 `reviews-e2e` 隔离库生成新 fixture,再运行 `check-game-distribution-review-moderation-e2e.mjs check`;后台凭据通过该脚本的 `prepare` 模式提前准备,按现有后台 owner 配置启动 API。`check` 以本次读取的评价为基线,不依赖旧快照或迁移证据;`snapshot` / `migration` 仅用于下面的旧库升级验收。非法输入的完整矩阵保留在领域/API 测试,真实 HTTP 回归保留代表性拒绝、无写入及权限、统计、幂等与删除重建验证。
- 将隐藏字段追加在已有 Rust 表结构末尾并设置明确默认值 false,按 SpacetimeDB 2.8.3 兼容追加规则发布;新增管理记录表为空。不手工回填或清空旧评价。
- 同步 `migration.rs`、后端表目录、生成绑定和 schema 检查。上线前用隔离数据库验证旧 schema 的评价升级后内容、唯一性、时间保留且全部公开;不把新数据库空表测试当成存量迁移证据。
- 回滚应用时保留新增字段和管理记录;旧后端不认识隐藏状态,会重新暴露已隐藏评价,因此产生隐藏记录后禁止直接回退到未过滤隐藏记录的旧读取实现,采用兼容修复。
| 条款 | 必须获得的证据 | 当前状态 |
| --- | --- | --- |
| 查找与分页 | 超过 20 条记录,四类筛选组合、跨页关键词、游戏名称/ID选择、入口预填、重置、空态、越界页恢复 | 定向交互测试及真实 HTTP 通过;浏览器确认四条件 AND、名称选择、入口预填、21 条跨页和刷新保留页码 |
| 管理操作 | 隐藏/删除必填原因、恢复无原因;取消无写入,失败保留草稿,完整正文只读、分数不可编辑 | 自动化及真实 HTTP 通过;浏览器确认隐藏空原因报错、取消、提交、恢复、删除警示和取消,长评论详情只读 |
| 统计与个人状态 | 隐藏后列表/人数/均分同步排除,全部隐藏为空态;自己显示精确提示,编辑仍隐藏,恢复重新计入 | 真实 HTTP 通过;浏览器确认隐藏后 21→20 人、均分更新,恢复后 20→21 人,个人提示、编辑预填及保存保持隐藏 |
| 删除与重试 | 物理删除、可重新提交唯一评价;旧目标/旧 key 重试不能删新评价,同 key 同请求不重复日志、不同请求 409 | 真实数据库/HTTP 通过,覆盖删除后重建、旧目标 409、旧 key 重放及同状态操作重试;浏览器未执行永久删除 |
| 权限与事务 | 401/403、伪造管理员/隐藏状态无效;私有记录不泄漏原因,失败不部分写入,下架游戏后台可管 | API/领域测试及真实 HTTP 通过;额外伪造管理员请求确认操作人仍来自认证会话 |
| 存量与工程 | 旧数据库无损升级、schema/绑定/DTO、定向 Rust/Vitest/typecheck,真实数据库/API及桌面/移动浏览器 | 21 条旧评价升级无损且默认公开;87 表 schema、生成绑定、31 组 DTO 校验通过;桌面及 375×812 视口确认筛选、表格、操作弹窗和长正文滚动 |
2026-10-01 本地验证:领域 21 项、API game_distribution 34 项、独立权限映射 1 项、基础共享 DTO 4 项及后台 DTO 1 项测试通过;最终前端回归为 7 个文件、74 项通过,含后台新评价页 8 项(提交途中切换游戏不覆盖新上下文)、网站评价 11 项,以及相关路由/client/游戏管理和发行审核。Spacetime module/client cargo check、Rust 格式、admin-web/网站类型检查、定向 ESLint、admin-web 构建、DDD/runtime 边界、文档索引、编码和 diff 检查通过。
真实运行时通过项目 dev 脚本启动隔离数据库与 API(`/healthz` 正常);旧 wasm 带 21 条评价兼容升级,逐项核对 ID、正文、评分、创建/修改时间均保持且隐藏默认 false。`scripts/check-game-distribution-review-moderation-e2e.mjs` 完成 57 项 HTTP 检查,另验证伪造管理员身份不能覆盖认证操作人。浏览器验证仅操作本地合成账号与游戏,没有生产数据写入或 OSS 上传。
回归脚本精简后,在现有本机隔离服务上以新 fixture 复跑:网站 40 项、后台 51 项 HTTP 检查通过,后台 `prepare` / `check` 不再依赖旧库验收产物。网站交互 11 项、评价领域 5 项、Spacetime module 编译、网站类型检查及定向格式/静态检查通过。此轮未重新发布 module 或重跑旧库迁移、浏览器验收;HTTP 结果用于验证脚本回归流程,新的内部读取简化由编译和源码条件对照验证。
未验证:生产部署、真实移动设备/系统输入法、无关全量测试和真实游戏包游玩。永久删除由真实 HTTP 验证,浏览器仅验证提示与取消;网络失败草稿由交互自动化验证,未在浏览器断网复现。用户验收尚未完成,计划和里程碑保留;不据此宣称已上线。
## 游戏广场评分展示合同
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | implemented(本地验证通过,待用户验收;未部署) |
| Date | 2026-10-01 |
| 入口 | 网站 `/games` 游戏广场卡片 |
| 交付目标 | 游客与登录用户在游戏卡片查看真实平均分和评分人数,统计与详情评价及后台管理口径一致 |
| 里程碑 | [游戏广场评分展示](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md) |
### 范围与展示规则
- 在广场卡片简介下方、分类和作者信息上方增加评分摘要,保留标题、游玩次数、封面、卡片点击、现有排序及筛选。移动端摘要允许换行,长标题和较大人数不能撑宽卡片。
- 有评分时显示如 `8.2/10 · 26 人评分`,均分固定一位小数;不把 10 分制转换成 5 星制。无人评分时显示“暂无评分”。
- 摘要来自列表响应,不为每张卡片请求公共评价页或个人评价;详情仍由现有评价读取/保存结果驱动,保存后可即时更新详情摘要。
- 广场返回时重新读取列表,保留既有筛选与滚动位置;用户改分或管理员隐藏、恢复、删除后的下一次成功读取反映新统计。不增加推送、轮询或后台管理通知。
- 本增量只覆盖网站公开评分摘要,不增加评分排序、排行、推荐、评论预览、卡片评分输入、AGC 评价或作者管理页评分展示。
### 统计、可见性与失败行为
- 统计沿用已有有效评价口径:只计算未隐藏的评价,包含空评论、当前用户和作者的评价;每账号每游戏最多计一次,编辑不增加人数,新发行版本不清空评价。
- 游戏列表仍只返回满足现有公开可见性与有效公开版本规则的游戏;评分不放宽下架、封禁或版本可见性。游戏资料和摘要在同一次数据库读取事务内形成一致快照。
- 无有效评价时严格返回 `averageScore=null`、`ratingCount=0`;全部评价被隐藏或删除后同样返回该空态。均分由后端对该游戏全部有效评价计算,不由前端分页或累计推导。
- 列表读取失败沿用整页错误与重新加载入口,不能用虚构零分或“暂无评分”代替失败。兼容期响应缺少评分摘要时,卡片显示“评分暂不可用”,保留游戏资料和打开详情能力;只有明确的 `null/0` 摘要表示无人评分。
- 公共摘要只包含均分与人数,不返回评论正文、管理原因、管理员身份或个人评价状态;游客读取不要求登录或发布权限。
### API、兼容与数据边界
| 方法与路径 | 增量响应合同 |
| --- | --- |
| `GET /api/game-distribution/games` | `data.games[]` 每项保证增加 `ratingSummary:{averageScore:number|null,ratingCount:number}`,原字段和 envelope 保留 |
| `GET /api/game-distribution/games/{gameId}` | 与列表共用公开投影,保证返回同形状 `ratingSummary`;详情评价接口仍提供提交/刷新时的权威摘要 |
- 公开列表和详情使用 `Cache-Control: no-store`,避免浏览器或代理复用过期摘要;既有评价接口的 no-store 行为保留。
- 共用游戏 DTO 中 `ratingSummary` 为可选字段,以允许作者侧响应和旧响应省略;当前公开列表/详情必须返回对象。字段缺失与 `averageScore=null,ratingCount=0` 具有不同含义,旧客户端可忽略新增字段。
- 同步 Rust/TypeScript 共享 DTO、数据库 procedure 返回类型、后端 facade/mapper、生成绑定和响应字段契约检查。摘要在读取时计算,不增加持久化统计表、字段、缓存、回填或重算任务。
- 公开投影返回类型变化需要数据库 module 与后端绑定配套更新;先验证配套后端/module,再更新网站。现有持久化表及评价数据不变,无破坏性迁移。
- 不扩展 `/api/external/v1`,不改变外部 OpenAPI、后台管理接口或评价写入语义。
### 验收与当前证据
| 条款 | 验收方式 | 当前证据 |
| --- | --- | --- |
| 展示与字段 | 现有页面测试补正常评分/零评价/缺摘要三种情况,现有公开响应/DTO 测试核对新增字段 | 前端两文件 36 项、API 定向 34 项通过;仅新增评分文案参数化断言并扩展已有响应测试 |
| 统计口径 | 复用有效评价统计及管理测试;用两款游戏和少量评价做一次列表/详情/评价摘要对照,确认隐藏过滤与统计不串联 | 领域 summary 2 项通过;隔离库两款游戏、两条空评论评价,`8/2` 隐藏一条后为 `7/1`,另一游戏始终 `null/0`,三接口摘要一致 |
| 页面与原行为 | 桌面及 375px 移动检查评分行和详情返回后的更新;可见性、列表失败、筛选与滚动恢复沿用现有测试 | 1280px/375px 浏览器卡片无横向溢出;详情期间经 API 改分,返回广场由 `7.0/1` 更新为 `6.0/1`;既有目录/详情测试通过 |
| 工程与兼容 | 受影响代码定向测试/typecheck、DTO/绑定/schema、编码/文档索引/diff 检查及上述一次隔离 API smoke | Rust shared-contracts 4 项、module/client 编译、TypeScript typecheck、受影响文件静态/格式检查及 DTO/绑定/schema/编码/文档索引/diff 检查通过;真实列表/详情 no-store 通过 |
测试按本增量风险控制:只补新展示与摘要契约的必要断言,复用已有评价、管理和目录测试;不新增框架、专用 E2E 脚本、大批量分页数据、故障注入或重复的多层映射测试。既有证据可支撑未改动的规则,但仍须验证本次公开投影和页面接入。
2026-10-01 按用户后续实现授权完成公开快照、共享契约、后端响应和共享评分文本接入。绑定以仓库固定 SpacetimeDB 2.8.3 生成,持久化表未变;只为列表最终返回游戏聚合评价。隔离库没有既有评分 fixture,本次复用既有 helper 临时创建两个账号、两款游戏和两条评价,未新增仓库 E2E 脚本或完整管理矩阵。
本地验证通过,尚待用户验收和生产部署。未跑完整仓库测试、完整评价/后台管理 E2E、真实手机、长标题/极大人数浏览器矩阵、浏览器登录改分和实际发行包游玩;返回刷新使用真实 API 改分验证,登录用户公开列表已在 API smoke 对照。

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