import 'server-only'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import { buildFileImageUrl } from '@/lib/data/repositories/file-repository'; import { CONTENT_TYPE_CODE } from '@/lib/domain/content'; import type { ContentItem, ContentKeyword } from '@/lib/domain/content'; import type { ContentQuery } from '@/lib/domain/content-query'; import type { ComicFormValues, ShortsFormValues, } from '@/lib/domain/content-form'; /** * 금융쇼츠·4컷금융만화 Repository — 두 화면은 같은 테이블(`TB_COM_CNTNTS`)을 쓰고 * `CNTNTS_TYPE`으로 갈린다(`V` 쇼츠 / `T` 만화). * * ``` * GET /api/v1/mngr/cntntns/pagination searchCntntsType 필수 * GET /api/v1/mngr/cntntns/{cntntsId} 단건 * POST /api/v1/mngr/cntntns 어노테이션 없음 → form(중첩 목록은 인덱스 표기) * PUT /api/v1/mngr/cntntns/{cntntsId} @RequestBody(JSON) * DELETE /api/v1/mngr/cntntns/{cntntsId} soft delete(DEL_YN) * ``` * * 이슈: 경로가 `cntnts`가 아니라 **`cntntns`**다(백엔드 오타). 고쳐지면 아래 상수만 바꾼다. * * ⚠️ 백엔드 결함(보고함, 미수정) — 그래서 목록은 전량 수령 후 로컬 검색·정렬·페이징이다. * 1. 검색 조건(`c.CNTNTS_TITLE`·`k.CNTNTS_KEYWORD`)이 별칭을 쓰는데 `selectPaginationCount`는 * `FROM TB_COM_CNTNTS`뿐이라 검색어를 실으면 count가 SQL 오류로 죽는다. * 2. 목록 정렬이 이중 역순이다 — rnum이 등록일 내림차순인데 `ORDER BY RNUM DESC`로 다시 뒤집어 * **옛 글부터** 나간다. 새로 등록한 글이 첫 페이지가 아니라 마지막 페이지에 붙는다. * 3. 키워드·학교급·파일 LEFT JOIN으로 한 콘텐츠가 여러 행이 되고 LIMIT이 그 행 기준이라, 페이지 * 경계에서 잘린 콘텐츠는 키워드·컷 목록이 반쪽으로 온다. * 4. 학교·학년 파라미터는 `` 본문이 값 대신 `#{searchKeyword}`를 비교해 보내도 의미가 없다. */ const CONTENT_BASE_PATH = '/api/v1/mngr/cntntns'; type ContentApiKind = 'shorts' | 'comic'; 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]; return typeof value === 'string' && value.length > 0 ? value : null; } 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 toFirstFileName(raw: unknown): string | null { if (!Array.isArray(raw)) { return null; } for (const entry of raw) { if (isRecord(entry)) { const name = readString(entry, 'orgnlFileNm'); if (name) { return name; } } } return null; } /** 한 `atchFileId` 아래 여러 컷이 `fileSn`으로 들어온다. */ function toFileNumbers(raw: unknown): number[] { if (!Array.isArray(raw)) { return []; } return raw .flatMap((entry) => (isRecord(entry) ? [Number(entry.fileSn)] : [])) .filter((value) => Number.isInteger(value) && value > 0) .sort((a, b) => a - b); } function toContentItem(raw: unknown): ContentItem { if (!isRecord(raw)) { throw new Error('콘텐츠 응답 항목의 형식이 올바르지 않습니다.'); } const cntntsId = readString(raw, 'cntntsId'); if (!cntntsId) { throw new Error('콘텐츠 응답에 cntntsId이 없습니다.'); } const schulGrd = toSchoolGrades(raw.schulGrdList); return { cntntsId, cutFileNumbers: toFileNumbers(raw.fileList), name: readString(raw, 'cntntsNm') ?? '', title: readString(raw, 'cntntsTitle') ?? '', subject: readString(raw, 'cntntsSbjct') ?? '', message: readString(raw, 'cntntsMssage') ?? '', schoolCode: schulGrd.schoolCode, grades: schulGrd.grades, keywords: toKeywords(raw.keywordList), attachmentId: readString(raw, 'atchFileId'), attachmentFileName: toFirstFileName(raw.fileList), thumbnailFileId: readString(raw, 'thumbAtchFileId'), isVisible: readString(raw, 'useYn') !== 'N', modifiedAt: readString(raw, 'lastMdfcnDtStr') ?? readString(raw, 'frstRegDtStr'), }; } function buildListParams( kind: ContentApiKind, pageIndex: number, recordCountPerPage: number ): Record { return { // 없으면 `CNTNTS_TYPE = NULL`이 되어 한 건도 오지 않는다. // 검색어는 보내지 않는다 — 검색 조건이 c.·k. 별칭을 쓰는데 count 쿼리에는 별칭이 없어 // 키워드를 실으면 count가 SQL 오류로 죽는다(파일 상단 ⚠️ 1). 검색은 아래에서 로컬로 건다. searchCntntsType: CONTENT_TYPE_CODE[kind], pageIndex, recordCountPerPage, }; } async function requestList( kind: ContentApiKind, pageIndex: number, recordCountPerPage: number ): Promise<{ items: ContentItem[]; reportedTotalCount: number }> { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${CONTENT_BASE_PATH}/pagination`, { method: 'GET', query: buildListParams(kind, pageIndex, recordCountPerPage), accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } const data = result.data; // 금융쇼츠는 컨트롤러가 null을 돌려준다(파일 상단 이슈) — 빈 목록으로 다룬다. if (data === null || data === undefined) { return { items: [], reportedTotalCount: 0 }; } if (!isRecord(data) || !Array.isArray(data.list)) { throw new Error('콘텐츠 목록 응답의 형식이 올바르지 않습니다.'); } return { items: data.list.map(toContentItem), reportedTotalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length, }; } export type ContentPage = { items: ContentItem[]; totalCount: number; isTotalCountExact: boolean; }; // TODO(백엔드): 목록 정렬·LIMIT이 고쳐지면 서버 페이징으로 되돌린다. 그전까지 이 상한을 // 넘는 콘텐츠는 목록에서 조용히 잘린다. const FETCH_LIMIT = 500; function matchesContentQuery(item: ContentItem, query: ContentQuery): boolean { if (query.school && item.schoolCode !== query.school) { return false; } if (query.grade && !item.grades.includes(query.grade)) { return false; } const keyword = query.keyword.trim().toLowerCase(); if (!keyword) { return true; } if (query.searchField === 'keyword') { return item.keywords.some((entry) => entry.keyword.toLowerCase().includes(keyword) ); } // title(쇼츠)·name(만화) 모두 같은 컬럼(CNTNTS_NM=CNTNTS_TITLE)이라 한 값을 본다. return item.title.toLowerCase().includes(keyword); } export async function fetchContents( kind: ContentApiKind, query: ContentQuery ): Promise { // 서버 페이징을 쓰지 않는다 — 목록 SQL이 rnum(등록일 내림차순)을 다시 뒤집어 옛 글부터 // 내보내고, LIMIT도 조인으로 불어난 행 기준이라 새 글이 마지막 페이지로 밀린다(⚠️ 2·3). const { items } = await requestList(kind, 1, FETCH_LIMIT); const matched = items .filter((item) => matchesContentQuery(item, query)) // rnum은 resultMap에 없고 등록일은 일 단위라, 새 글이 먼저 오도록 일련번호로 세운다. .sort((a, b) => Number(b.cntntsId) - Number(a.cntntsId)); const offset = (query.page - 1) * query.pageSize; return { items: matched.slice(offset, offset + query.pageSize), totalCount: matched.length, isTotalCountExact: true, }; } /** 단건 조회 — 목록과 같은 SQL이라 키워드·학교급·파일 목록이 함께 온다. */ export async function fetchContentDetail( kind: ContentApiKind, cntntsId: string ): Promise { void kind; const accessToken = await getSessionAccessToken(); const result = await backendFetch( `${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsId)}`, { 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 toContentItem(result.data); } // 이슈: 주제·전달메시지를 담을 컬럼이 테이블에 없다. 화면은 기획대로 두고 값은 보내지 않는다. type ContentWritePayload = { cntntsType?: string; cntntsNm: string; cntntsTitle: string; atchFileId: string; thumbAtchFileId: string; useYn: string; keywordList: Array<{ cntntsKeyword: string; cntntsExpln: string }>; schulGrdList: Array<{ schulCd: string; grdCd: string | null }>; }; function toKeywordPayload(keywords: readonly ContentKeyword[]) { return keywords.map((item) => ({ cntntsKeyword: item.keyword, cntntsExpln: item.description, })); } /** 학년마다 한 줄이다. 학년을 고르지 않았으면 학교만 담은 한 줄을 남긴다. */ 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 })); } export function buildShortsPayload( values: ShortsFormValues ): ContentWritePayload { return { cntntsNm: values.title, cntntsTitle: values.title, atchFileId: values.videoFileId, thumbAtchFileId: values.thumbnailFileId, useYn: values.isVisible ? 'Y' : 'N', keywordList: toKeywordPayload(values.keywords), schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades), }; } export function buildComicPayload(values: ComicFormValues): ContentWritePayload { return { cntntsNm: values.name, cntntsTitle: values.name, // 컷은 한 첨부 아래 fileSn 1..N으로 들어간다 — 배열 값이 모두 같은 첨부 아이디다. atchFileId: values.cutFileIds[0] ?? '', thumbAtchFileId: values.thumbnailFileId, useYn: values.isVisible ? 'Y' : 'N', keywordList: toKeywordPayload(values.keywords), schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades), }; } /** * 등록은 `@RequestBody`가 없어 form으로 간다 — 중첩 목록은 Spring이 읽는 인덱스 표기 * (`keywordList[0].cntntsKeyword`)로 편다. JSON으로 보내면 한 칸도 바인딩되지 않는다. * * TODO: 백엔드가 `insert`에 `@RequestBody`를 붙이면 이 함수를 지우고 수정과 같은 JSON으로 * 통일한다(사용자가 그 방향으로 확정했고, 백엔드 반영만 남았다). 반영 여부는 배포 서버의 * `/v3/api-docs`에서 이 경로의 POST에 `requestBody`가 생겼는지로 확인한다 — 지금은 * `parameters: [{ name: "requestVo", in: "query" }]`라 JSON 본문을 보내면 400이 온다. */ function toFormParams( payload: ContentWritePayload & { cntntsType?: string } ): Record { const params: Record = { cntntsType: payload.cntntsType, cntntsNm: payload.cntntsNm, cntntsTitle: payload.cntntsTitle, atchFileId: payload.atchFileId, thumbAtchFileId: payload.thumbAtchFileId, useYn: payload.useYn, }; payload.keywordList.forEach((item, index) => { params[`keywordList[${index}].cntntsKeyword`] = item.cntntsKeyword; params[`keywordList[${index}].cntntsExpln`] = item.cntntsExpln; }); payload.schulGrdList.forEach((item, index) => { params[`schulGrdList[${index}].schulCd`] = item.schulCd; // undefined면 파라미터 자체가 빠져 백엔드에서 null이 된다. params[`schulGrdList[${index}].grdCd`] = item.grdCd ?? undefined; }); return params; } /** * 실패한 쓰기의 사유와 보낸 본문을 남긴다. 요청 자체는 테스트 환경에서 backend-fetch가 * 전부 찍으므로(중앙 로거) 여기는 실패만 맡는다 — 운영에서도 원인 추적에 이게 유일한 단서다. */ function logWriteFailure( method: string, path: string, body: unknown, form: Record | undefined, failure: { code: number; message: string } ): void { const lines = [ `[contents] ${method} ${path} 실패 (code ${failure.code}: ${failure.message})`, body === undefined ? null : `body(JSON):\n${JSON.stringify(body, null, 2)}`, form === undefined ? null : `form:\n${new URLSearchParams( Object.entries(form).flatMap(([key, value]) => value === undefined ? [] : [[key, String(value)] as [string, string]] ) ).toString()}`, ].filter(Boolean); console.error(lines.join('\n')); } 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) { logWriteFailure(method, path, body, form, result); throw new BackendRequestError(result); } return typeof result.data === 'string' ? result.data : null; } export async function createContent( kind: ContentApiKind, payload: ContentWritePayload, cntntsType: string ): Promise { return sendWrite(CONTENT_BASE_PATH, 'POST', undefined, toFormParams({ ...payload, cntntsType })); } export async function updateContent( kind: ContentApiKind, cntntsId: string, payload: ContentWritePayload ): Promise { await sendWrite( `${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsId)}`, 'PUT', // 수정 SQL이 `CNTNTS_TYPE = #{cntntsType}`을 조건 없이 덮는다. 빼고 보내면 종류가 지워져 // 목록(`CNTNTS_TYPE = 'V'|'T'`)에서 사라진다. { ...payload, cntntsType: CONTENT_TYPE_CODE[kind] } ); } export async function deleteContent( kind: ContentApiKind, cntntsId: string ): Promise { await sendWrite(`${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsId)}`, 'DELETE'); } export function contentThumbnailUrl(item: ContentItem): string | null { return buildFileImageUrl(item.thumbnailFileId); } /** 컷 이미지 주소 — 응답이 준 파일 번호를 쓰고, 없으면 1..count로 채운다. */ export function contentCutUrls(item: ContentItem, count: number): string[] { const numbers = item.cutFileNumbers.length > 0 ? item.cutFileNumbers : Array.from({ length: count }, (_, index) => index + 1); return numbers .map((fileSn) => buildFileImageUrl(item.attachmentId, fileSn)) .filter((url): url is string => url !== null); }