--- app/(protected)/(basic)/points/standards/_actions.ts
+++ app/(protected)/(basic)/points/standards/_actions.ts
... | ... | @@ -45,7 +45,10 @@ |
| 45 | 45 |
} |
| 46 | 46 |
|
| 47 | 47 |
try {
|
| 48 |
- await updatePointStandard(validation.values); |
|
| 48 |
+ await updatePointStandard( |
|
| 49 |
+ validation.values, |
|
| 50 |
+ readString(formData, 'eventCode') |
|
| 51 |
+ ); |
|
| 49 | 52 |
} catch {
|
| 50 | 53 |
return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
| 51 | 54 |
} |
--- 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 @@ |
| 74 | 74 |
<p className={styles.required}>* 는 필수 항목입니다.</p>
|
| 75 | 75 |
|
| 76 | 76 |
<input type="hidden" name="id" value={standard.id} />
|
| 77 |
+ {/* 화면에서는 읽기 전용이지만 백엔드 UPDATE가 EVENT_CD를 덮어써서 그대로 돌려보낸다. */}
|
|
| 78 |
+ <input type="hidden" name="eventCode" value={standard.eventCode} />
|
|
| 77 | 79 |
|
| 78 | 80 |
<div className={styles.field}>
|
| 79 | 81 |
{/* 기획 ① 이벤트행은 수정불가(Read Only). 값은 hidden id가 갖는다. */}
|
--- lib/data/repositories/point-standard-repository.ts
+++ lib/data/repositories/point-standard-repository.ts
... | ... | @@ -1,131 +1,205 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 |
+import { getSessionAccessToken } from '@/lib/auth/dal';
|
|
| 3 |
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
|
|
| 2 | 4 |
import type { PointStandard } from '@/lib/domain/point-standard';
|
| 3 | 5 |
import type { PointStandardQuery } from '@/lib/domain/point-standard-query';
|
| 4 | 6 |
import type { PointStandardValues } from '@/lib/domain/point-standard-form';
|
| 5 | 7 |
|
| 6 |
-// TODO(백엔드): 포인트 기준관리 API가 아직 없다. 아래 mock 저장소를 지우고 다른 Repository처럼 |
|
| 7 |
-// backendFetch + getSessionAccessToken으로 교체하면 호출부는 그대로 둘 수 있다. |
|
| 8 |
-// 이슈: 저장소가 모듈 메모리라 서버를 다시 띄우면 수정·삭제가 초기값으로 되돌아간다. |
|
| 8 |
+/** |
|
| 9 |
+ * 포인트 지급기준(포인트 정책) Repository — 백엔드 `TB_COM_EVENT_POINT`. |
|
| 10 |
+ * |
|
| 11 |
+ * ``` |
|
| 12 |
+ * GET /api/v1/mngr/point/pagination 목록 |
|
| 13 |
+ * PUT /api/v1/mngr/point/{eventPtSn} @RequestBody(JSON)
|
|
| 14 |
+ * DELETE /api/v1/mngr/point/{eventPtSn} soft delete(DEL_YN)
|
|
| 15 |
+ * ``` |
|
| 16 |
+ * |
|
| 17 |
+ * `POST /api/v1/mngr/point`(등록)도 있지만 쓰지 않는다 — 기획상 이벤트는 읽기 전용이라 화면에 |
|
| 18 |
+ * 신규 등록이 없다. 등록 화면이 생기면 콘텐츠·퀴즈와 같은 form 규약으로 붙이면 된다. |
|
| 19 |
+ * |
|
| 20 |
+ * ⚠️ 백엔드 결함(보고함, 미수정) |
|
| 21 |
+ * 1. **`MngrPointPolicyVo`에 `eventPtSn` 말고는 필드가 없다.** 목록 SQL은 EVENT_CD· |
|
| 22 |
+ * GRANT_POINT_AMT·DAY_LMT_POINT_AMT·DUPL_YN·EVENT_EXPLN·USE_YN을 select하지만 담을 속성이 |
|
| 23 |
+ * 없어 조용히 버려진다 — 지금 목록은 번호와 등록일만 온다. 아래 매핑은 필드가 채워지면 |
|
| 24 |
+ * 바로 동작하도록 미리 맞춰 두었다. |
|
| 25 |
+ * 2. **등록이 실패한다.** 같은 이유로 INSERT의 `#{eventCd}` 등이 VO에 없어 MyBatis가
|
|
| 26 |
+ * `There is no getter for property named 'eventCd'`로 끊는다. INSERT 컬럼 목록에 PK인 |
|
| 27 |
+ * `EVENT_PT_SN`을 넣고 `#{eventPtSn}`(항상 null)을 바인딩하는 것도 함께 봐야 한다.
|
|
| 28 |
+ * 3. **수정·삭제가 항상 실패한다.** `MngrPointPolicyServiceImpl.selectOne`이 `return null;` |
|
| 29 |
+ * 스텁이라 컨트롤러가 늘 "포인트 정책 조회에 실패했습니다"로 끊는다. |
|
| 30 |
+ * 4. `selectOne` SQL은 `includeSql`이 이미 `WHERE`를 갖고 있는데 뒤에 `WHERE`를 또 붙이고, |
|
| 31 |
+ * 없는 별칭 `m.`을 쓴다 — 스텁이 걷히면 이 SQL도 함께 고쳐야 한다. |
|
| 32 |
+ * 5. 목록에 검색 조건이 없다(`MngrPointPolicySearchVo`가 비어 있다). 화면의 검색어는 받아온 |
|
| 33 |
+ * 뒤 여기서 거른다. |
|
| 34 |
+ */ |
|
| 9 | 35 |
|
| 10 |
-let STANDARDS: PointStandard[] = [ |
|
| 11 |
- {
|
|
| 12 |
- id: 'EVENT-1', |
|
| 13 |
- eventCode: 'EVENT-1', |
|
| 14 |
- point: 5, |
|
| 15 |
- limitPoint: 10, |
|
| 16 |
- isDuplicable: true, |
|
| 17 |
- siteCode: 'SELF_STUDY', |
|
| 18 |
- description: '과제 제출 시 20포인트 지급', |
|
| 19 |
- isActive: true, |
|
| 20 |
- updatedAt: '2026-01-10 09:40', |
|
| 21 |
- }, |
|
| 22 |
- {
|
|
| 23 |
- id: 'EVENT-2', |
|
| 24 |
- eventCode: 'EVENT-2', |
|
| 25 |
- point: 10, |
|
| 26 |
- limitPoint: 0, |
|
| 27 |
- isDuplicable: true, |
|
| 28 |
- siteCode: 'SELF_STUDY', |
|
| 29 |
- description: '수업완료 시 10포인트 지급', |
|
| 30 |
- isActive: true, |
|
| 31 |
- updatedAt: '2026-01-18 10:02', |
|
| 32 |
- }, |
|
| 33 |
- {
|
|
| 34 |
- id: 'EVENT-3', |
|
| 35 |
- eventCode: 'EVENT-3', |
|
| 36 |
- point: 5, |
|
| 37 |
- limitPoint: 0, |
|
| 38 |
- isDuplicable: false, |
|
| 39 |
- siteCode: 'SELF_STUDY', |
|
| 40 |
- description: '과정 이수 시 100포인트 지급', |
|
| 41 |
- isActive: true, |
|
| 42 |
- updatedAt: '2026-02-05 13:26', |
|
| 43 |
- }, |
|
| 44 |
- {
|
|
| 45 |
- id: 'EVENT-4', |
|
| 46 |
- eventCode: 'EVENT-4', |
|
| 47 |
- point: 3, |
|
| 48 |
- limitPoint: 30, |
|
| 49 |
- isDuplicable: true, |
|
| 50 |
- siteCode: 'FOX_PAY_APP', |
|
| 51 |
- description: '출석 체크 시 3포인트 지급', |
|
| 52 |
- isActive: true, |
|
| 53 |
- updatedAt: '2026-01-24 16:11', |
|
| 54 |
- }, |
|
| 55 |
- {
|
|
| 56 |
- id: 'EVENT-5', |
|
| 57 |
- eventCode: 'EVENT-5', |
|
| 58 |
- point: 20, |
|
| 59 |
- limitPoint: 60, |
|
| 60 |
- isDuplicable: false, |
|
| 61 |
- siteCode: 'FOX_PAY_APP', |
|
| 62 |
- description: '금융OX퀴즈 정답 시 20포인트 지급', |
|
| 63 |
- isActive: false, |
|
| 64 |
- updatedAt: '2026-01-02 11:35', |
|
| 65 |
- }, |
|
| 66 |
-]; |
|
| 36 |
+const POINT_PATH = '/api/v1/mngr/point'; |
|
| 37 |
+ |
|
| 38 |
+function isRecord(value: unknown): value is Record<string, unknown> {
|
|
| 39 |
+ return value !== null && typeof value === 'object'; |
|
| 40 |
+} |
|
| 41 |
+ |
|
| 42 |
+function readString(source: Record<string, unknown>, key: string): string {
|
|
| 43 |
+ const value = source[key]; |
|
| 44 |
+ if (typeof value === 'string') return value; |
|
| 45 |
+ if (typeof value === 'number') return String(value); |
|
| 46 |
+ return ''; |
|
| 47 |
+} |
|
| 48 |
+ |
|
| 49 |
+function readNumber(source: Record<string, unknown>, key: string): number {
|
|
| 50 |
+ const value = source[key]; |
|
| 51 |
+ if (typeof value === 'number') return value; |
|
| 52 |
+ const parsed = Number(value); |
|
| 53 |
+ return Number.isFinite(parsed) ? parsed : 0; |
|
| 54 |
+} |
|
| 55 |
+ |
|
| 56 |
+function toPointStandard(raw: unknown): PointStandard {
|
|
| 57 |
+ if (!isRecord(raw)) {
|
|
| 58 |
+ throw new Error('포인트 정책 응답 항목의 형식이 올바르지 않습니다.');
|
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 61 |
+ const id = readString(raw, 'eventPtSn'); |
|
| 62 |
+ if (!id) {
|
|
| 63 |
+ throw new Error('포인트 정책 응답에 eventPtSn이 없습니다.');
|
|
| 64 |
+ } |
|
| 65 |
+ |
|
| 66 |
+ return {
|
|
| 67 |
+ id, |
|
| 68 |
+ eventCode: readString(raw, 'eventCd'), |
|
| 69 |
+ point: readNumber(raw, 'grantPointAmt'), |
|
| 70 |
+ limitPoint: readNumber(raw, 'dayLmtPointAmt'), |
|
| 71 |
+ isDuplicable: readString(raw, 'duplYn') !== 'N', |
|
| 72 |
+ // 이슈: 사이트 구분 컬럼이 백엔드에 없다. 화면은 기획대로 열을 두고 값은 비워 둔다. |
|
| 73 |
+ siteCode: '', |
|
| 74 |
+ description: readString(raw, 'eventExpln'), |
|
| 75 |
+ isActive: readString(raw, 'useYn') !== 'N', |
|
| 76 |
+ updatedAt: |
|
| 77 |
+ readString(raw, 'lastMdfcnDtStr') || readString(raw, 'frstRegDtStr'), |
|
| 78 |
+ }; |
|
| 79 |
+} |
|
| 80 |
+ |
|
| 81 |
+async function requestList( |
|
| 82 |
+ pageIndex: number, |
|
| 83 |
+ recordCountPerPage: number |
|
| 84 |
+): Promise<{ items: PointStandard[]; reportedTotalCount: number }> {
|
|
| 85 |
+ const accessToken = await getSessionAccessToken(); |
|
| 86 |
+ |
|
| 87 |
+ const result = await backendFetch<unknown>(`${POINT_PATH}/pagination`, {
|
|
| 88 |
+ method: 'GET', |
|
| 89 |
+ query: { pageIndex, recordCountPerPage },
|
|
| 90 |
+ accessToken: accessToken ?? undefined, |
|
| 91 |
+ cache: 'no-store', |
|
| 92 |
+ canHaveNullData: true, |
|
| 93 |
+ canHaveEmptyBody: true, |
|
| 94 |
+ }); |
|
| 95 |
+ |
|
| 96 |
+ if (!result.ok) {
|
|
| 97 |
+ throw new BackendRequestError(result); |
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 100 |
+ const data = result.data; |
|
| 101 |
+ if (data === null || data === undefined) {
|
|
| 102 |
+ return { items: [], reportedTotalCount: 0 };
|
|
| 103 |
+ } |
|
| 104 |
+ if (!isRecord(data) || !Array.isArray(data.list)) {
|
|
| 105 |
+ throw new Error('포인트 정책 목록 응답의 형식이 올바르지 않습니다.');
|
|
| 106 |
+ } |
|
| 107 |
+ |
|
| 108 |
+ return {
|
|
| 109 |
+ items: data.list.map(toPointStandard), |
|
| 110 |
+ reportedTotalCount: |
|
| 111 |
+ typeof data.totalCount === 'number' ? data.totalCount : data.list.length, |
|
| 112 |
+ }; |
|
| 113 |
+} |
|
| 67 | 114 |
|
| 68 | 115 |
function matchesKeyword(standard: PointStandard, keyword: string): boolean {
|
| 69 |
- const needle = keyword.toLowerCase(); |
|
| 116 |
+ const needle = keyword.trim().toLowerCase(); |
|
| 70 | 117 |
return ( |
| 71 | 118 |
standard.eventCode.toLowerCase().includes(needle) || |
| 72 | 119 |
standard.description.toLowerCase().includes(needle) |
| 73 | 120 |
); |
| 74 | 121 |
} |
| 75 | 122 |
|
| 76 |
-/** 기획 PTS10100001 ② — 최근 수정일시 순 정렬. */ |
|
| 77 |
-function byUpdatedAtDesc(a: PointStandard, b: PointStandard): number {
|
|
| 78 |
- return b.updatedAt.localeCompare(a.updatedAt); |
|
| 79 |
-} |
|
| 123 |
+/** 검색어가 없으면 백엔드 페이징을 그대로 쓰고, 있으면 전체를 받아 여기서 거른다(결함 5). */ |
|
| 124 |
+const KEYWORD_SCAN_LIMIT = 500; |
|
| 80 | 125 |
|
| 81 | 126 |
export async function fetchPointStandards( |
| 82 | 127 |
query: PointStandardQuery |
| 83 | 128 |
): Promise<{ items: PointStandard[]; totalCount: number }> {
|
| 84 |
- const filtered = query.keyword |
|
| 85 |
- ? STANDARDS.filter((standard) => matchesKeyword(standard, query.keyword)) |
|
| 86 |
- : [...STANDARDS]; |
|
| 129 |
+ if (!query.keyword.trim()) {
|
|
| 130 |
+ const { items, reportedTotalCount } = await requestList(
|
|
| 131 |
+ query.page, |
|
| 132 |
+ query.pageSize |
|
| 133 |
+ ); |
|
| 134 |
+ const confirmed = (query.page - 1) * query.pageSize + items.length; |
|
| 135 |
+ return { items, totalCount: Math.max(reportedTotalCount, confirmed) };
|
|
| 136 |
+ } |
|
| 87 | 137 |
|
| 88 |
- filtered.sort(byUpdatedAtDesc); |
|
| 89 |
- |
|
| 138 |
+ const { items } = await requestList(1, KEYWORD_SCAN_LIMIT);
|
|
| 139 |
+ const filtered = items.filter((item) => matchesKeyword(item, query.keyword)); |
|
| 90 | 140 |
const start = (query.page - 1) * query.pageSize; |
| 141 |
+ |
|
| 91 | 142 |
return {
|
| 92 | 143 |
items: filtered.slice(start, start + query.pageSize), |
| 93 | 144 |
totalCount: filtered.length, |
| 94 | 145 |
}; |
| 95 | 146 |
} |
| 96 | 147 |
|
| 97 |
-function nowStamp(): string {
|
|
| 98 |
- const now = new Date(); |
|
| 99 |
- const pad = (value: number) => String(value).padStart(2, '0'); |
|
| 100 |
- return ( |
|
| 101 |
- `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
|
|
| 102 |
- `${pad(now.getHours())}:${pad(now.getMinutes())}`
|
|
| 103 |
- ); |
|
| 104 |
-} |
|
| 148 |
+type PointStandardPayload = {
|
|
| 149 |
+ eventCd: string; |
|
| 150 |
+ grantPointAmt: string; |
|
| 151 |
+ dayLmtPointAmt: string; |
|
| 152 |
+ duplYn: string; |
|
| 153 |
+ eventExpln: string; |
|
| 154 |
+ useYn: string; |
|
| 155 |
+}; |
|
| 105 | 156 |
|
| 106 |
-export async function updatePointStandard( |
|
| 107 |
- values: PointStandardValues |
|
| 108 |
-): Promise<void> {
|
|
| 109 |
- const index = STANDARDS.findIndex((standard) => standard.id === values.id); |
|
| 110 |
- if (index < 0) {
|
|
| 111 |
- throw new Error(`포인트 기준을 찾을 수 없습니다: ${values.id}`);
|
|
| 112 |
- } |
|
| 113 |
- |
|
| 114 |
- STANDARDS[index] = {
|
|
| 115 |
- ...STANDARDS[index], |
|
| 116 |
- point: values.point, |
|
| 117 |
- limitPoint: values.limitPoint, |
|
| 118 |
- isDuplicable: values.isDuplicable, |
|
| 119 |
- description: values.description, |
|
| 120 |
- isActive: values.isActive, |
|
| 121 |
- updatedAt: nowStamp(), |
|
| 157 |
+function buildPayload( |
|
| 158 |
+ values: PointStandardValues, |
|
| 159 |
+ eventCode: string |
|
| 160 |
+): PointStandardPayload {
|
|
| 161 |
+ return {
|
|
| 162 |
+ eventCd: eventCode, |
|
| 163 |
+ grantPointAmt: String(values.point), |
|
| 164 |
+ dayLmtPointAmt: String(values.limitPoint), |
|
| 165 |
+ duplYn: values.isDuplicable ? 'Y' : 'N', |
|
| 166 |
+ eventExpln: values.description, |
|
| 167 |
+ useYn: values.isActive ? 'Y' : 'N', |
|
| 122 | 168 |
}; |
| 123 | 169 |
} |
| 124 | 170 |
|
| 125 |
-export async function deletePointStandard(id: string): Promise<void> {
|
|
| 126 |
- const next = STANDARDS.filter((standard) => standard.id !== id); |
|
| 127 |
- if (next.length === STANDARDS.length) {
|
|
| 128 |
- throw new Error(`포인트 기준을 찾을 수 없습니다: ${id}`);
|
|
| 171 |
+async function sendWrite( |
|
| 172 |
+ path: string, |
|
| 173 |
+ method: 'PUT' | 'DELETE', |
|
| 174 |
+ body?: unknown |
|
| 175 |
+): Promise<void> {
|
|
| 176 |
+ const accessToken = await getSessionAccessToken(); |
|
| 177 |
+ |
|
| 178 |
+ const result = await backendFetch<unknown>(path, {
|
|
| 179 |
+ method, |
|
| 180 |
+ ...(body === undefined ? {} : { body }),
|
|
| 181 |
+ accessToken: accessToken ?? undefined, |
|
| 182 |
+ cache: 'no-store', |
|
| 183 |
+ canHaveNullData: true, |
|
| 184 |
+ canHaveEmptyBody: true, |
|
| 185 |
+ }); |
|
| 186 |
+ |
|
| 187 |
+ if (!result.ok) {
|
|
| 188 |
+ throw new BackendRequestError(result); |
|
| 129 | 189 |
} |
| 130 |
- STANDARDS = next; |
|
| 190 |
+} |
|
| 191 |
+ |
|
| 192 |
+export async function updatePointStandard( |
|
| 193 |
+ values: PointStandardValues, |
|
| 194 |
+ eventCode: string |
|
| 195 |
+): Promise<void> {
|
|
| 196 |
+ await sendWrite( |
|
| 197 |
+ `${POINT_PATH}/${encodeURIComponent(values.id)}`,
|
|
| 198 |
+ 'PUT', |
|
| 199 |
+ buildPayload(values, eventCode) |
|
| 200 |
+ ); |
|
| 201 |
+} |
|
| 202 |
+ |
|
| 203 |
+export async function deletePointStandard(id: string): Promise<void> {
|
|
| 204 |
+ await sendWrite(`${POINT_PATH}/${encodeURIComponent(id)}`, 'DELETE');
|
|
| 131 | 205 |
} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?