임동욱 임동욱 08-20
merge: 콘텐츠 새 API 연결
@3a5a104c74c009bcc823dbe4cf4de5287cdb2bff
app/(protected)/(basic)/contents/_actions.ts
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
@@ -5,7 +5,10 @@
 import { verifySession } from '@/lib/auth/dal';
 import { BackendRequestError } from '@/lib/http/backend-fetch';
 import { fetchSchoolGradeCodes } from '@/lib/data/repositories/common-code-repository';
-import { uploadAttachment } from '@/lib/data/repositories/file-repository';
+import {
+  uploadAttachment,
+  uploadAttachments,
+} from '@/lib/data/repositories/file-repository';
 import {
   buildComicPayload,
   buildShortsPayload,
@@ -84,14 +87,7 @@
  * 새로 고른 파일을 알맞은 저장소에 올려 파일 ID를 얻는다. 고르지 않았으면 폼이 들고 있던 기존
  * ID를 그대로 쓴다. 확장자는 백엔드가 사유 없이 실패하기 전에 여기서 먼저 잡는다.
  */
-async function resolveFileId(
-  file: File | null,
-  retainedId: string,
-  module: FileModule
-): Promise<string> {
-  if (!file) {
-    return retainedId;
-  }
+function assertUploadable(file: File, module: FileModule): void {
   if (file.size > MAX_ATTACHMENT_BYTES) {
     throw new ContentInputError(OVERSIZE_MESSAGE);
   }
@@ -100,6 +96,17 @@
       `${module.description}에는 ${allowedExtensions(module).join(', ')} 파일만 올릴 수 있습니다.`
     );
   }
+}
+
+async function resolveFileId(
+  file: File | null,
+  retainedId: string,
+  module: FileModule
+): Promise<string> {
+  if (!file) {
+    return retainedId;
+  }
+  assertUploadable(file, module);
   return uploadAttachment(file, module.id);
 }
 
@@ -184,25 +191,33 @@
   );
 }
 
-/** 컷은 개별 이미지로 올린다(기획). 고르지 않은 칸은 폼이 들고 있던 기존 ID를 그대로 쓴다. */
+/**
+ * 컷은 **한 첨부 아래** 올린다 — 백엔드가 `fileSn`을 1,2,3…으로 채번한다. 그래서 돌려주는
+ * 배열은 컷 수만큼 같은 아이디가 반복된다(컷을 가리키는 건 순번이지 아이디가 아니다).
+ *
+ * 한 컷만 바꿔도 묶음 전체를 다시 올려야 순번이 맞으므로, 새로 고른 것이 하나라도 있으면
+ * 고른 것들로 묶음을 새로 만든다. 하나도 고르지 않았으면 기존 아이디를 그대로 쓴다.
+ */
 async function resolveCutFileIds(formData: FormData): Promise<string[]> {
-  const files = readFiles(formData, 'cutFile');
+  // 고르지 않은 칸은 null로 오므로 걸러 낸다.
+  const files = readFiles(formData, 'cutFile').filter(
+    (file): file is File => file !== null
+  );
   const retained = formData
     .getAll('cutFileId')
-    .map((entry) => (typeof entry === 'string' ? entry : ''));
+    .map((entry) => (typeof entry === 'string' ? entry : ''))
+    .filter(Boolean);
 
-  const ids: string[] = [];
-  for (const [index, file] of files.entries()) {
-    const id = await resolveFileId(
-      file,
-      retained[index] ?? '',
-      FILE_MODULE.toonContent
-    );
-    if (id) {
-      ids.push(id);
-    }
+  if (files.length === 0) {
+    return retained;
   }
-  return ids;
+
+  for (const file of files) {
+    assertUploadable(file, FILE_MODULE.toonContent);
+  }
+
+  const attachmentId = await uploadAttachments(files, FILE_MODULE.toonContent.id);
+  return files.map(() => attachmentId);
 }
 
 export async function createShortsAction(
lib/data/repositories/content-repository.ts
--- lib/data/repositories/content-repository.ts
+++ lib/data/repositories/content-repository.ts
@@ -2,6 +2,7 @@
 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 {
@@ -10,44 +11,32 @@
 } from '@/lib/domain/content-form';
 
 /**
- * 금융쇼츠·4컷금융만화 Repository — 두 화면은 같은 테이블(`TB_COM_CNTNTS`)을 쓰고 경로만
- * 갈린다. 금융OX퀴즈는 테이블도 API도 달라 `content-quiz-repository.ts`가 따로 맡는다.
+ * 금융쇼츠·4컷금융만화 Repository — 두 화면은 같은 테이블(`TB_COM_CNTNTS`)을 쓰고
+ * `CNTNTS_TYPE`으로 갈린다(`V` 쇼츠 / `T` 만화).
  *
  * ```
- * GET /api/v1/mngr/cntntns/toon/pagination    4컷금융만화 목록
- * GET /api/v1/mngr/cntntns/video/pagination   금융쇼츠 목록
+ * GET    /api/v1/mngr/cntntns/pagination    searchCntntsType 필수
+ * GET    /api/v1/mngr/cntntns/{cntntsSn}    단건
+ * POST   /api/v1/mngr/cntntns               어노테이션 없음 → form(중첩 목록은 인덱스 표기)
+ * PUT    /api/v1/mngr/cntntns/{cntntsSn}    @RequestBody(JSON)
+ * DELETE /api/v1/mngr/cntntns/{cntntsSn}    soft delete(DEL_YN)
  * ```
  *
  * 이슈: 경로가 `cntnts`가 아니라 **`cntntns`**다(백엔드 오타). 고쳐지면 아래 상수만 바꾼다.
- * 이슈: `video/pagination` 컨트롤러가 `return null;`이라 응답 봉투 없이 **본문이 통째로 빈**
- *   200이 온다(서비스가 주입돼 있지 않다). 그래서 목록 조회는 `canHaveEmptyBody`가 필요하고,
- *   금융쇼츠 목록은 늘 빈 목록으로 보인다.
- * 이슈: 등록·수정·삭제·단건조회 API가 없다. 아래 쓰기 함수는 다른 관리 화면과 같은 규약
- *   (`POST {base}` / `PUT {base}/{sn}` / `DELETE {base}/{sn}`)으로 미리 맞춰 둔 것이라
- *   백엔드가 생기기 전에는 실패한다.
- * 이슈: 학교(`COM_SCHUL_CD`)·학년(`GRD_CD`)을 담을 컬럼도 필터 SQL도 없다 — 검색 VO에 파라미터만
- *   있고 매퍼의 `<if>`가 비어 있다. 주제·전달메시지도 컬럼이 없어 목록·상세에서 늘 빈 값이다.
- * 이슈: toon 매퍼의 검색 조건이 금칙어 테이블 컬럼(`BEN_WORD_NM`)을 보고 있어 검색어가 걸리면
- *   SQL이 깨진다. 그래서 검색어는 보내되 백엔드 수정 전까지는 결과를 신뢰할 수 없다.
- * 이슈: 목록 SQL이 키워드를 LEFT JOIN해 행이 키워드 수만큼 늘어나는데 count는 조인 없이 센다.
- *   한 페이지에 실제로 오는 콘텐츠 수가 `recordCountPerPage`보다 적을 수 있다.
+ *
+ * ⚠️ 백엔드 결함(보고함, 미수정)
+ * 1. `selectPaginationCount`가 `FROM TB_COM_CNTNTS`만 두고 `searchWhere`를 그대로 포함한다.
+ *    그 조건은 `c.`·`k.`·`g.` 별칭을 쓰므로 별칭을 모르는 count 쿼리에서 SQL이 깨진다.
+ * 2. 학교·학년 조건의 `<if test='searchSchulGradeCd != null != ""'>`가 OGNL에서 항상 참이고,
+ *    본문도 값 대신 `#{searchKeyword}`를 비교한다. 파라미터를 보내도 의미가 없어 보내지 않고,
+ *    학교·학년 필터는 받아온 뒤 여기서 건다.
+ * 3. 목록 SQL이 키워드·학교급·파일을 모두 LEFT JOIN해 한 콘텐츠가 여러 행이 된다. LIMIT이 그
+ *    늘어난 행에 걸려 한 페이지에 오는 콘텐츠 수가 `recordCountPerPage`보다 적을 수 있다.
  */
 
 const CONTENT_BASE_PATH = '/api/v1/mngr/cntntns';
 
-const SEGMENT: Record<'shorts' | 'comic', string> = {
-  shorts: 'video',
-  comic: 'toon',
-};
-
-type ContentApiKind = keyof typeof SEGMENT;
-
-/** 단건 조회 API가 없어 목록에서 찾는다. 그때 한 번에 받아올 최대 행 수. */
-const DETAIL_SCAN_LIMIT = 500;
-
-function basePath(kind: ContentApiKind): string {
-  return `${CONTENT_BASE_PATH}/${SEGMENT[kind]}`;
-}
+type ContentApiKind = 'shorts' | 'comic';
 
 function isRecord(value: unknown): value is Record<string, unknown> {
   return value !== null && typeof value === 'object';
@@ -74,6 +63,40 @@
   });
 }
 
+/** 학교급/학년 매핑은 학년마다 한 줄씩 온다 — 학교는 하나로 접고 학년만 모은다. */
+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 };
+}
+
+/** 한 `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('콘텐츠 응답 항목의 형식이 올바르지 않습니다.');
@@ -84,17 +107,17 @@
     throw new Error('콘텐츠 응답에 cntntsSn이 없습니다.');
   }
 
+  const schulGrd = toSchoolGrades(raw.schulGrdList);
+
   return {
     cntntsSn,
+    cutFileNumbers: toFileNumbers(raw.fileList),
     name: readString(raw, 'cntntsNm') ?? '',
     title: readString(raw, 'cntntsTitle') ?? '',
     subject: readString(raw, 'cntntsSbjct') ?? '',
     message: readString(raw, 'cntntsMssage') ?? '',
-    schoolCode: readString(raw, 'schulCd') ?? '',
-    grades: (readString(raw, 'gradeCd') ?? '')
-      .split(',')
-      .map((grade) => grade.trim())
-      .filter(Boolean),
+    schoolCode: schulGrd.schoolCode,
+    grades: schulGrd.grades,
     keywords: toKeywords(raw.keywordList),
     attachmentId: readString(raw, 'atchFileId'),
     thumbnailFileId: readString(raw, 'thumbAtchFileId'),
@@ -105,6 +128,7 @@
 }
 
 function buildListParams(
+  kind: ContentApiKind,
   query: ContentQuery,
   pageIndex: number,
   recordCountPerPage: number
@@ -112,13 +136,10 @@
   const keyword = query.keyword.trim();
 
   return {
-    searchSchulGradeCd: query.school,
-    searchGradeCd: query.grade,
+    // 없으면 `CNTNTS_TYPE = NULL`이 되어 한 건도 오지 않는다.
+    searchCntntsType: CONTENT_TYPE_CODE[kind],
     searchCondition: keyword ? '1' : '',
     searchKeyword: keyword,
-    // 이슈: 백엔드가 정렬 파라미터를 읽지 않는다(등록일 내림차순 고정). 화면 선택지는 기획대로
-    // 유지하고 값만 미리 실어 보낸다.
-    searchSortOrder: query.sort,
     pageIndex,
     recordCountPerPage,
   };
@@ -132,9 +153,9 @@
 ): Promise<{ items: ContentItem[]; reportedTotalCount: number }> {
   const accessToken = await getSessionAccessToken();
 
-  const result = await backendFetch<unknown>(`${basePath(kind)}/pagination`, {
+  const result = await backendFetch<unknown>(`${CONTENT_BASE_PATH}/pagination`, {
     method: 'GET',
-    query: buildListParams(query, pageIndex, recordCountPerPage),
+    query: buildListParams(kind, query, pageIndex, recordCountPerPage),
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
     canHaveNullData: true,
@@ -190,47 +211,44 @@
   };
 }
 
-/**
- * 단건 조회.
- *
- * TODO: `GET {base}/{cntntsSn}`이 생기면 이 스캔을 그 호출로 교체한다. 지금은 단건 API가 없어
- * 목록에서 찾는다 — 목록이 돌려주는 필드만 채워지고 나머지는 빈 값이다.
- */
+/** 단건 조회 — 목록과 같은 SQL이라 키워드·학교급·파일 목록이 함께 온다. */
 export async function fetchContentDetail(
   kind: ContentApiKind,
   cntntsSn: string
 ): Promise<ContentItem | null> {
-  const { items } = await requestList(
-    kind,
+  void kind;
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(
+    `${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsSn)}`,
     {
-      school: '',
-      grade: '',
-      level: '',
-      searchField: '',
-      keyword: '',
-      sort: 'created',
-      page: 1,
-      pageSize: 10,
-    },
-    1,
-    DETAIL_SCAN_LIMIT
+      method: 'GET',
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      canHaveNullData: true,
+    }
   );
 
-  return items.find((item) => item.cntntsSn === cntntsSn) ?? null;
+  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;
-  cntntsSbjct: string;
-  cntntsMssage: string;
-  schulCd: string;
-  gradeCd: string;
   atchFileId: string;
   thumbAtchFileId: string;
   useYn: string;
   keywordList: Array<{ cntntsKeyword: string; cntntsExpln: string }>;
+  schulGrdList: Array<{ schulCd: string; grdCd: string }>;
 };
 
 function toKeywordPayload(keywords: readonly ContentKeyword[]) {
@@ -240,20 +258,28 @@
   }));
 }
 
+/** 학년마다 한 줄이다. 학년을 고르지 않았으면 학교만 담은 한 줄을 남긴다. */
+function toSchoolGradePayload(schoolCode: string, grades: readonly string[]) {
+  if (!schoolCode) {
+    return [];
+  }
+  if (grades.length === 0) {
+    return [{ schulCd: schoolCode, grdCd: '' }];
+  }
+  return grades.map((grade) => ({ schulCd: schoolCode, grdCd: grade }));
+}
+
 export function buildShortsPayload(
   values: ShortsFormValues
 ): ContentWritePayload {
   return {
     cntntsNm: values.title,
     cntntsTitle: values.title,
-    cntntsSbjct: values.subject,
-    cntntsMssage: values.message,
-    schulCd: values.schoolCode,
-    gradeCd: values.grades.join(','),
     atchFileId: values.videoFileId,
     thumbAtchFileId: values.thumbnailFileId,
     useYn: values.isVisible ? 'Y' : 'N',
     keywordList: toKeywordPayload(values.keywords),
+    schulGrdList: toSchoolGradePayload(values.schoolCode, values.grades),
   };
 }
 
@@ -261,29 +287,55 @@
   return {
     cntntsNm: values.name,
     cntntsTitle: values.name,
-    cntntsSbjct: values.subject,
-    cntntsMssage: values.message,
-    schulCd: values.schoolCode,
-    gradeCd: values.grades.join(','),
-    // 이슈: 기획은 "각 컷을 개별 이미지로 업로드"인데 백엔드 컬럼은 `atchFileId` 한 칸뿐이라
-    // 컷 묶음을 여러 개 연결할 자리가 없다. 지금은 첫 묶음만 저장된다.
+    // 컷은 한 첨부 아래 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으로 보내면 한 칸도 바인딩되지 않는다.
+ */
+function toFormParams(
+  payload: ContentWritePayload & { cntntsType?: string }
+): Record<string, string | number | undefined> {
+  const params: Record<string, string | number | undefined> = {
+    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;
+    params[`schulGrdList[${index}].grdCd`] = item.grdCd;
+  });
+
+  return params;
 }
 
 async function sendWrite(
   path: string,
   method: 'POST' | 'PUT' | 'DELETE',
-  body?: unknown
+  body?: unknown,
+  form?: Record<string, string | number | undefined>
 ): Promise<string | null> {
   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,
@@ -302,7 +354,7 @@
   payload: ContentWritePayload,
   cntntsType: string
 ): Promise<string | null> {
-  return sendWrite(basePath(kind), 'POST', { ...payload, cntntsType });
+  return sendWrite(CONTENT_BASE_PATH, 'POST', undefined, toFormParams({ ...payload, cntntsType }));
 }
 
 export async function updateContent(
@@ -311,7 +363,7 @@
   payload: ContentWritePayload
 ): Promise<void> {
   await sendWrite(
-    `${basePath(kind)}/${encodeURIComponent(cntntsSn)}`,
+    `${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsSn)}`,
     'PUT',
     payload
   );
@@ -321,15 +373,21 @@
   kind: ContentApiKind,
   cntntsSn: string
 ): Promise<void> {
-  await sendWrite(`${basePath(kind)}/${encodeURIComponent(cntntsSn)}`, 'DELETE');
+  await sendWrite(`${CONTENT_BASE_PATH}/${encodeURIComponent(cntntsSn)}`, 'DELETE');
 }
 
 export function contentThumbnailUrl(item: ContentItem): string | null {
   return buildFileImageUrl(item.thumbnailFileId);
 }
 
+/** 컷 이미지 주소 — 응답이 준 파일 번호를 쓰고, 없으면 1..count로 채운다. */
 export function contentCutUrls(item: ContentItem, count: number): string[] {
-  return Array.from({ length: count }, (_, index) =>
-    buildFileImageUrl(item.attachmentId, index + 1)
-  ).filter((url): url is string => url !== null);
+  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);
 }
lib/data/repositories/file-repository.ts
--- lib/data/repositories/file-repository.ts
+++ lib/data/repositories/file-repository.ts
@@ -67,6 +67,44 @@
   return result.data;
 }
 
+/**
+ * 여러 장을 **한 `atchFileId` 아래** 올린다 — 파일마다 `fileSn`이 1,2,3…으로 붙는다.
+ * 4컷만화처럼 묶음이 하나의 콘텐츠에 매달리는 자리에 쓴다.
+ */
+export async function uploadAttachments(
+  files: File[],
+  moduleId: string
+): Promise<string> {
+  const accessToken = await getSessionAccessToken();
+
+  const multipart = new FormData();
+  for (const file of files) {
+    // 파트 이름이 모두 `file`이어야 백엔드가 List<MultipartFile>로 받는다.
+    multipart.append('file', file);
+  }
+
+  const result = await backendFetch<unknown>(
+    `${FILE_UPLOAD_PATH}/list/${moduleId}`,
+    {
+      method: 'POST',
+      multipart,
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      timeoutMs: UPLOAD_TIMEOUT_MS,
+    }
+  );
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  if (typeof result.data !== 'string' || result.data.length === 0) {
+    throw new Error('첨부파일 업로드 응답에 파일 식별자가 없습니다.');
+  }
+
+  return result.data;
+}
+
 /** 업로드된 파일의 이미지 URL. 이 GET은 인증이 필요 없어 브라우저가 직접 부른다. */
 export function buildFileImageUrl(
   atchFileId: string | null,
lib/domain/content.ts
--- lib/domain/content.ts
+++ lib/domain/content.ts
@@ -86,6 +86,8 @@
   keywords: ContentKeyword[];
   /** 만화의 컷 이미지·쇼츠의 영상이 묶인 첨부 ID. */
   attachmentId: string | null;
+  /** 만화 컷처럼 한 첨부 아래 여러 파일이 있을 때의 파일 번호들. */
+  cutFileNumbers: number[];
   thumbnailFileId: string | null;
   isVisible: boolean;
   modifiedAt: string | null;
Add a comment
List