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 {
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<string, unknown> {
return value !== null && typeof value === 'object';
}
function readString(source: Record<string, unknown>, 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<string, unknown>, 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<string, string | number>
): Promise<{ rows: unknown[]; reportedTotalCount: number }> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(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),
// 검색 대상이 아이디일 때만 백엔드가 걸러 준다 — 이름 검색은 아래에서 건다.
searchUserId: 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) };
}