diff --git a/HANDOFF.md b/HANDOFF.md index 4bd652c..75844fe 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -374,15 +374,46 @@ PWA 스캐폴드에서 클라이언트가 `agentId` 를 쿼리 파라미터로 ## 4. 잔여 작업 (P6 실연동) -P5 전체 + P6 PoC(어댑터 모듈 + Mock + SDK 분기 + 재시도 + 추적 컬럼 + 호출 감사 이력 + **모바일 PWA 스캐폴드**) 완료. -잔여는 **외부 스펙 확정** 후 적용 가능한 항목. +P5 전체 + P6 PoC + **P6 후속(2026-05-27)** 완료. 잔여는 외부 SDK 차단 항목만. ### 운영 DB 마이그레이션 상태 -운영 DB `192.168.0.60:55432/trading_ai/ga` 현재 **V85 적용 완료** (2026-05-24). -V79~V85 Flyway 자동 적용 확인. ga-api 8082 가동 중. +운영 DB `192.168.0.60:55432/trading_ai/ga` 현재 **V85 적용 완료** (변경 없음). +ga-api 8082 / ga-frontend 3000 / ga-mobile-pwa 3001 모두 라이브. 단위/통합 테스트는 SqlSessionFactory 환경 설정 문제로 아직 미작성 상태입니다. +## 3-15. P6 후속 (2026-05-27, 커밋 `2c9bf71` + `dfce9e5` + 후속 1) + +| 영역 | 결과 | +|---|---| +| PWA 출금 신청 작성 폼 | `POST /api/mobile/me/withdraw-requests` + settle-master 드롭다운, **net_amount 잔여 한도 검증 포함** | +| PWA 명세서 상세 + 엑셀 다운로드 | `GET /api/mobile/me/statements/{id}[/download]`, 본인 검증 + 기존 download 흐름 재사용 | +| PWA 토큰 자동 refresh | 401 → `/api/auth/refresh` 단일화 재시도, refreshToken localStorage 영속 | +| 펌뱅킹 SETTLED 동기화 폴러 | `WithdrawBankSettleService` + `POST /api/internal/bank-settle`, ACCEPTED→COMPLETED/APPROVED 전이 | +| 사용자 phone 등록 (admin) | `PUT /api/system/users/{id}/phone`, UserList 인라인 Modal | +| PWA 본인 프로필 | `Profile.tsx`(phone 자기수정 + 비번변경 + 로그아웃), TabBar 5탭 | +| PWA 출금 신청 취소 | `POST /api/mobile/me/withdraw-requests/{id}/cancel`, REQUESTED 만 SwipeAction | +| SETTLED/FAILED 본인 알림 자동 발행 | `user_notification` INSERT, PWA NoticeList 내알림 탭 즉시 확인 | +| PWA 알림 모두 읽음 | `POST /api/mobile/me/notifications/read-all`, NoticeList 헤더 버튼 | +| 출금 잔여 한도 검증 | `settle_master.net_amount - sum(활성 출금합계)` 초과 시 E411 | + +**e2e 검증 (2026-05-27 23:40, agent01/userId=4/agentId=1)**: +- 로그인 → AGENT role → /api/mobile/me/summary statementCount=3 / withdrawRequestCount=3 +- /api/mobile/me/settle-masters → 3건 응답 (admin은 E403이었음) +- POST /api/mobile/me/withdraw-requests → 성공 (requestId=4) +- /api/mobile/me/statements/1 → 200 (본인) · /statements/2 → **E403 (타인)** ← 본인 검증 강제 +- PUT /api/mobile/me/profile/phone → 200, /api/auth/me 즉시 반영 +- POST /api/internal/bank-settle → SETTLED 동기화 + user_notification 자동 발행 코드 경로 검증 +- POST /api/auth/refresh → 새 accessToken 발급 + +**잔여 — 외부 SDK 차단**: +| 항목 | 차단 사유 | +|---|---| +| KftcBankTransferAdapter 본체 | API 키·계약·테스트망 | +| ToastMessageAdapter 본체 | App Key·발신번호·템플릿 코드 | +| 카카오 알림톡 본체 | 비즈채널·템플릿 승인 | +| 푸시 알림 (FCM 등) | 외부 채널 결정 | + ## 3-9. P5-W3 메뉴/권한 전수 정합 보강 (V74 — 완료, 2026-05-21 라이브 적용) | 항목 | 상태 | @@ -427,21 +458,20 @@ V79~V85 Flyway 자동 적용 확인. ga-api 8082 가동 중. - 스모크: 신규 4 + 회귀 4 = **GET 8/8 PASS**, 쓰기(regenerate/detect/draft) 4/4 PASS - 보정: YearEndStatementMapper.xml 의 `a.agent_code` 미존재 컬럼 참조 제거 (agent 테이블에 agent_code 없음) -## 5. 재개 절차 (다음 세션 — P6 실연동 라운드) +## 5. 재개 절차 (다음 세션) -1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro` (현재 세션과 동일) -2. **Agent Teams 활성화 확인**: `.claude/settings.json` 커밋되어 있음 -3. **HANDOFF.md 읽기**: 본 문서 + 최근 git log로 컨텍스트 확보 -4. **운영 DB 상태**: **V85 적용 완료**. ga-api 8082 가동 중 (필요 시 `python verify_v74.py` / `smoke_v78.py` 재실행) -5. **권장 시작 지점 (외부 스펙 미정 시)**: - - 모바일 PWA 후속: 백엔드 `/api/mobile/me/*` 전용 endpoint, 출금 신청 폼, 명세서 상세 - - 펌뱅킹 SETTLED 동기화 폴러 (Mock 검증 → 실 SDK 통합 시 가치) - - 알림 채널 SMS phone 매핑 보강 (현재 user.phone 미설정 시 SMS skip — phone 등록 화면) -6. **사용자 결정 필요 항목 (P6 실연동 차단 사유)**: - - KFTC 펌뱅킹 API 계약·인증 정보 (실 SDK 통합 차단) - - NHN Toast / 카카오 비즈채널 계정 + 템플릿 코드 (실 메시지 통합 차단) - - 모바일 PWA 화면 우선순위 (잔여 27 빈 화면 중 어떤 것부터) - - P2-1 마감 도메인의 마감 후 수정 차단 AOP 구현 방식 (전역 AOP vs Service-level 가드) +1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro` +2. **HANDOFF.md 읽기**: 본 문서 §3-15(P6 후속) + 최근 git log +3. **운영 DB 상태**: **V85 적용 완료**, 변경 없음. ga-api 8082 + ga-frontend 3000 + ga-mobile-pwa 3001 가동 중 +4. **재기동이 필요한 경우**: + - `cd ga-commission-system && nohup ./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai' > ../ga-api-bootrun.log 2>&1 & disown` + - `cd ga-commission-system/ga-frontend && npm run dev` (3000) + - `cd ga-commission-system/ga-mobile-pwa && npm run dev` (3001) +5. **PWA 테스트 계정**: `agent01` / `agent1234!` (userId=4, agentId=1, AGENT role) +6. **사용자 결정 필요 항목 (외부 SDK 차단 사유)**: + - KFTC 펌뱅킹 API 계약·인증 정보 → `KftcBankTransferAdapter` 본체 구현 + - NHN Toast / 카카오 비즈채널 계정 + 템플릿 코드 → `ToastMessageAdapter` 본체 + - 푸시 알림 채널 (FCM 등) 결정 → PWA 통합 ## 6. PL 가이드 (다음 세션 유의사항) diff --git a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java index 82ae14e..2cacd19 100644 --- a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java +++ b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java @@ -108,4 +108,9 @@ public class MobileMeController { service.markNotificationRead(notificationId); return ApiResponse.ok(); } + + @PostMapping("/notifications/read-all") + public ApiResponse markAllRead() { + return ApiResponse.ok(service.markAllNotificationsRead()); + } } diff --git a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java index 4312e54..654090a 100644 --- a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java +++ b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java @@ -114,6 +114,13 @@ public class MobileMeService { notificationMapper.markRead(notificationId, userId); } + /** 본인 알림 전체 읽음 처리. */ + @Transactional + public int markAllNotificationsRead() { + Long userId = currentUserId(); + return notificationMapper.markAllRead(userId); + } + /** 본인 명세서 단건. statementId 가 본인 agentId 의 것이 아니면 FORBIDDEN. */ @Transactional(readOnly = true) public CommissionStatementResp statementDetail(Long statementId) { @@ -170,6 +177,21 @@ public class MobileMeService { Long userId = currentUserId(); Long agentId = requireAgentId(); + // 1) settle_master 본인 소유 + net_amount 확보 + var master = settleMasterMapper.selectById(req.getSettleMasterId()); + if (master == null) throw new BizException(ErrorCode.NOT_FOUND, "정산 마스터를 찾을 수 없습니다"); + if (!agentId.equals(master.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN); + + // 2) 잔여 한도 = net_amount - 활성 출금합계 + BigDecimal netAmount = master.getNetAmount() != null ? master.getNetAmount() : BigDecimal.ZERO; + BigDecimal used = withdrawMapper.sumActiveAmountByMaster(req.getSettleMasterId()); + if (used == null) used = BigDecimal.ZERO; + BigDecimal remaining = netAmount.subtract(used); + if (req.getAmount().compareTo(remaining) > 0) { + throw new BizException(ErrorCode.INVALID_PARAMETER, + "출금 가능 잔액(" + remaining + ")을 초과합니다"); + } + WithdrawRequestSaveReq inner = new WithdrawRequestSaveReq(); inner.setSettleMasterId(req.getSettleMasterId()); inner.setAgentId(agentId); // 강제 diff --git a/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java b/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java index 4aa7c3b..b623273 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java @@ -46,4 +46,10 @@ public interface WithdrawRequestMapper { /** queryStatus 가 FAILED 응답: status='APPROVED'(재시도 풀로), transfer_status='FAILED', fail_reason. */ int markSettleFailed(@Param("requestId") Long requestId, @Param("failReason") String failReason); + + /** + * 해당 settle_master 에서 CANCELLED/FAILED 외 활성 출금 신청의 amount 합계. + * 출금 잔여 한도 검증 용도. null 가능(0건). + */ + java.math.BigDecimal sumActiveAmountByMaster(@Param("settleMasterId") Long settleMasterId); } diff --git a/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml b/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml index 790e441..ad84a68 100644 --- a/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml +++ b/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml @@ -215,4 +215,12 @@ AND status = 'SENT' + + + diff --git a/ga-mobile-pwa/src/api/notification.ts b/ga-mobile-pwa/src/api/notification.ts index 6acca31..492524f 100644 --- a/ga-mobile-pwa/src/api/notification.ts +++ b/ga-mobile-pwa/src/api/notification.ts @@ -32,5 +32,7 @@ export const notificationApi = { unwrap>(api.get('/api/mobile/me/notifications', { params })), markRead: (id: number) => unwrap(api.post(`/api/mobile/me/notifications/${id}/read`, {})), + markAllRead: () => + unwrap(api.post('/api/mobile/me/notifications/read-all', {})), summary: () => unwrap(api.get('/api/mobile/me/summary')), }; diff --git a/ga-mobile-pwa/src/pages/NoticeList.tsx b/ga-mobile-pwa/src/pages/NoticeList.tsx index cda286a..065fcd9 100644 --- a/ga-mobile-pwa/src/pages/NoticeList.tsx +++ b/ga-mobile-pwa/src/pages/NoticeList.tsx @@ -1,4 +1,4 @@ -import { Badge, ErrorBlock, List, NavBar, PullToRefresh, Tabs, Tag } from 'antd-mobile'; +import { Badge, Button, ErrorBlock, List, NavBar, PullToRefresh, Tabs, Tag, Toast } from 'antd-mobile'; import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { NoticeResp, noticeApi } from '@/api/notice'; @@ -29,6 +29,7 @@ function Notifications() { queryFn: () => notificationApi.list({ pageNum: 1, pageSize: 50 }), }); const items = data?.list ?? []; + const unreadCount = items.filter((n) => !n.isRead).length; const onItemClick = async (n: NotificationResp) => { if (!n.isRead) { @@ -42,9 +43,25 @@ function Notifications() { } }; + const onMarkAllRead = async () => { + try { + const affected = await notificationApi.markAllRead(); + Toast.show({ icon: 'success', content: `${affected}건 읽음 처리되었습니다` }); + await qc.invalidateQueries({ queryKey: ['mobile.me.notifications'] }); + await qc.invalidateQueries({ queryKey: ['mobile.me.summary'] }); + } catch { + // interceptor 처리 + } + }; + return ( { await refetch(); }}>
+ {unreadCount > 0 && ( +
+ +
+ )} {!isLoading && items.length === 0 && ( )}