File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import 'server-only';
import { ApiError, apiGet } from '@/lib/data/api-client';
import type { StudentMember } from '@/lib/domain/student-member';
import type {
StudentMemberQuery,
StudentMemberSearchField,
} from '@/lib/domain/student-member-query';
/**
* 학생 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터·
* 응답 매핑). 백엔드와 말하는 공통 규약(URL·인증 헤더·응답 봉투·에러 정규화)은
* `lib/data/api-client.ts`가 소유하므로 여기에 들어오지 않는다.
*
* GET /api/v1/mngr/user/pagination (ROLE_ADMIN 전용)
* → data: { list: [{ rnum, userId, loginId, userNm }], page, size, totalCount, totalPages }
*
* 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다
* — MngrUserApiController / MngrUserServiceImpl / PaginationUtil / MngrUserMapper.xml.
*
* - **응답이 담는 값은 식별자·이름 계열 넷뿐이다.** 조회 SQL 자체는 USER_TELNO·USER_EML_ADDR·
* SCH_NM·GRADE·CLS_NO·BIRTH까지 이미 select하지만 응답 VO(MngrUserVo)가 4개 필드만 노출해
* 나머지가 버려진다. 그래서 화면의 나머지 항목은 `null` → `-`다. **백엔드가 VO에 필드를
* 추가하면** `toStudentMember`의 매핑만 늘리면 되고 화면은 손대지 않는다.
* - **정렬은 고정이다** — `ORDER BY rnum DESC`(rnum은 최초등록일시 기준 행번호)라 사실상
* 가입일 최신순이고, 정렬 파라미터는 없다.
* - **사용자 유형을 걸러 주지 않는다** — 조회 대상이 TB_COM_USER 전체라 student 외
* teacher·manager도 섞일 수 있고, 응답에 USER_TYPE도 없어 구분할 방법이 없다.
* 화면이 역할을 '학생'으로 표기하는 것은 이 화면의 전제일 뿐 백엔드가 보장하는 값이 아니다.
*
* 캐시: `apiGet`이 `no-store`를 고정한다 — 개인정보 목록이고 검색 조건이 매 요청 다르다.
*/
const STUDENT_MEMBER_PAGINATION_PATH = '/api/v1/mngr/user/pagination';
/**
* 화면의 "검색 대상" → 백엔드 `searchCondition` 값 매핑. 값은 MngrUserMapper.xml의
* `<choose>` 분기에서 그대로 가져온 것이다(1=USER_NM, 2=LOGIN_ID, 3=USER_TELNO).
*
* 목록에 없는 값을 보내면 백엔드가 **검색 조건 없이 전체를 반환**하므로(분기 미매칭 시
* WHERE가 비는 구조) 화면의 검색 대상 선택지도 이 세 가지로 맞춰 두었다.
*/
const SEARCH_CONDITION_BY_FIELD: Record<StudentMemberSearchField, string> = {
name: '1',
loginId: '2',
phoneNumber: '3',
};
/**
* 역할 표기. 백엔드 응답에 USER_TYPE이 없어 이 화면(학생 회원 목록)의 전제를 그대로 적는 상수다
* — 백엔드가 사용자 유형으로 필터링하지도, 유형을 내려주지도 않으므로 **보장된 값이 아니다.**
* 백엔드가 userType을 노출하면 그 값을 매핑해 이 상수를 없애야 한다.
*/
const STUDENT_ROLE_LABEL = '학생';
/**
* 페이징 파라미터. 계약 예시에는 7종이 나열돼 있지만 **실제로 쓰이는 것은 두 개뿐**이다.
*
* `MngrUserServiceImpl`이 `PaginationUtil.execute(pageIndex, recordCountPerPage, ...)`로
* offset을 직접 계산해 `firstIndex`·`recordCountPerPage`를 덮어쓰고, SQL은 그 두 값으로만
* `LIMIT/OFFSET`을 건다. 클라이언트가 보낸 `firstIndex`·`lastIndex`·`pageUnit`·`pageSize`는
* 읽히지 않으므로 보내지 않는다 — 보내면 "이 값이 결과에 영향을 준다"는 오해만 남는다.
*/
function buildPaginationParams(
query: StudentMemberQuery
): Record<string, string | number> {
return {
pageIndex: query.page,
recordCountPerPage: query.pageSize,
};
}
/**
* 검색 파라미터. 검색어가 비어 있으면 조건도 함께 빈 값으로 보낸다 — 백엔드도 `searchKeyword`가
* 비면 조건 분기 자체를 타지 않으므로(전체 조회) 결과는 같고, 의미 없는 조건을 실어 보내지 않는다.
*/
function buildSearchParams(
query: StudentMemberQuery
): Record<string, string> {
const keyword = query.keyword.trim();
if (!keyword) {
return { searchCondition: '', searchKeyword: '' };
}
return {
searchCondition: SEARCH_CONDITION_BY_FIELD[query.searchField],
searchKeyword: keyword,
};
}
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 ApiError(
'contract',
`학생 회원 응답에 ${key}가 없습니다.`,
null
);
}
return value;
}
/**
* 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다(설계서 §8.1 5층 —
* 원본 응답을 그대로 흘리지 않고 화면에 필요한 필드만 골라 담는다).
*
* `rnum`은 담지 않는다 — 표의 "번호"는 백엔드의 행 번호가 아니라 현재 페이지 기준 표시 순번
* (`(page-1)*pageSize + 행 인덱스 + 1`)이고, 그 계산은 이미 표 컴포넌트가 한다.
*
* 식별자·이름 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라
* 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다.
*/
function toStudentMember(raw: unknown): StudentMember {
if (!isRecord(raw)) {
throw new ApiError('contract', '학생 회원 응답 항목의 형식이 올바르지 않습니다.');
}
const userId = readRequiredString(raw, 'userId');
return {
id: userId,
// 백엔드에 회원코드 전용 필드가 없어 식별자를 그대로 노출한다 — 별도 코드가 생기면 교체한다.
memberCode: userId,
name: readRequiredString(raw, 'userNm'),
loginId: readRequiredString(raw, 'loginId'),
phoneNumber: null,
email: null,
role: STUDENT_ROLE_LABEL,
schoolName: null,
grade: null,
classNumber: null,
studentNumber: null,
guardianName: null,
guardianPhoneNumber: null,
joinedAt: null,
birthDate: null,
isActive: null,
};
}
export type StudentMemberPage = {
items: StudentMember[];
/** 전체 건수. `isTotalCountExact`가 false면 "적어도 이만큼"이라는 하한값이다. */
totalCount: number;
/** 위 값이 확정된 전체 건수인지. false면 화면이 "N명 이상"으로 표기하고 다음 페이지를 열어 둔다. */
isTotalCountExact: boolean;
};
/**
* 검색·페이징이 적용된 학생 회원 목록을 조회한다.
*
* 정렬(`query.sort`)은 백엔드에 정렬 파라미터가 없어 전달하지 않는다 — 순서는 백엔드가
* `ORDER BY rnum DESC`로 고정(사실상 가입일 최신순)하며, 화면의 정렬 select도 그래서 비활성이다.
*
* **응답의 `totalCount`는 전체 건수가 아니라 "그 페이지에 담긴 행 수"다.** 백엔드에 count 쿼리가
* 없어 `PaginationUtil`이 `list.size()`를 그대로 총건수로 쓰기 때문이다(edupay-backend
* `PaginationUtil.execute`). 그 값을 그대로 믿으면 한 페이지가 가득 찰 때마다 `totalPages`가 1로
* 계산돼 **2페이지 이후 데이터에 영원히 접근할 수 없다.** 그래서 여기서 하한값으로 보정한다:
*
* - 페이지가 가득 차지 않았다 → 마지막 페이지다 → 전체 건수 = offset + 받은 행 수 (확정).
* - 가득 찼다 → 더 있을 수 있다 → 하한값만 알리고(`isTotalCountExact: false`) 화면이 다음
* 페이지를 열어 두게 한다.
*
* 백엔드가 진짜 count 쿼리를 넣으면 `totalCount`가 offset+행 수보다 커지므로 `Math.max`가
* 자동으로 그 값을 채택하고 확정으로 표기된다 — 이 함수를 다시 고칠 필요가 없다.
*/
export async function fetchStudentMembers(
query: StudentMemberQuery
): Promise<StudentMemberPage> {
const data = await apiGet(STUDENT_MEMBER_PAGINATION_PATH, {
...buildSearchParams(query),
...buildPaginationParams(query),
});
if (!isRecord(data) || !Array.isArray(data.list)) {
throw new ApiError('contract', '학생 회원 목록 응답의 형식이 올바르지 않습니다.');
}
const items = data.list.map(toStudentMember);
const reportedTotalCount =
typeof data.totalCount === 'number' ? data.totalCount : items.length;
const offset = (query.page - 1) * query.pageSize;
const confirmedCount = offset + items.length;
const reachedLastPage = items.length < query.pageSize;
return {
items,
totalCount: Math.max(reportedTotalCount, confirmedCount),
isTotalCountExact: reachedLastPage || reportedTotalCount > confirmedCount,
};
}