File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
09-17
import 'server-only';
import { getSessionAccessToken } from '@/lib/auth/dal';
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
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_CNTNTS_QUIZ`)은 다르지만 콘텐츠관리
* API 아래로 들어와 규약은 같다.
*
* ```
* GET /api/v1/mngr/cntntns/quiz/pagination 학교·학년·난이도·검색어
* GET /api/v1/mngr/cntntns/quiz/{quizId} 단건
* 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 엑셀 다운로드
* ```
*
* 학교·학년은 `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<string, unknown> {
return value !== null && typeof value === 'object';
}
function readString(source: Record<string, unknown>, 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,
answerMessages: toAnswerMessages(raw.descrList),
isVisible: readString(raw, 'useYn') !== 'N',
modifiedAt:
readString(raw, 'lastMdfcnDtStr') ?? readString(raw, 'frstRegDtStr'),
};
}
function buildListParams(
query: ContentQuery,
pageIndex: number,
recordCountPerPage: number
): Record<string, string | number> {
// 검색어는 보내지 않는다 — 백엔드 조건이 이름이 바뀌기 전 컬럼을 보고 있어 실으면 목록이
// SQL 오류로 죽는다. 난이도도 받기만 하고 쓰지 않아 함께 뺀다. 둘 다 화면에서 감췄다.
return {
searchSchulGradeCd: query.school,
searchGradeCd: query.grade,
pageIndex,
recordCountPerPage,
};
}
async function requestList(
query: ContentQuery,
pageIndex: number,
recordCountPerPage: number
): Promise<{ items: QuizItem[]; reportedTotalCount: number }> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(`${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<QuizPage> {
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<QuizItem | null> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(
`${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 = {
/** 목록 자체의 이름. */
list: 'keywordList',
/** 항목의 키워드 필드. */
keyword: 'cntntsKeyword',
/** 항목의 설명 필드. */
description: 'cntntsExpln',
} as const;
type QuizKeywordPayload = Record<string, string>;
/**
* 정답별 노출 문구 — 백엔드 `descrList`(`CntntsQuizDescrVo` 배열, `TB_COM_CNTNTS_QUIZ_DESCR`).
*
* 칸이 하나(`CNTNTS_QUIZ_DESCR`)뿐이라 코드가 아니라 **문구 자체**를 싣는다. 학습자 앱이
* 이 값을 그대로 말풍선에 띄우기 때문이다(`CmmBotChatServiceImpl`이 조회해 응답에 넣는다).
*/
type QuizAnswerDescrPayload = {
cntntsQuizAnswer: string;
cntntsQuizDescr: string;
};
/** `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 = {
cntntsQuizLev: string;
cntntsQuizQuestion: string;
cntntsQuizExplan: string;
cntntsQuizAnswer: string;
useYn: string;
keywordList: QuizKeywordPayload[];
descrList: QuizAnswerDescrPayload[];
schulGrdList: Array<{ schulCd: string; grdCd: string | null }>;
};
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,
})),
// `text`에는 검증 단계가 고른 문구를 넣어 둔다(코드를 골랐으면 그 코드명).
descrList: values.answerMessages.map((message) => ({
cntntsQuizAnswer: message.answer,
cntntsQuizDescr: message.text,
})),
schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades),
};
}
async function sendWrite(
path: string,
method: 'POST' | 'PUT' | 'DELETE',
body?: unknown
): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(path, {
method,
...(body === undefined ? {} : { body }),
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
canHaveEmptyBody: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
}
export async function createQuiz(values: QuizFormValues): Promise<void> {
await sendWrite(QUIZ_PATH, 'POST', buildQuizPayload(values));
}
export async function updateQuiz(
quizId: string,
values: QuizFormValues
): Promise<void> {
await sendWrite(
`${QUIZ_PATH}/${encodeURIComponent(quizId)}`,
'PUT',
buildQuizPayload(values)
);
}
export async function deleteQuiz(quizId: string): Promise<void> {
await sendWrite(`${QUIZ_PATH}/${encodeURIComponent(quizId)}`, 'DELETE');
}