diff --git a/packages/backend/src/entities/judgement-visibility-request.ts b/packages/backend/src/entities/judgement-visibility-request.ts new file mode 100644 index 0000000..b92c4a0 --- /dev/null +++ b/packages/backend/src/entities/judgement-visibility-request.ts @@ -0,0 +1,17 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm'; +import { BaseEntity } from './base'; + +@Entity({ name: 'judgement_visibility_request' }) +export class JudgementVisibilityRequest extends BaseEntity { + @PrimaryColumn({ type: 'int', unsigned: true }) + uid: number; + + @Column({ name: 'hidden_until', type: 'int', unsigned: true }) + hiddenUntil: number; + + @CreateDateColumn({ name: 'created_at', type: 'datetime' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime' }) + updatedAt: Date; +} diff --git a/packages/backend/src/routers/admin.router.ts b/packages/backend/src/routers/admin.router.ts index 53796bb..47dd997 100644 --- a/packages/backend/src/routers/admin.router.ts +++ b/packages/backend/src/routers/admin.router.ts @@ -9,6 +9,8 @@ import { AnnouncementService } from '@/services/announcement.service'; import { SiteNotificationService } from '@/services/site-notification.service'; import { AdvertisementService } from '@/services/advertisement.service'; import { DeletionRequestService } from '@/services/deletion-request.service'; +import { JudgementService } from '@/services/judgement.service'; +import { JudgementQueryError, parseJudgementVisibilityUids } from '@/shared/judgement'; const router = new Router({ prefix: '/admin' }); @@ -190,6 +192,22 @@ router.get( } ); +router.post( + '/judgements/hide', + requiresPermission(Permission.MANAGE_CONTENT), + async (ctx: Context) => { + try { + const { uids } = (ctx.request.body || {}) as { uids?: unknown }; + ctx.success({ + items: await JudgementService.hideHistories(parseJudgementVisibilityUids(uids)) + }); + } catch (error) { + if (!(error instanceof JudgementQueryError)) throw error; + ctx.fail(error.status, error.message); + } + } +); + router.post( '/deletion-requests/:id/approve', requiresPermission(Permission.MANAGE_CONTENT), diff --git a/packages/backend/src/services/judgement.service.ts b/packages/backend/src/services/judgement.service.ts index 1b109c9..b05a2ad 100644 --- a/packages/backend/src/services/judgement.service.ts +++ b/packages/backend/src/services/judgement.service.ts @@ -1,6 +1,7 @@ import { AppDataSource } from '@/data-source'; import { JudgementFetchLog } from '@/entities/judgement-fetch-log'; import { JudgementRecord } from '@/entities/judgement-record'; +import { JudgementVisibilityRequest } from '@/entities/judgement-visibility-request'; import { logger } from '@/lib/logger'; import { createJudgementDedupKey, @@ -173,8 +174,18 @@ export class JudgementService { }); const [records, total] = await builder.getManyAndCount(); + const visibilityRequests = records.length + ? await JudgementVisibilityRequest.getRepository().findBy({ + uid: In([...new Set(records.map(record => record.uid))]) + }) + : []; + const hiddenUntilByUid = new Map( + visibilityRequests.map(request => [request.uid, request.hiddenUntil]) + ); return { - items: records.map(toJudgementListItem), + items: records.map(record => + toJudgementListItem(record, record.time <= (hiddenUntilByUid.get(record.uid) ?? 0)) + ), pagination: { page: query.page, limit: query.limit, @@ -184,6 +195,29 @@ export class JudgementService { }; } + static async hideHistories(uids: number[]) { + const hiddenUntil = Math.floor(Date.now() / 1000); + return AppDataSource.transaction(async manager => { + for (const uid of uids) { + await manager.query( + `INSERT INTO judgement_visibility_request (uid, hidden_until) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE + hidden_until = GREATEST(hidden_until, VALUES(hidden_until)), + updated_at = CURRENT_TIMESTAMP`, + [uid, hiddenUntil] + ); + } + const requests = await manager.getRepository(JudgementVisibilityRequest).findBy({ + uid: In(uids) + }); + const hiddenUntilByUid = new Map( + requests.map(request => [request.uid, request.hiddenUntil]) + ); + return uids.map(uid => ({ uid, hiddenUntil: hiddenUntilByUid.get(uid)! })); + }); + } + static async listLogs(query: JudgementPaginationQuery) { const [logs, total] = await JudgementFetchLog.getRepository().findAndCount({ order: { id: 'DESC' }, diff --git a/packages/backend/src/shared/judgement.ts b/packages/backend/src/shared/judgement.ts index 734c7c6..c929f41 100644 --- a/packages/backend/src/shared/judgement.ts +++ b/packages/backend/src/shared/judgement.ts @@ -50,6 +50,37 @@ export interface JudgementPaginationQuery { limit: number; } +export function parseJudgementVisibilityUids(value: unknown): number[] { + if (typeof value !== 'string') { + throw new JudgementQueryError('uids must be a string'); + } + + const values = value + .split(/[\r\n,]+/) + .map(item => item.trim()) + .filter(Boolean); + if (!values.length) { + throw new JudgementQueryError('At least one Luogu UID is required'); + } + + const uids: number[] = []; + const seen = new Set(); + for (const rawUid of values) { + if (!/^[1-9]\d*$/.test(rawUid)) { + throw new JudgementQueryError('uids must contain positive Luogu UIDs'); + } + const uid = Number(rawUid); + if (!Number.isSafeInteger(uid) || uid > UINT32_MAX) { + throw new JudgementQueryError('uids is out of range'); + } + if (!seen.has(uid)) { + seen.add(uid); + uids.push(uid); + } + } + return uids; +} + interface JudgementListRecord { id: number; uid: number; @@ -64,10 +95,28 @@ interface JudgementListRecord { createdAt: Date; } -export function toJudgementListItem(record: JudgementListRecord) { +export function toJudgementListItem(record: JudgementListRecord, hidden = false) { + if (hidden) { + return { + id: record.id, + uid: record.uid, + hidden: true as const, + name: record.name, + reason: '此记录已被账号所有者要求隐藏', + revoked_permission: 0, + added_permission: 0, + time: record.time, + user: record.userSnapshot, + fetch_log_id: record.fetchLogId, + log_fetched_at: record.fetchLog?.fetchedAt ?? null, + created_at: record.createdAt + }; + } + return { id: record.id, uid: record.uid, + hidden: false as const, name: record.name, reason: record.reason, revoked_permission: record.revokedPermission, diff --git a/packages/frontend/src/api/admin.ts b/packages/frontend/src/api/admin.ts index c0e54f7..0a3fb55 100644 --- a/packages/frontend/src/api/admin.ts +++ b/packages/frontend/src/api/admin.ts @@ -53,6 +53,11 @@ export interface AdminAdvertisement { updatedAt?: string; } +export interface AdminJudgementVisibilityItem { + uid: number; + hiddenUntil: number; +} + interface CreateWorkflowTemplateResponse { workflowId: string; taskIds: Record; @@ -104,6 +109,13 @@ export async function restorePaste(id: string) { })) as ApiResponse<{ id: string; restored: boolean }>; } +export async function hideAdminJudgementHistories(uids: string) { + return (await apiFetch('/admin/judgements/hide', { + method: 'POST', + data: { uids } + })) as ApiResponse<{ items: AdminJudgementVisibilityItem[] }>; +} + export async function getAdminAnnouncement() { return (await apiFetch('/admin/announcement')) as ApiResponse; } diff --git a/packages/frontend/src/api/judgement.ts b/packages/frontend/src/api/judgement.ts index 7cb2bb8..a834308 100644 --- a/packages/frontend/src/api/judgement.ts +++ b/packages/frontend/src/api/judgement.ts @@ -14,6 +14,7 @@ export interface JudgementItem { fetch_log_id: number; log_fetched_at: string | null; created_at: string; + hidden: boolean; } export interface JudgementUser { diff --git a/packages/frontend/src/routers/modules/admin.ts b/packages/frontend/src/routers/modules/admin.ts index 1280fa2..c3442e3 100644 --- a/packages/frontend/src/routers/modules/admin.ts +++ b/packages/frontend/src/routers/modules/admin.ts @@ -21,6 +21,12 @@ export default [ component: () => import('@/views/admin/panels/AdminReviewPanel.vue'), meta: { activeMenu: 'admin', title: '内容审核' } }, + { + path: 'judgements', + name: 'admin-judgements', + component: () => import('@/views/admin/panels/AdminJudgementPanel.vue'), + meta: { activeMenu: 'admin', title: '陶片记录' } + }, { path: 'site', name: 'admin-site', @@ -33,6 +39,12 @@ export default [ component: () => import('@/views/admin/panels/AdminUsersPanel.vue'), meta: { activeMenu: 'admin', title: '用户管理' } }, + { + path: 'judgements', + name: 'admin-judgements', + component: () => import('@/views/admin/panels/AdminJudgementPanel.vue'), + meta: { activeMenu: 'admin', title: '陶片管理' } + }, { path: 'ops', name: 'admin-ops', diff --git a/packages/frontend/src/views/admin/AdminView.vue b/packages/frontend/src/views/admin/AdminView.vue index 00a8846..f911a38 100644 --- a/packages/frontend/src/views/admin/AdminView.vue +++ b/packages/frontend/src/views/admin/AdminView.vue @@ -14,6 +14,7 @@ const router = useRouter(); const TABS = [ { name: 'admin', label: '概览' }, { name: 'admin-review', label: '内容审核' }, + { name: 'admin-judgements', label: '陶片记录' }, { name: 'admin-site', label: '公告与通知' }, { name: 'admin-users', label: '用户管理' }, { name: 'admin-ops', label: '系统运维' } diff --git a/packages/frontend/src/views/admin/panels/AdminJudgementPanel.vue b/packages/frontend/src/views/admin/panels/AdminJudgementPanel.vue new file mode 100644 index 0000000..b3f5b0e --- /dev/null +++ b/packages/frontend/src/views/admin/panels/AdminJudgementPanel.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/packages/frontend/src/views/admin/panels/AdminOverviewPanel.vue b/packages/frontend/src/views/admin/panels/AdminOverviewPanel.vue index e008a09..f975ed1 100644 --- a/packages/frontend/src/views/admin/panels/AdminOverviewPanel.vue +++ b/packages/frontend/src/views/admin/panels/AdminOverviewPanel.vue @@ -2,7 +2,7 @@ import { computed, onMounted, ref } from 'vue'; import { useRouter } from 'vue-router'; import { NBadge, NIcon, NSpace, NTag } from 'naive-ui'; -import { CircleCheck, Wrench, Megaphone, Users } from 'lucide-vue-next'; +import { CircleCheck, Wrench, Megaphone, Users, Hammer } from 'lucide-vue-next'; import Card from '@/components/Card.vue'; import { getAdminDeletionRequests } from '@/api/deletion-request.ts'; import { currentAuth } from '@/utils/auth.ts'; @@ -58,6 +58,13 @@ const entries = computed(() => [ icon: Users, badge: 0 }, + { + name: 'admin-judgements', + title: '陶片管理', + description: '隐藏陶片放逐记录', + icon: Hammer, + badge: 0 + }, { name: 'admin-ops', title: '系统运维', diff --git a/packages/frontend/src/views/judgement/JudgementView.vue b/packages/frontend/src/views/judgement/JudgementView.vue index f79bda4..aa47bdf 100644 --- a/packages/frontend/src/views/judgement/JudgementView.vue +++ b/packages/frontend/src/views/judgement/JudgementView.vue @@ -263,7 +263,14 @@ const columns = computed>(() => { } return result; } - if (displayOptions.value.uid) result.push({ title: 'UID', key: 'uid', width: 100 }); + if (displayOptions.value.uid) { + result.push({ + title: 'UID', + key: 'uid', + width: 100, + render: row => row.uid + }); + } if (displayOptions.value.avatar) { result.push({ title: '头像', diff --git a/packages/frontend/src/views/user/UserProfileView.vue b/packages/frontend/src/views/user/UserProfileView.vue index ecb8d99..f11f633 100755 --- a/packages/frontend/src/views/user/UserProfileView.vue +++ b/packages/frontend/src/views/user/UserProfileView.vue @@ -507,7 +507,10 @@ onUnmounted(() => { :time="formatDate(item.time * 1000)" >
{ + {{ name }}
-
+
记录未包含权限位变更

diff --git a/spec/authentication.spec.md b/spec/authentication.spec.md index 84cb8bf..448ea46 100644 --- a/spec/authentication.spec.md +++ b/spec/authentication.spec.md @@ -104,6 +104,8 @@ The `auth.cpOAuth` configuration object SHALL contain: | `scopes` | string[] | `['openid', 'profile', 'link:luogu']` | Scopes requested from CP OAuth | | `stateExpireSeconds` | number | 600 | Redis TTL for OAuth state and PKCE verifier | +For the checked-in local `config.yml`, `redirectUri` SHALL be `http://127.0.0.1:30010/auth/cp/callback`, which is the configured backend listener, and `frontendRedirectUri` SHALL be `http://localhost:5173/auth/callback`, which is the Vite development-server route. The CP OAuth client registration SHALL contain the same backend `redirectUri` value. + ### 6.2 GET /auth/cp/login Start the CP OAuth authorization code flow. diff --git a/spec/judgement-system.spec.md b/spec/judgement-system.spec.md index 1e08211..b43006f 100644 --- a/spec/judgement-system.spec.md +++ b/spec/judgement-system.spec.md @@ -2,7 +2,7 @@ ## 1. Scope -The judgement system integrates the Luogu judgement history into the main Luogu Saver backend and frontend. It owns persistence, fixed-interval synchronization, read-only APIs, and one-time import of the legacy SQLite database. +The judgement system integrates the Luogu judgement history into the main Luogu Saver backend and frontend. It owns persistence, fixed-interval synchronization, public read APIs, administrator-controlled history hiding, and one-time import of the legacy SQLite database. The legacy Express server, legacy static pages, the legacy SQLite runtime, and browser requests to `jdmt.luogu.me` are outside the integrated runtime. @@ -48,6 +48,19 @@ Table name: `judgement_fetch_log`. Every persisted judgement record SHALL reference an existing fetch log. +### 2.3 Judgement Visibility Request + +Table name: `judgement_visibility_request`. + +| Column | Type | Constraints | Description | +| -------------- | ------------ | ----------- | ------------------------------------------------- | +| `uid` | INT UNSIGNED | PRIMARY KEY | Luogu UID whose history an administrator hid | +| `hidden_until` | INT UNSIGNED | NOT NULL | Inclusive Luogu event-time cutoff in Unix seconds | +| `created_at` | DATETIME | NOT NULL | First request persistence time | +| `updated_at` | DATETIME | NOT NULL | Most recent request persistence time | + +The table SHALL contain at most one row for one Luogu UID. It SHALL retain the request independently of the judgement records; neither a visibility request nor a hide operation SHALL delete or modify `judgement_record`, its snapshots, or its fetch-log association. + ## 3. Worker Handler Upstream Fetch The `JudgementHandler` module SHALL request the fixed URL `https://www.luogu.com.cn/judgement` itself with: @@ -81,7 +94,7 @@ The method SHALL: The persistence service SHALL not perform upstream HTTP requests or response validation. Runtime logs SHALL contain counts and identifiers but SHALL NOT contain raw responses or snapshots. -## 5. Read-only HTTP API +## 5. HTTP API ### 5.1 Common Pagination and Validation @@ -104,7 +117,9 @@ The endpoint SHALL accept these optional filters: All supplied filters SHALL be combined with AND. `%`, `_`, and the SQL escape character in `name` and `reason` SHALL be treated literally. An omitted time boundary SHALL leave that side of the interval unbounded. If both time boundaries are supplied, `start_time` SHALL NOT exceed `end_time`. Each supplied time boundary SHALL NOT exceed `4294967295`. Results SHALL be ordered by `time DESC, id DESC`. -The endpoint SHALL call `ctx.success` with: +For a record whose `time` is less than or equal to `hidden_until` in the matching visibility-request row, the endpoint SHALL retain its UID, name, user snapshot, event time, fetch-log metadata, and creation time; return `hidden: true`; return `reason: "此记录已被账号所有者要求隐藏"`; and return both permission fields as `0`. It SHALL NOT return `full_record`. + +For every other record, the endpoint SHALL return `hidden: false` and its persisted display fields. The endpoint SHALL call `ctx.success` with list-item fields: ```typescript { @@ -120,6 +135,7 @@ The endpoint SHALL call `ctx.success` with: fetch_log_id: number; log_fetched_at: Date | null; created_at: Date; + hidden: boolean; }>; pagination: { page: number; @@ -132,11 +148,25 @@ The endpoint SHALL call `ctx.success` with: The endpoint SHALL NOT return `full_record`. The `full_record` database column SHALL remain the complete immutable upstream snapshot for persistence and forensic recovery. The list query SHALL NOT select the `full_record` column from the database. -### 5.3 GET /judgement/logs +### 5.3 POST /admin/judgements/hide + +The endpoint SHALL require `Permission.MANAGE_CONTENT`. The request body SHALL be `{ uids: string }`. + +`uids` SHALL contain one or more decimal positive Luogu UIDs, separated by commas, CRLF, or LF. Whitespace surrounding a UID is ignored. Empty segments caused by consecutive separators are ignored. Every UID SHALL be an integer from `1` through `4294967295`. Duplicate UIDs SHALL be processed once in first-occurrence order. Any invalid non-empty segment, an empty result, or a non-string body field SHALL produce application error code `400`. + +On each request, the service SHALL set every submitted UID's `hidden_until` to the current Unix second, creating a visibility-request row when absent. An update SHALL retain the greater of the stored cutoff and the current Unix second. The response SHALL be `{ items: Array<{ uid: number; hiddenUntil: number }> }`, in normalized input order. + +The operation applies to every current and later-read record for each submitted UID with an event time not later than its `hiddenUntil`; it SHALL not support selecting individual records. A record with an event time after the cutoff, including a record first fetched after this request, SHALL remain visible unless a later administrator request advances the cutoff. + +The public judgement page SHALL NOT expose an account-owner deletion or hiding action. The administrator console SHALL expose a `MANAGE_CONTENT`-guarded control that accepts comma- or newline-separated UIDs and invokes this endpoint. + +For a returned item with `hidden: true`, the judgement list SHALL preserve the UID, avatar, username, and time; render `—` for permission changes; and render the API-supplied placeholder reason. The user-profile judgement timeline SHALL preserve its time and other record layout, omit permission-change tags, and render the API-supplied placeholder reason. + +### 5.4 GET /judgement/logs This endpoint SHALL use common pagination, order by `id DESC`, and return fetch-log metadata without `raw_response`. -### 5.4 GET /judgement/stats +### 5.5 GET /judgement/stats This endpoint SHALL return `totalJudgements`, `totalFetchLogs`, `lastFetchAt`, and `lastFetchStatus`.