From acb317deebc7512310f437102578ed0a70612efb Mon Sep 17 00:00:00 2001 From: rjsgud49 Date: Tue, 20 Oct 2026 07:13:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(reports):=20=EC=8B=A0=EA=B3=A0=201?= =?UTF-8?q?=EA=B1=B4=EB=B6=80=ED=84=B0=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EB=A9=94=EB=AA=A8=EC=99=80=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 열린 신고를 한 건부터 목록에 보여주고, 무시하거나 삭제할 때 처리 메모와 시각을 남긴다. Co-authored-by: park gunhyung --- .../migration.sql | 3 ++ fishing/apps/api/prisma/schema.prisma | 8 ++-- .../src/admin/admin-report-resolve.spec.ts | 46 +++++++++++++++++++ .../apps/api/src/admin/admin.controller.ts | 11 +++-- fishing/apps/api/src/admin/admin.service.ts | 17 ++++--- .../api/src/admin/dto/resolve-report.dto.ts | 11 +++++ fishing/apps/mobile/app/admin/reports.tsx | 4 +- fishing/apps/mobile/app/catch/[id].tsx | 34 ++++++++++++++ .../apps/web/src/app/admin/reports/page.tsx | 19 ++++++-- 9 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 fishing/apps/api/prisma/migrations/20261020140000_report_resolution/migration.sql create mode 100644 fishing/apps/api/src/admin/admin-report-resolve.spec.ts create mode 100644 fishing/apps/api/src/admin/dto/resolve-report.dto.ts diff --git a/fishing/apps/api/prisma/migrations/20261020140000_report_resolution/migration.sql b/fishing/apps/api/prisma/migrations/20261020140000_report_resolution/migration.sql new file mode 100644 index 0000000..3073f72 --- /dev/null +++ b/fishing/apps/api/prisma/migrations/20261020140000_report_resolution/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "content_reports" +ADD COLUMN "admin_note" VARCHAR(500), +ADD COLUMN "resolved_at" TIMESTAMP(3); diff --git a/fishing/apps/api/prisma/schema.prisma b/fishing/apps/api/prisma/schema.prisma index 39a8885..f74a916 100644 --- a/fishing/apps/api/prisma/schema.prisma +++ b/fishing/apps/api/prisma/schema.prisma @@ -208,9 +208,11 @@ model ContentReport { targetId String @map("target_id") reporterId String @map("reporter_id") reason String @db.VarChar(100) - detail String? @db.VarChar(500) - status String @default("open") // open | dismissed | resolved - createdAt DateTime @default(now()) @map("created_at") + detail String? @db.VarChar(500) + status String @default("open") // open | dismissed | resolved + adminNote String? @map("admin_note") @db.VarChar(500) + resolvedAt DateTime? @map("resolved_at") + createdAt DateTime @default(now()) @map("created_at") reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade) diff --git a/fishing/apps/api/src/admin/admin-report-resolve.spec.ts b/fishing/apps/api/src/admin/admin-report-resolve.spec.ts new file mode 100644 index 0000000..3d7f5fa --- /dev/null +++ b/fishing/apps/api/src/admin/admin-report-resolve.spec.ts @@ -0,0 +1,46 @@ +import { AdminService } from './admin.service'; + +describe('AdminService.resolveFlaggedReport', () => { + const prisma = { + catch: { findUnique: jest.fn(), update: jest.fn() }, + contentReport: { updateMany: jest.fn() }, + }; + const service = new AdminService(prisma as never); + + beforeEach(() => { + jest.clearAllMocks(); + prisma.catch.findUnique.mockResolvedValue({ id: 'c1' }); + prisma.catch.update.mockResolvedValue({}); + prisma.contentReport.updateMany.mockResolvedValue({ count: 1 }); + }); + + it('한 건 신고를 삭제 처리할 때 메모와 처리 시각을 남긴다', async () => { + await service.resolveFlaggedReport('catch', 'c1', 'delete', ' 사진이 같음 '); + + expect(prisma.contentReport.updateMany).toHaveBeenCalledWith({ + where: { targetType: 'catch', targetId: 'c1', status: 'open' }, + data: { + status: 'resolved', + adminNote: '사진이 같음', + resolvedAt: expect.any(Date), + }, + }); + expect(prisma.catch.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ imageHash: null, perceptualHash: null }), + }), + ); + }); + + it('무시할 때도 처리 시각을 남긴다', async () => { + await service.resolveFlaggedReport('catch', 'c1', 'dismiss'); + expect(prisma.contentReport.updateMany).toHaveBeenCalledWith({ + where: { targetType: 'catch', targetId: 'c1', status: 'open' }, + data: { + status: 'dismissed', + adminNote: null, + resolvedAt: expect.any(Date), + }, + }); + }); +}); diff --git a/fishing/apps/api/src/admin/admin.controller.ts b/fishing/apps/api/src/admin/admin.controller.ts index dfefc59..26f98d1 100644 --- a/fishing/apps/api/src/admin/admin.controller.ts +++ b/fishing/apps/api/src/admin/admin.controller.ts @@ -6,6 +6,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'; import { AdminService } from './admin.service'; import { ReviewCatchDto } from './dto/review-catch.dto'; import { ReviewDayCodeDto } from './dto/review-day-code.dto'; +import { ResolveReportDto } from './dto/resolve-report.dto'; import { UpsertAnnouncementDto } from './dto/upsert-announcement.dto'; import { UpdateFeedbackDto } from './dto/update-feedback.dto'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -97,10 +98,10 @@ export class AdminController { } @Get('reports/flagged') - @ApiOperation({ summary: '신고 3건 이상 콘텐츠 (신고 수 내림차순)' }) + @ApiOperation({ summary: '열린 신고 콘텐츠 (기본 1건부터, 신고 수 내림차순)' }) @ApiQuery({ name: 'minCount', required: false }) - async listFlaggedReports(@Query('minCount') minCount = '3') { - const data = await this.admin.listFlaggedReports(Number(minCount) || 3); + async listFlaggedReports(@Query('minCount') minCount = '1') { + const data = await this.admin.listFlaggedReports(Number(minCount) || 1); return { success: true, data }; } @@ -109,9 +110,9 @@ export class AdminController { async resolveFlaggedReport( @Param('targetType') targetType: 'catch' | 'post', @Param('targetId') targetId: string, - @Body() body: { action: 'dismiss' | 'delete' }, + @Body() body: ResolveReportDto, ) { - const data = await this.admin.resolveFlaggedReport(targetType, targetId, body.action); + const data = await this.admin.resolveFlaggedReport(targetType, targetId, body.action, body.adminNote); return { success: true, data }; } diff --git a/fishing/apps/api/src/admin/admin.service.ts b/fishing/apps/api/src/admin/admin.service.ts index 5f1b5bf..28dc87f 100644 --- a/fishing/apps/api/src/admin/admin.service.ts +++ b/fishing/apps/api/src/admin/admin.service.ts @@ -26,7 +26,7 @@ export class AdminService { totalUsers, ] = await Promise.all([ this.prisma.catch.count({ where: { deletedAt: null, status: 'pending', recordType: 'certified' } }), - this.countFlaggedReports(3), + this.countFlaggedReports(1), this.prisma.userFeedback.count({ where: { status: 'open' } }), this.prisma.catch.count({ where: { deletedAt: null } }), this.prisma.post.count({ where: { deletedAt: null } }), @@ -46,7 +46,7 @@ export class AdminService { take: 5, }); - const recentFlagged = await this.listFlaggedReports(3, 5); + const recentFlagged = await this.listFlaggedReports(1, 5); const recentPosts = await this.prisma.post.findMany({ where: { deletedAt: null }, @@ -79,7 +79,7 @@ export class AdminService { }; } - async countFlaggedReports(minCount = 3) { + async countFlaggedReports(minCount = 1) { const rows = await this.prisma.$queryRaw>` SELECT COUNT(*)::bigint AS count FROM ( @@ -93,7 +93,7 @@ export class AdminService { return Number(rows[0]?.count ?? 0); } - async listFlaggedReports(minCount = 3, limit = 50) { + async listFlaggedReports(minCount = 1, limit = 50) { const rows = await this.prisma.$queryRaw< Array<{ target_type: string; target_id: string; report_count: number }> >` @@ -155,7 +155,12 @@ export class AdminService { targetType: 'catch' | 'post', targetId: string, action: 'dismiss' | 'delete', + adminNote?: string, ) { + const resolution = { + adminNote: adminNote?.trim() || null, + resolvedAt: new Date(), + }; if (action === 'delete') { if (targetType === 'catch') { const catch_ = await this.prisma.catch.findUnique({ where: { id: targetId, deletedAt: null } }); @@ -174,14 +179,14 @@ export class AdminService { } await this.prisma.contentReport.updateMany({ where: { targetType, targetId, status: 'open' }, - data: { status: 'resolved' }, + data: { status: 'resolved', ...resolution }, }); return { action, targetType, targetId }; } await this.prisma.contentReport.updateMany({ where: { targetType, targetId, status: 'open' }, - data: { status: 'dismissed' }, + data: { status: 'dismissed', ...resolution }, }); return { action, targetType, targetId }; } diff --git a/fishing/apps/api/src/admin/dto/resolve-report.dto.ts b/fishing/apps/api/src/admin/dto/resolve-report.dto.ts new file mode 100644 index 0000000..3c0f690 --- /dev/null +++ b/fishing/apps/api/src/admin/dto/resolve-report.dto.ts @@ -0,0 +1,11 @@ +import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class ResolveReportDto { + @IsIn(['dismiss', 'delete']) + action: 'dismiss' | 'delete'; + + @IsOptional() + @IsString() + @MaxLength(500) + adminNote?: string; +} diff --git a/fishing/apps/mobile/app/admin/reports.tsx b/fishing/apps/mobile/app/admin/reports.tsx index 5bd1cd5..29e3327 100644 --- a/fishing/apps/mobile/app/admin/reports.tsx +++ b/fishing/apps/mobile/app/admin/reports.tsx @@ -9,7 +9,7 @@ import { colors } from '@/theme/colors'; export default function AdminReportsScreen() { const { data, isLoading } = useQuery({ queryKey: ['admin', 'reports'], - queryFn: async () => (await api.get('/admin/reports/flagged')).data.data.items, + queryFn: async () => (await api.get('/admin/reports/flagged?minCount=1')).data.data, }); return ( @@ -21,7 +21,7 @@ export default function AdminReportsScreen() { ) : ( item.id} + keyExtractor={(item: { targetType: string; targetId: string }) => `${item.targetType}-${item.targetId}`} ListEmptyComponent={} renderItem={({ item }: { item: { targetType: string; reportCount: number } }) => ( diff --git a/fishing/apps/mobile/app/catch/[id].tsx b/fishing/apps/mobile/app/catch/[id].tsx index 1c9a09e..c38c8f7 100644 --- a/fishing/apps/mobile/app/catch/[id].tsx +++ b/fishing/apps/mobile/app/catch/[id].tsx @@ -10,12 +10,14 @@ import { RecordStatusBadge, GradeBadge } from '@/components/ui/StatusBadge'; import { getImageUrl } from '@/lib/images'; import { formatLength, formatTimeAgo } from '@/lib/format'; import { colors } from '@/theme/colors'; +import { useAuthStore } from '@/store/auth.store'; import { text } from '@/theme/text'; import { radius, spacing } from '@/theme/layout'; export default function CatchDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); + const me = useAuthStore((s) => s.user); const { data, isPending, isError, refetch } = useQuery({ queryKey: ['catch', id], @@ -90,6 +92,21 @@ export default function CatchDetailScreen() { {data.memo ? : null} + {me && data.user?.id && me.id !== data.user.id ? ( + { + Alert.alert('기록 신고', '사유를 선택하세요', [ + { text: '취소', style: 'cancel' }, + { text: '허위 기록', onPress: () => submitReport('허위 기록') }, + { text: '사진 도용', onPress: () => submitReport('사진 도용') }, + ]); + }} + > + 신고 + + ) : null} + {data.recordType === 'personal' ? ( ); + + async function submitReport(reason: string) { + try { + await api.post('/reports', { targetType: 'catch', targetId: id, reason }); + Alert.alert('신고 접수', '관리자가 확인합니다.'); + } catch { + Alert.alert('신고 실패', '이미 신고했거나 로그인 상태를 확인해 주세요.'); + } + } } function InfoRow({ label, value }: { label: string; value: string }) { @@ -176,4 +202,12 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, voteBtnText: { ...text.bold(15), color: '#fff' }, + reportBtn: { + marginTop: spacing.md, + alignSelf: 'flex-start', + minHeight: 40, + justifyContent: 'center', + paddingHorizontal: 4, + }, + reportBtnText: { ...text.bold(13), color: colors.textMuted }, }); diff --git a/fishing/apps/web/src/app/admin/reports/page.tsx b/fishing/apps/web/src/app/admin/reports/page.tsx index 527030b..35df712 100644 --- a/fishing/apps/web/src/app/admin/reports/page.tsx +++ b/fishing/apps/web/src/app/admin/reports/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import Link from 'next/link'; import api from '@/lib/api'; @@ -8,10 +9,11 @@ import { formatLength, formatTimeAgo } from '@/lib/utils'; export default function AdminReportsPage() { const queryClient = useQueryClient(); + const [note, setNote] = useState(''); const { data, isLoading } = useQuery({ queryKey: ['admin-flagged-reports'], - queryFn: async () => (await api.get('/admin/reports/flagged?minCount=3')).data.data, + queryFn: async () => (await api.get('/admin/reports/flagged?minCount=1')).data.data, }); const resolveMutation = useMutation({ @@ -19,11 +21,13 @@ export default function AdminReportsPage() { targetType, targetId, action, + adminNote, }: { targetType: 'catch' | 'post'; targetId: string; action: 'dismiss' | 'delete'; - }) => api.patch(`/admin/reports/${targetType}/${targetId}`, { action }), + adminNote?: string; + }) => api.patch(`/admin/reports/${targetType}/${targetId}`, { action, adminNote }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-flagged-reports'] }); queryClient.invalidateQueries({ queryKey: ['admin-stats'] }); @@ -34,7 +38,7 @@ export default function AdminReportsPage() {

신고 관리

-

신고가 3건 이상 접수된 콘텐츠를 신고 수가 많은 순으로 표시합니다.

+

신고 1건부터 검토합니다. 처리 메모는 기록에 남고, 랭킹은 콘텐츠를 삭제할 때만 빠집니다.

{isLoading ? ( @@ -104,6 +108,13 @@ export default function AdminReportsPage() { ))} +