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 { return mockStudentMembers.find((member) => member.id === id) ?? null; } /** * 사용여부만 갱신한다(조회 팝업에서 유일하게 수정 가능한 필드). mock 한정 — 모듈 레벨 배열을 * 불변 갱신(map으로 새 배열 생성)하고 프로세스 생존 동안 유지한다. 쓰기 함수라 캐시 대상이 * 아니다 — 호출부(Server Action)가 `revalidatePath('/students')`로 목록 화면을 재검증한다. */ export async function updateStudentMemberActiveStatus( id: string, isActive: boolean ): Promise { const exists = mockStudentMembers.some((member) => member.id === id); if (!exists) { throw new Error(`존재하지 않는 학생 회원입니다: ${id}`); } mockStudentMembers = mockStudentMembers.map((member) => member.id === id ? { ...member, isActive } : member ); }