임동욱 임동욱 09-04
fix: OX퀴즈 등록을 JSON으로 보내고 노출 문구를 descrList에 싣는다
백엔드 insert에 @RequestBody가 붙었는데(MngrCntntsQuizApiController) 우리는 여전히 form으로
보내고 있어 등록이 HttpMediaTypeNotSupportedException으로 실패했다. 수정과 같은 JSON으로
통일하고 form 경로를 지운다. 쇼츠·만화 insert는 아직 어노테이션이 없어 그대로 form이다.

노출 문구 필드도 실제 계약으로 맞춘다 — answerMessageList(가칭)가 아니라 descrList이고
칸이 cntntsQuizDescr 하나뿐이다. 코드가 아니라 문구 자체를 싣는다: 학습자 앱이 이 값을
그대로 말풍선에 띄운다(CmmBotChatServiceImpl). 코드를 골랐으면 그 코드명이, 기타면 직접
적은 값이 저장된다.

단건 조회가 descrList를 돌려주므로 수정 화면이 저장된 문구를 되살린다. 코드명과 맞는 것이
있으면 그 코드로, 없으면 기타 + 직접 입력으로 돌린다.

Co-Authored-By: Claude Opus 5 
@0f2aa7d285f74ae330bcb58fb78e36ba16caf690
app/(protected)/(basic)/contents/quizzes/_components/quiz-form.tsx
--- app/(protected)/(basic)/contents/quizzes/_components/quiz-form.tsx
+++ app/(protected)/(basic)/contents/quizzes/_components/quiz-form.tsx
@@ -67,14 +67,34 @@
   const [answer, setAnswer] = useState<string>(
     item?.answer || QUIZ_ANSWER_OPTIONS[0]
   );
+  /**
+   * 저장된 문구를 선택 상자로 되살린다. 백엔드는 코드가 아니라 문구만 갖고 있어, 코드명과
+   * 맞는 것이 있으면 그 코드로, 없으면 「기타」 + 직접 입력으로 돌린다.
+   */
+  const initialMessageOf = (option: QuizAnswer) => {
+    const saved =
+      item?.answerMessages.find((message) => message.answer === option)
+        ?.message ?? '';
+    const matched = messageOptionsByAnswer[option].find(
+      (code) => code.label === saved
+    );
+    if (matched) {
+      return { code: matched.code, text: '' };
+    }
+    if (saved) {
+      return { code: ANSWER_MESSAGE_ETC, text: saved };
+    }
+    // 코드가 아직 등록되지 않았을 수 있다 — 그때는 「기타」로 시작한다.
+    return {
+      code: messageOptionsByAnswer[option][0]?.code ?? ANSWER_MESSAGE_ETC,
+      text: '',
+    };
+  };
+
   // O·X 각각의 노출 문구. 정답이 아닌 쪽도 보내므로 둘 다 따로 들고 있는다.
   const [messages, setMessages] = useState<
     Record<QuizAnswer, { code: string; text: string }>
-  >(() => ({
-    // 코드가 아직 등록되지 않았을 수 있다 — 그때는 「기타」로 시작한다.
-    O: { code: messageOptionsByAnswer.O[0]?.code ?? ANSWER_MESSAGE_ETC, text: '' },
-    X: { code: messageOptionsByAnswer.X[0]?.code ?? ANSWER_MESSAGE_ETC, text: '' },
-  }));
+  >(() => ({ O: initialMessageOf('O'), X: initialMessageOf('X') }));
 
   /** 코드 목록 + 「기타」. 기타는 코드가 아니라 직접 입력을 여는 자리라 항상 뒤에 붙인다. */
   const messageOptionsOf = (option: QuizAnswer) => [
lib/data/repositories/content-quiz-repository.ts
--- lib/data/repositories/content-quiz-repository.ts
+++ lib/data/repositories/content-quiz-repository.ts
@@ -1,18 +1,22 @@
 import 'server-only';
 import { getSessionAccessToken } from '@/lib/auth/dal';
 import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
-import type { ContentKeyword, QuizItem } from '@/lib/domain/content';
+import type {
+  ContentKeyword,
+  QuizAnswerText,
+  QuizItem,
+} from '@/lib/domain/content';
 import type { ContentQuery } from '@/lib/domain/content-query';
 import type { QuizFormValues } from '@/lib/domain/content-form';
 
 /**
- * 금융OX퀴즈 Repository — 쇼츠·만화와 테이블(`TB_COM_QUIZ`)은 다르지만 콘텐츠관리 API 아래로
- * 들어와 규약은 같다(등록은 form, 수정은 JSON, 삭제는 soft delete).
+ * 금융OX퀴즈 Repository — 쇼츠·만화와 테이블(`TB_COM_CNTNTS_QUIZ`)은 다르지만 콘텐츠관리
+ * API 아래로 들어와 규약은 같다.
  *
  * ```
  * GET    /api/v1/mngr/cntntns/quiz/pagination      학교·학년·난이도·검색어
  * GET    /api/v1/mngr/cntntns/quiz/{quizId}        단건
- * POST   /api/v1/mngr/cntntns/quiz                 어노테이션 없음 → form(중첩 목록은 인덱스 표기)
+ * POST   /api/v1/mngr/cntntns/quiz                 @RequestBody(JSON)
  * PUT    /api/v1/mngr/cntntns/quiz/{quizId}        @RequestBody(JSON)
  * DELETE /api/v1/mngr/cntntns/quiz/{quizId}        soft delete(DEL_YN)
  * GET    /api/v1/mngr/cntntns/quiz/excel/download  엑셀 다운로드
@@ -113,6 +117,7 @@
     answer: readString(raw, 'cntntsQuizAnswer') ?? '',
     schoolCode: schulGrd.schoolCode,
     grades: schulGrd.grades,
+    answerMessages: toAnswerMessages(raw.descrList),
     isVisible: readString(raw, 'useYn') !== 'N',
     modifiedAt:
       readString(raw, 'lastMdfcnDtStr') ?? readString(raw, 'frstRegDtStr'),
@@ -217,7 +222,7 @@
 
 /** 키워드 목록의 필드명. 백엔드 `CntntsKeywordVo`와 같다(콘텐츠 API와 동일한 구조다). */
 const QUIZ_KEYWORD_FIELD = {
-  /** 목록 자체의 이름. form에서는 `keywordList[0].…` 형태가 된다. */
+  /** 목록 자체의 이름. */
   list: 'keywordList',
   /** 항목의 키워드 필드. */
   keyword: 'cntntsKeyword',
@@ -228,31 +233,31 @@
 type QuizKeywordPayload = Record<string, string>;
 
 /**
- * 정답 노출 문구 필드명. **백엔드가 아직 이 필드를 모른다**(작업 중) — 지금 보내면 조용히
- * 무시된다(폼은 미지의 파라미터를, JSON은 미지의 속성을 버린다). 이름이 확정되면 여기 세 값만
- * 고치면 요청·전송이 모두 따라간다(키워드와 같은 방식).
+ * 정답별 노출 문구 — 백엔드 `descrList`(`CntntsQuizDescrVo` 배열, `TB_COM_CNTNTS_QUIZ_DESCR`).
+ *
+ * 칸이 하나(`CNTNTS_QUIZ_DESCR`)뿐이라 코드가 아니라 **문구 자체**를 싣는다. 학습자 앱이
+ * 이 값을 그대로 말풍선에 띄우기 때문이다(`CmmBotChatServiceImpl`이 조회해 응답에 넣는다).
  */
-const QUIZ_ANSWER_MESSAGE_FIELD = {
-  /** 목록 자체의 이름. form에서는 `answerMessageList[0].…` 형태가 된다. */
-  list: 'answerMessageList',
-  /** 어느 쪽(O/X)에 붙는 문구인지. */
-  answer: 'cntntsQuizAnswer',
-  /** 고른 문구의 코드값. */
-  code: 'answerMessageCode',
-  /** 기타일 때 직접 적은 문구. */
-  text: 'answerMessageText',
-} as const;
+type QuizAnswerDescrPayload = {
+  cntntsQuizAnswer: string;
+  cntntsQuizDescr: string;
+};
 
-type QuizAnswerMessagePayload = Record<string, string>;
-
-function toAnswerMessagePayload(
-  messages: QuizFormValues['answerMessages']
-): QuizAnswerMessagePayload[] {
-  return messages.map((message) => ({
-    [QUIZ_ANSWER_MESSAGE_FIELD.answer]: message.answer,
-    [QUIZ_ANSWER_MESSAGE_FIELD.code]: message.code,
-    [QUIZ_ANSWER_MESSAGE_FIELD.text]: message.text,
-  }));
+/** `descrList` → 화면이 수정 폼에 되살릴 정답별 문구. */
+function toAnswerMessages(raw: unknown): QuizAnswerText[] {
+  if (!Array.isArray(raw)) {
+    return [];
+  }
+  return raw.flatMap((entry) => {
+    if (!isRecord(entry)) {
+      return [];
+    }
+    const answer = readString(entry, 'cntntsQuizAnswer');
+    if (!answer) {
+      return [];
+    }
+    return [{ answer, message: readString(entry, 'cntntsQuizDescr') ?? '' }];
+  });
 }
 
 type QuizWritePayload = {
@@ -262,14 +267,10 @@
   cntntsQuizAnswer: string;
   useYn: string;
   keywordList: QuizKeywordPayload[];
-  answerMessageList: QuizAnswerMessagePayload[];
+  descrList: QuizAnswerDescrPayload[];
   schulGrdList: Array<{ schulCd: string; grdCd: string | null }>;
 };
 
-/**
- * 이슈: 정답 노출 문구(기획 "기본 3종 + 기타")를 담을 필드가 백엔드에 없다. 화면은 값을 받지만
- *   저장되지 않는다 — 컬럼이 생기면 여기에 실어 보낸다.
- */
 function buildQuizPayload(values: QuizFormValues): QuizWritePayload {
   return {
     cntntsQuizLev: values.level,
@@ -281,76 +282,25 @@
       [QUIZ_KEYWORD_FIELD.keyword]: item.keyword,
       [QUIZ_KEYWORD_FIELD.description]: item.description,
     })),
-    answerMessageList: toAnswerMessagePayload(values.answerMessages),
+    // `text`에는 검증 단계가 고른 문구를 넣어 둔다(코드를 골랐으면 그 코드명).
+    descrList: values.answerMessages.map((message) => ({
+      cntntsQuizAnswer: message.answer,
+      cntntsQuizDescr: message.text,
+    })),
     schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades),
-  };
-}
-
-
-/**
- * 등록은 `@RequestBody`가 없어 form으로 간다(쇼츠·만화와 같은 갈림).
- *
- * TODO: 백엔드가 `insert`에 `@RequestBody`를 붙이면 이 함수를 지우고 수정과 같은 JSON으로
- * 통일한다. 반영 여부는 `/v3/api-docs`에서 이 경로의 POST에 `requestBody`가 생겼는지로 본다.
- */
-function toFormParams(
-  payload: QuizWritePayload
-): Record<string, string | number | undefined> {
-  const params: Record<string, string | number | undefined> = {
-    cntntsQuizLev: payload.cntntsQuizLev,
-    cntntsQuizQuestion: payload.cntntsQuizQuestion,
-    cntntsQuizExplan: payload.cntntsQuizExplan,
-    cntntsQuizAnswer: payload.cntntsQuizAnswer,
-    useYn: payload.useYn,
-  };
-
-  // 중첩 목록은 Spring이 읽는 인덱스 표기로 편다 — JSON으로 보내면 한 칸도 바인딩되지 않는다.
-  payload.keywordList.forEach((item, index) => {
-    for (const [field, value] of Object.entries(item)) {
-      params[`${QUIZ_KEYWORD_FIELD.list}[${index}].${field}`] = value;
-    }
-  });
-
-  payload.answerMessageList.forEach((item, index) => {
-    for (const [field, value] of Object.entries(item)) {
-      params[`${QUIZ_ANSWER_MESSAGE_FIELD.list}[${index}].${field}`] = value;
-    }
-  });
-
-  payload.schulGrdList.forEach((item, index) => {
-    params[`schulGrdList[${index}].schulCd`] = item.schulCd;
-    // undefined면 파라미터 자체가 빠져 백엔드에서 null이 된다.
-    params[`schulGrdList[${index}].grdCd`] = item.grdCd ?? undefined;
-  });
-
-  return params;
-}
-
-/**
- * 수정은 JSON이라 목록 이름이 본문의 키가 된다 — 상수를 실제로 따르도록 여기서 바꿔 끼운다.
- * (form 경로는 `toFormParams`가 같은 상수로 인덱스 표기를 만든다.)
- */
-function toJsonBody(payload: QuizWritePayload): Record<string, unknown> {
-  const { keywordList, answerMessageList, ...rest } = payload;
-  return {
-    ...rest,
-    [QUIZ_KEYWORD_FIELD.list]: keywordList,
-    [QUIZ_ANSWER_MESSAGE_FIELD.list]: answerMessageList,
   };
 }
 
 async function sendWrite(
   path: string,
   method: 'POST' | 'PUT' | 'DELETE',
-  body?: unknown,
-  form?: Record<string, string | number | undefined>
+  body?: unknown
 ): Promise<void> {
   const accessToken = await getSessionAccessToken();
 
   const result = await backendFetch<unknown>(path, {
     method,
     ...(body === undefined ? {} : { body }),
-    ...(form === undefined ? {} : { form }),
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
     canHaveNullData: true,
@@ -363,7 +313,7 @@
 }
 
 export async function createQuiz(values: QuizFormValues): Promise<void> {
-  await sendWrite(QUIZ_PATH, 'POST', undefined, toFormParams(buildQuizPayload(values)));
+  await sendWrite(QUIZ_PATH, 'POST', buildQuizPayload(values));
 }
 
 export async function updateQuiz(
@@ -373,7 +323,7 @@
   await sendWrite(
     `${QUIZ_PATH}/${encodeURIComponent(quizId)}`,
     'PUT',
-    toJsonBody(buildQuizPayload(values))
+    buildQuizPayload(values)
   );
 }
 
lib/domain/content-form.ts
--- lib/domain/content-form.ts
+++ lib/domain/content-form.ts
@@ -275,6 +275,8 @@
         '노출 문구를 다시 선택해 주세요.';
     }
 
+    // 백엔드 칸이 하나뿐이라(`cntntsQuizDescr`) 여기서 문구를 확정한다 — 코드를 골랐으면
+    // 그 코드명이, 기타면 직접 적은 값이 그대로 저장·노출된다.
     let text = message.text.trim().slice(0, ANSWER_MESSAGE_MAX_LENGTH);
     if (message.code === ANSWER_MESSAGE_ETC) {
       if (text === '') {
@@ -282,7 +284,8 @@
           `${message.answer}에 노출할 문구를 입력해 주세요.`;
       }
     } else {
-      text = '';
+      text =
+        allowedCodes.find((option) => option.code === message.code)?.label ?? '';
     }
     return { ...message, text };
   });
lib/domain/content.ts
--- lib/domain/content.ts
+++ lib/domain/content.ts
@@ -53,6 +53,15 @@
  */
 export const ANSWER_MESSAGE_ETC = 'ETC';
 
+/**
+ * 저장된 정답별 문구. 백엔드 `descrList`는 코드가 아니라 문구 자체를 갖는다 —
+ * 학습자 앱이 이 값을 그대로 말풍선에 띄운다.
+ */
+export type QuizAnswerText = {
+  answer: string;
+  message: string;
+};
+
 /** 정답여부 한 줄이 갖는 노출 문구. O·X 각각 하나씩 둔다. */
 export type QuizAnswerMessage = {
   /** 'O' 또는 'X'. 어느 쪽에 붙는 문구인지. */
@@ -110,6 +119,8 @@
   question: string;
   explanation: string;
   answer: string;
+  /** 저장된 정답별 노출 문구. 수정 화면이 선택 상자를 되살리는 데 쓴다. */
+  answerMessages: QuizAnswerText[];
   /** 공통코드 `COM_SCHUL_CD` 코드값. */
   schoolCode: string;
   /** 공통코드 `GRD_CD` 코드값. */
Add a comment
List