임동욱 임동욱 08-21
feat: 포인트 지급기준을 실제 API(/mngr/point/**)에 연결
메모리 mock 저장소를 지우고 `TB_COM_EVENT_POINT`를 보는 실 API로 바꾼다.

  GET    /api/v1/mngr/point/pagination
  PUT    /api/v1/mngr/point/{eventPtSn}   @RequestBody(JSON)
  DELETE /api/v1/mngr/point/{eventPtSn}   soft delete

`POST`(등록)는 붙이지 않았다 — 기획상 이벤트는 읽기 전용이라 화면에 신규 등록이 없다.

이벤트 코드를 hidden으로 실어 보낸다. 화면에서는 읽기 전용이지만 백엔드 UPDATE가
`EVENT_CD = #{eventCd}`로 덮어써서, 안 보내면 수정할 때마다 이벤트 코드가 지워진다
(퀴즈 포인트와 같은 종류의 함정이다).

목록 검색은 백엔드에 조건이 없어(`MngrPointPolicySearchVo`가 비어 있다) 검색어가 있을 때만
전체를 받아 여기서 거른다. 검색어가 없으면 백엔드 페이징을 그대로 쓴다.

확인(임시 프로브): 목록 응답이 도메인 타입으로 정확히 매핑되고, 수정은
`PUT /point/3` JSON에 `eventCd`가 함께 실리며, 삭제는 `DELETE /point/3`으로 나간다.

⚠️ 백엔드 결함 5건을 Repository 주석에 적었다. 지금은 목록만 부분 동작하고 수정·삭제는
100% 실패한다 — `MngrPointPolicyServiceImpl.selectOne`이 `return null;` 스텁이라 컨트롤러가
늘 "포인트 정책 조회에 실패했습니다"로 끊는다. 목록도 `MngrPointPolicyVo`에 `eventPtSn` 말고
필드가 없어 이벤트·포인트·설명이 전부 버려진다.

Co-Authored-By: Claude Opus 5 
@9184c340101adfd8087ba5fb8fe59baa609ff3cc
app/(protected)/(basic)/points/standards/_actions.ts
--- app/(protected)/(basic)/points/standards/_actions.ts
+++ app/(protected)/(basic)/points/standards/_actions.ts
@@ -45,7 +45,10 @@
   }
 
   try {
-    await updatePointStandard(validation.values);
+    await updatePointStandard(
+      validation.values,
+      readString(formData, 'eventCode')
+    );
   } catch {
     return { status: 'error', message: SAVE_FAILED_MESSAGE };
   }
app/(protected)/(basic)/points/standards/_components/point-standard-form-modal.tsx
--- app/(protected)/(basic)/points/standards/_components/point-standard-form-modal.tsx
+++ app/(protected)/(basic)/points/standards/_components/point-standard-form-modal.tsx
@@ -74,6 +74,8 @@
         <p className={styles.required}>* 는 필수 항목입니다.</p>
 
         <input type="hidden" name="id" value={standard.id} />
+        {/* 화면에서는 읽기 전용이지만 백엔드 UPDATE가 EVENT_CD를 덮어써서 그대로 돌려보낸다. */}
+        <input type="hidden" name="eventCode" value={standard.eventCode} />
 
         <div className={styles.field}>
           {/* 기획 ① 이벤트행은 수정불가(Read Only). 값은 hidden id가 갖는다. */}
lib/data/repositories/point-standard-repository.ts
--- lib/data/repositories/point-standard-repository.ts
+++ lib/data/repositories/point-standard-repository.ts
@@ -1,131 +1,205 @@
 import 'server-only';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
 import type { PointStandard } from '@/lib/domain/point-standard';
 import type { PointStandardQuery } from '@/lib/domain/point-standard-query';
 import type { PointStandardValues } from '@/lib/domain/point-standard-form';
 
-// TODO(백엔드): 포인트 기준관리 API가 아직 없다. 아래 mock 저장소를 지우고 다른 Repository처럼
-// backendFetch + getSessionAccessToken으로 교체하면 호출부는 그대로 둘 수 있다.
-// 이슈: 저장소가 모듈 메모리라 서버를 다시 띄우면 수정·삭제가 초기값으로 되돌아간다.
+/**
+ * 포인트 지급기준(포인트 정책) Repository — 백엔드 `TB_COM_EVENT_POINT`.
+ *
+ * ```
+ * GET    /api/v1/mngr/point/pagination      목록
+ * PUT    /api/v1/mngr/point/{eventPtSn}     @RequestBody(JSON)
+ * DELETE /api/v1/mngr/point/{eventPtSn}     soft delete(DEL_YN)
+ * ```
+ *
+ * `POST /api/v1/mngr/point`(등록)도 있지만 쓰지 않는다 — 기획상 이벤트는 읽기 전용이라 화면에
+ * 신규 등록이 없다. 등록 화면이 생기면 콘텐츠·퀴즈와 같은 form 규약으로 붙이면 된다.
+ *
+ * ⚠️ 백엔드 결함(보고함, 미수정)
+ * 1. **`MngrPointPolicyVo`에 `eventPtSn` 말고는 필드가 없다.** 목록 SQL은 EVENT_CD·
+ *    GRANT_POINT_AMT·DAY_LMT_POINT_AMT·DUPL_YN·EVENT_EXPLN·USE_YN을 select하지만 담을 속성이
+ *    없어 조용히 버려진다 — 지금 목록은 번호와 등록일만 온다. 아래 매핑은 필드가 채워지면
+ *    바로 동작하도록 미리 맞춰 두었다.
+ * 2. **등록이 실패한다.** 같은 이유로 INSERT의 `#{eventCd}` 등이 VO에 없어 MyBatis가
+ *    `There is no getter for property named 'eventCd'`로 끊는다. INSERT 컬럼 목록에 PK인
+ *    `EVENT_PT_SN`을 넣고 `#{eventPtSn}`(항상 null)을 바인딩하는 것도 함께 봐야 한다.
+ * 3. **수정·삭제가 항상 실패한다.** `MngrPointPolicyServiceImpl.selectOne`이 `return null;`
+ *    스텁이라 컨트롤러가 늘 "포인트 정책 조회에 실패했습니다"로 끊는다.
+ * 4. `selectOne` SQL은 `includeSql`이 이미 `WHERE`를 갖고 있는데 뒤에 `WHERE`를 또 붙이고,
+ *    없는 별칭 `m.`을 쓴다 — 스텁이 걷히면 이 SQL도 함께 고쳐야 한다.
+ * 5. 목록에 검색 조건이 없다(`MngrPointPolicySearchVo`가 비어 있다). 화면의 검색어는 받아온
+ *    뒤 여기서 거른다.
+ */
 
-let STANDARDS: PointStandard[] = [
-  {
-    id: 'EVENT-1',
-    eventCode: 'EVENT-1',
-    point: 5,
-    limitPoint: 10,
-    isDuplicable: true,
-    siteCode: 'SELF_STUDY',
-    description: '과제 제출 시 20포인트 지급',
-    isActive: true,
-    updatedAt: '2026-01-10 09:40',
-  },
-  {
-    id: 'EVENT-2',
-    eventCode: 'EVENT-2',
-    point: 10,
-    limitPoint: 0,
-    isDuplicable: true,
-    siteCode: 'SELF_STUDY',
-    description: '수업완료 시 10포인트 지급',
-    isActive: true,
-    updatedAt: '2026-01-18 10:02',
-  },
-  {
-    id: 'EVENT-3',
-    eventCode: 'EVENT-3',
-    point: 5,
-    limitPoint: 0,
-    isDuplicable: false,
-    siteCode: 'SELF_STUDY',
-    description: '과정 이수 시 100포인트 지급',
-    isActive: true,
-    updatedAt: '2026-02-05 13:26',
-  },
-  {
-    id: 'EVENT-4',
-    eventCode: 'EVENT-4',
-    point: 3,
-    limitPoint: 30,
-    isDuplicable: true,
-    siteCode: 'FOX_PAY_APP',
-    description: '출석 체크 시 3포인트 지급',
-    isActive: true,
-    updatedAt: '2026-01-24 16:11',
-  },
-  {
-    id: 'EVENT-5',
-    eventCode: 'EVENT-5',
-    point: 20,
-    limitPoint: 60,
-    isDuplicable: false,
-    siteCode: 'FOX_PAY_APP',
-    description: '금융OX퀴즈 정답 시 20포인트 지급',
-    isActive: false,
-    updatedAt: '2026-01-02 11:35',
-  },
-];
+const POINT_PATH = '/api/v1/mngr/point';
+
+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 toPointStandard(raw: unknown): PointStandard {
+  if (!isRecord(raw)) {
+    throw new Error('포인트 정책 응답 항목의 형식이 올바르지 않습니다.');
+  }
+
+  const id = readString(raw, 'eventPtSn');
+  if (!id) {
+    throw new Error('포인트 정책 응답에 eventPtSn이 없습니다.');
+  }
+
+  return {
+    id,
+    eventCode: readString(raw, 'eventCd'),
+    point: readNumber(raw, 'grantPointAmt'),
+    limitPoint: readNumber(raw, 'dayLmtPointAmt'),
+    isDuplicable: readString(raw, 'duplYn') !== 'N',
+    // 이슈: 사이트 구분 컬럼이 백엔드에 없다. 화면은 기획대로 열을 두고 값은 비워 둔다.
+    siteCode: '',
+    description: readString(raw, 'eventExpln'),
+    isActive: readString(raw, 'useYn') !== 'N',
+    updatedAt:
+      readString(raw, 'lastMdfcnDtStr') || readString(raw, 'frstRegDtStr'),
+  };
+}
+
+async function requestList(
+  pageIndex: number,
+  recordCountPerPage: number
+): Promise<{ items: PointStandard[]; reportedTotalCount: number }> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(`${POINT_PATH}/pagination`, {
+    method: 'GET',
+    query: { pageIndex, recordCountPerPage },
+    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 { items: [], reportedTotalCount: 0 };
+  }
+  if (!isRecord(data) || !Array.isArray(data.list)) {
+    throw new Error('포인트 정책 목록 응답의 형식이 올바르지 않습니다.');
+  }
+
+  return {
+    items: data.list.map(toPointStandard),
+    reportedTotalCount:
+      typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
+  };
+}
 
 function matchesKeyword(standard: PointStandard, keyword: string): boolean {
-  const needle = keyword.toLowerCase();
+  const needle = keyword.trim().toLowerCase();
   return (
     standard.eventCode.toLowerCase().includes(needle) ||
     standard.description.toLowerCase().includes(needle)
   );
 }
 
-/** 기획 PTS10100001 ② — 최근 수정일시 순 정렬. */
-function byUpdatedAtDesc(a: PointStandard, b: PointStandard): number {
-  return b.updatedAt.localeCompare(a.updatedAt);
-}
+/** 검색어가 없으면 백엔드 페이징을 그대로 쓰고, 있으면 전체를 받아 여기서 거른다(결함 5). */
+const KEYWORD_SCAN_LIMIT = 500;
 
 export async function fetchPointStandards(
   query: PointStandardQuery
 ): Promise<{ items: PointStandard[]; totalCount: number }> {
-  const filtered = query.keyword
-    ? STANDARDS.filter((standard) => matchesKeyword(standard, query.keyword))
-    : [...STANDARDS];
+  if (!query.keyword.trim()) {
+    const { items, reportedTotalCount } = await requestList(
+      query.page,
+      query.pageSize
+    );
+    const confirmed = (query.page - 1) * query.pageSize + items.length;
+    return { items, totalCount: Math.max(reportedTotalCount, confirmed) };
+  }
 
-  filtered.sort(byUpdatedAtDesc);
-
+  const { items } = await requestList(1, KEYWORD_SCAN_LIMIT);
+  const filtered = items.filter((item) => matchesKeyword(item, query.keyword));
   const start = (query.page - 1) * query.pageSize;
+
   return {
     items: filtered.slice(start, start + query.pageSize),
     totalCount: filtered.length,
   };
 }
 
-function nowStamp(): string {
-  const now = new Date();
-  const pad = (value: number) => String(value).padStart(2, '0');
-  return (
-    `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
-    `${pad(now.getHours())}:${pad(now.getMinutes())}`
-  );
-}
+type PointStandardPayload = {
+  eventCd: string;
+  grantPointAmt: string;
+  dayLmtPointAmt: string;
+  duplYn: string;
+  eventExpln: string;
+  useYn: string;
+};
 
-export async function updatePointStandard(
-  values: PointStandardValues
-): Promise<void> {
-  const index = STANDARDS.findIndex((standard) => standard.id === values.id);
-  if (index < 0) {
-    throw new Error(`포인트 기준을 찾을 수 없습니다: ${values.id}`);
-  }
-
-  STANDARDS[index] = {
-    ...STANDARDS[index],
-    point: values.point,
-    limitPoint: values.limitPoint,
-    isDuplicable: values.isDuplicable,
-    description: values.description,
-    isActive: values.isActive,
-    updatedAt: nowStamp(),
+function buildPayload(
+  values: PointStandardValues,
+  eventCode: string
+): PointStandardPayload {
+  return {
+    eventCd: eventCode,
+    grantPointAmt: String(values.point),
+    dayLmtPointAmt: String(values.limitPoint),
+    duplYn: values.isDuplicable ? 'Y' : 'N',
+    eventExpln: values.description,
+    useYn: values.isActive ? 'Y' : 'N',
   };
 }
 
-export async function deletePointStandard(id: string): Promise<void> {
-  const next = STANDARDS.filter((standard) => standard.id !== id);
-  if (next.length === STANDARDS.length) {
-    throw new Error(`포인트 기준을 찾을 수 없습니다: ${id}`);
+async function sendWrite(
+  path: string,
+  method: 'PUT' | 'DELETE',
+  body?: unknown
+): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(path, {
+    method,
+    ...(body === undefined ? {} : { body }),
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
   }
-  STANDARDS = next;
+}
+
+export async function updatePointStandard(
+  values: PointStandardValues,
+  eventCode: string
+): Promise<void> {
+  await sendWrite(
+    `${POINT_PATH}/${encodeURIComponent(values.id)}`,
+    'PUT',
+    buildPayload(values, eventCode)
+  );
+}
+
+export async function deletePointStandard(id: string): Promise<void> {
+  await sendWrite(`${POINT_PATH}/${encodeURIComponent(id)}`, 'DELETE');
 }
Add a comment
List