임동욱 임동욱 08-21
merge: 퀴즈 콘텐츠관리 API 연결
@67130a9e6de5a7bcb479ff06da97c20dbf6f9d56
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -20,7 +20,6 @@
   createQuiz,
   deleteQuiz,
   updateQuiz,
-  uploadQuizExcel,
 } from '@/lib/data/repositories/content-quiz-repository';
 import { CONTENT_TYPE_CODE, type ContentKind } from '@/lib/domain/content';
 import {
@@ -445,27 +444,5 @@
   }
 
   revalidatePath(contentListPath(kind));
-  return { status: 'success' };
-}
-
-/** 금융OX퀴즈 엑셀 일괄등록. 한 행이라도 검증에 실패하면 백엔드가 전체를 되돌린다. */
-export async function uploadQuizExcelAction(
-  _prevState: ContentFormState,
-  formData: FormData
-): Promise<ContentFormState> {
-  await verifySession();
-
-  const file = readFiles(formData, 'excelFile')[0] ?? null;
-  if (!file) {
-    return { status: 'error', message: '엑셀 파일을 선택해 주세요.' };
-  }
-
-  try {
-    await uploadQuizExcel(file);
-  } catch (error) {
-    return failure(error, '엑셀을 등록하지 못했습니다.');
-  }
-
-  revalidatePath(contentListPath('quiz'));
   return { status: 'success' };
 }
app/(protected)/(basic)/contents/quizzes/_components/quiz-excel-actions.tsx
--- app/(protected)/(basic)/contents/quizzes/_components/quiz-excel-actions.tsx
+++ app/(protected)/(basic)/contents/quizzes/_components/quiz-excel-actions.tsx
@@ -1,64 +1,26 @@
 'use client';
 
-import { useActionState, useEffect, useRef } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
-import { useFeedback } from '@/app/_hooks/use-feedback';
-import { INITIAL_CONTENT_FORM_STATE } from '@/lib/domain/content-form';
-import { uploadQuizExcelAction } from '../../_actions';
 
 const EXCEL_DOWNLOAD_PATH = '/contents/quizzes/excel';
 
-/** 엑셀 일괄등록 / 다운로드 — 기획 ②. 등록은 파일을 고르는 즉시 올린다. */
+/**
+ * 엑셀 다운로드 — 기획 ②.
+ *
+ * TODO: 같은 자리의 「엑셀등록」(일괄등록)은 기획 중이라 아직 넣지 않았다. 백엔드에도 업로드
+ * 엔드포인트가 없다(`/api/v1/mngr/cntntns/quiz`에 `excel/download`만 있다). 기획이 확정되면
+ * 이 컴포넌트에 버튼과 Server Action을 함께 붙인다.
+ */
 export function QuizExcelActions() {
-  const { showToast } = useFeedback();
-  const inputRef = useRef<HTMLInputElement>(null);
-  const formRef = useRef<HTMLFormElement>(null);
-  const [state, formAction, isPending] = useActionState(
-    uploadQuizExcelAction,
-    INITIAL_CONTENT_FORM_STATE
-  );
-
-  useEffect(() => {
-    if (state.status === 'success') {
-      showToast({ variant: 'success', message: '엑셀을 등록했습니다.' });
-    }
-    if (state.status === 'error') {
-      showToast({
-        variant: 'danger',
-        message: state.message ?? '엑셀을 등록하지 못했습니다.',
-      });
-    }
-  }, [state, showToast]);
-
   return (
-    <>
-      <form ref={formRef} action={formAction} hidden>
-        <input
-          ref={inputRef}
-          type="file"
-          name="excelFile"
-          accept=".xlsx,.xls"
-          onChange={() => formRef.current?.requestSubmit()}
-        />
-      </form>
-
+    <form action={EXCEL_DOWNLOAD_PATH} method="get">
       <FoxButton
         type="secondary"
         size="md"
-        leadingIcon={<i className="fox-ico fox-ico-UploadSimple" aria-hidden="true" />}
-        label="엑셀등록"
-        loading={isPending}
-        onAction={() => inputRef.current?.click()}
+        htmlType="submit"
+        leadingIcon={<i className="fox-ico fox-ico-DownloadSimple" aria-hidden="true" />}
+        label="엑셀다운로드"
       />
-      <form action={EXCEL_DOWNLOAD_PATH} method="get">
-        <FoxButton
-          type="secondary"
-          size="md"
-          htmlType="submit"
-          leadingIcon={<i className="fox-ico fox-ico-DownloadSimple" aria-hidden="true" />}
-          label="엑셀다운로드"
-        />
-      </form>
-    </>
+    </form>
   );
 }
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
@@ -80,6 +80,8 @@
           <ContentGradeField
             schools={schools}
             gradesBySchool={gradesBySchool}
+            defaultSchoolCode={item?.schoolCode}
+            defaultGrades={item?.grades}
             schoolError={errors.schoolCode}
             gradesError={errors.grades}
           />
app/(protected)/(basic)/contents/quizzes/excel/route.ts
--- app/(protected)/(basic)/contents/quizzes/excel/route.ts
+++ app/(protected)/(basic)/contents/quizzes/excel/route.ts
@@ -1,7 +1,7 @@
 import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
 import { backendFetchStream } from '@/lib/http/backend-fetch';
 
-const QUIZ_EXCEL_PATH = '/api/v1/mngr/quiz/excel/download';
+const QUIZ_EXCEL_PATH = '/api/v1/mngr/cntntns/quiz/excel/download';
 
 const EXCEL_TIMEOUT_MS = 60_000;
 
lib/data/repositories/content-quiz-repository.ts
--- lib/data/repositories/content-quiz-repository.ts
+++ lib/data/repositories/content-quiz-repository.ts
@@ -6,30 +6,38 @@
 import type { QuizFormValues } from '@/lib/domain/content-form';
 
 /**
- * 금융OX퀴즈 Repository — 쇼츠·만화와 테이블(`TB_COM_QUIZ`)도 API도 다르다.
+ * 금융OX퀴즈 Repository — 쇼츠·만화와 테이블(`TB_COM_QUIZ`)은 다르지만 콘텐츠관리 API 아래로
+ * 들어와 규약은 같다(등록은 form, 수정은 JSON, 삭제는 soft delete).
  *
  * ```
- * GET  /api/v1/mngr/quiz/pagination       목록
- * GET  /api/v1/mngr/quiz/excel/download   엑셀 다운로드
- * POST /api/v1/mngr/quiz/excel/upload     엑셀 일괄등록 (multipart, 파트 이름 `file`)
+ * GET    /api/v1/mngr/cntntns/quiz/pagination      학교·학년·난이도·검색어
+ * POST   /api/v1/mngr/cntntns/quiz                 어노테이션 없음 → form(중첩 목록은 인덱스 표기)
+ * PUT    /api/v1/mngr/cntntns/quiz/{quizSn}        @RequestBody(JSON)
+ * DELETE /api/v1/mngr/cntntns/quiz/{quizSn}        soft delete(DEL_YN)
+ * GET    /api/v1/mngr/cntntns/quiz/excel/download  엑셀 다운로드
  * ```
  *
- * 이슈: 등록·수정·삭제·단건조회 API가 없다. 아래 쓰기 함수는 다른 관리 화면과 같은 규약으로
- *   미리 맞춰 둔 것이라 백엔드가 생기기 전에는 실패한다.
- * 이슈: 학교(`COM_SCHUL_CD`)·학년(`GRD_CD`)을 담을 컬럼이 없다. 검색 VO에 `searchSchul`/
- *   `searchGrade`가 있지만 매퍼의 `<if>` 블록이 비어 있어 필터가 조용히 무시된다.
- * 이슈: 키워드가 별도 테이블이 아니라 `quiz_title` 한 칸이다. 목록의 「키워드」 열이 이 값이고,
- *   기획의 "키워드+설명 쌍"을 담을 자리는 아직 없다.
- * 이슈: 정답 노출 문구(기획 "기본 3종 + 기타")를 담을 컬럼이 없다.
+ * 학교·학년은 `TB_COM_QUIZ_SCHUL_GRD`에 따로 저장되고 `schulGrdList`로 오간다 — 쇼츠·만화와
+ * 같은 모양이라 화면 쪽 취급도 같다.
+ *
+ * ⚠️ 백엔드 결함(보고함, 미수정)
+ * 1. **등록 INSERT의 컬럼 수와 값 수가 다르다** — 컬럼은 11개인데 값은 10개고 `USE_YN` 자리가
+ *    비어 있다. 지금은 등록이 SQL 오류로 끝난다. 프론트는 `useYn`을 정상적으로 보내고 있으므로
+ *    백엔드가 값 목록만 맞추면 그대로 동작한다.
+ * 2. 등록 INSERT의 `keyProperty`/`keyColumn`이 `cntntsSn`/`CNTNTS_SN`이다(이 테이블은 `QUIZ_SN`).
+ *    채번된 키가 VO에 담기지 않아 학교·학년 매핑이 `quiz_sn` 없이 저장된다.
+ * 3. 단건 조회 API가 없다 — 매퍼·서비스에는 `selectOne`이 있는데 컨트롤러가 열지 않았다.
+ *    그래서 수정 화면은 목록에서 찾는다(아래 `fetchQuizDetail`).
+ * 4. 검색어가 질문·정답만 본다(`QUIZ_ANSWER`·`QUIZ_QUESTION`). 화면의 「키워드」 검색은
+ *    `QUIZ_TITLE`을 봐야 하는데 조건에 없어 결과가 비어 온다.
+ * 5. 난이도 필터가 SQL에 없다 — `searchLevelCd`를 받지만 `searchWhere`가 쓰지 않는다.
+ *    화면 선택지는 기획대로 두고 값만 실어 보낸다.
  */
 
-const QUIZ_PATH = '/api/v1/mngr/quiz';
+const QUIZ_PATH = '/api/v1/mngr/cntntns/quiz';
 
-/** 목록에서 단건을 찾을 때 한 번에 받아올 최대 행 수(단건 조회 API가 없다). */
+/** 목록에서 단건을 찾을 때 한 번에 받아올 최대 행 수(단건 조회 API가 없다 — 결함 3). */
 const DETAIL_SCAN_LIMIT = 500;
-
-/** 엑셀 업로드는 일반 조회보다 오래 걸린다. */
-const UPLOAD_TIMEOUT_MS = 60_000;
 
 function isRecord(value: unknown): value is Record<string, unknown> {
   return value !== null && typeof value === 'object';
@@ -40,6 +48,40 @@
   if (typeof value === 'string' && value.length > 0) return value;
   if (typeof value === 'number') return String(value);
   return null;
+}
+
+/** `schulGrdList` → 화면이 쓰는 학교 코드 + 학년 코드 목록(쇼츠·만화와 같은 규칙). */
+function toSchoolGrades(raw: unknown): { schoolCode: string; grades: string[] } {
+  if (!Array.isArray(raw)) {
+    return { schoolCode: '', grades: [] };
+  }
+
+  const grades: string[] = [];
+  let schoolCode = '';
+
+  for (const entry of raw) {
+    if (!isRecord(entry)) {
+      continue;
+    }
+    schoolCode = schoolCode || (readString(entry, 'schulCd') ?? '');
+    const grade = readString(entry, 'grdCd');
+    if (grade && !grades.includes(grade)) {
+      grades.push(grade);
+    }
+  }
+
+  return { schoolCode, grades };
+}
+
+function toSchoolGradePayload(schoolCode: string, grades: readonly string[]) {
+  if (!schoolCode) {
+    return [];
+  }
+  if (grades.length === 0) {
+    // 학년 없는 학교(전체연령 등)는 학교만 남긴다 — 빈 문자열을 넣으면 GRD_CD에 ''가 박힌다.
+    return [{ schulCd: schoolCode, grdCd: null }];
+  }
+  return grades.map((grade) => ({ schulCd: schoolCode, grdCd: grade }));
 }
 
 function toQuizItem(raw: unknown): QuizItem {
@@ -53,6 +95,7 @@
   }
 
   const points = raw.quizPoint;
+  const schulGrd = toSchoolGrades(raw.schulGrdList);
 
   return {
     quizSn,
@@ -62,6 +105,8 @@
     explanation: readString(raw, 'quizExplan') ?? '',
     answer: readString(raw, 'quizAnswer') ?? '',
     points: typeof points === 'number' ? points : null,
+    schoolCode: schulGrd.schoolCode,
+    grades: schulGrd.grades,
     isVisible: readString(raw, 'useYn') !== 'N',
     modifiedAt:
       readString(raw, 'lastMdfcnDtStr') ?? readString(raw, 'frstRegDtStr'),
@@ -76,12 +121,11 @@
   const keyword = query.keyword.trim();
 
   return {
-    searchSchul: query.school,
-    searchGrade: query.grade,
-    searchLevel: query.level,
-    searchCondition: keyword ? '1' : '',
+    searchSchulGradeCd: query.school,
+    searchGradeCd: query.grade,
+    // 백엔드 SQL이 아직 쓰지 않는다(결함 5). 화면 선택지는 기획대로 두고 값만 보낸다.
+    searchLevelCd: query.level,
     searchKeyword: keyword,
-    searchSortOrder: query.sort,
     pageIndex,
     recordCountPerPage,
   };
@@ -100,6 +144,7 @@
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
     canHaveNullData: true,
+    canHaveEmptyBody: true,
   });
 
   if (!result.ok) {
@@ -144,7 +189,7 @@
   };
 }
 
-/** TODO: `GET /api/v1/mngr/quiz/{quizSn}`이 생기면 이 스캔을 그 호출로 교체한다. */
+/** TODO: 단건 조회 API(결함 3)가 열리면 이 스캔을 `GET {base}/{quizSn}` 호출로 교체한다. */
 export async function fetchQuizDetail(quizSn: string): Promise<QuizItem | null> {
   const { items } = await requestList(
     {
@@ -164,31 +209,67 @@
   return items.find((item) => item.quizSn === quizSn) ?? null;
 }
 
-function buildQuizPayload(values: QuizFormValues) {
+type QuizWritePayload = {
+  quizTitle: string;
+  quizLev: string;
+  quizQuestion: string;
+  quizExplan: string;
+  quizAnswer: string;
+  useYn: string;
+  schulGrdList: Array<{ schulCd: string; grdCd: string | null }>;
+};
+
+/**
+ * 이슈: 정답 노출 문구(기획 "기본 3종 + 기타")를 담을 필드가 백엔드에 없다. 화면은 값을 받지만
+ *   저장되지 않는다 — 컬럼이 생기면 여기에 실어 보낸다.
+ * 이슈: 키워드가 별도 테이블이 아니라 `quizTitle` 한 칸이라 쉼표로 이어 붙인다.
+ */
+function buildQuizPayload(values: QuizFormValues): QuizWritePayload {
   return {
     quizTitle: values.keywords.map((item) => item.keyword).join(', '),
     quizLev: values.level,
     quizQuestion: values.question,
     quizExplan: values.explanation,
     quizAnswer: values.answer,
-    schulCd: values.schoolCode,
-    gradeCd: values.grades.join(','),
-    answerMssageCd: values.answerMessageCode,
-    answerMssage: values.answerMessageText,
     useYn: values.isVisible ? 'Y' : 'N',
+    schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades),
   };
+}
+
+/** 등록만 form이다 — 컨트롤러가 `@RequestBody` 없이 받는다(쇼츠·만화와 같은 갈림). */
+function toFormParams(
+  payload: QuizWritePayload
+): Record<string, string | number | undefined> {
+  const params: Record<string, string | number | undefined> = {
+    quizTitle: payload.quizTitle,
+    quizLev: payload.quizLev,
+    quizQuestion: payload.quizQuestion,
+    quizExplan: payload.quizExplan,
+    quizAnswer: payload.quizAnswer,
+    useYn: payload.useYn,
+  };
+
+  payload.schulGrdList.forEach((item, index) => {
+    params[`schulGrdList[${index}].schulCd`] = item.schulCd;
+    // undefined면 파라미터 자체가 빠져 백엔드에서 null이 된다.
+    params[`schulGrdList[${index}].grdCd`] = item.grdCd ?? undefined;
+  });
+
+  return params;
 }
 
 async function sendWrite(
   path: string,
   method: 'POST' | 'PUT' | 'DELETE',
-  body?: unknown
-): Promise<string | null> {
+  body?: unknown,
+  form?: Record<string, string | number | undefined>
+): 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,
@@ -198,12 +279,10 @@
   if (!result.ok) {
     throw new BackendRequestError(result);
   }
-
-  return typeof result.data === 'string' ? result.data : null;
 }
 
-export async function createQuiz(values: QuizFormValues): Promise<string | null> {
-  return sendWrite(QUIZ_PATH, 'POST', buildQuizPayload(values));
+export async function createQuiz(values: QuizFormValues): Promise<void> {
+  await sendWrite(QUIZ_PATH, 'POST', undefined, toFormParams(buildQuizPayload(values)));
 }
 
 export async function updateQuiz(
@@ -219,25 +298,4 @@
 
 export async function deleteQuiz(quizSn: string): Promise<void> {
   await sendWrite(`${QUIZ_PATH}/${encodeURIComponent(quizSn)}`, 'DELETE');
-}
-
-/** 엑셀 일괄등록. 한 행이라도 검증에 실패하면 백엔드가 전체를 되돌린다. */
-export async function uploadQuizExcel(file: File): Promise<void> {
-  const accessToken = await getSessionAccessToken();
-
-  const multipart = new FormData();
-  multipart.set('file', file);
-
-  const result = await backendFetch<unknown>(`${QUIZ_PATH}/excel/upload`, {
-    method: 'POST',
-    multipart,
-    accessToken: accessToken ?? undefined,
-    cache: 'no-store',
-    timeoutMs: UPLOAD_TIMEOUT_MS,
-    canHaveNullData: true,
-  });
-
-  if (!result.ok) {
-    throw new BackendRequestError(result);
-  }
 }
lib/domain/content.ts
--- lib/domain/content.ts
+++ lib/domain/content.ts
@@ -103,6 +103,10 @@
   explanation: string;
   answer: string;
   points: number | null;
+  /** 공통코드 `COM_SCHUL_CD` 코드값. */
+  schoolCode: string;
+  /** 공통코드 `GRD_CD` 코드값. */
+  grades: string[];
   isVisible: boolean;
   modifiedAt: string | null;
 };
Add a comment
List