File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import 'server-only';
import { getSessionAccessToken } from '@/lib/auth/dal';
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
import type { AdminMember } from '@/lib/domain/admin-member';
import type {
AdminMemberQuery,
AdminMemberSearchField,
} from '@/lib/domain/admin-member-query';
import {
applyMockDeletions,
isMockDeleted,
markMockDeleted,
} from '@/lib/data/mock/admin-member-store';
/**
* 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트·파라미터·
* 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은
* `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다.
*
* GET /api/v1/mngr/admin/pagination 목록
* GET /api/v1/mngr/admin/{admUserId} 단건
* GET /api/v1/mngr/admin/duplication/{id} 로그인 ID 중복 확인
* POST /api/v1/mngr/admin 등록
* PUT /api/v1/mngr/admin 수정
*
* 아래는 백엔드 저장소(edupay-backend, develop 924db37)의 실제 구현과 dev 서버 응답을 확인한 것이다.
*
* - **등록·수정은 JSON 본문을 받지 않는다.** 두 핸들러 모두 `@RequestBody` 없이
* `@ParameterObject MngrAdmin*RequestVo`를 받아 query/form으로만 바인딩된다. 그래서 `form`으로 보낸다.
* - **이메일·휴대전화번호를 되읽을 수 없다.** 저장은 되지만 조회 SQL의 select 목록에 두 컬럼이 없어
* 응답은 항상 `null`이다(실측). 매핑은 미리 해 두었으므로 백엔드가 컬럼을 추가하면 화면까지 그대로 흐른다.
* - **이름(`admNm`)은 수정 대상이 아니다** — `MngrAdminUpdateRequestVo`에 필드가 없다.
* - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY RNUM DESC`가 하드코딩돼 있다. rnum이
* `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, adm_nm DESC)`, 즉 최초등록일시 오름차순 순번이라
* 그것을 뒤집은 고정 순서가 곧 **생성일 최신순**이다. 그래서 생성일순 정렬은 "백엔드가 준 순서를
* 그대로 쓴다"는 뜻이고, 이름순만 우리가 정렬한다.
* - **`totalCount`가 전체 건수가 아니다.** count 쿼리가 없어 `PaginationUtil.execute`가
* `list.size()`(= 그 페이지의 행 수)를 총건수로 그대로 쓴다. 그래서 이 값은 신뢰하지 않는다.
*
* 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서
* 꺼내 Bearer로 붙인다.
*
* 캐시: `no-store` — 개인정보 목록이고 검색 조건이 매 요청 다르다.
*/
const ADMIN_MEMBER_BASE_PATH = '/api/v1/mngr/admin';
const ADMIN_MEMBER_PAGINATION_PATH = `${ADMIN_MEMBER_BASE_PATH}/pagination`;
/**
* 한 번에 받아올 최대 행 수. **이 화면은 백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다.**
* 이유가 세 가지 겹친다:
*
* 1. **총건수가 없다.** 위에서 적었듯 `totalCount`가 현재 페이지 행 수라, 그 값을 믿으면
* 페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 2페이지 이후에 영원히 닿을 수 없다.
* 시안(ADM_ADM_101)은 "총 N명 | 현재페이지 1/1"과 번호 열(총건수에서 거꾸로 세는 순번)을
* 요구하는데, 둘 다 정확한 전체 건수를 전제한다.
* 2. **검색·정렬을 백엔드에 맡길 수 없다.** 이름순 정렬 파라미터가 없고, 휴대전화번호 검색
* 분기는 관리자 테이블에 없는 컬럼을 참조한다(`admin-member-query.ts` 주석 참조).
* 3. **삭제가 mock이다.** 삭제된 행을 걷어낸 뒤 세어야 총건수가 맞는데, 페이징이 백엔드에 있으면
* 페이지마다 구멍이 생겨 건수와 순번이 어긋난다.
*
* 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면
* 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다.
* (같은 이유로 학생 목록의 이름순 정렬과 엑셀 다운로드도 이미 같은 방식을 쓴다.)
*/
const ADMIN_MEMBER_FETCH_LIMIT = 10_000;
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
function readRequiredString(
source: Record<string, unknown>,
key: string
): string {
const value = source[key];
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`관리자 회원 응답에 ${key}가 없습니다.`);
}
return value;
}
function readOptionalString(
source: Record<string, unknown>,
key: string
): string | null {
const value = source[key];
return typeof value === 'string' && value.length > 0 ? value : null;
}
/** 백엔드의 Y/N 플래그 → boolean. 값이 없거나 Y/N이 아니면 "모름"(null)이다. */
function parseYesNo(value: unknown): boolean | null {
if (value === 'Y') return true;
if (value === 'N') return false;
return null;
}
/**
* 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`(목록은 빈 배열)로 둔다.
*
* `rnum`은 담지 않는다 — 표의 "번호"는 백엔드의 행 번호가 아니라 전체 건수 기준 역순 순번이고
* (시안이 6·5·4처럼 내림차순으로 표기한다), 그 계산은 표 컴포넌트가 한다.
*
* 식별자·이름·ID 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라
* 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다.
*/
function toAdminMember(raw: unknown): AdminMember {
if (!isRecord(raw)) {
throw new Error('관리자 회원 응답 항목의 형식이 올바르지 않습니다.');
}
const loginFailCnt = raw.loginFailCnt;
return {
id: readRequiredString(raw, 'admUserId'),
name: readRequiredString(raw, 'admNm'),
loginId: readRequiredString(raw, 'loginId'),
// 지금은 항상 null이다 — 조회 SQL이 두 컬럼을 select하지 않는다(위 주석 참조).
phoneNumber: readOptionalString(raw, 'admTelNo'),
email: readOptionalString(raw, 'admEmlAddr'),
roleCode: readOptionalString(raw, 'admRoleCd') ?? '',
menuCodes: [],
createdAt: null,
isLocked: parseYesNo(raw.acctLockYn),
isActive: parseYesNo(raw.useYn),
loginFailCount: typeof loginFailCnt === 'number' ? loginFailCnt : null,
};
}
/**
* 백엔드 목록 전체를 한 번에 받아온다.
*
* 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 검색은
* mock 행까지 포함해 일관되게 걸러야 하므로 아래 `filterByKeyword`가 전담한다. 백엔드에 검색을
* 맡기면 백엔드 행만 걸러지고 mock 행은 그대로 남아 결과가 어긋난다.
*/
async function fetchAllAdminMembers(): Promise<AdminMember[]> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(ADMIN_MEMBER_PAGINATION_PATH, {
method: 'GET',
// `PaginationUtil`이 offset을 직접 계산해 firstIndex를 덮어쓰므로 실제로 쓰이는 파라미터는
// 이 둘뿐이다 — 나머지(pageUnit/pageSize/firstIndex/lastIndex)는 읽히지 않아 보내지 않는다.
query: { pageIndex: 1, recordCountPerPage: ADMIN_MEMBER_FETCH_LIMIT },
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 data.list.map(toAdminMember);
}
/** 검색 대상 → 비교할 값. 목록에 실제로 보이는 값으로만 거른다. */
const SEARCH_VALUE_BY_FIELD: Record<
AdminMemberSearchField,
(member: AdminMember) => string
> = {
name: (member) => member.name,
loginId: (member) => member.loginId,
};
function filterByKeyword(
items: AdminMember[],
query: AdminMemberQuery
): AdminMember[] {
const keyword = query.keyword.trim().toLowerCase();
if (!keyword) {
return items;
}
const readValue = SEARCH_VALUE_BY_FIELD[query.searchField];
return items.filter((item) => readValue(item).toLowerCase().includes(keyword));
}
/**
* 정렬. 생성일순은 **정렬하지 않는다** — 백엔드가 이미 생성일 최신순으로 내려주고(고정
* `ORDER BY rnum DESC`), mock 신규 등록 행은 오버레이가 맨 앞에 붙여 두기 때문이다. 응답에
* `createdAt` 값 자체가 없어 우리가 다시 정렬할 수단도 없다.
*/
function sortItems(
items: AdminMember[],
query: AdminMemberQuery
): AdminMember[] {
if (query.sort !== 'name') {
return items;
}
return [...items].sort((a, b) => a.name.localeCompare(b.name, 'ko'));
}
export type AdminMemberPage = {
items: AdminMember[];
/** 검색 조건을 적용한 전체 건수. 전체를 손에 쥐고 세므로 확정값이다. */
totalCount: number;
};
/** 검색·정렬·페이징이 적용된 관리자 회원 목록을 조회한다. */
export async function fetchAdminMembers(
query: AdminMemberQuery
): Promise<AdminMemberPage> {
const all = applyMockDeletions(await fetchAllAdminMembers());
const matched = sortItems(filterByKeyword(all, query), query);
const offset = (query.page - 1) * query.pageSize;
return {
items: matched.slice(offset, offset + query.pageSize),
totalCount: matched.length,
};
}
/** 단건 조회 — 수정 팝업이 쓰는 진입점. mock 삭제된 행은 없는 것으로 취급한다. */
export async function findAdminMemberById(
id: string
): Promise<AdminMember | null> {
if (isMockDeleted(id)) {
return null;
}
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(
`${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
{
method: 'GET',
accessToken: accessToken ?? undefined,
cache: 'no-store',
// 없는 id면 `{success:true, data:null}`이 온다 — 실패가 아니라 "없음"이다.
canHaveNullData: true,
}
);
if (!result.ok) {
throw new BackendRequestError(result);
}
return result.data === null ? null : toAdminMember(result.data);
}
/**
* 로그인 ID 중복 확인(시안 ADM_ADM_102_p ①).
*
* 이미 쓰는 ID면 해당 관리자 정보를, 아니면 `data: null`을 준다(실측). 확인 시점과 저장 시점
* 사이에 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 이 호출로 막을 수 없다 — 최종 유일성은
* 등록 API가 저장 직전에 다시 검사한다.
*/
export async function isAdminLoginIdTaken(loginId: string): Promise<boolean> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(
`${ADMIN_MEMBER_BASE_PATH}/duplication/${encodeURIComponent(loginId.trim())}`,
{
method: 'GET',
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
}
);
if (!result.ok) {
throw new BackendRequestError(result);
}
return result.data !== null;
}
/*
* ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
* 등록·수정은 실제 API를 쓴다. 삭제만 백엔드에 엔드포인트가 없어 mock 오버레이에 남아 있다.
*
* 두 요청 모두 `form`으로 보낸다 — 백엔드 핸들러가 `@RequestBody` 없이 VO를 받아 JSON 본문을
* 바인딩하지 못하기 때문이다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다.
*
* 메뉴 선택(`menuCodes`)은 **보내지 않는다** — 백엔드에 관리자별 메뉴 권한 개념이 없다
* (`/api/v1/common/menu`는 개인 북마크용이고 등록·수정 VO에도 해당 필드가 없다).
*/
export type CreateAdminMemberInput = {
name: string;
loginId: string;
password: string;
phoneNumber: string;
email: string;
roleCode: string;
};
export type UpdateAdminMemberInput = {
/**
* 백엔드 UPDATE 문이 `LOGIN_PW`를 무조건 덮어쓰므로 빈 값을 보내면 그 계정이 로그인 불가가 된다.
* 그래서 이 필드는 선택이 아니라 필수다 — 호출부가 빈 값을 걸러 여기까지 오지 않게 한다.
*/
password: string;
phoneNumber: string;
email: string;
roleCode: string;
isLocked: boolean;
};
/** Y/N 플래그로 변환. 백엔드는 `ACCT_LOCK_YN`에 이 문자열을 그대로 넣는다. */
function toYesNo(value: boolean): string {
return value ? 'Y' : 'N';
}
export async function createAdminMember(
input: CreateAdminMemberInput
): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
method: 'POST',
form: {
admNm: input.name,
loginId: input.loginId,
loginPw: input.password,
admEmlAddr: input.email,
admTelNo: input.phoneNumber,
admRoleCd: input.roleCode,
},
accessToken: accessToken ?? undefined,
canHaveNullData: true,
});
if (!result.ok) {
// 아이디 중복도 여기로 온다(code 300 "이미 등록된 아이디 입니다") — 호출부가 메시지를 살려 쓴다.
throw new BackendRequestError(result);
}
}
export async function updateAdminMember(
id: string,
input: UpdateAdminMemberInput
): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
method: 'PUT',
form: {
admUserId: id,
loginPw: input.password,
admEmlAddr: input.email,
admTelNo: input.phoneNumber,
admRoleCd: input.roleCode,
acctLockYn: toYesNo(input.isLocked),
},
accessToken: accessToken ?? undefined,
canHaveNullData: true,
});
if (!result.ok) {
throw new BackendRequestError(result);
}
}
/** 삭제 API가 없어 화면에서만 감춘다 — 한계는 `admin-member-store.ts` 주석 참조. */
export async function deleteAdminMember(id: string): Promise<void> {
markMockDeleted(id);
}