实现后台游戏评价管理
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
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:
@@ -5,12 +5,16 @@ import {
|
||||
executeAdminRechargeRefund,
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminGameReview,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminAgcTrackingEvents,
|
||||
listAdminGameDistributionGames,
|
||||
listAdminGameDistributionReviews,
|
||||
listAdminGameReviewGames,
|
||||
listAdminGameReviews,
|
||||
listAdminRechargeOrders,
|
||||
moderateAdminGameReview,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
restoreAdminGameDistributionGame,
|
||||
@@ -27,6 +31,52 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('用户评价查询编码筛选和目标,管理请求携带原创建时间及幂等key', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: {} }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await listAdminGameReviews('token', {
|
||||
gameId: 'game+1',
|
||||
userId: 'user/1',
|
||||
keyword: '中文 广告',
|
||||
status: 'hidden',
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
});
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-reviews?gameId=game%2B1&userId=user%2F1&keyword=%E4%B8%AD%E6%96%87+%E5%B9%BF%E5%91%8A&status=hidden&page=2&pageSize=20',
|
||||
);
|
||||
await listAdminGameReviewGames('token', { query: ' 中文 ', page: 1 });
|
||||
expect(fetchMock.mock.calls[1]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-review-games?query=%E4%B8%AD%E6%96%87&page=1',
|
||||
);
|
||||
await getAdminGameReview('token', '6:game/1user');
|
||||
expect(fetchMock.mock.calls[2]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-reviews/6%3Agame%2F1user',
|
||||
);
|
||||
const body = {
|
||||
action: 'delete' as const,
|
||||
expectedCreatedAt: '2026-10-01T00:00:00Z',
|
||||
reason: '广告',
|
||||
};
|
||||
await moderateAdminGameReview('token', '6:game/1user', 'same-attempt', body);
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
'/admin/api/game-distribution/user-reviews/6%3Agame%2F1user/moderation',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer token',
|
||||
'Idempotency-Key': 'same-attempt',
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
|
||||
const payload = { entries: [], nextCursor: null };
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
|
||||
@@ -38,6 +38,13 @@ import type {
|
||||
AdminGameDistributionReviewListResponse,
|
||||
AdminGameDistributionReviewRequest,
|
||||
AdminGameDistributionReviewResponse,
|
||||
AdminGameReviewDetailResponse,
|
||||
AdminGameReviewGamesQuery,
|
||||
AdminGameReviewGamesResponse,
|
||||
AdminGameReviewModerationRequest,
|
||||
AdminGameReviewModerationResponse,
|
||||
AdminGameReviewsQuery,
|
||||
AdminGameReviewsResponse,
|
||||
AdminImportAgcTemplatesResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
@@ -1243,6 +1250,61 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
||||
);
|
||||
}
|
||||
|
||||
function gameReviewQuery(
|
||||
query: AdminGameReviewGamesQuery | AdminGameReviewsQuery,
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && String(value).trim())
|
||||
params.set(key, String(value).trim());
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function listAdminGameReviewGames(
|
||||
token: string,
|
||||
query: AdminGameReviewGamesQuery = {},
|
||||
) {
|
||||
return request<AdminGameReviewGamesResponse>(
|
||||
`/admin/api/game-distribution/user-review-games?${gameReviewQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminGameReviews(
|
||||
token: string,
|
||||
query: AdminGameReviewsQuery = {},
|
||||
) {
|
||||
return request<AdminGameReviewsResponse>(
|
||||
`/admin/api/game-distribution/user-reviews?${gameReviewQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminGameReview(token: string, reviewId: string) {
|
||||
return request<AdminGameReviewDetailResponse>(
|
||||
`/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function moderateAdminGameReview(
|
||||
token: string,
|
||||
reviewId: string,
|
||||
idempotencyKey: string,
|
||||
payload: AdminGameReviewModerationRequest,
|
||||
) {
|
||||
return request<AdminGameReviewModerationResponse>(
|
||||
`/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}/moderation`,
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminGameDistributionGames(
|
||||
token: string,
|
||||
options: { limit?: number } = {},
|
||||
|
||||
@@ -1186,6 +1186,90 @@ export interface AdminGameDistributionRestoreResponse {
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
/** 游戏用户评价管理,与发行版本审核分开。 */
|
||||
export interface AdminGameReviewGamesQuery {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGame {
|
||||
gameId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGamesResponse {
|
||||
games: AdminGameReviewGame[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewsQuery {
|
||||
gameId?: string;
|
||||
userId?: string;
|
||||
keyword?: string;
|
||||
status?: 'all' | 'visible' | 'hidden';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGameInfo {
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReview {
|
||||
id: string;
|
||||
gameId: string;
|
||||
game: AdminGameReviewGameInfo;
|
||||
author: { id: string; name: string; avatarUrl?: string | null };
|
||||
score: number;
|
||||
comment: string;
|
||||
isHidden: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewOperation {
|
||||
id: string;
|
||||
reviewId: string;
|
||||
gameId: string;
|
||||
userId: string;
|
||||
reviewCreatedAt: string;
|
||||
action: string;
|
||||
adminUserId: string;
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewsResponse {
|
||||
reviews: AdminGameReview[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewDetailResponse {
|
||||
review: AdminGameReview;
|
||||
operations: AdminGameReviewOperation[];
|
||||
}
|
||||
|
||||
export interface AdminGameReviewModerationRequest {
|
||||
action: 'hide' | 'restore' | 'delete';
|
||||
expectedCreatedAt: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewModerationResponse {
|
||||
review: AdminGameReview | null;
|
||||
operation: AdminGameReviewOperation;
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplatePayload {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseRevie
|
||||
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
||||
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
|
||||
import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage';
|
||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
@@ -141,7 +142,7 @@ export function AdminApp() {
|
||||
|
||||
setRouteId(nextRouteId);
|
||||
const nextHash = routeHash(nextRouteId);
|
||||
if (window.location.hash !== nextHash) {
|
||||
if ((window.location.hash.split('?')[0] ?? '').toLowerCase() !== nextHash) {
|
||||
window.history.replaceState(null, '', nextHash);
|
||||
}
|
||||
}, [accessibleRoutes, admin, routeId, status]);
|
||||
@@ -325,6 +326,15 @@ export function AdminApp() {
|
||||
) : null}
|
||||
{activeRouteId === 'game-management' ? (
|
||||
<AdminGameManagementPage
|
||||
token={token}
|
||||
canManageReviews={accessibleRoutes.some(
|
||||
(route) => route.id === 'game-reviews',
|
||||
)}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'game-reviews' ? (
|
||||
<AdminGameReviewsPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
|
||||
@@ -55,6 +55,7 @@ const routeIcons = {
|
||||
'editor-showcase': Star,
|
||||
'game-distribution': Gamepad2,
|
||||
'game-management': Swords,
|
||||
'game-reviews': Star,
|
||||
'editor-assets': Images,
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
|
||||
@@ -8,6 +8,22 @@ import {
|
||||
routeHash,
|
||||
} from './adminRoutes';
|
||||
|
||||
test('游戏评价路由独立授权并支持游戏参数', () => {
|
||||
expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews');
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['game-reviews'],
|
||||
}).map((route) => route.id),
|
||||
).toEqual(['game-reviews']);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['game-management'],
|
||||
}).some((route) => route.id === 'game-reviews'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('客户端埋点路由遵守成员页签权限', () => {
|
||||
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
|
||||
expect(
|
||||
|
||||
@@ -18,6 +18,7 @@ export type AdminRouteId =
|
||||
| 'editor-showcase'
|
||||
| 'game-distribution'
|
||||
| 'game-management'
|
||||
| 'game-reviews'
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
@@ -62,6 +63,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
|
||||
{ id: 'game-reviews', label: '游戏评价', hash: '#game-reviews' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
|
||||
@@ -117,6 +117,20 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('有评价权限时游戏管理提供带gameId的评价入口', async () => {
|
||||
window.history.replaceState(null, '', '#game-management');
|
||||
render(
|
||||
<AdminGameManagementPage
|
||||
token="token"
|
||||
canManageReviews
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看评价' }));
|
||||
expect(window.location.hash).toBe('#game-reviews?gameId=game_1');
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -28,6 +28,7 @@ import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminGameManagementPageProps {
|
||||
token: string;
|
||||
canManageReviews?: boolean;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
@@ -86,6 +87,7 @@ function createGameActionIdempotencyKey(
|
||||
|
||||
export function AdminGameManagementPage({
|
||||
token,
|
||||
canManageReviews = false,
|
||||
onUnauthorized,
|
||||
}: AdminGameManagementPageProps) {
|
||||
const [games, setGames] = useState<AdminGameDistributionGameEntry[]>([]);
|
||||
@@ -327,6 +329,17 @@ export function AdminGameManagementPage({
|
||||
>
|
||||
版本历史
|
||||
</AdminButton>
|
||||
{canManageReviews ? (
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.hash = `#game-reviews?gameId=${encodeURIComponent(entry.gameId)}`;
|
||||
}}
|
||||
>
|
||||
查看评价
|
||||
</AdminButton>
|
||||
) : null}
|
||||
</AdminActionRow>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminGameReview,
|
||||
listAdminGameReviewGames,
|
||||
listAdminGameReviews,
|
||||
moderateAdminGameReview,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameReview,
|
||||
AdminGameReviewDetailResponse,
|
||||
AdminGameReviewsResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminGameReviewsPage } from './AdminGameReviewsPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
listAdminGameReviews: vi.fn(),
|
||||
listAdminGameReviewGames: vi.fn(),
|
||||
getAdminGameReview: vi.fn(),
|
||||
moderateAdminGameReview: vi.fn(),
|
||||
isAdminApiError: (error: unknown) =>
|
||||
typeof error === 'object' && error !== null && 'status' in error,
|
||||
formatAdminApiError: (error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
}));
|
||||
|
||||
const review: AdminGameReview = {
|
||||
id: '6:game_1user_1',
|
||||
gameId: 'game_1',
|
||||
game: { title: '测试游戏', status: 'published' },
|
||||
author: { id: 'user_1', name: '用户甲' },
|
||||
score: 8,
|
||||
comment: '<b>中文😀</b>\n完整正文',
|
||||
isHidden: false,
|
||||
createdAt: '2026-10-01T01:00:00Z',
|
||||
updatedAt: '2026-10-01T01:00:00Z',
|
||||
};
|
||||
function list(
|
||||
overrides: Partial<AdminGameReviewsResponse> = {},
|
||||
): AdminGameReviewsResponse {
|
||||
return {
|
||||
reviews: [review],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const unauthorized = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.history.replaceState(null, '', '#game-reviews');
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(list());
|
||||
vi.mocked(listAdminGameReviewGames).mockResolvedValue({
|
||||
games: [{ gameId: 'game_2', title: '第二个游戏', status: 'suspended' }],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
vi.mocked(getAdminGameReview).mockResolvedValue({ review, operations: [] });
|
||||
vi.mocked(moderateAdminGameReview).mockResolvedValue({
|
||||
review: { ...review, isHidden: true },
|
||||
operation: {
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin',
|
||||
reason: '广告',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
function show() {
|
||||
return render(
|
||||
<AdminGameReviewsPage token="token" onUnauthorized={unauthorized} />,
|
||||
);
|
||||
}
|
||||
async function row() {
|
||||
return (await screen.findByText('用户甲')).closest('tr')!;
|
||||
}
|
||||
|
||||
test('入口游戏参数和hash变化生效,组合筛选分页在服务端查询,重置回第一页', async () => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_1');
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ total: 21, totalPages: 2 }),
|
||||
);
|
||||
show();
|
||||
await row();
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_1', page: 1 }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('评价用户 ID'), {
|
||||
target: { value: 'user_1' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('评论关键词'), {
|
||||
target: { value: '中文' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('评价状态'), {
|
||||
target: { value: 'hidden' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', {
|
||||
gameId: 'game_1',
|
||||
userId: 'user_1',
|
||||
keyword: '中文',
|
||||
status: 'hidden',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
await row();
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('navigation', { name: '评价分页' })).getByRole(
|
||||
'button',
|
||||
{ name: '下一页' },
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 2, keyword: '中文' }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', {
|
||||
gameId: '',
|
||||
userId: '',
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_2');
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2', page: 1 }),
|
||||
),
|
||||
);
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
});
|
||||
|
||||
test('名称查找包含下架游戏,选择后按游戏ID筛选', async () => {
|
||||
show();
|
||||
await row();
|
||||
fireEvent.click(screen.getByRole('button', { name: '按名称查找游戏' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '查找游戏' });
|
||||
await within(dialog).findByText('第二个游戏');
|
||||
fireEvent.change(within(dialog).getByLabelText('游戏名称或 ID'), {
|
||||
target: { value: '第二个' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '搜索' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviewGames).toHaveBeenLastCalledWith('token', {
|
||||
query: '第二个',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
await within(dialog).findByText('第二个游戏');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '选择' }));
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('隐藏原因必填,失败保留草稿,同次重试复用幂等key,取消不写入', async () => {
|
||||
vi.mocked(moderateAdminGameReview).mockRejectedValueOnce(
|
||||
new Error('网络失败'),
|
||||
);
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' }));
|
||||
let dialog = screen.getByRole('dialog', { name: '隐藏评价' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
expect(
|
||||
await within(dialog).findByText('原因必须为 1–4000 个字符'),
|
||||
).toBeTruthy();
|
||||
expect(moderateAdminGameReview).not.toHaveBeenCalled();
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: ' 广告😀 ' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
await within(dialog).findByText('网络失败');
|
||||
expect(
|
||||
(within(dialog).getByLabelText('操作原因(必填)') as HTMLTextAreaElement)
|
||||
.value,
|
||||
).toBe(' 广告😀 ');
|
||||
const first = vi.mocked(moderateAdminGameReview).mock.calls[0]!;
|
||||
expect(first[3]).toEqual({
|
||||
action: 'hide',
|
||||
expectedCreatedAt: review.createdAt,
|
||||
reason: '广告😀',
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
await screen.findByText('评价已隐藏');
|
||||
expect(vi.mocked(moderateAdminGameReview).mock.calls[1]![2]).toBe(first[2]);
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
dialog = screen.getByRole('dialog', { name: '删除评价' });
|
||||
expect(
|
||||
within(dialog).getByText('删除后不可恢复,用户可以重新评价'),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
||||
expect(moderateAdminGameReview).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('4001码点原因不能提交,恢复复用确认且不要求原因', async () => {
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ reviews: [{ ...review, isHidden: true }] }),
|
||||
);
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
const deletion = screen.getByRole('dialog', { name: '删除评价' });
|
||||
fireEvent.change(within(deletion).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '😀'.repeat(4001) },
|
||||
});
|
||||
expect(within(deletion).getByText('4001/4000')).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
within(deletion).getByRole('button', {
|
||||
name: '确认删除',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
fireEvent.click(within(deletion).getByRole('button', { name: '取消' }));
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '恢复' }));
|
||||
const confirmation = screen.getByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirmation).getByRole('button', { name: '确认' }));
|
||||
await waitFor(() =>
|
||||
expect(moderateAdminGameReview).toHaveBeenCalledWith(
|
||||
'token',
|
||||
review.id,
|
||||
expect.any(String),
|
||||
{ action: 'restore', expectedCreatedAt: review.createdAt },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('删除使当前页越界时回到最后一页并保留条件', async () => {
|
||||
vi.mocked(listAdminGameReviews).mockImplementation(async (_token, query) =>
|
||||
list({ page: query?.page ?? 1, total: 21, totalPages: 2 }),
|
||||
);
|
||||
show();
|
||||
await row();
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('navigation', { name: '评价分页' })).getByRole(
|
||||
'button',
|
||||
{ name: '下一页' },
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 2 }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '删除评价' });
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '重复内容' },
|
||||
});
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ total: 20, totalPages: 1 }),
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 1 }),
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('评价已删除')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('详情完整纯文本只读并展示后台操作原因', async () => {
|
||||
vi.mocked(getAdminGameReview).mockResolvedValue({
|
||||
review,
|
||||
operations: [
|
||||
{
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin_1',
|
||||
reason: '广告内容',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
show();
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
const dialog = screen.getByRole('dialog', { name: '评价详情' });
|
||||
expect(await within(dialog).findByText('广告内容')).toBeTruthy();
|
||||
expect(dialog.textContent).toContain(review.comment);
|
||||
expect(dialog.querySelector('b')).toBeNull();
|
||||
expect(within(dialog).getByText('admin_1')).toBeTruthy();
|
||||
expect(within(dialog).queryByRole('textbox')).toBeNull();
|
||||
});
|
||||
|
||||
test('详情关闭后旧响应不会覆盖新目标', async () => {
|
||||
const second = {
|
||||
...review,
|
||||
id: 'review_2',
|
||||
author: { id: 'user_2', name: '用户乙' },
|
||||
comment: '第二条正文',
|
||||
};
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ reviews: [review, second], total: 2 }),
|
||||
);
|
||||
let resolveOld!: (value: { review: AdminGameReview; operations: [] }) => void;
|
||||
vi.mocked(getAdminGameReview)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AdminGameReviewDetailResponse>((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({ review: second, operations: [] });
|
||||
show();
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog')).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByText('用户乙').closest('tr')!).getByRole('button', {
|
||||
name: '查看详情',
|
||||
}),
|
||||
);
|
||||
await within(screen.getByRole('dialog')).findByText('第二条正文');
|
||||
await act(async () => {
|
||||
resolveOld({ review, operations: [] });
|
||||
});
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText('第二条正文'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('dialog').textContent).not.toContain(review.comment);
|
||||
});
|
||||
|
||||
test('管理提交中切换游戏,成功后刷新当前游戏而不恢复旧筛选', async () => {
|
||||
let finish!: (
|
||||
value: Awaited<ReturnType<typeof moderateAdminGameReview>>,
|
||||
) => void;
|
||||
vi.mocked(moderateAdminGameReview).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Awaited<ReturnType<typeof moderateAdminGameReview>>>(
|
||||
(resolve) => {
|
||||
finish = resolve;
|
||||
},
|
||||
),
|
||||
);
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_1');
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '隐藏评价' });
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '广告' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
act(() => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_2');
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
await within(screen.getByRole('dialog', { name: '评价详情' })).findByText(
|
||||
'评价 ID',
|
||||
);
|
||||
await act(async () => {
|
||||
finish({
|
||||
review: null,
|
||||
operation: {
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin',
|
||||
reason: '广告',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
});
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
);
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
expect(screen.getByRole('dialog', { name: '评价详情' })).toBeTruthy();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -766,6 +766,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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user