File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
09-17
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 } 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의 `<if test="itemExplan != ''">` 가드가 빈 문자열을
* 건너뛴다. 지우려는 의도가 조용히 무시되므로 백엔드에 조건 완화를 요청한다.
* - **응답의 `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<DecorationItemType, string> = {
individual: 'N',
set: 'S',
};
const ITEM_TYPE_BY_CODE: Record<string, DecorationItemType> = {
N: 'individual',
S: 'set',
};
/**
* 화면의 "검색 대상" → 백엔드 `searchCondition` 값.
*
* **백엔드에 구현된 분기는 `"1"`(아이템명) 하나뿐이다.** 아이템ID 검색은 사용자 지시로 화면
* 선택지를 유지한 채 백엔드에 추가를 요청하기로 했고, 그때 쓸 값으로 `"2"`를 예약해 둔다
* (학생 목록이 1=이름·2=아이디·3=휴대전화를 쓰는 관례와 같은 순번). **백엔드가 이 값을 모르는
* 동안에는 검색어가 무시되고 전체 목록이 반환된다.**
*/
const SEARCH_CONDITION_BY_FIELD: Record<DecorationItemSearchField, string> = {
name: '1',
itemId: '2',
};
/** 이름순 정렬이 아니라 "전체 건수를 알기 위해" 한 번에 받아올 최대 행 수(아래 주석 참조). */
const TYPE_TOTAL_COUNT_FETCH_LIMIT = 1_000;
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key];
return typeof value === 'string' && value.length > 0 ? value : null;
}
function readNumber(source: Record<string, unknown>, 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<string, string | number> {
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<unknown>(
`${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<DecorationItemPage> {
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<string, string | number | undefined> {
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<string, string | number | undefined> } | { body: unknown }
): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<null>(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<void> {
await sendWrite(DECORATION_ITEM_PATH, 'POST', { form: buildWriteForm(values) });
}
/**
* 수정(시안 ADM_ITM_103_p) — 컨트롤러가 `@RequestBody`라 **JSON**이다. 등록과 같은 필드를
* 보내지만 형식이 다르다.
*
* UPDATE 문이 필드마다 `<if>`로 감싸여 있어 **보내지 않은 값은 건드리지 않는다** — 썸네일
* 파일 ID(`thumbAtchFileId`)를 보내지 않는 것이 기존 값을 지우지 않는 이유다.
*/
export async function updateDecorationItem(
itemSn: number,
values: DecorationItemEditableValues
): Promise<void> {
await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', {
body: buildWriteForm(values),
});
}
export async function deleteDecorationItem(itemSn: number): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<null>(`${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<string> {
const accessToken = await getSessionAccessToken();
const multipart = new FormData();
multipart.set('file', file);
const result = await backendFetch<string>(
`${FILE_UPLOAD_PATH}/${FILE_MODULE.userItem.id}`,
{
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;
}