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
... | ... | @@ -60,8 +60,6 @@ |
| 60 | 60 |
* 상세를 덧입혀 준다. [+ 업체 등록]은 가맹점을 고르는 등록 화면으로, 행의 [수정]은 그 가맹점의 |
| 61 | 61 |
* 상세 화면으로 간다(상세가 없으면 거기서 처음 만든다). |
| 62 | 62 |
* |
| 63 |
- * 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지 |
|
| 64 |
- * 않아 대부분 `-`로 보인다. |
|
| 65 | 63 |
*/ |
| 66 | 64 |
export function MerchantDetailList({
|
| 67 | 65 |
items, |
... | ... | @@ -227,8 +225,7 @@ |
| 227 | 225 |
header: '메뉴', |
| 228 | 226 |
width: 72, |
| 229 | 227 |
align: 'center', |
| 230 |
- // 목록 응답에 메뉴 건수가 없다(보고함). |
|
| 231 |
- render: () => EMPTY_FIELD_PLACEHOLDER, |
|
| 228 |
+ render: (row) => (row.productCount === null ? EMPTY_FIELD_PLACEHOLDER : String(row.productCount)), |
|
| 232 | 229 |
}, |
| 233 | 230 |
{
|
| 234 | 231 |
key: 'status', |
--- lib/data/repositories/file-repository.ts
+++ lib/data/repositories/file-repository.ts
... | ... | @@ -11,11 +11,9 @@ |
| 11 | 11 |
* |
| 12 | 12 |
* 게시판 등록/수정은 이 id를 `atchFileId`로 실어 보내 게시물과 파일을 연결한다. |
| 13 | 13 |
* |
| 14 |
- * **파일 메타데이터(파일명·용량) 조회 API가 없다.** 백엔드 파일 컨트롤러에는 업로드·다운로드·삭제· |
|
| 15 |
- * 이미지·스트림만 있고 "이 atchFileId에 무슨 파일이 붙어 있는가"를 알려 주는 엔드포인트가 없다. |
|
| 16 |
- * 그래서 시안(A_BOA_008_p)의 `pic_0001.jpg 88KB` 같은 표기는 **이미 저장된 첨부에 대해서는 |
|
| 17 |
- * 불가능**하고, 화면은 "첨부 있음 + 다운로드 링크"까지만 보여준다. 방금 업로드한 파일은 |
|
| 18 |
- * 브라우저가 이름·크기를 알고 있어 그 세션 동안만 표시할 수 있다. |
|
| 14 |
+ * 묶음의 파일 목록은 `GET /api/v1/common/file/image/list/{atchFileId}`(백엔드 1c7b0e0)로 받는다 —
|
|
| 15 |
+ * `fileSn`·원본 파일명·크기·확장자가 온다. 게시판 첨부의 `pic_0001.jpg 88KB` 표기도 이걸로 할 수 |
|
| 16 |
+ * 있다(TODO: 게시판 화면은 아직 "첨부 있음 + 다운로드 링크"만 보인다). |
|
| 19 | 17 |
* |
| 20 | 18 |
* 다운로드는 이 파일이 다루지 않는다 — 파일 응답은 라우트 핸들러가 스트림으로 중계한다 |
| 21 | 19 |
* (`app/(protected)/(basic)/boards/attachments/route.ts`). |
... | ... | @@ -23,6 +21,54 @@ |
| 23 | 21 |
|
| 24 | 22 |
const FILE_UPLOAD_PATH = '/api/v1/common/file/upload'; |
| 25 | 23 |
const FILE_IMAGE_PATH = '/api/v1/common/file/image'; |
| 24 |
+const FILE_IMAGE_LIST_PATH = '/api/v1/common/file/image/list'; |
|
| 25 |
+ |
|
| 26 |
+/** 묶음 안의 파일 하나 — 백엔드 `FileUploadVo`에서 화면이 쓰는 것만. */ |
|
| 27 |
+export type AttachmentFile = {
|
|
| 28 |
+ fileSn: number; |
|
| 29 |
+ name: string; |
|
| 30 |
+ size: number | null; |
|
| 31 |
+ extension: string | null; |
|
| 32 |
+}; |
|
| 33 |
+ |
|
| 34 |
+function isRecord(value: unknown): value is Record<string, unknown> {
|
|
| 35 |
+ return value !== null && typeof value === 'object'; |
|
| 36 |
+} |
|
| 37 |
+ |
|
| 38 |
+function toAttachmentFile(raw: unknown): AttachmentFile[] {
|
|
| 39 |
+ if (!isRecord(raw)) return []; |
|
| 40 |
+ const fileSn = Number(raw.fileSn); |
|
| 41 |
+ if (!Number.isInteger(fileSn) || fileSn < 1) return []; |
|
| 42 |
+ return [ |
|
| 43 |
+ {
|
|
| 44 |
+ fileSn, |
|
| 45 |
+ name: typeof raw.orgnlFileNm === 'string' ? raw.orgnlFileNm : '', |
|
| 46 |
+ size: typeof raw.fileSz === 'number' ? raw.fileSz : null, |
|
| 47 |
+ extension: typeof raw.fileExtnNm === 'string' ? raw.fileExtnNm : null, |
|
| 48 |
+ }, |
|
| 49 |
+ ]; |
|
| 50 |
+} |
|
| 51 |
+ |
|
| 52 |
+/** 한 `atchFileId`에 붙은 파일 전부, `fileSn` 오름차순. 없는 id면 빈 배열. */ |
|
| 53 |
+export async function fetchAttachmentFiles(atchFileId: string): Promise<AttachmentFile[]> {
|
|
| 54 |
+ const accessToken = await getSessionAccessToken(); |
|
| 55 |
+ |
|
| 56 |
+ const result = await backendFetch<unknown>( |
|
| 57 |
+ `${FILE_IMAGE_LIST_PATH}/${encodeURIComponent(atchFileId)}`,
|
|
| 58 |
+ {
|
|
| 59 |
+ method: 'GET', |
|
| 60 |
+ accessToken: accessToken ?? undefined, |
|
| 61 |
+ cache: 'no-store', |
|
| 62 |
+ canHaveNullData: true, |
|
| 63 |
+ } |
|
| 64 |
+ ); |
|
| 65 |
+ |
|
| 66 |
+ if (!result.ok) {
|
|
| 67 |
+ throw new BackendRequestError(result); |
|
| 68 |
+ } |
|
| 69 |
+ const files = Array.isArray(result.data) ? result.data.flatMap(toAttachmentFile) : []; |
|
| 70 |
+ return files.sort((a, b) => a.fileSn - b.fileSn); |
|
| 71 |
+} |
|
| 26 | 72 |
|
| 27 | 73 |
|
| 28 | 74 |
/** 파일 생성은 일반 조회보다 오래 걸릴 수 있어 넉넉히 잡는다. */ |
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
... | ... | @@ -1,22 +1,19 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 |
-import { request as httpRequest } from 'node:http';
|
|
| 3 |
-import { request as httpsRequest } from 'node:https';
|
|
| 4 | 2 |
import { getSessionAccessToken } from '@/lib/auth/dal';
|
| 5 | 3 |
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
|
| 6 |
-import { buildFileImageUrl } from '@/lib/data/repositories/file-repository';
|
|
| 7 | 4 |
import {
|
| 8 |
- MAX_EXTRA_IMAGES, |
|
| 9 |
- type MerchantDetail, |
|
| 10 |
- type MerchantDetailSummary, |
|
| 11 |
-} from '@/lib/domain/merchant-detail'; |
|
| 5 |
+ buildFileImageUrl, |
|
| 6 |
+ fetchAttachmentFiles, |
|
| 7 |
+} from '@/lib/data/repositories/file-repository'; |
|
| 8 |
+import type { MerchantDetail, MerchantDetailSummary } from '@/lib/domain/merchant-detail';
|
|
| 12 | 9 |
import type { MerchantDetailValues } from '@/lib/domain/merchant-detail-form';
|
| 13 | 10 |
import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
|
| 14 | 11 |
|
| 15 | 12 |
/** |
| 16 |
- * 가맹몰 상세 Repository — edupay-backend origin/develop 8b1bf81(feature/voc 병합, 2026-09-17) 기준. |
|
| 13 |
+ * 가맹몰 상세 Repository — edupay-backend origin/develop 1c7b0e0(2026-09-17) 기준. |
|
| 17 | 14 |
* |
| 18 | 15 |
* ``` |
| 19 |
- * GET /api/v1/mngr/voc/detail/pagination 상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL ⋈ 코드) |
|
| 16 |
+ * GET /api/v1/mngr/voc/detail/pagination 상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL) + 메뉴 건수 |
|
| 20 | 17 |
* GET /api/v1/mngr/voc/detail/{frcsNo} 상세가 없으면 성공 봉투에 data: null (구 버전은 NOT_FOUND)
|
| 21 | 18 |
* POST /api/v1/mngr/voc/detail @RequestBody · 상세가 있으면 409, 가맹점이 없으면 404 |
| 22 | 19 |
* PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody · 전 컬럼 덮어씀
|
... | ... | @@ -25,8 +22,10 @@ |
| 25 | 22 |
* ``` |
| 26 | 23 |
* |
| 27 | 24 |
* 목록·단건이 소개·카테고리 코드·이름(`frcsTypeCdNm`)·영업시간·휴무일·추가이미지·순서·우편번호· |
| 28 |
- * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다. 업종·업태 이름 |
|
| 29 |
- * (`frcsTpbizCdNm`·`frcsBzstatCdNm`·`frcsCdNm`)도 오지만 화면(시안)에 자리가 없어 읽지 않는다. |
|
| 25 |
+ * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)·메뉴 건수(`productCnt`)를 |
|
| 26 |
+ * 돌려준다. 업종·업태 이름(`frcsTpbizCdNm`·`frcsBzstatCdNm`·`frcsCdNm`)도 오지만 시안에 자리가 없어 |
|
| 27 |
+ * 읽지 않는다. 카테고리 코드표 조인은 1c7b0e0에서 빠져 어느 그룹의 코드든 목록에 남는다. |
|
| 28 |
+ * 추가 이미지 장수는 묶음 파일 목록 API로 알아낸다(더듬기 없음). |
|
| 30 | 29 |
* |
| 31 | 30 |
* ⚠️ 백엔드 결함(보고함, 미수정): |
| 32 | 31 |
* 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 목록은 가맹점 목록의 |
... | ... | @@ -109,8 +108,9 @@ |
| 109 | 108 |
thumbnailFileId: readString(raw, 'thumbAtchFileId'), |
| 110 | 109 |
thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')), |
| 111 | 110 |
categoryCode: readString(raw, 'frcsTypeCd'), |
| 112 |
- categoryLabel: readString(raw, 'frcsTypeCdNm') ?? readString(raw, 'cdNm'), |
|
| 111 |
+ categoryLabel: readString(raw, 'frcsTypeCdNm'), |
|
| 113 | 112 |
isVisible: readYesNo(raw, 'useYn'), |
| 113 |
+ productCount: readNumber(raw, 'productCnt'), |
|
| 114 | 114 |
registeredAt, |
| 115 | 115 |
hasDetail: |
| 116 | 116 |
registeredAt !== null || |
... | ... | @@ -139,55 +139,20 @@ |
| 139 | 139 |
categoryCode: null, |
| 140 | 140 |
categoryLabel: null, |
| 141 | 141 |
isVisible: null, |
| 142 |
+ productCount: null, |
|
| 142 | 143 |
registeredAt: null, |
| 143 | 144 |
hasDetail: false, |
| 144 | 145 |
}, |
| 145 | 146 |
]; |
| 146 | 147 |
} |
| 147 | 148 |
|
| 148 |
-const PROBE_TIMEOUT_MS = 5_000; |
|
| 149 |
- |
|
| 150 |
-/** |
|
| 151 |
- * 이미지 한 장이 있는지 — 응답 머리만 보고 바로 끊는다. |
|
| 152 |
- * |
|
| 153 |
- * `fetch`가 아니라 `node:http`를 쓴다. 백엔드 인증 예외가 GET에만 열려 HEAD는 401이고, Next가 |
|
| 154 |
- * 감싼 `fetch`는 본문을 `cancel()`해도 끝나지 않아(확인함) 화면이 영영 뜨지 않는다. |
|
| 155 |
- */ |
|
| 156 |
-function probeImageUrl(url: string): Promise<boolean> {
|
|
| 157 |
- return new Promise((resolve) => {
|
|
| 158 |
- const target = new URL(url); |
|
| 159 |
- const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)( |
|
| 160 |
- target, |
|
| 161 |
- { method: 'GET', timeout: PROBE_TIMEOUT_MS },
|
|
| 162 |
- (response) => {
|
|
| 163 |
- response.destroy(); |
|
| 164 |
- // 파일이 없어도 200에 빈 본문으로 답하는 서버가 있다(보고함) — 빈 것은 없는 것으로 본다. |
|
| 165 |
- const isEmpty = response.headers['content-length'] === '0'; |
|
| 166 |
- resolve((response.statusCode ?? 500) < 400 && !isEmpty); |
|
| 167 |
- } |
|
| 168 |
- ); |
|
| 169 |
- request.on('timeout', () => {
|
|
| 170 |
- request.destroy(); |
|
| 171 |
- resolve(false); |
|
| 172 |
- }); |
|
| 173 |
- request.on('error', () => resolve(false));
|
|
| 174 |
- request.end(); |
|
| 175 |
- }); |
|
| 176 |
-} |
|
| 177 |
- |
|
| 178 |
-/** |
|
| 179 |
- * 묶음 안의 이미지 수를 알려 주는 API가 없다(보고함). fileSn이 1부터 빈틈없이 붙으므로 최대 장수를 |
|
| 180 |
- * 한꺼번에 두드려 이어지는 앞부분만 취한다 — 차례로 두드리면 장수만큼 왕복이 늘어난다. |
|
| 181 |
- * 없는 번호는 백엔드가 500으로 답한다. |
|
| 182 |
- */ |
|
| 183 |
-async function probeImageUrls(fileId: string | null): Promise<string[]> {
|
|
| 149 |
+/** 추가 이미지 묶음의 URL을 `fileSn` 순으로 — 묶음 파일 목록 API(백엔드 1c7b0e0)로 장수를 안다. */ |
|
| 150 |
+async function resolveImageUrls(fileId: string | null): Promise<string[]> {
|
|
| 184 | 151 |
if (!fileId) return []; |
| 185 |
- const candidates = Array.from({ length: MAX_EXTRA_IMAGES }, (_, index) =>
|
|
| 186 |
- buildFileImageUrl(fileId, index + 1) |
|
| 187 |
- ).filter((url): url is string => url !== null); |
|
| 188 |
- const exists = await Promise.all(candidates.map(probeImageUrl)); |
|
| 189 |
- const missingAt = exists.indexOf(false); |
|
| 190 |
- return missingAt === -1 ? candidates : candidates.slice(0, missingAt); |
|
| 152 |
+ const files = await fetchAttachmentFiles(fileId); |
|
| 153 |
+ return files |
|
| 154 |
+ .map((file) => buildFileImageUrl(fileId, file.fileSn)) |
|
| 155 |
+ .filter((url): url is string => url !== null); |
|
| 191 | 156 |
} |
| 192 | 157 |
|
| 193 | 158 |
async function toDetail(raw: unknown, withImages: boolean): Promise<MerchantDetail | null> {
|
... | ... | @@ -197,7 +162,7 @@ |
| 197 | 162 |
const imageFileId = readString(raw, 'atchFileId'); |
| 198 | 163 |
return {
|
| 199 | 164 |
...summary, |
| 200 |
- imageUrls: withImages ? await probeImageUrls(imageFileId) : [], |
|
| 165 |
+ imageUrls: withImages ? await resolveImageUrls(imageFileId) : [], |
|
| 201 | 166 |
description: readString(raw, 'frcsCn'), |
| 202 | 167 |
openTime: readString(raw, 'startHour'), |
| 203 | 168 |
closeTime: readString(raw, 'endHour'), |
... | ... | @@ -265,8 +230,8 @@ |
| 265 | 230 |
} |
| 266 | 231 |
|
| 267 | 232 |
/** |
| 268 |
- * 단건. `withImages`를 끄면 추가 이미지 URL 더듬기(HEAD 여러 번)를 건너뛴다 — 노출 토글처럼 |
|
| 269 |
- * 값만 다시 보내는 곳은 URL이 필요 없다. |
|
| 233 |
+ * 단건. `withImages`를 끄면 추가 이미지 목록 조회를 건너뛴다 — 노출 토글처럼 값만 다시 보내는 곳은 |
|
| 234 |
+ * URL이 필요 없다. |
|
| 270 | 235 |
*/ |
| 271 | 236 |
export async function fetchMerchantDetail( |
| 272 | 237 |
frcsNo: string, |
--- lib/data/repositories/merchant-repository.ts
+++ lib/data/repositories/merchant-repository.ts
... | ... | @@ -27,8 +27,9 @@ |
| 27 | 27 |
* 뒤집어 오래된 가맹점부터 나온다. |
| 28 | 28 |
* 5. (feature/voc bf17fc5에서 해소) 목록이 `USE_YN`과 `FRCS_DV_CD_NM`을 돌려주기 시작했다 — |
| 29 | 29 |
* develop에선 아직 없어 사용여부가 `-`로 보인다. |
| 30 |
- * 6. 브랜드명·메모·등록일·최종수정일 컬럼이 없다 — 기획에 있는 항목이라 화면은 그대로 두고 |
|
| 31 |
- * 값만 비워 `-`로 보인다. 등록 시 보내는 브랜드명·메모도 요청 VO에 없어 버려진다. |
|
| 30 |
+ * 6. 브랜드명·메모·최종수정일 컬럼이 없다 — 기획에 있는 항목이라 화면은 그대로 두고 값만 비워 |
|
| 31 |
+ * `-`로 보인다(등록일은 1c7b0e0부터 `frstRegDtStr`로 온다). 등록 시 보내는 브랜드명·메모도 요청 |
|
| 32 |
+ * VO에 없어 버려진다. |
|
| 32 | 33 |
*/ |
| 33 | 34 |
|
| 34 | 35 |
const VOC_PATH = '/api/v1/mngr/voc'; |
--- lib/domain/merchant-detail.ts
+++ lib/domain/merchant-detail.ts
... | ... | @@ -29,6 +29,8 @@ |
| 29 | 29 |
categoryLabel: string | null; |
| 30 | 30 |
/** 백엔드 상세 `useYn`. 상세가 없는 가맹점은 null이다. */ |
| 31 | 31 |
isVisible: boolean | null; |
| 32 |
+ /** 백엔드 `productCnt` — 등록된 메뉴 수. 상세가 없는 가맹점은 null이다. */ |
|
| 33 |
+ productCount: number | null; |
|
| 32 | 34 |
/** 상세 등록일(`frstRegDtStr`, YYYY-MM-DD). */ |
| 33 | 35 |
registeredAt: string | null; |
| 34 | 36 |
/** 상세 행이 있는지 — 목록 행은 참, 등록 선택지는 거짓. */ |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?