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 {
applyMockOverlay,
createMockAdminMember,
deleteMockAdminMember,
listMockCreatedLoginIds,
updateMockAdminMember,
type CreateAdminMemberInput,
type UpdateAdminMemberInput,
} from '@/lib/data/mock/admin-member-store';
/**
* 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터·
* 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은
* `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다.
*
* GET /api/v1/mngr/admin/pagination (ROLE_ADMIN 전용)
* → data: { list: [{ rnum, admUserId, admNm, loginId, admRoleCd,
* loginFailCnt, acctLockYn, useYn }], page, size, totalCount, totalPages }
*
* 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다
* — MngrAdminApiController / MngrAdminServiceImpl / MngrAdminMapper.xml / PaginationUtil.
*
* - **응답에 휴대전화번호·이메일·생성일이 없다.** 조회 SQL의 select 목록 자체에 그 컬럼들이 없고
* `MngrAdminVo`에도 필드가 없다. 그래서 시안의 해당 열은 `null` → `-`다. 백엔드가 컬럼과 VO
* 필드를 추가하면 `toAdminMember`의 매핑만 늘리면 되고 화면은 손대지 않는다.
* - **정렬 파라미터가 없다** — 목록 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_PAGINATION_PATH = '/api/v1/mngr/admin/pagination';
/**
* 한 번에 받아올 최대 행 수. **이 화면은 백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다.**
* 이유가 세 가지 겹친다:
*
* 1. **총건수가 없다.** 위에서 적었듯 `totalCount`가 현재 페이지 행 수라, 그 값을 믿으면
* 페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 2페이지 이후에 영원히 닿을 수 없다.
* 시안(ADM_ADM_101)은 "총 N명 | 현재페이지 1/1"과 번호 열(총건수에서 거꾸로 세는 순번)을
* 요구하는데, 둘 다 정확한 전체 건수를 전제한다.
* 2. **검색·정렬을 백엔드에 맡길 수 없다.** 이름순 정렬 파라미터가 없고, 휴대전화번호 검색
* 분기는 관리자 테이블에 없는 컬럼을 참조한다(`admin-member-query.ts` 주석 참조).
* 3. **등록/수정/삭제가 mock이다.** 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'),
phoneNumber: null,
email: null,
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 = applyMockOverlay(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,
};
}
/** 단건 조회 — 수정 팝업이 쓰는 진입점. 백엔드에 단건 API가 없어 목록에서 찾는다. */
export async function findAdminMemberById(
id: string
): Promise<AdminMember | null> {
const all = applyMockOverlay(await fetchAllAdminMembers());
return all.find((item) => item.id === id) ?? null;
}
/**
* ID 중복 확인(시안 ADM_ADM_102_p ①). 백엔드에 중복 확인 API가 없어 목록에 이미 있는 ID인지로
* 판정한다 — 목록은 실제 계정 전부를 담으므로 판정 자체는 맞지만, 확인 시점과 저장 시점 사이에
* 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 막지 못한다. 저장 시 유일성 보장은 결국 백엔드
* (DB 유니크 제약)의 몫이다.
*/
export async function isAdminLoginIdTaken(loginId: string): Promise<boolean> {
const normalized = loginId.trim().toLowerCase();
const backendLoginIds = (await fetchAllAdminMembers()).map(
(item) => item.loginId
);
return [...backendLoginIds, ...listMockCreatedLoginIds()].some(
(existing) => existing.toLowerCase() === normalized
);
}
/*
* ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
* 백엔드에 등록·수정·삭제 API가 없어 세 함수 모두 mock 저장소에 위임한다
* (`lib/data/mock/admin-member-store.ts`의 주석에 한계를 적어 두었다).
* 백엔드 API가 생기면 **이 세 함수의 본문만** `backendFetch` 호출로 바꾸면 되고,
* Server Action과 화면은 그대로다 — 그러라고 호출부가 이 계층만 보게 두었다.
*/
export async function createAdminMember(
input: CreateAdminMemberInput
): Promise<void> {
createMockAdminMember(input);
}
export async function updateAdminMember(
id: string,
input: UpdateAdminMemberInput
): Promise<void> {
updateMockAdminMember(id, input);
}
export async function deleteAdminMember(id: string): Promise<void> {
deleteMockAdminMember(id);
}