Merge branch 'claude/admin-member-list-design-b1fec7' into hub
@b4f838eac1793c8f3bbc939438167d5b312c121f
--- lib/data/repositories/point-account-repository.ts
+++ lib/data/repositories/point-account-repository.ts
... | ... | @@ -1,153 +1,176 @@ |
| 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 {
|
| 3 | 5 |
PointAccount, |
| 4 | 6 |
PointHistoryEntry, |
| 5 | 7 |
} from '@/lib/domain/point-account'; |
| 6 | 8 |
import type { PointAccountQuery } from '@/lib/domain/point-account-query';
|
| 7 | 9 |
|
| 8 |
-// TODO(백엔드): 누적포인트 API가 아직 없다. 아래 mock 생성기를 지우고 다른 Repository처럼 |
|
| 9 |
-// backendFetch + getSessionAccessToken으로 교체한다. |
|
| 10 |
-// 이슈: 구분(사이트)은 기획대로 FOX PAY App. 하나뿐이다 — 확장은 백엔드가 사이트를 실어 준 뒤다. |
|
| 10 |
+/** |
|
| 11 |
+ * 누적포인트 관리 Repository. |
|
| 12 |
+ * |
|
| 13 |
+ * ``` |
|
| 14 |
+ * GET /api/v1/mngr/point/stats/pagination 학생별 누적포인트 목록 |
|
| 15 |
+ * GET /api/v1/mngr/point/stats/log/pagination 한 학생의 포인트 내역(searchUserId 필수) |
|
| 16 |
+ * ``` |
|
| 17 |
+ * |
|
| 18 |
+ * 조회 전용이다 — 백엔드에 쓰기 엔드포인트가 없다. |
|
| 19 |
+ * |
|
| 20 |
+ * ⚠️ 백엔드 이슈 — 화면이 기획대로 동작하지 못하는 부분 |
|
| 21 |
+ * 1. 목록 검색이 전혀 걸리지 않는다. `MngrPointStatsSearchVo`에 `searchSchulNm`· |
|
| 22 |
+ * `searchGradeCd`·`searchClsNo`·`searchNo`·`searchUserId`가 있지만 `MngrPointStatsMapper.xml` |
|
| 23 |
+ * 의 `includeSql`에 WHERE 절이 없어 항상 전체가 나온다. 아래에서 파라미터는 규약대로 |
|
| 24 |
+ * 보내 두었으니 매퍼에 조건만 추가되면 프론트 수정 없이 동작한다. |
|
| 25 |
+ * 2. 번호(출석번호)·회원번호·구분(사이트) 열의 원본이 응답에 없다. 회원번호 자리는 |
|
| 26 |
+ * 로그인 아이디로 대신 채우고 나머지는 빈 값이다. |
|
| 27 |
+ * 3. 현재포인트는 SQL에서 `p.current_point`를 뽑지만 VO에 필드가 없어 응답에서 사라진다. |
|
| 28 |
+ * `total_point = current_point + use_point` 정의를 역산해 채운다. |
|
| 29 |
+ * 4. 차감포인트는 개념 자체가 없다(`POINT_TYPE_CD`는 취득 `E`·사용 `S` 둘뿐). 0으로 둔다. |
|
| 30 |
+ */ |
|
| 11 | 31 |
|
| 12 |
-const TOTAL_ACCOUNTS = 3290; |
|
| 13 |
-const SITE_NAME = 'FOX PAY App.'; |
|
| 14 |
-const SCHOOL_NAMES = [ |
|
| 15 |
- '폭스고등학교', |
|
| 16 |
- '여우고등학교', |
|
| 17 |
- '한빛고등학교', |
|
| 18 |
- '새롬고등학교', |
|
| 19 |
-]; |
|
| 20 |
-const EVENT_TITLES = ['EVENT-1', 'EVENT-2', 'EVENT-3']; |
|
| 21 |
-const BASE_DATE = Date.UTC(2026, 1, 5); |
|
| 22 |
-const DAY_MS = 24 * 60 * 60 * 1000; |
|
| 32 |
+const STATS_PATH = '/api/v1/mngr/point/stats'; |
|
| 23 | 33 |
|
| 24 |
-function dateAt(offsetDays: number): string {
|
|
| 25 |
- return new Date(BASE_DATE - offsetDays * DAY_MS).toISOString().slice(0, 10); |
|
| 34 |
+function isRecord(value: unknown): value is Record<string, unknown> {
|
|
| 35 |
+ return value !== null && typeof value === 'object'; |
|
| 26 | 36 |
} |
| 27 | 37 |
|
| 28 |
-function timestampAt(index: number): string {
|
|
| 29 |
- const hour = 9 + (index % 9); |
|
| 30 |
- const minute = (index * 7) % 60; |
|
| 31 |
- const pad = (value: number) => String(value).padStart(2, '0'); |
|
| 32 |
- return `${dateAt(index % 400)} ${pad(hour)}:${pad(minute)}`;
|
|
| 38 |
+function readString(source: Record<string, unknown>, key: string): string {
|
|
| 39 |
+ const value = source[key]; |
|
| 40 |
+ if (typeof value === 'string') return value; |
|
| 41 |
+ if (typeof value === 'number') return String(value); |
|
| 42 |
+ return ''; |
|
| 33 | 43 |
} |
| 34 | 44 |
|
| 35 |
-/** 인덱스로만 만든다 — 난수를 쓰면 새로고침마다 목록이 흔들린다. */ |
|
| 36 |
-function buildAccount(index: number): PointAccount {
|
|
| 37 |
- const serial = TOTAL_ACCOUNTS - index; |
|
| 38 |
- const totalPoint = 120 + ((index * 37) % 880); |
|
| 39 |
- const deductedPoint = (index * 13) % 60; |
|
| 40 |
- const usedPoint = (index * 29) % 300; |
|
| 45 |
+function readNumber(source: Record<string, unknown>, key: string): number {
|
|
| 46 |
+ const value = source[key]; |
|
| 47 |
+ if (typeof value === 'number') return value; |
|
| 48 |
+ const parsed = Number(value); |
|
| 49 |
+ return Number.isFinite(parsed) ? parsed : 0; |
|
| 50 |
+} |
|
| 51 |
+ |
|
| 52 |
+function toPointAccount(raw: unknown): PointAccount {
|
|
| 53 |
+ if (!isRecord(raw)) {
|
|
| 54 |
+ throw new Error('누적포인트 응답 항목의 형식이 올바르지 않습니다.');
|
|
| 55 |
+ } |
|
| 56 |
+ |
|
| 57 |
+ const total = readNumber(raw, 'totalPoint'); |
|
| 58 |
+ const used = readNumber(raw, 'usePoint'); |
|
| 41 | 59 |
|
| 42 | 60 |
return {
|
| 43 |
- id: `PA-${String(serial).padStart(5, '0')}`,
|
|
| 44 |
- memberNo: `NO_${String(10000 + serial)}`,
|
|
| 45 |
- loginId: `user_${String(serial).padStart(4, '0')}`,
|
|
| 46 |
- studentName: `학생${serial}`,
|
|
| 47 |
- schoolName: SCHOOL_NAMES[index % SCHOOL_NAMES.length], |
|
| 48 |
- grade: (index % 3) + 1, |
|
| 49 |
- classNo: (index % 10) + 1, |
|
| 50 |
- studentNo: (index % 30) + 1, |
|
| 51 |
- totalPoint, |
|
| 52 |
- deductedPoint, |
|
| 53 |
- usedPoint, |
|
| 54 |
- currentPoint: totalPoint - deductedPoint - usedPoint, |
|
| 55 |
- updatedAt: timestampAt(index), |
|
| 56 |
- siteName: SITE_NAME, |
|
| 61 |
+ id: readString(raw, 'userId'), |
|
| 62 |
+ // 백엔드에 회원번호가 없다 — 로그인 아이디가 그 자리를 대신한다. |
|
| 63 |
+ memberNo: readString(raw, 'loginId'), |
|
| 64 |
+ loginId: readString(raw, 'loginId'), |
|
| 65 |
+ studentName: readString(raw, 'userNm'), |
|
| 66 |
+ schoolName: readString(raw, 'schNm'), |
|
| 67 |
+ grade: readNumber(raw, 'grade'), |
|
| 68 |
+ classNo: readNumber(raw, 'clsNo'), |
|
| 69 |
+ studentNo: 0, |
|
| 70 |
+ totalPoint: total, |
|
| 71 |
+ deductedPoint: 0, |
|
| 72 |
+ usedPoint: used, |
|
| 73 |
+ currentPoint: total - used, |
|
| 74 |
+ updatedAt: readString(raw, 'lastMdfcnDtStr') || readString(raw, 'frstRegDtStr'), |
|
| 75 |
+ siteName: '', |
|
| 57 | 76 |
}; |
| 58 | 77 |
} |
| 59 | 78 |
|
| 60 |
-const ACCOUNTS: PointAccount[] = Array.from( |
|
| 61 |
- { length: TOTAL_ACCOUNTS },
|
|
| 62 |
- (_, index) => buildAccount(index) |
|
| 63 |
-); |
|
| 79 |
+function toHistoryEntry(raw: unknown, index: number): PointHistoryEntry {
|
|
| 80 |
+ if (!isRecord(raw)) {
|
|
| 81 |
+ throw new Error('포인트 내역 응답 항목의 형식이 올바르지 않습니다.');
|
|
| 82 |
+ } |
|
| 64 | 83 |
|
| 65 |
-function matchesQuery(account: PointAccount, query: PointAccountQuery): boolean {
|
|
| 66 |
- if ( |
|
| 67 |
- query.schoolName && |
|
| 68 |
- !account.schoolName.toLowerCase().includes(query.schoolName.toLowerCase()) |
|
| 69 |
- ) {
|
|
| 70 |
- return false; |
|
| 71 |
- } |
|
| 72 |
- if (query.grade !== null && account.grade !== query.grade) {
|
|
| 73 |
- return false; |
|
| 74 |
- } |
|
| 75 |
- if (query.classNo !== null && account.classNo !== query.classNo) {
|
|
| 76 |
- return false; |
|
| 77 |
- } |
|
| 78 |
- if (query.studentNo !== null && account.studentNo !== query.studentNo) {
|
|
| 79 |
- return false; |
|
| 80 |
- } |
|
| 81 |
- if (query.keyword) {
|
|
| 82 |
- const needle = query.keyword.toLowerCase(); |
|
| 83 |
- const haystack = |
|
| 84 |
- query.searchField === 'loginId' ? account.loginId : account.studentName; |
|
| 85 |
- if (!haystack.toLowerCase().includes(needle)) {
|
|
| 86 |
- return false; |
|
| 87 |
- } |
|
| 88 |
- } |
|
| 89 |
- return true; |
|
| 84 |
+ const amount = readNumber(raw, 'pointAmt'); |
|
| 85 |
+ // POINT_TYPE_CD는 취득 `E`·사용 `S`. 사용은 음수로 보여야 부호가 곧 방향이 된다(기획 PTS10300001 ②). |
|
| 86 |
+ const isUse = readString(raw, 'pointTypeCd').toUpperCase() === 'S'; |
|
| 87 |
+ |
|
| 88 |
+ return {
|
|
| 89 |
+ id: readString(raw, 'rnum') || `${index}`,
|
|
| 90 |
+ title: readString(raw, 'pointSj'), |
|
| 91 |
+ point: isUse ? -Math.abs(amount) : amount, |
|
| 92 |
+ siteName: '', |
|
| 93 |
+ date: readString(raw, 'frstRegDtStr'), |
|
| 94 |
+ }; |
|
| 90 | 95 |
} |
| 91 | 96 |
|
| 92 |
-/** 기획 PTS10200001 ② — 최근 수정일시로 정렬. */ |
|
| 93 |
-function byUpdatedAtDesc(a: PointAccount, b: PointAccount): number {
|
|
| 94 |
- return b.updatedAt.localeCompare(a.updatedAt); |
|
| 97 |
+async function requestList( |
|
| 98 |
+ path: string, |
|
| 99 |
+ query: Record<string, string | number> |
|
| 100 |
+): Promise<{ rows: unknown[]; reportedTotalCount: number }> {
|
|
| 101 |
+ const accessToken = await getSessionAccessToken(); |
|
| 102 |
+ |
|
| 103 |
+ const result = await backendFetch<unknown>(path, {
|
|
| 104 |
+ method: 'GET', |
|
| 105 |
+ query, |
|
| 106 |
+ accessToken: accessToken ?? undefined, |
|
| 107 |
+ cache: 'no-store', |
|
| 108 |
+ canHaveNullData: true, |
|
| 109 |
+ canHaveEmptyBody: true, |
|
| 110 |
+ }); |
|
| 111 |
+ |
|
| 112 |
+ if (!result.ok) {
|
|
| 113 |
+ throw new BackendRequestError(result); |
|
| 114 |
+ } |
|
| 115 |
+ |
|
| 116 |
+ const data = result.data; |
|
| 117 |
+ if (data === null || data === undefined) {
|
|
| 118 |
+ return { rows: [], reportedTotalCount: 0 };
|
|
| 119 |
+ } |
|
| 120 |
+ if (!isRecord(data) || !Array.isArray(data.list)) {
|
|
| 121 |
+ throw new Error('누적포인트 목록 응답의 형식이 올바르지 않습니다.');
|
|
| 122 |
+ } |
|
| 123 |
+ |
|
| 124 |
+ return {
|
|
| 125 |
+ rows: data.list, |
|
| 126 |
+ reportedTotalCount: |
|
| 127 |
+ typeof data.totalCount === 'number' ? data.totalCount : data.list.length, |
|
| 128 |
+ }; |
|
| 95 | 129 |
} |
| 96 | 130 |
|
| 97 | 131 |
export async function fetchPointAccounts( |
| 98 | 132 |
query: PointAccountQuery |
| 99 | 133 |
): Promise<{ items: PointAccount[]; totalCount: number }> {
|
| 100 |
- const filtered = ACCOUNTS.filter((account) => matchesQuery(account, query)); |
|
| 101 |
- filtered.sort(byUpdatedAtDesc); |
|
| 134 |
+ const keyword = query.keyword.trim(); |
|
| 102 | 135 |
|
| 103 |
- const start = (query.page - 1) * query.pageSize; |
|
| 104 |
- return {
|
|
| 105 |
- items: filtered.slice(start, start + query.pageSize), |
|
| 106 |
- totalCount: filtered.length, |
|
| 107 |
- }; |
|
| 136 |
+ const { rows, reportedTotalCount } = await requestList(
|
|
| 137 |
+ `${STATS_PATH}/pagination`,
|
|
| 138 |
+ {
|
|
| 139 |
+ searchSchulNm: query.schoolName, |
|
| 140 |
+ searchGradeCd: query.grade === null ? '' : String(query.grade), |
|
| 141 |
+ searchClsNo: query.classNo === null ? '' : String(query.classNo), |
|
| 142 |
+ searchNo: query.studentNo === null ? '' : String(query.studentNo), |
|
| 143 |
+ // 검색 대상이 아이디일 때만 백엔드가 걸러 준다 — 이름 검색은 아래에서 건다. |
|
| 144 |
+ searchUserId: query.searchField === 'loginId' ? keyword : '', |
|
| 145 |
+ searchKeyword: keyword, |
|
| 146 |
+ pageIndex: query.page, |
|
| 147 |
+ recordCountPerPage: query.pageSize, |
|
| 148 |
+ } |
|
| 149 |
+ ); |
|
| 150 |
+ |
|
| 151 |
+ const items = rows.map(toPointAccount); |
|
| 152 |
+ const confirmed = (query.page - 1) * query.pageSize + items.length; |
|
| 153 |
+ |
|
| 154 |
+ return { items, totalCount: Math.max(reportedTotalCount, confirmed) };
|
|
| 108 | 155 |
} |
| 109 | 156 |
|
| 110 |
-export async function fetchPointAccount( |
|
| 111 |
- id: string |
|
| 112 |
-): Promise<PointAccount | null> {
|
|
| 113 |
- return ACCOUNTS.find((account) => account.id === id) ?? null; |
|
| 114 |
-} |
|
| 115 |
- |
|
| 116 |
-function buildHistory(account: PointAccount): PointHistoryEntry[] {
|
|
| 117 |
- const seed = Number(account.id.slice(3)); |
|
| 118 |
- const length = 12 + (seed % 9); |
|
| 119 |
- |
|
| 120 |
- return Array.from({ length }, (_, index) => {
|
|
| 121 |
- const isPurchase = (seed + index) % 4 === 3; |
|
| 122 |
- return {
|
|
| 123 |
- id: `${account.id}-H${String(index + 1).padStart(3, '0')}`,
|
|
| 124 |
- title: isPurchase |
|
| 125 |
- ? `배지 이미지 구입 - BADGE_${String(((seed + index) % 20) + 1).padStart(3, '0')}`
|
|
| 126 |
- : EVENT_TITLES[(seed + index) % EVENT_TITLES.length], |
|
| 127 |
- point: isPurchase ? -100 : 10, |
|
| 128 |
- siteName: SITE_NAME, |
|
| 129 |
- date: dateAt(index), |
|
| 130 |
- }; |
|
| 131 |
- }); |
|
| 132 |
-} |
|
| 133 |
- |
|
| 134 |
-/** 기획 PTS10300001 ② — 최근 날짜순 정렬. */ |
|
| 157 |
+/** 상세 팝업의 포인트 내역 — `searchUserId`가 필수다. */ |
|
| 135 | 158 |
export async function fetchPointHistory( |
| 136 | 159 |
accountId: string, |
| 137 | 160 |
page: number, |
| 138 | 161 |
pageSize: number |
| 139 | 162 |
): Promise<{ items: PointHistoryEntry[]; totalCount: number }> {
|
| 140 |
- const account = await fetchPointAccount(accountId); |
|
| 141 |
- if (!account) {
|
|
| 142 |
- return { items: [], totalCount: 0 };
|
|
| 143 |
- } |
|
| 163 |
+ const { rows, reportedTotalCount } = await requestList(
|
|
| 164 |
+ `${STATS_PATH}/log/pagination`,
|
|
| 165 |
+ {
|
|
| 166 |
+ searchUserId: accountId, |
|
| 167 |
+ pageIndex: page, |
|
| 168 |
+ recordCountPerPage: pageSize, |
|
| 169 |
+ } |
|
| 170 |
+ ); |
|
| 144 | 171 |
|
| 145 |
- const entries = buildHistory(account); |
|
| 146 |
- entries.sort((a, b) => b.date.localeCompare(a.date)); |
|
| 172 |
+ const items = rows.map(toHistoryEntry); |
|
| 173 |
+ const confirmed = (page - 1) * pageSize + items.length; |
|
| 147 | 174 |
|
| 148 |
- const start = (page - 1) * pageSize; |
|
| 149 |
- return {
|
|
| 150 |
- items: entries.slice(start, start + pageSize), |
|
| 151 |
- totalCount: entries.length, |
|
| 152 |
- }; |
|
| 175 |
+ return { items, totalCount: Math.max(reportedTotalCount, confirmed) };
|
|
| 153 | 176 |
} |
--- lib/data/repositories/point-standard-repository.ts
+++ lib/data/repositories/point-standard-repository.ts
... | ... | @@ -9,13 +9,16 @@ |
| 9 | 9 |
* 포인트 지급기준(포인트 정책) Repository — 백엔드 `TB_COM_EVENT_POINT`. |
| 10 | 10 |
* |
| 11 | 11 |
* ``` |
| 12 |
- * GET /api/v1/mngr/point/pagination 목록 |
|
| 13 |
- * POST /api/v1/mngr/point 어노테이션 없음 → form |
|
| 14 |
- * PUT /api/v1/mngr/point/{eventPtSn} @RequestBody(JSON)
|
|
| 15 |
- * DELETE /api/v1/mngr/point/{eventPtSn} soft delete(DEL_YN)
|
|
| 12 |
+ * GET /api/v1/mngr/point/policy/pagination 목록 |
|
| 13 |
+ * POST /api/v1/mngr/point/policy 어노테이션 없음 → form |
|
| 14 |
+ * PUT /api/v1/mngr/point/policy/{eventPtSn} @RequestBody(JSON)
|
|
| 15 |
+ * DELETE /api/v1/mngr/point/policy/{eventPtSn} soft delete(DEL_YN)
|
|
| 16 | 16 |
* ``` |
| 17 | 17 |
* |
| 18 | 18 |
* 등록은 form, 수정은 JSON으로 갈린다 — 콘텐츠·퀴즈와 같은 규약이다. |
| 19 |
+ * |
|
| 20 |
+ * 이슈: 경로가 `/mngr/point`에서 `/mngr/point/policy`로 옮겨졌다(2026-08-21, 누적포인트 |
|
| 21 |
+ * `/point/stats`가 생기면서 갈라졌다). 옮기기 전 경로로 부르면 404다. |
|
| 19 | 22 |
* |
| 20 | 23 |
* ⚠️ 백엔드 결함(보고함, 미수정) |
| 21 | 24 |
* 1. **`MngrPointPolicyVo`에 `eventPtSn` 말고는 필드가 없다.** 목록 SQL은 EVENT_CD· |
... | ... | @@ -33,7 +36,7 @@ |
| 33 | 36 |
* 뒤 여기서 거른다. |
| 34 | 37 |
*/ |
| 35 | 38 |
|
| 36 |
-const POINT_PATH = '/api/v1/mngr/point'; |
|
| 39 |
+const POINT_PATH = '/api/v1/mngr/point/policy'; |
|
| 37 | 40 |
|
| 38 | 41 |
function isRecord(value: unknown): value is Record<string, unknown> {
|
| 39 | 42 |
return value !== null && typeof value === 'object'; |
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?