实现后台游戏评价管理
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled

新增后台评价查询分页、详情和独立页面权限
支持管理员隐藏、恢复和删除评价,隐藏删除必须填写原因
新增私有操作记录及幂等重试保护,兼容升级评价隐藏字段
同步网站隐藏提示、公开评价列表和评分统计
补充定向测试、运行时验证脚本及方案验收证据
This commit is contained in:
2026-10-01 12:22:13 +08:00
parent 264fdee747
commit cb689fea2a
52 changed files with 4579 additions and 56 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
+23
View File
@@ -766,6 +766,29 @@ 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-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:
+2 -2
View File
@@ -21,8 +21,8 @@
- [当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md):现役入口、账号钱包、UI 和后端分层。
- [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-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):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。
- [外部 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 契约唯一机器可读来源。
@@ -3,7 +3,7 @@
| 字段 | 值 |
| --- | --- |
| Milestone | [后台游戏评价管理](./【里程碑】后台游戏评价管理-2026-10-01.md) |
| Status | draft(待技术方案评审,未开始编码) |
| Status | implemented(本地验证通过,待用户验收;未部署) |
| Owner | Codex |
## 修改边界
@@ -45,4 +45,4 @@
## 当前状态
只编写方案和计划,业务代码/schema 均未修改,所有功能测试及运行时证据待实现后提供。
已按上述边界完成实现;定向自动化、旧 schema 带 21 条评价无损升级、真实 HTTP 管理链路和桌面/375×812 移动视口验证通过。完整证据及未验证项见主规范“迁移与验收”。保留计划供用户验收;未部署。
@@ -3,7 +3,7 @@
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | proposed(产品规则已确认,技术方案待评审;未实现) |
| Status | implemented(本地验证通过,待用户验收;未部署) |
| Date | 2026-10-01 |
| Parent Spec | [后台游戏评价管理合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同) Version 1.0 |
@@ -27,7 +27,7 @@
## 依赖与前置条件
- 主规范与本里程碑的技术方案评审后才进入编码;本轮仅方案文档,实施计划为待评审草案。
- 用户已确认主规范与本里程碑按方案实施;实施计划进入执行,范围仍限本里程碑。
- 基础网站评价已实现且本地验证通过,其用户验收/生产发布状态独立;本次不推定该功能已验收。
- 现有后台认证/账号授权、游戏/账号读取、评价唯一写入、受信服务身份及 SpacetimeDB 2.8.3 可用。
- 有隔离数据库可验证带真实存量评价的旧 schema 升级,以及超过 20 条评价的筛选/分页。
@@ -50,4 +50,4 @@
- 自动化:领域原因校验/统计过滤,API鉴权/错误/DTO,后台筛选及操作交互、网站隐藏个人状态。
- 运行时:存量 schema 升级,21 条以上评价的筛选分页、隐藏/恢复/删除、重新提交与重试;浏览器后台和网站联动。
- 边界:401/403、管理原因泄漏、原子失败、状态并发、删除后主键复用、全部隐藏和页码越界。
- 当前仅文档验证,不将既有评价功能证据复用为本里程碑通过记录。
- 本里程碑的自动化、存量升级、真实 HTTP 及桌面/移动视口证据已回写[主规范](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#迁移与验收);上方复选框保留供用户验收,不以既有评价功能证据替代。
@@ -5,8 +5,8 @@
- 用户确认新增管理员隐藏/删除评价,后台不能修改分数或正文;隐藏整条评价并从公共列表、平均分、人数和公共分页总数排除,恢复后重新参与。个人区域固定提示“已被管理员隐藏”,用户仍可编辑但不能自动恢复公开。
- 隐藏和删除必须填写原因,恢复不要求;原因仅后台展示。删除物理移除,不能恢复,用户可重新评价,唯一规则继续成立。
- 当前方案范围为游戏名称选择/ID定位、评价用户 ID、评论关键词、状态四类组合筛选,以及分页、详情、单条操作和持久操作记录。不增加批量、导出、举报、自动审核或评分/时间范围筛选。
- 数据方案:评价表末尾追加默认 false 的 is_hidden;新增私有管理记录,事务保存操作人/原因/时间,以创建时间区分删除后重建记录,同 key 重试不得再次操作新评价。不增加统计缓存。
- 状态:用户本次要求先写方案,未授权本轮编码/部署;产品规则已确认,接口/schema 技术方案待评审,既有评价测试不代表管理功能完成。
- 实现边界:评价表末尾追加默认 false 的 is_hidden;新增私有管理记录,事务保存操作人/原因/时间,以创建时间区分删除后重建记录,同 key 重试不得再次操作新评价。不增加统计缓存;用户编辑保持隐藏状态,管理操作不改变用户内容时间。
- 状态:用户确认按方案实施;后台与网站联动已实现并通过本地存量升级、真实 HTTP 和浏览器验证,证据见主规范。待用户验收,未部署。产生隐藏记录后不得直接回退到未过滤隐藏状态的旧后端。
- 权威入口:[后台游戏评价管理合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同);活动[里程碑](../plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](../plans/【实施计划】后台游戏评价管理-2026-10-01.md)。
## 2026-09-30 网站游戏评价范围与状态边界
@@ -468,20 +468,27 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 事务内复用公开详情可见性,创建或更新唯一评价;重复相同内容不改时间。公共分页按创建时间和主键倒序,作者资料从 `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)
### 后台游戏评价管理数据增量(2026-10-01)
状态:`proposed`,产品规则已确认,技术方案待评审;尚未实现。下述字段/表不属于当前 schema。完整合同见[后台游戏评价管理](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同)。
状态:`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 检查;在存量测试数据库兼容升级,验证 false 默认值及既有记录内容/唯一性/时间不变。部署后若已隐藏评价,不直接回退到未过滤隐藏记录的旧后端。
- 已同步 `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、审核者和阶段时间。
@@ -2,7 +2,7 @@
> 更新时间:`2026-10-01`
>
> 本文描述现役主站平台壳、图片画布编辑器和游戏分发合同;拟新增能力在各节单独标明状态。网站游戏评分与评价已实现并通过本地验证,后台游戏评价管理尚未实现。当前实现以路由树、shared-contracts 和 SpacetimeDB module / bindings 为准。
> 本文描述现役主站平台壳、图片画布编辑器和游戏分发合同;拟新增能力在各节单独标明状态。网站游戏评分与评价、后台游戏评价管理已实现并通过本地验证,待用户验收,未部署。当前实现以路由树、shared-contracts 和 SpacetimeDB module / bindings 为准。
## 现役平台入口
@@ -179,7 +179,7 @@
## 网站游戏评分与评价合同
本节记录已实现的基础评价合同;后台管理的增量规则见下方“后台游戏评价管理合同”,该增量尚未实现,不把基础功能的验收证据视为管理功能证据。
本节记录已实现的基础评价合同;已实现的后台管理增量规则及其独立验证证据见下方“后台游戏评价管理合同”。
| 字段 | 值 |
| --- | --- |
@@ -313,7 +313,7 @@
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | proposed(产品规则已确认,技术方案待评审;未实现) |
| Status | implemented(本地验证通过,待用户验收;未部署) |
| Date | 2026-10-01 |
| 交付目标 | 管理员查找游戏评价并隐藏、恢复或删除;公共列表和评分统计同步遵循管理状态,用户仍可编辑自己的评价 |
| 入口 | 后台“游戏评价”页,以及“游戏管理”的“查看评价”入口 |
@@ -365,7 +365,7 @@
- 操作记录独立于评价行存续,删除评价不级联删除记录;本次不新增自动清理任务或单独日志管理页面。历史仍可通过已有受权限保护的表查询查看,详情只展示当前评价实例的记录。
- 分层沿用 `module-game-distribution` 规则、`spacetime-module` 表/事务、`spacetime-client` facade、`api-server` HTTP/权限、共享 DTO、admin-web/网站表现;不新增直接访问私有表的前端路径。
### HTTP 与 DTO 草案
### HTTP 与 DTO
新增路径以 `/admin/api/game-distribution` 为前缀,沿用平台 envelope 和 `Cache-Control: no-store`。原 `/reviews` 保持发行审核队列含义;评价 ID 在路径中按组件编码。
@@ -389,11 +389,15 @@
| 条款 | 必须获得的证据 | 当前状态 |
| --- | --- | --- |
| 查找与分页 | 超过 20 条记录,四类筛选组合、跨页关键词、游戏名称/ID选择、入口预填、重置、空态、越界页恢复 | 待实现验证 |
| 管理操作 | 隐藏/删除必填原因、恢复无原因;取消无写入,失败保留草稿,完整正文只读、分数不可编辑 | 待实现验证 |
| 统计与个人状态 | 隐藏后列表/人数/均分同步排除,全部隐藏为空态;自己显示精确提示,编辑仍隐藏,恢复重新计入 | 待实现验证 |
| 删除与重试 | 物理删除、可重新提交唯一评价;旧目标/旧 key 重试不能删新评价,同 key 同请求不重复日志、不同请求 409 | 待实现验证 |
| 权限与事务 | 401/403、伪造管理员/隐藏状态无效;私有记录不泄漏原因,失败不部分写入,下架游戏后台可管 | 待实现验证 |
| 存量与工程 | 旧数据库无损升级、schema/绑定/DTO、定向 Rust/Vitest/typecheck,真实数据库/API及桌面/移动浏览器 | 待实现验证 |
| 查找与分页 | 超过 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 上传。
未验证:生产部署、真实移动设备/系统输入法、无关全量测试和真实游戏包游玩。永久删除由真实 HTTP 验证,浏览器仅验证提示与取消;网络失败草稿由交互自动化验证,未在浏览器断网复现。用户验收尚未完成,计划和里程碑保留;不据此宣称已上线。
@@ -42,6 +42,8 @@ export type GameDistributionAuthor = {
};
export type GameDistributionReview = {
/** 管理员隐藏后仍允许本人读取和编辑,不参与公共统计。 */
isHidden: boolean;
id: string;
gameId: string;
author: GameDistributionAuthor;
+27 -5
View File
@@ -14,6 +14,8 @@ import fs from 'node:fs';
const RUST_FILE = 'server-rs/crates/shared-contracts/src/game_distribution.rs';
const TS_FILE = 'packages/shared/src/contracts/gameDistribution.ts';
const ADMIN_RUST_FILE = 'server-rs/crates/shared-contracts/src/admin.rs';
const ADMIN_TS_FILE = 'apps/admin-web/src/api/adminApiTypes.ts';
const API_MODULE_FILE =
'server-rs/crates/api-server/src/modules/game_distribution.rs';
@@ -48,6 +50,17 @@ const PAIRS = [
'GameDistributionCreateVersionRequest',
],
['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'],
['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'],
['AdminGameReviewGame', 'AdminGameReviewGame'],
['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'],
['AdminGameReviewsQuery', 'AdminGameReviewsQuery'],
['AdminGameReviewGameInfo', 'AdminGameReviewGameInfo'],
['AdminGameReview', 'AdminGameReview'],
['AdminGameReviewOperation', 'AdminGameReviewOperation'],
['AdminGameReviewsResponse', 'AdminGameReviewsResponse'],
['AdminGameReviewDetailResponse', 'AdminGameReviewDetailResponse'],
['AdminGameReviewModerationRequest', 'AdminGameReviewModerationRequest'],
['AdminGameReviewModerationResponse', 'AdminGameReviewModerationResponse'],
];
// 服务端逐字段手拼 JSON 的响应/请求(`public_game_payload` / `game_payload` 等),TS 里这些类型
@@ -107,7 +120,7 @@ function renamedMember(value, kind, rename) {
function rustDefinitions(source) {
const result = new Map();
const pattern =
/\n(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+)(?<body>[\s\S]*?)\n\}/g;
/\n(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+|AdminGameReview\w*)(?<body>[\s\S]*?)\n\}/g;
let match;
while ((match = pattern.exec(source))) {
const { attrs, kind, name, body } = match.groups;
@@ -128,7 +141,7 @@ function rustDefinitions(source) {
function tsDefinitions(source) {
const result = new Map();
const objectPattern =
/export type (GameDistribution\w+) = \{([\s\S]*?)\n\};/g;
/export (?:type|interface) (GameDistribution\w+|AdminGameReview\w*)(?: =)? \{([\s\S]*?)\n\};?/g;
let match;
while ((match = objectPattern.exec(source))) {
const members = [];
@@ -141,7 +154,8 @@ function tsDefinitions(source) {
}
result.set(match[1], { kind: 'struct', members, required });
}
const unionPattern = /export type (GameDistribution\w+) =\s*([^;]+);/g;
const unionPattern =
/export type (GameDistribution\w+|AdminGameReview\w*) =\s*([^;]+);/g;
while ((match = unionPattern.exec(source))) {
if (result.has(match[1])) continue;
result.set(match[1], {
@@ -303,8 +317,16 @@ function insertedKeys(body) {
);
}
const rust = rustDefinitions(fs.readFileSync(RUST_FILE, 'utf8'));
const ts = tsDefinitions(fs.readFileSync(TS_FILE, 'utf8'));
const rust = rustDefinitions(
fs.readFileSync(RUST_FILE, 'utf8') +
'\n' +
fs.readFileSync(ADMIN_RUST_FILE, 'utf8'),
);
const ts = tsDefinitions(
fs.readFileSync(TS_FILE, 'utf8') +
'\n' +
fs.readFileSync(ADMIN_TS_FILE, 'utf8'),
);
const failures = [];
const mappedRust = new Set(PAIRS.map(([rustName]) => rustName));
const mappedTs = new Set(PAIRS.map(([, tsName]) => tsName));
File diff suppressed because it is too large Load Diff
+67
View File
@@ -2215,6 +2215,12 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission
"/admin/api/editor-assets" => AnyTab(&["editor-assets"]),
"/admin/api/assets/read-url" => AnyTab(&["editor-assets", "editor-showcase"]),
path if path.starts_with("/admin/api/editor-showcase/") => AnyTab(&["editor-showcase"]),
"/admin/api/game-distribution/user-review-games" => AnyTab(&["game-reviews"]),
path if path == "/admin/api/game-distribution/user-reviews"
|| path.starts_with("/admin/api/game-distribution/user-reviews/") =>
{
AnyTab(&["game-reviews"])
}
path if path.starts_with("/admin/api/game-distribution/reviews") => {
AnyTab(&["editor-showcase"])
}
@@ -7502,6 +7508,67 @@ mod tests {
);
}
#[test]
fn game_reviews_tab_authorizes_all_user_review_routes_only() {
for (method, path) in [
(
Method::GET,
"/admin/api/game-distribution/user-review-games",
),
(Method::GET, "/admin/api/game-distribution/user-reviews"),
(
Method::GET,
"/admin/api/game-distribution/user-reviews/review_1",
),
(
Method::POST,
"/admin/api/game-distribution/user-reviews/review_1/moderation",
),
] {
assert!(enforce_admin_request_permission("owner", &[], &[], &method, path).is_ok());
assert!(
enforce_admin_request_permission(
"member",
&["game-reviews".into()],
&[],
&method,
path
)
.is_ok()
);
for permissions in [
vec![],
vec!["game-management".into()],
vec!["editor-showcase".into()],
] {
assert_eq!(
enforce_admin_request_permission("member", &permissions, &[], &method, path)
.unwrap_err()
.status_code(),
StatusCode::FORBIDDEN
);
}
}
for path in [
"/admin/api/game-distribution/reviews",
"/admin/api/game-distribution/games",
"/admin/api/game-distribution/versions/version_1/review",
] {
assert_eq!(
enforce_admin_request_permission(
"member",
&["game-reviews".into()],
&[],
&Method::GET,
path
)
.unwrap_err()
.status_code(),
StatusCode::FORBIDDEN
);
}
}
#[test]
fn wallet_consumption_reconcile_requires_its_standalone_action_permission() {
assert!(
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,10 @@ mod release;
mod reviews;
pub use reviews::{
UserReviewValidationError, normalize_review_comment, review_id, review_order, review_summary,
ReviewModerationValidationError, UserReviewValidationError, normalize_review_comment,
normalize_review_moderation_reason, review_id, review_matches_filters,
review_moderation_operation_id, review_order, review_summary, validate_review_list_status,
visible_review_summary,
};
pub use application::{
@@ -46,6 +46,14 @@ pub fn review_summary(scores: impl Iterator<Item = i64>) -> (Option<f64>, u64) {
(average, count)
}
pub fn visible_review_summary(reviews: impl Iterator<Item = (i64, bool)>) -> (Option<f64>, u64) {
review_summary(
reviews
.filter(|(_, is_hidden)| !is_hidden)
.map(|(score, _)| score),
)
}
pub fn review_order(
left_created: i64,
left_id: &str,
@@ -57,6 +65,72 @@ pub fn review_order(
.then_with(|| right_id.cmp(left_id))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewModerationValidationError {
InvalidAction,
InvalidStatus,
InvalidReason,
}
impl fmt::Display for ReviewModerationValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::InvalidAction => "REVIEW_BAD_REQUEST: 管理动作须为 hide、restore 或 delete",
Self::InvalidStatus => "REVIEW_BAD_REQUEST: 状态须为 all、visible 或 hidden",
Self::InvalidReason => "REVIEW_VALIDATION: 原因去除首尾空白后须为 1–4000 字符",
})
}
}
pub fn normalize_review_moderation_reason(
action: &str,
reason: Option<&str>,
) -> Result<Option<String>, ReviewModerationValidationError> {
match action {
"restore" => Ok(None),
"hide" | "delete" => {
let reason = reason.unwrap_or("").trim();
if !(1..=4000).contains(&reason.chars().count()) {
return Err(ReviewModerationValidationError::InvalidReason);
}
Ok(Some(reason.to_string()))
}
_ => Err(ReviewModerationValidationError::InvalidAction),
}
}
pub fn validate_review_list_status(status: &str) -> Result<(), ReviewModerationValidationError> {
match status {
"all" | "visible" | "hidden" => Ok(()),
_ => Err(ReviewModerationValidationError::InvalidStatus),
}
}
pub fn review_matches_filters(
review_game_id: &str,
review_user_id: &str,
comment: &str,
is_hidden: bool,
game_id: Option<&str>,
user_id: Option<&str>,
keyword: Option<&str>,
status: &str,
) -> bool {
game_id.is_none_or(|value| value == review_game_id)
&& user_id.is_none_or(|value| value == review_user_id)
&& keyword.is_none_or(|value| comment.contains(value))
&& match status {
"all" => true,
"visible" => !is_hidden,
"hidden" => is_hidden,
_ => false,
}
}
pub fn review_moderation_operation_id(admin_user_id: &str, idempotency_key: &str) -> String {
format!("{}:{admin_user_id}{idempotency_key}", admin_user_id.len())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -106,4 +180,122 @@ mod tests {
assert_eq!(review_order(1, "z", 2, "a"), Ordering::Greater);
assert_eq!(review_order(2, "b", 2, "a"), Ordering::Less);
}
#[test]
fn moderation_reason_and_action_follow_contract() {
for action in ["hide", "delete"] {
assert_eq!(
normalize_review_moderation_reason(action, None),
Err(ReviewModerationValidationError::InvalidReason)
);
assert_eq!(
normalize_review_moderation_reason(action, Some(" \n ")),
Err(ReviewModerationValidationError::InvalidReason)
);
assert_eq!(
normalize_review_moderation_reason(action, Some(" 原因😀 \n")),
Ok(Some("原因😀".into()))
);
assert!(normalize_review_moderation_reason(action, Some(&"😀".repeat(4000))).is_ok());
assert_eq!(
normalize_review_moderation_reason(action, Some(&"😀".repeat(4001))),
Err(ReviewModerationValidationError::InvalidReason)
);
}
assert_eq!(
normalize_review_moderation_reason("restore", None),
Ok(None)
);
assert_eq!(
normalize_review_moderation_reason("restore", Some("忽略")),
Ok(None)
);
assert_eq!(
normalize_review_moderation_reason("edit", Some("原因")),
Err(ReviewModerationValidationError::InvalidAction)
);
assert_ne!(
review_moderation_operation_id("a", "bc"),
review_moderation_operation_id("ab", "c")
);
assert_ne!(
review_moderation_operation_id("admin1", "key"),
review_moderation_operation_id("admin2", "key")
);
}
#[test]
fn review_filters_combine_exact_ids_case_sensitive_text_and_visibility() {
assert!(review_matches_filters(
"game1",
"user1",
"Text 中文",
true,
Some("game1"),
Some("user1"),
Some("Text"),
"hidden"
));
assert!(!review_matches_filters(
"game1",
"user1",
"Text 中文",
true,
Some("game1"),
Some("user1"),
Some("text"),
"hidden"
));
assert!(!review_matches_filters(
"game1",
"user1",
"Text",
true,
Some("game2"),
None,
None,
"all"
));
assert!(!review_matches_filters(
"game1",
"user1",
"",
false,
None,
None,
Some("Text"),
"visible"
));
assert!(!review_matches_filters(
"game1", "user1", "", true, None, None, None, "visible"
));
assert!(review_matches_filters(
"game1", "user1", "", false, None, None, None, "visible"
));
assert_eq!(validate_review_list_status("all"), Ok(()));
assert_eq!(
validate_review_list_status("published"),
Err(ReviewModerationValidationError::InvalidStatus)
);
}
#[test]
fn hidden_reviews_never_contribute_to_public_rating_summary() {
assert_eq!(
visible_review_summary([(10, true), (1, true)].into_iter()),
(None, 0)
);
assert_eq!(
visible_review_summary([(10, true), (1, false), (9, false)].into_iter()),
(Some(5.0), 2)
);
assert_eq!(
visible_review_summary([(1, true), (1, false), (9, false)].into_iter()),
(Some(5.0), 2)
);
assert_eq!(
visible_review_summary([(10, false), (1, false), (9, false)].into_iter()),
(Some(6.7), 3)
);
}
}

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