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 { ContentQuery } from '@/lib/domain/content-query'; import type { QuizFormValues } from '@/lib/domain/content-form'; /** * 금융OX퀴즈 Repository — 쇼츠·만화와 테이블(`TB_COM_QUIZ`)은 다르지만 콘텐츠관리 API 아래로 * 들어와 규약은 같다(등록은 form, 수정은 JSON, 삭제는 soft delete). * * ``` * GET /api/v1/mngr/cntntns/quiz/pagination 학교·학년·난이도·검색어 * GET /api/v1/mngr/cntntns/quiz/{quizId} 단건 * POST /api/v1/mngr/cntntns/quiz 어노테이션 없음 → form(중첩 목록은 인덱스 표기) * 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 엑셀 다운로드 * ``` * * 학교·학년은 `TB_COM_QUIZ_SCHUL_GRD`에 따로 저장되고 `schulGrdList`로 오간다 — 쇼츠·만화와 * 같은 모양이라 화면 쪽 취급도 같다. * * ⚠️ 백엔드 결함(보고함, 미수정) * 1. 검색어가 질문·정답만 본다(`QUIZ_ANSWER`·`QUIZ_QUESTION`). 화면의 「키워드」 검색은 * `QUIZ_TITLE`을 봐야 하는데 조건에 없어 결과가 비어 온다. * 2. 난이도 필터가 SQL에 없다 — `searchLevelCd`를 받지만 `searchWhere`가 쓰지 않는다. * 화면 선택지는 기획대로 두고 값만 실어 보낸다. */ const QUIZ_PATH = '/api/v1/mngr/cntntns/quiz'; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } function readString(source: Record, key: string): string | null { const value = source[key]; if (typeof value === 'string' && value.length > 0) return value; if (typeof value === 'number') return String(value); return null; } /** `schulGrdList` → 화면이 쓰는 학교 코드 + 학년 코드 목록(쇼츠·만화와 같은 규칙). */ /** 키워드 목록 — 백엔드 `CntntsKeywordVo` 배열. 키워드가 빈 줄은 버린다. */ function toKeywords(raw: unknown): ContentKeyword[] { if (!Array.isArray(raw)) { return []; } return raw.flatMap((entry) => { if (!isRecord(entry)) { return []; } const keyword = readString(entry, 'cntntsKeyword'); if (!keyword) { return []; } return [{ keyword, description: readString(entry, 'cntntsExpln') ?? '' }]; }); } 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 { if (!isRecord(raw)) { throw new Error('퀴즈 응답 항목의 형식이 올바르지 않습니다.'); } const quizId = readString(raw, 'cntntsId'); if (!quizId) { throw new Error('퀴즈 응답에 cntntsId가 없습니다.'); } const schulGrd = toSchoolGrades(raw.schulGrdList); return { quizId, keywords: toKeywords(raw.keywordList), level: readString(raw, 'cntntsQuizLev') ?? '', question: readString(raw, 'cntntsQuizQuestion') ?? '', explanation: readString(raw, 'cntntsQuizExplan') ?? '', answer: readString(raw, 'cntntsQuizAnswer') ?? '', schoolCode: schulGrd.schoolCode, grades: schulGrd.grades, isVisible: readString(raw, 'useYn') !== 'N', modifiedAt: readString(raw, 'lastMdfcnDtStr') ?? readString(raw, 'frstRegDtStr'), }; } function buildListParams( query: ContentQuery, pageIndex: number, recordCountPerPage: number ): Record { const keyword = query.keyword.trim(); return { searchSchulGradeCd: query.school, searchGradeCd: query.grade, // 백엔드 SQL이 아직 쓰지 않는다(결함 2). 화면 선택지는 기획대로 두고 값만 보낸다. searchLevelCd: query.level, searchKeyword: keyword, pageIndex, recordCountPerPage, }; } async function requestList( query: ContentQuery, pageIndex: number, recordCountPerPage: number ): Promise<{ items: QuizItem[]; reportedTotalCount: number }> { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${QUIZ_PATH}/pagination`, { method: 'GET', query: buildListParams(query, pageIndex, recordCountPerPage), accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } const data = result.data; if (data === null || data === undefined) { return { items: [], reportedTotalCount: 0 }; } if (!isRecord(data) || !Array.isArray(data.list)) { throw new Error('퀴즈 목록 응답의 형식이 올바르지 않습니다.'); } return { items: data.list.map(toQuizItem), reportedTotalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length, }; } export type QuizPage = { items: QuizItem[]; totalCount: number; isTotalCountExact: boolean; }; export async function fetchQuizzes(query: ContentQuery): Promise { const { items, reportedTotalCount } = await requestList( query, query.page, query.pageSize ); const confirmedCount = (query.page - 1) * query.pageSize + items.length; return { items, totalCount: Math.max(reportedTotalCount, confirmedCount), isTotalCountExact: items.length < query.pageSize || reportedTotalCount > confirmedCount, }; } export async function fetchQuizDetail(quizId: string): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch( `${QUIZ_PATH}/${encodeURIComponent(quizId)}`, { method: 'GET', accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, } ); if (!result.ok) { throw new BackendRequestError(result); } if (result.data === null || result.data === undefined) { return null; } return toQuizItem(result.data); } /** 키워드 목록의 필드명. 백엔드 `CntntsKeywordVo`와 같다(콘텐츠 API와 동일한 구조다). */ const QUIZ_KEYWORD_FIELD = { /** 목록 자체의 이름. form에서는 `keywordList[0].…` 형태가 된다. */ list: 'keywordList', /** 항목의 키워드 필드. */ keyword: 'cntntsKeyword', /** 항목의 설명 필드. */ description: 'cntntsExpln', } as const; type QuizKeywordPayload = Record; /** * 정답 노출 문구 필드명. **백엔드가 아직 이 필드를 모른다**(작업 중) — 지금 보내면 조용히 * 무시된다(폼은 미지의 파라미터를, JSON은 미지의 속성을 버린다). 이름이 확정되면 여기 세 값만 * 고치면 요청·전송이 모두 따라간다(키워드와 같은 방식). */ const QUIZ_ANSWER_MESSAGE_FIELD = { /** 목록 자체의 이름. form에서는 `answerMessageList[0].…` 형태가 된다. */ list: 'answerMessageList', /** 어느 쪽(O/X)에 붙는 문구인지. */ answer: 'cntntsQuizAnswer', /** 고른 문구의 코드값. */ code: 'answerMessageCode', /** 기타일 때 직접 적은 문구. */ text: 'answerMessageText', } as const; type QuizAnswerMessagePayload = Record; 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, })); } type QuizWritePayload = { cntntsQuizLev: string; cntntsQuizQuestion: string; cntntsQuizExplan: string; cntntsQuizAnswer: string; useYn: string; keywordList: QuizKeywordPayload[]; answerMessageList: QuizAnswerMessagePayload[]; schulGrdList: Array<{ schulCd: string; grdCd: string | null }>; }; /** * 이슈: 정답 노출 문구(기획 "기본 3종 + 기타")를 담을 필드가 백엔드에 없다. 화면은 값을 받지만 * 저장되지 않는다 — 컬럼이 생기면 여기에 실어 보낸다. */ function buildQuizPayload(values: QuizFormValues): QuizWritePayload { return { cntntsQuizLev: values.level, cntntsQuizQuestion: values.question, cntntsQuizExplan: values.explanation, cntntsQuizAnswer: values.answer, useYn: values.isVisible ? 'Y' : 'N', keywordList: values.keywords.map((item) => ({ [QUIZ_KEYWORD_FIELD.keyword]: item.keyword, [QUIZ_KEYWORD_FIELD.description]: item.description, })), answerMessageList: toAnswerMessagePayload(values.answerMessages), schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades), }; } /** * 등록은 `@RequestBody`가 없어 form으로 간다(쇼츠·만화와 같은 갈림). * * TODO: 백엔드가 `insert`에 `@RequestBody`를 붙이면 이 함수를 지우고 수정과 같은 JSON으로 * 통일한다. 반영 여부는 `/v3/api-docs`에서 이 경로의 POST에 `requestBody`가 생겼는지로 본다. */ function toFormParams( payload: QuizWritePayload ): Record { const params: Record = { 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 { 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 ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(path, { method, ...(body === undefined ? {} : { body }), ...(form === undefined ? {} : { form }), accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } } export async function createQuiz(values: QuizFormValues): Promise { await sendWrite(QUIZ_PATH, 'POST', undefined, toFormParams(buildQuizPayload(values))); } export async function updateQuiz( quizId: string, values: QuizFormValues ): Promise { await sendWrite( `${QUIZ_PATH}/${encodeURIComponent(quizId)}`, 'PUT', toJsonBody(buildQuizPayload(values)) ); } export async function deleteQuiz(quizId: string): Promise { await sendWrite(`${QUIZ_PATH}/${encodeURIComponent(quizId)}`, 'DELETE'); }