임동욱 임동욱 08-21
fix: 포인트 화면을 옮겨진 백엔드 경로와 새 통계 API에 다시 맞춘다
백엔드 포인트 패키지가 policy/stats로 갈라지면서 기준관리가 쓰던
/api/v1/mngr/point 가 /api/v1/mngr/point/policy 로 옮겨져 목록이 404였다.
배포 서버 /v3/api-docs 로 확인한 실제 경로로 되돌린다.

같은 개편에서 /point/stats/pagination 과 /point/stats/log/pagination 이
새로 생겨, 그동안 메모리 목업이던 누적포인트 화면을 실제 API에 붙인다.
응답에 없는 값(번호·회원번호·사이트·차감포인트)과 매퍼에 WHERE 절이 없어
걸리지 않는 검색은 파일 머리말에 이슈로 남겼다.

Co-Authored-By: Claude Opus 5 
@8e992d6849110132802c666426b972a874e74d45
lib/data/repositories/point-account-repository.ts
--- lib/data/repositories/point-account-repository.ts
+++ lib/data/repositories/point-account-repository.ts
@@ -1,153 +1,176 @@
 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';
 
-// TODO(백엔드): 누적포인트 API가 아직 없다. 아래 mock 생성기를 지우고 다른 Repository처럼
-// backendFetch + getSessionAccessToken으로 교체한다.
-// 이슈: 구분(사이트)은 기획대로 FOX PAY App. 하나뿐이다 — 확장은 백엔드가 사이트를 실어 준 뒤다.
+/**
+ * 누적포인트 관리 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 TOTAL_ACCOUNTS = 3290;
-const SITE_NAME = 'FOX PAY App.';
-const SCHOOL_NAMES = [
-  '폭스고등학교',
-  '여우고등학교',
-  '한빛고등학교',
-  '새롬고등학교',
-];
-const EVENT_TITLES = ['EVENT-1', 'EVENT-2', 'EVENT-3'];
-const BASE_DATE = Date.UTC(2026, 1, 5);
-const DAY_MS = 24 * 60 * 60 * 1000;
+const STATS_PATH = '/api/v1/mngr/point/stats';
 
-function dateAt(offsetDays: number): string {
-  return new Date(BASE_DATE - offsetDays * DAY_MS).toISOString().slice(0, 10);
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
 }
 
-function timestampAt(index: number): string {
-  const hour = 9 + (index % 9);
-  const minute = (index * 7) % 60;
-  const pad = (value: number) => String(value).padStart(2, '0');
-  return `${dateAt(index % 400)} ${pad(hour)}:${pad(minute)}`;
+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 buildAccount(index: number): PointAccount {
-  const serial = TOTAL_ACCOUNTS - index;
-  const totalPoint = 120 + ((index * 37) % 880);
-  const deductedPoint = (index * 13) % 60;
-  const usedPoint = (index * 29) % 300;
+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: `PA-${String(serial).padStart(5, '0')}`,
-    memberNo: `NO_${String(10000 + serial)}`,
-    loginId: `user_${String(serial).padStart(4, '0')}`,
-    studentName: `학생${serial}`,
-    schoolName: SCHOOL_NAMES[index % SCHOOL_NAMES.length],
-    grade: (index % 3) + 1,
-    classNo: (index % 10) + 1,
-    studentNo: (index % 30) + 1,
-    totalPoint,
-    deductedPoint,
-    usedPoint,
-    currentPoint: totalPoint - deductedPoint - usedPoint,
-    updatedAt: timestampAt(index),
-    siteName: SITE_NAME,
+    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: '',
   };
 }
 
-const ACCOUNTS: PointAccount[] = Array.from(
-  { length: TOTAL_ACCOUNTS },
-  (_, index) => buildAccount(index)
-);
+function toHistoryEntry(raw: unknown, index: number): PointHistoryEntry {
+  if (!isRecord(raw)) {
+    throw new Error('포인트 내역 응답 항목의 형식이 올바르지 않습니다.');
+  }
 
-function matchesQuery(account: PointAccount, query: PointAccountQuery): boolean {
-  if (
-    query.schoolName &&
-    !account.schoolName.toLowerCase().includes(query.schoolName.toLowerCase())
-  ) {
-    return false;
-  }
-  if (query.grade !== null && account.grade !== query.grade) {
-    return false;
-  }
-  if (query.classNo !== null && account.classNo !== query.classNo) {
-    return false;
-  }
-  if (query.studentNo !== null && account.studentNo !== query.studentNo) {
-    return false;
-  }
-  if (query.keyword) {
-    const needle = query.keyword.toLowerCase();
-    const haystack =
-      query.searchField === 'loginId' ? account.loginId : account.studentName;
-    if (!haystack.toLowerCase().includes(needle)) {
-      return false;
-    }
-  }
-  return true;
+  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'),
+  };
 }
 
-/** 기획 PTS10200001 ② — 최근 수정일시로 정렬. */
-function byUpdatedAtDesc(a: PointAccount, b: PointAccount): number {
-  return b.updatedAt.localeCompare(a.updatedAt);
+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 filtered = ACCOUNTS.filter((account) => matchesQuery(account, query));
-  filtered.sort(byUpdatedAtDesc);
+  const keyword = query.keyword.trim();
 
-  const start = (query.page - 1) * query.pageSize;
-  return {
-    items: filtered.slice(start, start + query.pageSize),
-    totalCount: filtered.length,
-  };
+  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) };
 }
 
-export async function fetchPointAccount(
-  id: string
-): Promise<PointAccount | null> {
-  return ACCOUNTS.find((account) => account.id === id) ?? null;
-}
-
-function buildHistory(account: PointAccount): PointHistoryEntry[] {
-  const seed = Number(account.id.slice(3));
-  const length = 12 + (seed % 9);
-
-  return Array.from({ length }, (_, index) => {
-    const isPurchase = (seed + index) % 4 === 3;
-    return {
-      id: `${account.id}-H${String(index + 1).padStart(3, '0')}`,
-      title: isPurchase
-        ? `배지 이미지 구입 - BADGE_${String(((seed + index) % 20) + 1).padStart(3, '0')}`
-        : EVENT_TITLES[(seed + index) % EVENT_TITLES.length],
-      point: isPurchase ? -100 : 10,
-      siteName: SITE_NAME,
-      date: dateAt(index),
-    };
-  });
-}
-
-/** 기획 PTS10300001 ② — 최근 날짜순 정렬. */
+/** 상세 팝업의 포인트 내역 — `searchUserId`가 필수다. */
 export async function fetchPointHistory(
   accountId: string,
   page: number,
   pageSize: number
 ): Promise<{ items: PointHistoryEntry[]; totalCount: number }> {
-  const account = await fetchPointAccount(accountId);
-  if (!account) {
-    return { items: [], totalCount: 0 };
-  }
+  const { rows, reportedTotalCount } = await requestList(
+    `${STATS_PATH}/log/pagination`,
+    {
+      searchUserId: accountId,
+      pageIndex: page,
+      recordCountPerPage: pageSize,
+    }
+  );
 
-  const entries = buildHistory(account);
-  entries.sort((a, b) => b.date.localeCompare(a.date));
+  const items = rows.map(toHistoryEntry);
+  const confirmed = (page - 1) * pageSize + items.length;
 
-  const start = (page - 1) * pageSize;
-  return {
-    items: entries.slice(start, start + pageSize),
-    totalCount: entries.length,
-  };
+  return { items, totalCount: Math.max(reportedTotalCount, confirmed) };
 }
lib/data/repositories/point-standard-repository.ts
--- lib/data/repositories/point-standard-repository.ts
+++ lib/data/repositories/point-standard-repository.ts
@@ -9,13 +9,16 @@
  * 포인트 지급기준(포인트 정책) Repository — 백엔드 `TB_COM_EVENT_POINT`.
  *
  * ```
- * GET    /api/v1/mngr/point/pagination      목록
- * POST   /api/v1/mngr/point                 어노테이션 없음 → form
- * PUT    /api/v1/mngr/point/{eventPtSn}     @RequestBody(JSON)
- * DELETE /api/v1/mngr/point/{eventPtSn}     soft delete(DEL_YN)
+ * GET    /api/v1/mngr/point/policy/pagination      목록
+ * POST   /api/v1/mngr/point/policy                 어노테이션 없음 → form
+ * PUT    /api/v1/mngr/point/policy/{eventPtSn}     @RequestBody(JSON)
+ * DELETE /api/v1/mngr/point/policy/{eventPtSn}     soft delete(DEL_YN)
  * ```
  *
  * 등록은 form, 수정은 JSON으로 갈린다 — 콘텐츠·퀴즈와 같은 규약이다.
+ *
+ * 이슈: 경로가 `/mngr/point`에서 `/mngr/point/policy`로 옮겨졌다(2026-08-21, 누적포인트
+ *   `/point/stats`가 생기면서 갈라졌다). 옮기기 전 경로로 부르면 404다.
  *
  * ⚠️ 백엔드 결함(보고함, 미수정)
  * 1. **`MngrPointPolicyVo`에 `eventPtSn` 말고는 필드가 없다.** 목록 SQL은 EVENT_CD·
@@ -33,7 +36,7 @@
  *    뒤 여기서 거른다.
  */
 
-const POINT_PATH = '/api/v1/mngr/point';
+const POINT_PATH = '/api/v1/mngr/point/policy';
 
 function isRecord(value: unknown): value is Record<string, unknown> {
   return value !== null && typeof value === 'object';
Add a comment
List