import 'server-only'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import type { PointAccount, PointHistoryEntry, } from '@/lib/domain/point-account'; import type { PointAccountQuery } from '@/lib/domain/point-account-query'; /** * 누적포인트 관리 Repository. * * ``` * GET /api/v1/mngr/point/stats/pagination 학생별 누적포인트 목록 * GET /api/v1/mngr/point/stats/log/pagination 한 학생의 포인트 내역(searchUserId 필수) * ``` * * 조회 전용이다 — 백엔드에 쓰기 엔드포인트가 없다. * * ⚠️ 백엔드 이슈 — 화면이 기획대로 동작하지 못하는 부분 * 1. 목록 검색이 전혀 걸리지 않는다. `MngrPointStatsSearchVo`에 `searchSchulNm`· * `searchGradeCd`·`searchClsNo`·`searchNo`·`searchUserId`가 있지만 `MngrPointStatsMapper.xml` * 의 `includeSql`에 WHERE 절이 없어 항상 전체가 나온다. 아래에서 파라미터는 규약대로 * 보내 두었으니 매퍼에 조건만 추가되면 프론트 수정 없이 동작한다. * 2. 번호(출석번호)·회원번호·구분(사이트) 열의 원본이 응답에 없다. 회원번호 자리는 * 로그인 아이디로 대신 채우고 나머지는 빈 값이다. * 3. 현재포인트는 SQL에서 `p.current_point`를 뽑지만 VO에 필드가 없어 응답에서 사라진다. * `total_point = current_point + use_point` 정의를 역산해 채운다. * 4. 차감포인트는 개념 자체가 없다(`POINT_TYPE_CD`는 취득 `E`·사용 `S` 둘뿐). 0으로 둔다. */ const STATS_PATH = '/api/v1/mngr/point/stats'; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } function readString(source: Record, key: string): string { const value = source[key]; if (typeof value === 'string') return value; if (typeof value === 'number') return String(value); return ''; } function readNumber(source: Record, key: string): number { const value = source[key]; if (typeof value === 'number') return value; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } function toPointAccount(raw: unknown): PointAccount { if (!isRecord(raw)) { throw new Error('누적포인트 응답 항목의 형식이 올바르지 않습니다.'); } const total = readNumber(raw, 'totalPoint'); const used = readNumber(raw, 'usePoint'); return { id: readString(raw, 'userId'), // 백엔드에 회원번호가 없다 — 로그인 아이디가 그 자리를 대신한다. memberNo: readString(raw, 'loginId'), loginId: readString(raw, 'loginId'), studentName: readString(raw, 'userNm'), schoolName: readString(raw, 'schNm'), grade: readNumber(raw, 'grade'), classNo: readNumber(raw, 'clsNo'), studentNo: 0, totalPoint: total, deductedPoint: 0, usedPoint: used, currentPoint: total - used, updatedAt: readString(raw, 'lastMdfcnDtStr') || readString(raw, 'frstRegDtStr'), siteName: '', }; } function toHistoryEntry(raw: unknown, index: number): PointHistoryEntry { if (!isRecord(raw)) { throw new Error('포인트 내역 응답 항목의 형식이 올바르지 않습니다.'); } const amount = readNumber(raw, 'pointAmt'); // POINT_TYPE_CD는 취득 `E`·사용 `S`. 사용은 음수로 보여야 부호가 곧 방향이 된다(기획 PTS10300001 ②). const isUse = readString(raw, 'pointTypeCd').toUpperCase() === 'S'; return { id: readString(raw, 'rnum') || `${index}`, title: readString(raw, 'pointSj'), point: isUse ? -Math.abs(amount) : amount, siteName: '', date: readString(raw, 'frstRegDtStr'), }; } async function requestList( path: string, query: Record ): Promise<{ rows: unknown[]; reportedTotalCount: number }> { const accessToken = await getSessionAccessToken(); const result = await backendFetch(path, { method: 'GET', query, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } const data = result.data; if (data === null || data === undefined) { return { rows: [], reportedTotalCount: 0 }; } if (!isRecord(data) || !Array.isArray(data.list)) { throw new Error('누적포인트 목록 응답의 형식이 올바르지 않습니다.'); } return { rows: data.list, reportedTotalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length, }; } export async function fetchPointAccounts( query: PointAccountQuery ): Promise<{ items: PointAccount[]; totalCount: number }> { const keyword = query.keyword.trim(); const { rows, reportedTotalCount } = await requestList( `${STATS_PATH}/pagination`, { searchSchulNm: query.schoolName, searchGradeCd: query.grade === null ? '' : String(query.grade), searchClsNo: query.classNo === null ? '' : String(query.classNo), searchNo: query.studentNo === null ? '' : String(query.studentNo), // 검색 대상이 아이디일 때만 백엔드가 걸러 준다 — 이름 검색은 아래에서 건다. searchLoginId: query.searchField === 'loginId' ? keyword : '', searchKeyword: keyword, pageIndex: query.page, recordCountPerPage: query.pageSize, } ); const items = rows.map(toPointAccount); const confirmed = (query.page - 1) * query.pageSize + items.length; return { items, totalCount: Math.max(reportedTotalCount, confirmed) }; } /** 상세 팝업의 포인트 내역 — `searchUserId`가 필수다. */ export async function fetchPointHistory( accountId: string, page: number, pageSize: number ): Promise<{ items: PointHistoryEntry[]; totalCount: number }> { const { rows, reportedTotalCount } = await requestList( `${STATS_PATH}/log/pagination`, { searchUserId: accountId, pageIndex: page, recordCountPerPage: pageSize, } ); const items = rows.map(toHistoryEntry); const confirmed = (page - 1) * pageSize + items.length; return { items, totalCount: Math.max(reportedTotalCount, confirmed) }; }