File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import 'server-only';
import type { StudentMember } from '@/lib/domain/student-member';
import type {
StudentMemberQuery,
StudentMemberSearchField,
} from '@/lib/domain/student-member-query';
/**
* mock 학생 회원 Repository.
*
* 데이터 출처는 외부 시스템 「알콩」이며, 백엔드(edupay-backend)에 학생 회원 조회 API가 아직
* 없어 mock으로 구현한다. 공개 시그니처(도메인 타입만 주고받음)는 백엔드 연동 후에도 유지한다 —
* 연동 시점에는 이 파일의 내부 구현만 실제 API 호출로 교체하고, 각 함수에 캐시 전략
* (`fetchStudentMembers`/`fetchStudentMemberById`는 `cache: 'no-store'` — 개인정보를 담은
* 목록이고 검색 조건이 매 요청 달라지며 사용여부가 자주 바뀌므로 재사용 캐시를 두지 않는다)을
* 명시적으로 추가해야 한다. 지금은 실제 `fetch()` 호출이 없어(in-memory mock) 옵션을 걸 대상이
* 없다는 점에 유의 — 위 주석은 연동 시점에 적용할 의도를 남겨두는 것이다.
*
* mock 데이터는 결정적(deterministic)으로 생성한다 — `Math.random()`을 쓰지 않고 인덱스 기반
* 순환으로 이름·학교·학년 등을 만들어 매 요청 동일한 128건을 반환한다. 모듈이 처음 로드될 때
* 한 번만 생성해(module-level 배열) 이후 요청 간 같은 데이터를 유지하고,
* `updateStudentMemberActiveStatus`가 반영한 변경도 프로세스 생존 동안 유지된다(재시작 시
* 초기화 — 실제 영속 저장소가 아님에 유의).
*/
const TOTAL_MOCK_COUNT = 128;
const MEMBER_CODE_PREFIX = 'ST';
const SURNAMES = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임'];
const GIVEN_NAMES = [
'민준',
'서연',
'도윤',
'지우',
'하은',
'주원',
'수아',
'지호',
'예은',
'건우',
'서윤',
'연우',
'다은',
];
const GUARDIAN_GIVEN_NAMES = ['영수', '순자', '동현', '미경', '재훈', '은영'];
const SCHOOL_NAMES = [
'한빛초등학교',
'늘푸른초등학교',
'서울중학교',
'대한중학교',
'한강고등학교',
'동산고등학교',
'중앙초등학교',
'푸른중학교',
];
/** 모듈 로드 시점에 1회만 고정한다 — 이후 모든 날짜 계산이 이 값 기준 오프셋이라 같은
* 프로세스에서는 매 요청 동일한 결과를 반환한다(Math.random 없이 결정적). */
const GENERATED_AT = new Date();
function formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function subtractDays(base: Date, days: number): Date {
const result = new Date(base);
result.setDate(result.getDate() - days);
return result;
}
function padDigits(value: number, length: number): string {
const max = 10 ** length;
const normalized = ((value % max) + max) % max;
return String(normalized).padStart(length, '0');
}
function buildPhoneNumber(seed: number): string {
const middle = padDigits(1000 + seed * 137, 4);
const last = padDigits(2000 + seed * 271, 4);
return `010-${middle}-${last}`;
}
/**
* 인덱스 하나로부터 학생 회원 1건을 결정적으로 만든다. 회원코드는 ST00001~ST00128을
* 생성 순서(index) 그대로 부여하고, 가입일은 "최근 날짜부터 역순"(index가 커질수록 과거)으로
* 채운다 — 즉 index 0(ST00001)이 가장 최근 가입자, index 127(ST00128)이 가장 오래된
* 가입자다.
*/
function buildStudentMember(index: number): StudentMember {
const sequenceNumber = index + 1;
const memberCode = `${MEMBER_CODE_PREFIX}${padDigits(sequenceNumber, 5)}`;
// SURNAMES.length(10)와 GIVEN_NAMES.length(13)는 서로소라 둘 다 index를 그대로
// 모듈러로 써도 (성, 이름) 쌍이 lcm(10,13)=130번째(=128건 범위 밖)에야 반복된다 —
// floor(index / 10)처럼 나눗셈으로 이름 축을 늦게 회전시키면 성이 한 바퀴(10명) 도는
// 동안 이름이 고정돼 "김민준·이민준·박민준..." 식으로 화면 첫 페이지가 온통 같은
// given name으로 보이는 부자연스러운 반복이 생긴다(실제로 확인됨) — 그래서 두 축
// 모두 index를 직접 쓴다.
const surname = SURNAMES[index % SURNAMES.length];
const givenName = GIVEN_NAMES[index % GIVEN_NAMES.length];
const name = `${surname}${givenName}`;
const loginId = `stu${padDigits(sequenceNumber, 4)}`;
const grade = (index % 6) + 1;
const classNumber = (index % 10) + 1;
const studentNumber = (index % 30) + 1;
const guardianGivenName =
GUARDIAN_GIVEN_NAMES[index % GUARDIAN_GIVEN_NAMES.length];
const joinedAt = formatDate(subtractDays(GENERATED_AT, index));
const birthYear = GENERATED_AT.getFullYear() - (grade + 6);
const birthDate = `${birthYear}-${padDigits((index % 12) + 1, 2)}-${padDigits(
(index % 28) + 1,
2
)}`;
return {
id: memberCode,
memberCode,
name,
loginId,
phoneNumber: buildPhoneNumber(index),
email: `${loginId}@example.com`,
role: '학생',
schoolName: SCHOOL_NAMES[index % SCHOOL_NAMES.length],
grade,
classNumber,
studentNumber,
guardianName: `${surname}${guardianGivenName}`,
guardianPhoneNumber: buildPhoneNumber(index + 500),
joinedAt,
birthDate,
// 11명 중 1명 꼴로 비활성 — 조회 팝업의 사용여부 라디오가 실제로 두 상태 모두를
// 반영하는지 검증할 수 있도록 결정적으로 소수를 비활성화한다.
isActive: index % 11 !== 0,
};
}
let mockStudentMembers: StudentMember[] = Array.from(
{ length: TOTAL_MOCK_COUNT },
(_, index) => buildStudentMember(index)
);
function matchesKeyword(
member: StudentMember,
field: StudentMemberSearchField,
keyword: string
): boolean {
return member[field].toLowerCase().includes(keyword);
}
/**
* 검색·정렬·페이징이 적용된 학생 회원 목록을 조회한다.
* 캐시 전략: `no-store` 상당 — searchParams 기반이라 조건이 매 요청 달라지고 개인정보를
* 포함하므로 재사용 캐시를 두지 않는다.
*/
export async function fetchStudentMembers(
query: StudentMemberQuery
): Promise<{ items: StudentMember[]; totalCount: number }> {
const keyword = query.keyword.trim().toLowerCase();
const filtered = keyword
? mockStudentMembers.filter((member) =>
matchesKeyword(member, query.searchField, keyword)
)
: mockStudentMembers;
const sorted = [...filtered].sort((a, b) =>
query.sort === 'name'
? a.name.localeCompare(b.name, 'ko')
: b.joinedAt.localeCompare(a.joinedAt)
);
const totalCount = sorted.length;
const start = (query.page - 1) * query.pageSize;
const items = sorted.slice(start, start + query.pageSize);
return { items, totalCount };
}
/**
* 단건 조회 — Server Action의 입력 검증(존재 여부 확인)에 사용한다.
* 캐시 전략: `no-store` 상당(위와 동일한 이유).
*/
export async function fetchStudentMemberById(
id: string
): Promise<StudentMember | null> {
return mockStudentMembers.find((member) => member.id === id) ?? null;
}
/**
* 사용여부만 갱신한다(조회 팝업에서 유일하게 수정 가능한 필드). mock 한정 — 모듈 레벨 배열을
* 불변 갱신(map으로 새 배열 생성)하고 프로세스 생존 동안 유지한다. 쓰기 함수라 캐시 대상이
* 아니다 — 호출부(Server Action)가 `revalidatePath('/students')`로 목록 화면을 재검증한다.
*/
export async function updateStudentMemberActiveStatus(
id: string,
isActive: boolean
): Promise<void> {
const exists = mockStudentMembers.some((member) => member.id === id);
if (!exists) {
throw new Error(`존재하지 않는 학생 회원입니다: ${id}`);
}
mockStudentMembers = mockStudentMembers.map((member) =>
member.id === id ? { ...member, isActive } : member
);
}