File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
09-17
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,
PointAccountSearchField,
} from '@/lib/domain/point-account-query';
/**
* 누적포인트 관리 Repository.
*
* ```
* GET /api/v1/mngr/point/stats/pagination 학생별 누적포인트 목록
* GET /api/v1/mngr/point/stats/log/pagination 한 학생의 포인트 내역(searchUserId 필수)
* ```
*
* 조회 전용이다 — 백엔드에 쓰기 엔드포인트가 없다.
*
* ⚠️ 백엔드 이슈 — 화면이 기획대로 동작하지 못하는 부분
* 1. 목록 검색이 아직 걸리지 않는다. 매퍼에 검색 WHERE(`includeWhere` — `searchCondition`
* 1=아이디·2=이름, `searchSchulNm`·`searchGradeCd`·`searchClsNo`·`searchBno`)는 생겼지만
* `selectPagination`·`selectPaginationCount`가 이 블록을 include하지 않아 항상 전체가
* 나온다. 아래 파라미터는 VO·매퍼 규약대로 보내 두었으니 백엔드가 연결만 하면 프론트
* 수정 없이 동작한다.
* 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';
/** 화면 검색 대상 → 백엔드 `searchCondition`(매퍼 규약: 1=아이디, 2=이름). */
const SEARCH_CONDITION_BY_FIELD: Record<PointAccountSearchField, string> = {
loginId: '1',
studentName: '2',
};
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: readNumber(raw, 'bno'),
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),
searchBno: query.studentNo === null ? '' : String(query.studentNo),
searchCondition: SEARCH_CONDITION_BY_FIELD[query.searchField],
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) };
}