임동욱 임동욱 09-17
feat: 추가 이미지 장수를 묶음 파일 목록 API로 알아내고 목록에 메뉴 건수를 보인다
백엔드 1c7b0e0 — GET /common/file/image/list/{atchFileId}가 생겨 이미지 존재를 더듬던 코드
(HEAD 401·fetch 취소 멈춤·빈 200을 거치며 세 번 고친 것)를 전부 걷어낸다. 상세 목록·단건이
메뉴 건수(productCnt)를 주고 카테고리 코드표 INNER JOIN이 빠졌으며, 가맹점 목록은 등록일
(frstRegDtStr)을 준다.

Co-Authored-By: Claude Fable 5.1 
@e87fec66f7e464b53fbce79b19cf76c58b1dbf4a
app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
@@ -60,8 +60,6 @@
  * 상세를 덧입혀 준다. [+ 업체 등록]은 가맹점을 고르는 등록 화면으로, 행의 [수정]은 그 가맹점의
  * 상세 화면으로 간다(상세가 없으면 거기서 처음 만든다).
  *
- * 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지
- *   않아 대부분 `-`로 보인다.
  */
 export function MerchantDetailList({
   items,
@@ -227,8 +225,7 @@
       header: '메뉴',
       width: 72,
       align: 'center',
-      // 목록 응답에 메뉴 건수가 없다(보고함).
-      render: () => EMPTY_FIELD_PLACEHOLDER,
+      render: (row) => (row.productCount === null ? EMPTY_FIELD_PLACEHOLDER : String(row.productCount)),
     },
     {
       key: 'status',
lib/data/repositories/file-repository.ts
--- lib/data/repositories/file-repository.ts
+++ lib/data/repositories/file-repository.ts
@@ -11,11 +11,9 @@
  *
  * 게시판 등록/수정은 이 id를 `atchFileId`로 실어 보내 게시물과 파일을 연결한다.
  *
- * **파일 메타데이터(파일명·용량) 조회 API가 없다.** 백엔드 파일 컨트롤러에는 업로드·다운로드·삭제·
- * 이미지·스트림만 있고 "이 atchFileId에 무슨 파일이 붙어 있는가"를 알려 주는 엔드포인트가 없다.
- * 그래서 시안(A_BOA_008_p)의 `pic_0001.jpg 88KB` 같은 표기는 **이미 저장된 첨부에 대해서는
- * 불가능**하고, 화면은 "첨부 있음 + 다운로드 링크"까지만 보여준다. 방금 업로드한 파일은
- * 브라우저가 이름·크기를 알고 있어 그 세션 동안만 표시할 수 있다.
+ * 묶음의 파일 목록은 `GET /api/v1/common/file/image/list/{atchFileId}`(백엔드 1c7b0e0)로 받는다 —
+ * `fileSn`·원본 파일명·크기·확장자가 온다. 게시판 첨부의 `pic_0001.jpg 88KB` 표기도 이걸로 할 수
+ * 있다(TODO: 게시판 화면은 아직 "첨부 있음 + 다운로드 링크"만 보인다).
  *
  * 다운로드는 이 파일이 다루지 않는다 — 파일 응답은 라우트 핸들러가 스트림으로 중계한다
  * (`app/(protected)/(basic)/boards/attachments/route.ts`).
@@ -23,6 +21,54 @@
 
 const FILE_UPLOAD_PATH = '/api/v1/common/file/upload';
 const FILE_IMAGE_PATH = '/api/v1/common/file/image';
+const FILE_IMAGE_LIST_PATH = '/api/v1/common/file/image/list';
+
+/** 묶음 안의 파일 하나 — 백엔드 `FileUploadVo`에서 화면이 쓰는 것만. */
+export type AttachmentFile = {
+  fileSn: number;
+  name: string;
+  size: number | null;
+  extension: string | null;
+};
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function toAttachmentFile(raw: unknown): AttachmentFile[] {
+  if (!isRecord(raw)) return [];
+  const fileSn = Number(raw.fileSn);
+  if (!Number.isInteger(fileSn) || fileSn < 1) return [];
+  return [
+    {
+      fileSn,
+      name: typeof raw.orgnlFileNm === 'string' ? raw.orgnlFileNm : '',
+      size: typeof raw.fileSz === 'number' ? raw.fileSz : null,
+      extension: typeof raw.fileExtnNm === 'string' ? raw.fileExtnNm : null,
+    },
+  ];
+}
+
+/** 한 `atchFileId`에 붙은 파일 전부, `fileSn` 오름차순. 없는 id면 빈 배열. */
+export async function fetchAttachmentFiles(atchFileId: string): Promise<AttachmentFile[]> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(
+    `${FILE_IMAGE_LIST_PATH}/${encodeURIComponent(atchFileId)}`,
+    {
+      method: 'GET',
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      canHaveNullData: true,
+    }
+  );
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+  const files = Array.isArray(result.data) ? result.data.flatMap(toAttachmentFile) : [];
+  return files.sort((a, b) => a.fileSn - b.fileSn);
+}
 
 
 /** 파일 생성은 일반 조회보다 오래 걸릴 수 있어 넉넉히 잡는다. */
lib/data/repositories/merchant-detail-repository.ts
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
@@ -1,22 +1,19 @@
 import 'server-only';
-import { request as httpRequest } from 'node:http';
-import { request as httpsRequest } from 'node:https';
 import { getSessionAccessToken } from '@/lib/auth/dal';
 import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
-import { buildFileImageUrl } from '@/lib/data/repositories/file-repository';
 import {
-  MAX_EXTRA_IMAGES,
-  type MerchantDetail,
-  type MerchantDetailSummary,
-} from '@/lib/domain/merchant-detail';
+  buildFileImageUrl,
+  fetchAttachmentFiles,
+} from '@/lib/data/repositories/file-repository';
+import type { MerchantDetail, MerchantDetailSummary } from '@/lib/domain/merchant-detail';
 import type { MerchantDetailValues } from '@/lib/domain/merchant-detail-form';
 import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
 
 /**
- * 가맹몰 상세 Repository — edupay-backend origin/develop 8b1bf81(feature/voc 병합, 2026-09-17) 기준.
+ * 가맹몰 상세 Repository — edupay-backend origin/develop 1c7b0e0(2026-09-17) 기준.
  *
  * ```
- * GET    /api/v1/mngr/voc/detail/pagination   상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL ⋈ 코드)
+ * GET    /api/v1/mngr/voc/detail/pagination   상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL) + 메뉴 건수
  * GET    /api/v1/mngr/voc/detail/{frcsNo}     상세가 없으면 성공 봉투에 data: null (구 버전은 NOT_FOUND)
  * POST   /api/v1/mngr/voc/detail              @RequestBody · 상세가 있으면 409, 가맹점이 없으면 404
  * PUT    /api/v1/mngr/voc/detail/{frcsNo}     @RequestBody · 전 컬럼 덮어씀
@@ -25,8 +22,10 @@
  * ```
  *
  * 목록·단건이 소개·카테고리 코드·이름(`frcsTypeCdNm`)·영업시간·휴무일·추가이미지·순서·우편번호·
- * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다. 업종·업태 이름
- * (`frcsTpbizCdNm`·`frcsBzstatCdNm`·`frcsCdNm`)도 오지만 화면(시안)에 자리가 없어 읽지 않는다.
+ * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)·메뉴 건수(`productCnt`)를
+ * 돌려준다. 업종·업태 이름(`frcsTpbizCdNm`·`frcsBzstatCdNm`·`frcsCdNm`)도 오지만 시안에 자리가 없어
+ * 읽지 않는다. 카테고리 코드표 조인은 1c7b0e0에서 빠져 어느 그룹의 코드든 목록에 남는다.
+ * 추가 이미지 장수는 묶음 파일 목록 API로 알아낸다(더듬기 없음).
  *
  * ⚠️ 백엔드 결함(보고함, 미수정):
  * 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 목록은 가맹점 목록의
@@ -109,8 +108,9 @@
       thumbnailFileId: readString(raw, 'thumbAtchFileId'),
       thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')),
       categoryCode: readString(raw, 'frcsTypeCd'),
-      categoryLabel: readString(raw, 'frcsTypeCdNm') ?? readString(raw, 'cdNm'),
+      categoryLabel: readString(raw, 'frcsTypeCdNm'),
       isVisible: readYesNo(raw, 'useYn'),
+      productCount: readNumber(raw, 'productCnt'),
       registeredAt,
       hasDetail:
         registeredAt !== null ||
@@ -139,55 +139,20 @@
       categoryCode: null,
       categoryLabel: null,
       isVisible: null,
+      productCount: null,
       registeredAt: null,
       hasDetail: false,
     },
   ];
 }
 
-const PROBE_TIMEOUT_MS = 5_000;
-
-/**
- * 이미지 한 장이 있는지 — 응답 머리만 보고 바로 끊는다.
- *
- * `fetch`가 아니라 `node:http`를 쓴다. 백엔드 인증 예외가 GET에만 열려 HEAD는 401이고, Next가
- * 감싼 `fetch`는 본문을 `cancel()`해도 끝나지 않아(확인함) 화면이 영영 뜨지 않는다.
- */
-function probeImageUrl(url: string): Promise<boolean> {
-  return new Promise((resolve) => {
-    const target = new URL(url);
-    const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)(
-      target,
-      { method: 'GET', timeout: PROBE_TIMEOUT_MS },
-      (response) => {
-        response.destroy();
-        // 파일이 없어도 200에 빈 본문으로 답하는 서버가 있다(보고함) — 빈 것은 없는 것으로 본다.
-        const isEmpty = response.headers['content-length'] === '0';
-        resolve((response.statusCode ?? 500) < 400 && !isEmpty);
-      }
-    );
-    request.on('timeout', () => {
-      request.destroy();
-      resolve(false);
-    });
-    request.on('error', () => resolve(false));
-    request.end();
-  });
-}
-
-/**
- * 묶음 안의 이미지 수를 알려 주는 API가 없다(보고함). fileSn이 1부터 빈틈없이 붙으므로 최대 장수를
- * 한꺼번에 두드려 이어지는 앞부분만 취한다 — 차례로 두드리면 장수만큼 왕복이 늘어난다.
- * 없는 번호는 백엔드가 500으로 답한다.
- */
-async function probeImageUrls(fileId: string | null): Promise<string[]> {
+/** 추가 이미지 묶음의 URL을 `fileSn` 순으로 — 묶음 파일 목록 API(백엔드 1c7b0e0)로 장수를 안다. */
+async function resolveImageUrls(fileId: string | null): Promise<string[]> {
   if (!fileId) return [];
-  const candidates = Array.from({ length: MAX_EXTRA_IMAGES }, (_, index) =>
-    buildFileImageUrl(fileId, index + 1)
-  ).filter((url): url is string => url !== null);
-  const exists = await Promise.all(candidates.map(probeImageUrl));
-  const missingAt = exists.indexOf(false);
-  return missingAt === -1 ? candidates : candidates.slice(0, missingAt);
+  const files = await fetchAttachmentFiles(fileId);
+  return files
+    .map((file) => buildFileImageUrl(fileId, file.fileSn))
+    .filter((url): url is string => url !== null);
 }
 
 async function toDetail(raw: unknown, withImages: boolean): Promise<MerchantDetail | null> {
@@ -197,7 +162,7 @@
   const imageFileId = readString(raw, 'atchFileId');
   return {
     ...summary,
-    imageUrls: withImages ? await probeImageUrls(imageFileId) : [],
+    imageUrls: withImages ? await resolveImageUrls(imageFileId) : [],
     description: readString(raw, 'frcsCn'),
     openTime: readString(raw, 'startHour'),
     closeTime: readString(raw, 'endHour'),
@@ -265,8 +230,8 @@
 }
 
 /**
- * 단건. `withImages`를 끄면 추가 이미지 URL 더듬기(HEAD 여러 번)를 건너뛴다 — 노출 토글처럼
- * 값만 다시 보내는 곳은 URL이 필요 없다.
+ * 단건. `withImages`를 끄면 추가 이미지 목록 조회를 건너뛴다 — 노출 토글처럼 값만 다시 보내는 곳은
+ * URL이 필요 없다.
  */
 export async function fetchMerchantDetail(
   frcsNo: string,
lib/data/repositories/merchant-repository.ts
--- lib/data/repositories/merchant-repository.ts
+++ lib/data/repositories/merchant-repository.ts
@@ -27,8 +27,9 @@
  *    뒤집어 오래된 가맹점부터 나온다.
  * 5. (feature/voc bf17fc5에서 해소) 목록이 `USE_YN`과 `FRCS_DV_CD_NM`을 돌려주기 시작했다 —
  *    develop에선 아직 없어 사용여부가 `-`로 보인다.
- * 6. 브랜드명·메모·등록일·최종수정일 컬럼이 없다 — 기획에 있는 항목이라 화면은 그대로 두고
- *    값만 비워 `-`로 보인다. 등록 시 보내는 브랜드명·메모도 요청 VO에 없어 버려진다.
+ * 6. 브랜드명·메모·최종수정일 컬럼이 없다 — 기획에 있는 항목이라 화면은 그대로 두고 값만 비워
+ *    `-`로 보인다(등록일은 1c7b0e0부터 `frstRegDtStr`로 온다). 등록 시 보내는 브랜드명·메모도 요청
+ *    VO에 없어 버려진다.
  */
 
 const VOC_PATH = '/api/v1/mngr/voc';
lib/domain/merchant-detail.ts
--- lib/domain/merchant-detail.ts
+++ lib/domain/merchant-detail.ts
@@ -29,6 +29,8 @@
   categoryLabel: string | null;
   /** 백엔드 상세 `useYn`. 상세가 없는 가맹점은 null이다. */
   isVisible: boolean | null;
+  /** 백엔드 `productCnt` — 등록된 메뉴 수. 상세가 없는 가맹점은 null이다. */
+  productCount: number | null;
   /** 상세 등록일(`frstRegDtStr`, YYYY-MM-DD). */
   registeredAt: string | null;
   /** 상세 행이 있는지 — 목록 행은 참, 등록 선택지는 거짓. */
Add a comment
List