import 'server-only'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { getApiBaseUrl } from '@/lib/env'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import type { DecorationItem, DecorationItemType } from '@/lib/domain/decoration-item'; import type { DecorationItemEditableValues } from '@/lib/domain/decoration-item-form'; import type { DecorationItemQuery, DecorationItemSearchField, } from '@/lib/domain/decoration-item-query'; import { FILE_MODULE_ID } from '@/lib/domain/file-module'; /** * 꾸미기 아이템 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트· * 파라미터·응답 매핑). 통신 규약은 `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 * 소유하므로 여기에 들어오지 않는다. * * ``` * GET /api/v1/mngr/item/pagination 목록 (ROLE_ADMIN) * GET /api/v1/mngr/item/{itemSn} 단건 * POST /api/v1/mngr/item 등록 * PUT /api/v1/mngr/item/{itemSn} 수정 * DELETE /api/v1/mngr/item/{itemSn} 삭제 * POST /api/v1/common/file/upload/{moduleId} 이미지 업로드(multipart) * ``` * * 아래는 백엔드 저장소(edupay-backend, develop)의 실제 구현을 읽고 확인한 것이다 — * MngrItemApiController / MngrItemServiceImpl / MngrItemMapper.xml / FileCommonApiController. * * - **등록과 수정의 본문 형식이 다르다.** 등록은 `@ParameterObject`(form 인코딩), 수정은 * `@RequestBody`(JSON)로 받는다 — 한쪽 형식으로 통일해 보내면 반대쪽이 전 필드 null이 되거나 * 415로 떨어진다. 백엔드 커밋 "FIX API 수정"에서 수정만 JSON으로 바뀌었다. * - **목록은 `searchItemType`이 필수다** — SQL의 WHERE에 `AND a.ITEM_TYPE = #{searchItemType}`이 * 무조건 붙는다. 값이 없으면 아무것도 조회되지 않는다. * - **검색은 아이템명(`searchCondition="1"`)만 구현돼 있다.** 아이템ID 검색 분기는 없어, 그 * 값을 보내면 조건 없이 전체가 반환된다(사용자 지시로 화면 선택지는 유지하고 백엔드에 추가 요청). * - **정렬은 고정이다** — `ROW_NUMBER() OVER (ORDER BY SORT_ORDR DESC)`를 다시 역순으로 정렬해 * 결과적으로 정렬순서 오름차순이며(시안 ADM_ITM_101과 일치) 정렬 파라미터는 없다. * - **수정일시는 `lastMdfcnDt`가 아니라 `lastMdfcnDtStr`로 온다.** VO의 `lastMdfcnDt`는 * `@JsonIgnore`라 응답에 실리지 않고, SQL이 `DATE_FORMAT(...) AS last_mdfcn_dt_str`로 따로 * 내려 준다(`mapUnderscoreToCamelCase`). * - **수정으로는 설명을 비울 수 없다** — UPDATE의 `` 가드가 빈 문자열을 * 건너뛴다. 지우려는 의도가 조용히 무시되므로 백엔드에 조건 완화를 요청한다. * - **응답의 `totalCount`는 전체 건수가 아니라 그 페이지의 행 수다**(학생·관리자 목록과 동일한 * `PaginationUtil` 결함). 그대로 믿으면 페이지가 가득 찰 때마다 다음 페이지에 도달할 수 없어 * 하한값으로 보정한다. * * 캐시: 조회는 `no-store` — 개인화 데이터는 아니지만 관리 화면이라 신선도가 우선이고 검색 조건이 * 매 요청 달라진다. 쓰기는 캐시 대상이 아니며 호출부(Server Action)가 `revalidatePath`로 목록을 * 재검증한다. */ const DECORATION_ITEM_PATH = '/api/v1/mngr/item'; const FILE_UPLOAD_PATH = '/api/v1/common/file/upload'; const FILE_IMAGE_PATH = '/api/v1/common/file/image'; /** 화면·URL의 유형 값 ↔ 백엔드 `itemType` 코드값(사용자 확정 사항). */ const ITEM_TYPE_CODE: Record = { individual: 'N', set: 'S', }; const ITEM_TYPE_BY_CODE: Record = { N: 'individual', S: 'set', }; /** * 화면의 "검색 대상" → 백엔드 `searchCondition` 값. * * **백엔드에 구현된 분기는 `"1"`(아이템명) 하나뿐이다.** 아이템ID 검색은 사용자 지시로 화면 * 선택지를 유지한 채 백엔드에 추가를 요청하기로 했고, 그때 쓸 값으로 `"2"`를 예약해 둔다 * (학생 목록이 1=이름·2=아이디·3=휴대전화를 쓰는 관례와 같은 순번). **백엔드가 이 값을 모르는 * 동안에는 검색어가 무시되고 전체 목록이 반환된다.** */ const SEARCH_CONDITION_BY_FIELD: Record = { name: '1', itemId: '2', }; /** 이름순 정렬이 아니라 "전체 건수를 알기 위해" 한 번에 받아올 최대 행 수(아래 주석 참조). */ const TYPE_TOTAL_COUNT_FETCH_LIMIT = 1_000; 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 readNumber(source: Record, key: string): number | null { const value = source[key]; return typeof value === 'number' ? value : null; } /** 업로드된 파일 ID로 백엔드 이미지 URL을 만든다 — 이 GET은 인증이 필요 없어 브라우저가 직접 부른다. */ function buildImageUrl(imageFileId: string | null): string | null { if (!imageFileId) { return null; } const base = getApiBaseUrl().replace(/\/+$/, ''); const url = new URL(`${base}${FILE_IMAGE_PATH}`); url.searchParams.set('atchFileId', imageFileId); // 아이템은 파일을 1개만 등록하므로 첨부 순번은 항상 1이다(백엔드도 미지정 시 1로 기본값 처리). url.searchParams.set('fileSn', '1'); return url.toString(); } /** * 백엔드 응답 1건 → 도메인 타입. 식별자·이름 등 목록의 존재 이유인 값이 없으면 예외로 끊는다 * (fail-fast) — 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다. * * `rnum`은 담지 않는다 — 표의 "번호"는 현재 페이지 기준 표시 순번이고 표 컴포넌트가 계산한다. */ function toDecorationItem(raw: unknown): DecorationItem { if (!isRecord(raw)) { throw new Error('아이템 응답 항목의 형식이 올바르지 않습니다.'); } const itemSn = readNumber(raw, 'itemSn'); if (itemSn === null) { throw new Error('아이템 응답에 itemSn이 없습니다.'); } const imageFileId = readString(raw, 'atchFileId'); return { itemSn, // 코드값이 비었거나 모르는 값이면 화면 기본 유형으로 떨어뜨리지 않고 개별로 둔다 — // 목록은 유형별로 조회하므로 실제로는 요청한 유형의 행만 온다. itemType: ITEM_TYPE_BY_CODE[readString(raw, 'itemType') ?? ''] ?? 'individual', name: readString(raw, 'itemNm') ?? '', categoryCode: readString(raw, 'itemCateCd') ?? '', categoryName: readString(raw, 'itemCateNm'), points: readNumber(raw, 'itemAmount') ?? 0, description: readString(raw, 'itemExplan'), isActive: readString(raw, 'useYn') === 'Y', sortOrder: readNumber(raw, 'sortOrdr') ?? 0, imageFileId, imageUrl: buildImageUrl(imageFileId), updatedAt: readString(raw, 'lastMdfcnDtStr'), }; } function buildListParams( query: DecorationItemQuery, pageIndex: number, recordCountPerPage: number ): Record { const keyword = query.keyword.trim(); return { searchItemType: ITEM_TYPE_CODE[query.itemType], searchCondition: keyword ? SEARCH_CONDITION_BY_FIELD[query.searchField] : '', searchKeyword: keyword, // 유효한 페이징 파라미터는 이 둘뿐이다 — 서버가 offset을 직접 계산해 firstIndex· // recordCountPerPage를 덮어쓰고 나머지(pageUnit·pageSize·lastIndex)는 읽지 않는다. pageIndex, recordCountPerPage, }; } /** 목록 조회 1회 — 응답 검증까지만 하고 건수 판단은 호출부에 맡긴다. */ async function requestItemList( query: DecorationItemQuery, pageIndex: number, recordCountPerPage: number ): Promise<{ items: DecorationItem[]; reportedTotalCount: number }> { const accessToken = await getSessionAccessToken(); const result = await backendFetch( `${DECORATION_ITEM_PATH}/pagination`, { method: 'GET', query: buildListParams(query, pageIndex, recordCountPerPage), accessToken: accessToken ?? undefined, cache: 'no-store', } ); if (!result.ok) { throw new BackendRequestError(result); } const data = result.data; if (!isRecord(data) || !Array.isArray(data.list)) { throw new Error('아이템 목록 응답의 형식이 올바르지 않습니다.'); } return { items: data.list.map(toDecorationItem), reportedTotalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length, }; } export type DecorationItemPage = { items: DecorationItem[]; /** 검색어까지 적용한 결과 건수. `isTotalCountExact`가 false면 "적어도 이만큼"이라는 하한값이다. */ totalCount: number; isTotalCountExact: boolean; /** * 검색어와 무관하게 현재 유형(개별/셋트) 전체 등록 건수 — 등록/수정 팝업의 * "정렬순서 (총 등록 N개)" 힌트와 신규 등록 기본 정렬순서 계산에 쓴다. */ typeTotalCount: number; }; /** * 검색·유형·페이징이 적용된 목록을 조회한다. * * **`totalCount` 보정**: 백엔드는 count 쿼리 없이 `list.size()`를 총건수로 내려준다 * (`PaginationUtil.execute`). 그대로 쓰면 한 페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 * 2페이지 이후에 접근할 수 없으므로, 학생 목록과 동일하게 하한값으로 보정한다 — 페이지가 가득 * 차지 않았으면 마지막 페이지이므로 `offset + 행 수`가 확정 총건수이고, 가득 찼으면 하한값만 * 알린다. 백엔드가 진짜 count를 넣으면 `Math.max`가 자동으로 그 값을 채택한다. * * **`typeTotalCount`는 별도 조회다** — 검색 결과가 아니라 그 유형에 실제로 존재하는 전체 개수여야 * 하는데(정렬순서 힌트·기본값의 근거) 백엔드가 총건수를 주지 않으므로, 검색어를 뺀 조건으로 큰 * 상한을 걸어 한 번 더 조회해 길이를 센다. 백엔드에 count가 생기면 이 왕복을 없앨 수 있다. */ export async function fetchDecorationItems( query: DecorationItemQuery ): Promise { const { items, reportedTotalCount } = await requestItemList( query, query.page, query.pageSize ); const offset = (query.page - 1) * query.pageSize; const confirmedCount = offset + items.length; const reachedLastPage = items.length < query.pageSize; const typeTotal = await requestItemList( { ...query, keyword: '' }, 1, TYPE_TOTAL_COUNT_FETCH_LIMIT ); return { items, totalCount: Math.max(reportedTotalCount, confirmedCount), isTotalCountExact: reachedLastPage || reportedTotalCount > confirmedCount, typeTotalCount: typeTotal.items.length, }; } /* * ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── * 보내는 값은 등록·수정이 같고 **실어 보내는 형식만 다르다** — 등록은 form, 수정은 JSON * (파일 상단 주석 참조). */ /** 등록·수정이 공유하는 전송 필드. */ function buildWriteForm( values: DecorationItemEditableValues ): Record { return { itemNm: values.name, itemCateCd: values.categoryCode, itemAmount: values.points, itemExplan: values.description, useYn: values.isActive ? 'Y' : 'N', sortOrdr: values.sortOrder, itemType: ITEM_TYPE_CODE[values.itemType], // 썸네일은 백엔드가 자동생성하도록 바뀔 예정이라 지금은 atchFileId만 보낸다(사용자 확정 사항). atchFileId: values.imageFileId, }; } /** * 등록·수정 공통 호출. `payload`가 본문 형식을 정한다 — 이 한 곳에서만 갈린다. * * 등록·수정 성공 응답은 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다. */ async function sendWrite( path: string, method: 'POST' | 'PUT', payload: { form: Record } | { body: unknown } ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(path, { method, ...payload, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, }); if (!result.ok) { throw new BackendRequestError(result); } } /** 등록 — 컨트롤러가 `@ParameterObject`라 form 인코딩이다. */ export async function createDecorationItem( values: DecorationItemEditableValues ): Promise { await sendWrite(DECORATION_ITEM_PATH, 'POST', { form: buildWriteForm(values) }); } /** * 수정(시안 ADM_ITM_103_p) — 컨트롤러가 `@RequestBody`라 **JSON**이다. 등록과 같은 필드를 * 보내지만 형식이 다르다. * * UPDATE 문이 필드마다 ``로 감싸여 있어 **보내지 않은 값은 건드리지 않는다** — 썸네일 * 파일 ID(`thumbAtchFileId`)를 보내지 않는 것이 기존 값을 지우지 않는 이유다. */ export async function updateDecorationItem( itemSn: number, values: DecorationItemEditableValues ): Promise { await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', { body: buildWriteForm(values), }); } export async function deleteDecorationItem(itemSn: number): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${DECORATION_ITEM_PATH}/${itemSn}`, { method: 'DELETE', accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, }); if (!result.ok) { throw new BackendRequestError(result); } } /** * 이미지 업로드 — 성공 시 `atchFileId` 문자열을 돌려준다. * * 브라우저가 백엔드에 직접 올리지 않는다(토큰이 서버에만 있다) — Server Action이 받은 File을 * 그대로 백엔드로 중계한다. */ export async function uploadDecorationItemImage(file: File): Promise { const accessToken = await getSessionAccessToken(); const multipart = new FormData(); multipart.set('file', file); const result = await backendFetch( `${FILE_UPLOAD_PATH}/${FILE_MODULE_ID.decorationItem}`, { method: 'POST', multipart, accessToken: accessToken ?? undefined, cache: 'no-store', } ); if (!result.ok) { throw new BackendRequestError(result); } if (typeof result.data !== 'string' || !result.data) { throw new Error('이미지 업로드 응답에 파일 ID가 없습니다.'); } return result.data; }