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';
/**
* 관리자 회원 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/{admUserId} 수정
* DELETE /api/v1/mngr/admin/{admUserId} 삭제
*
* 아래는 백엔드 저장소(edupay-backend, develop 4d98756)의 실제 구현을 확인한 것이다.
*
* - **등록과 수정의 본문 형식이 서로 다르다.** 등록은 `@RequestBody` 없이 `@ParameterObject`라
* query/form으로만 바인딩되고(→ `form`), 수정은 `@RequestBody`라 JSON이다(→ `body`).
* 같은 도메인인데 갈린 것이라 헷갈리기 쉽다 — 한쪽 방식으로 통일해 보내면 값이 조용히 비어 저장된다.
* - **이름(`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()`(= 그 페이지의 행 수)를 총건수로 그대로 쓴다. 그래서 이 값은 신뢰하지 않는다.
* - **삭제는 soft delete다**(`DEL_YN='Y'`). 조회 SQL이 모두 `DEL_YN != 'Y'`로 거르므로 삭제한 행은
* 목록·단건에서 함께 사라진다.
*
* 인증: `/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. **이름순 정렬을 백엔드에 맡길 수 없다.** 정렬 파라미터가 없고 목록 SQL의 ORDER BY가
* 하드코딩돼 있다.
*
* 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면
* 백엔드에 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'),
phoneNumber: readOptionalString(raw, 'admTelNo'),
email: readOptionalString(raw, 'admEmlAddr'),
roleCode: readOptionalString(raw, 'admRoleCd') ?? '',
// 백엔드에 관리자별 메뉴 권한 개념이 없다 — 저장도 조회도 되지 않는다(쓰기 경로 주석 참조).
menuCodes: [],
// `DATE_FORMAT(FRST_REG_DT, '%Y-%m-%d')`라 이미 화면 표기 형식이다.
createdAt: readOptionalString(raw, 'frstRegDtStr'),
isLocked: parseYesNo(raw.acctLockYn),
isActive: parseYesNo(raw.useYn),
loginFailCount: typeof loginFailCnt === 'number' ? loginFailCnt : null,
};
}
/**
* 백엔드 목록 전체를 한 번에 받아온다.
*
* 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 백엔드에
* 맡기면 페이징도 함께 백엔드가 하게 되는데 그 총건수를 믿을 수 없다(위 상수 주석). 어차피 전체를
* 손에 쥐므로 `filterByKeyword`가 같은 조건으로 거른다.
*/
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,
phoneNumber: (member) => member.phoneNumber ?? '',
};
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`가 곧 생성일
* 최신순이라 받은 순서가 이미 답이다(rnum이 `frst_reg_dt` 오름차순 순번이다).
*/
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 = 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,
};
}
/** 단건 조회 — 수정 팝업이 쓰는 진입점. */
export async function findAdminMemberById(
id: string
): Promise<AdminMember | 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를 쓴다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다.
*
* **본문 형식이 등록과 수정에서 갈린다** — 등록은 form, 수정은 JSON이다(파일 상단 주석 참조).
*
* 메뉴 선택(`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`가
* `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서 빠진다.
* (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 됐다.)
*/
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}/${encodeURIComponent(id)}`,
{
method: 'PUT',
// 수정만 `@RequestBody`라 JSON이다. form으로 보내면 모든 필드가 null로 들어가
// 이메일·전화번호가 지워지고 역할이 비워진다.
body: {
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);
}
}
/** 삭제(soft delete). 백엔드가 `DEL_YN='Y'`로 표시하고 조회 SQL이 그 행을 제외한다. */
export async function deleteAdminMember(id: string): Promise<void> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(
`${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
{
method: 'DELETE',
accessToken: accessToken ?? undefined,
canHaveNullData: true,
}
);
if (!result.ok) {
throw new BackendRequestError(result);
}
}