import 'server-only'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import type { 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 학교·학년·난이도·검색어 * 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 엑셀 다운로드 * ``` * * 학교·학년은 `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/cntntns/quiz'; /** 목록에서 단건을 찾을 때 한 번에 받아올 최대 행 수(단건 조회 API가 없다 — 결함 3). */ const DETAIL_SCAN_LIMIT = 500; 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` → 화면이 쓰는 학교 코드 + 학년 코드 목록(쇼츠·만화와 같은 규칙). */ 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 quizSn = readString(raw, 'quizSn'); if (!quizSn) { throw new Error('퀴즈 응답에 quizSn이 없습니다.'); } const points = raw.quizPoint; const schulGrd = toSchoolGrades(raw.schulGrdList); return { quizSn, keywordText: readString(raw, 'quizTitle') ?? '', level: readString(raw, 'quizLev') ?? '', question: readString(raw, 'quizQuestion') ?? '', 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'), }; } function buildListParams( query: ContentQuery, pageIndex: number, recordCountPerPage: number ): Record { const keyword = query.keyword.trim(); return { searchSchulGradeCd: query.school, searchGradeCd: query.grade, // 백엔드 SQL이 아직 쓰지 않는다(결함 5). 화면 선택지는 기획대로 두고 값만 보낸다. 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, }; } /** TODO: 단건 조회 API(결함 3)가 열리면 이 스캔을 `GET {base}/{quizSn}` 호출로 교체한다. */ export async function fetchQuizDetail(quizSn: string): Promise { const { items } = await requestList( { school: '', grade: '', level: '', searchField: '', keyword: '', sort: 'created', page: 1, pageSize: 10, }, 1, DETAIL_SCAN_LIMIT ); return items.find((item) => item.quizSn === quizSn) ?? null; } type QuizWritePayload = { /** 값이 있을 때만 실어 보낸다 — 비어 있는 채로 보내면 백엔드가 NULL로 덮어쓴다. */ quizPoint?: string; 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 { ...(values.points === '' ? {} : { quizPoint: values.points }), quizTitle: values.keywords.map((item) => item.keyword).join(', '), quizLev: values.level, quizQuestion: values.question, quizExplan: values.explanation, quizAnswer: values.answer, useYn: values.isVisible ? 'Y' : 'N', 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 = { quizPoint: payload.quizPoint, 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, 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( quizSn: string, values: QuizFormValues ): Promise { await sendWrite( `${QUIZ_PATH}/${encodeURIComponent(quizSn)}`, 'PUT', buildQuizPayload(values) ); } export async function deleteQuiz(quizSn: string): Promise { await sendWrite(`${QUIZ_PATH}/${encodeURIComponent(quizSn)}`, 'DELETE'); }