From d29f375660990a550ebafbaa0e275ec3bbdb3363 Mon Sep 17 00:00:00 2001 From: juahcheon <132863519+juahcheon@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:38:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20statements=20route=20submittedAt=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EB=A1=9C?= =?UTF-8?q?=EA=B9=85,=20moderation=20JSON.parse=20=EC=97=90=EB=9F=AC=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 - statements: fail-open/approved 경로 모두 submittedAt 설정 → 재제출 방지 멱등성 보장 - statements: StatementData.submittedAt 타입 string | null로 수정 - statements: 외부 catch 블록에 console.error 에러 로깅 추가 - moderation: JSON.parse를 try-catch로 감싸 파싱 실패 시 명확한 에러 전파 Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/disputes/[id]/statements/route.ts | 14 +++++++++----- src/lib/ai/moderation.ts | 7 ++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/app/api/disputes/[id]/statements/route.ts b/src/app/api/disputes/[id]/statements/route.ts index a492f21..ba1f2c3 100644 --- a/src/app/api/disputes/[id]/statements/route.ts +++ b/src/app/api/disputes/[id]/statements/route.ts @@ -33,7 +33,7 @@ interface StatementData { disputeId: string role: string content: string - submittedAt: null + submittedAt: string | null hasPersonalInfo: boolean } @@ -173,8 +173,9 @@ export async function POST( role: participant.role, content, moderationStatus: 'pending', + submittedAt: new Date(), }, - update: { content, moderationStatus: 'pending' }, + update: { content, moderationStatus: 'pending', submittedAt: new Date() }, }) return NextResponse.json>( @@ -231,8 +232,9 @@ export async function POST( role: participant.role, content, moderationStatus: 'approved', + submittedAt: new Date(), }, - update: { content, moderationStatus: 'approved' }, + update: { content, moderationStatus: 'approved', submittedAt: new Date() }, }) await tx.moderationLog.create({ @@ -267,13 +269,15 @@ export async function POST( disputeId: statement.disputeId, role: statement.role.toLowerCase(), content: statement.content, - submittedAt: null, + submittedAt: statement.submittedAt?.toISOString() ?? null, hasPersonalInfo: moderation.hasPersonalInfo, }, }, { status: isNew ? 201 : 200 }, ) - } catch { + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error('[disputes/statements] api error', { message }) return NextResponse.json( { success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: '서버 오류가 발생했습니다.' } }, { status: 500 }, diff --git a/src/lib/ai/moderation.ts b/src/lib/ai/moderation.ts index e5b9d6e..506cc6a 100644 --- a/src/lib/ai/moderation.ts +++ b/src/lib/ai/moderation.ts @@ -72,7 +72,12 @@ export async function moderateContent(content: string): Promise Date: Thu, 18 Jun 2026 15:06:54 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20statements=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20=EC=9B=90=EC=9E=90=EC=A0=81=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upsert가 submittedAt 여부와 무관하게 update를 실행해 동시 요청이 모두 통과하는 경쟁 조건을 제거한다. - isNew: create 사용, P2002 unique 충돌 → StatementConflictError → 409 - !isNew: updateMany(where: submittedAt: null) 사용, count === 0 → StatementConflictError → 409 - fail-open/approved 경로 모두 동일 패턴 적용 - outer catch에서 StatementConflictError 분기하여 409 반환 Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/disputes/[id]/statements/route.ts | 102 +++++++++++++----- 1 file changed, 78 insertions(+), 24 deletions(-) diff --git a/src/app/api/disputes/[id]/statements/route.ts b/src/app/api/disputes/[id]/statements/route.ts index ba1f2c3..ce12892 100644 --- a/src/app/api/disputes/[id]/statements/route.ts +++ b/src/app/api/disputes/[id]/statements/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { z } from 'zod' +import { Prisma, type DisputeStatement } from '@prisma/client' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/db' import { getSessionUserId } from '@/lib/auth/session' @@ -28,6 +29,13 @@ const statementSchema = z.object({ .optional(), }) +// 동시 제출 경쟁 조건에서 발생하는 충돌을 outer catch까지 전달하기 위한 sentinel +class StatementConflictError extends Error { + constructor() { + super('STATEMENT_ALREADY_SUBMITTED') + } +} + interface StatementData { id: string disputeId: string @@ -162,21 +170,44 @@ export async function POST( moderation = await moderateContent(content) } catch (err) { // Gemini 실패 시 fail open — pending 상태로 저장, ModerationLog 생략 + // create/updateMany(where: submittedAt: null)로 원자적 중복 제출 방지 console.error('[moderation] Gemini call failed:', err) - const statement = await prisma.disputeStatement.upsert({ - where: { disputeId_role: { disputeId, role: participant.role } }, - create: { - disputeId, - participantId: participant.id, - userId, - role: participant.role, + let statement: DisputeStatement + + if (isNew) { + try { + statement = await prisma.disputeStatement.create({ + data: { + disputeId, + participantId: participant.id, + userId, + role: participant.role, + content, + moderationStatus: 'pending', + submittedAt: new Date(), + }, + }) + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new StatementConflictError() + } + throw e + } + } else { + const { count } = await prisma.disputeStatement.updateMany({ + where: { id: existingStatement.id, submittedAt: null }, + data: { content, moderationStatus: 'pending', submittedAt: new Date() }, + }) + if (count === 0) throw new StatementConflictError() + statement = { + ...existingStatement, content, moderationStatus: 'pending', submittedAt: new Date(), - }, - update: { content, moderationStatus: 'pending', submittedAt: new Date() }, - }) + updatedAt: new Date(), + } + } return NextResponse.json>( { @@ -186,7 +217,7 @@ export async function POST( disputeId: statement.disputeId, role: statement.role.toLowerCase(), content: statement.content, - submittedAt: null, + submittedAt: statement.submittedAt?.toISOString() ?? null, hasPersonalInfo: false, }, }, @@ -222,20 +253,37 @@ export async function POST( } // 정상 저장 + ModerationLog + user.mbti 업데이트 트랜잭션 + // create/updateMany(where: submittedAt: null)로 원자적 중복 제출 방지 const statement = await prisma.$transaction(async (tx) => { - const stmt = await tx.disputeStatement.upsert({ - where: { disputeId_role: { disputeId, role: participant.role } }, - create: { - disputeId, - participantId: participant.id, - userId, - role: participant.role, - content, - moderationStatus: 'approved', - submittedAt: new Date(), - }, - update: { content, moderationStatus: 'approved', submittedAt: new Date() }, - }) + let stmt: DisputeStatement + + if (isNew) { + try { + stmt = await tx.disputeStatement.create({ + data: { + disputeId, + participantId: participant.id, + userId, + role: participant.role, + content, + moderationStatus: 'approved', + submittedAt: new Date(), + }, + }) + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new StatementConflictError() + } + throw e + } + } else { + const { count } = await tx.disputeStatement.updateMany({ + where: { id: existingStatement!.id, submittedAt: null }, + data: { content, moderationStatus: 'approved', submittedAt: new Date() }, + }) + if (count === 0) throw new StatementConflictError() + stmt = await tx.disputeStatement.findUniqueOrThrow({ where: { id: existingStatement!.id } }) + } await tx.moderationLog.create({ data: { @@ -276,6 +324,12 @@ export async function POST( { status: isNew ? 201 : 200 }, ) } catch (error) { + if (error instanceof StatementConflictError) { + return NextResponse.json( + { success: false, error: { code: 'CONFLICT', message: '이미 제출된 진술은 수정할 수 없습니다.' } }, + { status: 409 }, + ) + } const message = error instanceof Error ? error.message : String(error) console.error('[disputes/statements] api error', { message }) return NextResponse.json(