10 Commits

Author SHA1 Message Date
GA Pro 4dbc688f79 fix: AgentDeductionMapper.updateStatusBatch XML 구문 누락 보강
AggregateStep(Step8) 이 차감 APPLIED 일괄 전이로 호출하는 updateStatusBatch 가
인터페이스에만 선언되고 XML 구문이 없어, PENDING 차감이 있는 정산월 집계 시
'Invalid bound statement (not found)' 로 aggregate 전체가 FAILED 되던 잠복 버그.
updateStatus 동일 패턴 + foreach IN 으로 구현. (계약승계 e2e 중 발견, 승계와 무관)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:44:34 +09:00
GA Pro bd5ff4ec14 feat: 계약 승계(contract succession) — 유지수수료 귀속 후임 전환 (V86)
설계사 해촉/이관 시 보험계약의 유지수수료 귀속을 승계월 기준 후임 설계사로 전환.
모집수수료는 원 모집설계사 귀속 유지(고아계약 승계 표준).

- DB V86: contract_succession 테이블 + 인덱스 + 메뉴/권한(GRP_PRD) + 공통코드 2그룹 + 테스트데이터 1건
- core: ContractSuccession VO/Resp/SaveReq/SearchParam + Mapper(.java/.xml)
  selectEffectiveSuccessions(settleMonth): 계약별 최근 ACTIVE 승계 1건 (DISTINCT ON)
- api: ContractSuccessionService/Controller (/api/contract-successions, CRUD+cancel)
- batch: CalcMaintainStep 이 effective 승계 사전로드(N+1 회피) 후 effectiveAgentId 결정,
  CommissionCalculator.calcMaintainLedger 오버로드로 후임 등급 payout + ledger agent_id 후임 기록.
  AggregateStep 은 ledger agent_id 집계라 무변경.
- frontend: api/succession.ts + ContractSuccessions.tsx + App.tsx 라우트

검증: 5모듈 compileJava SUCCESS / ga-frontend tsc 0 / Flyway v86 적용 /
  API CRUD+cancel+from==to 가드(E411) 라이브 PASS / resolver 쿼리 라이브 검증(계약1→후임 agent2).
배치 풀잡 e2e 는 정산월 원장 재생성(파괴적)이라 미실행.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:28:00 +09:00
GA Pro d8d64625a9 feat: P6 후속 4 — 출금 폼 잔여 한도 미리 표시
- 백엔드 GET /api/mobile/me/settle-masters/{id}/withdrawable-balance
  · 응답 {settleId, netAmount, usedAmount, remainingAmount}
  · 본인 settle_master 검증 후 net - sumActiveAmountByMaster 반환
  · createWithdrawRequest 가 동일 조회 로직 재사용 (중복 제거)
- PWA WithdrawCreate: settleMaster 선택 시 잔액 카드 표시 + amount 클라이언트 사전 검증

검증: ga-api compileJava BUILD SUCCESSFUL / ga-mobile-pwa tsc --noEmit 0 오류
(라이브 e2e 는 DB/docker 미가동으로 차단 — 환경 복구 후 스모크 예정)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:52:17 +09:00
GA Pro 366f49c3f0 feat: P6 후속 3 — 출금 잔여한도 검증 + 알림 모두읽음 + HANDOFF 갱신
출금 잔여 한도 검증:
- WithdrawRequestMapper.sumActiveAmountByMaster(settleMasterId) 추가
  · CANCELLED 외 모든 활성 출금의 amount SUM (COALESCE 0)
- MobileMeService.createWithdrawRequest 에 잔여 한도 가드 추가
  · 1) settle_master 본인 소유 확인 (FORBIDDEN)
  · 2) 잔여 = net_amount - 활성 출금합계
  · 3) 요청 amount > 잔여 시 INVALID_PARAMETER(E411) 던짐
  · 메시지에 잔여 금액 명시 ("출금 가능 잔액(N)을 초과합니다")

PWA 알림 모두읽음:
- POST /api/mobile/me/notifications/read-all (기존 UserNotificationMapper.markAllRead 재사용)
- NoticeList 내알림 탭 헤더에 "모두 읽음 (N)" 버튼 (안읽음 카운트 표시)
- 성공 시 invalidate notifications + summary 쿼리

검증:
- ga-api compileJava + ga-mobile-pwa tsc PASS
- e2e (agent01 / userId=4 / agentId=1):
  · settle#51 net=35,079원, 기존 사용 5,000원
  · amount=999,999 → E411 "출금 가능 잔액(30079.00)을 초과합니다"
  · amount=100   → success, requestId=5
  · markAllRead → 200, summary.unread=0
- 이전 e2e 검증 (총 9건) 모두 PASS

HANDOFF.md §3-15 갱신 (2026-05-27 P6 후속 전체 정리 + agent01 테스트 계정 명시).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:00:17 +09:00
GA Pro dfce9e5c02 feat: P6 후속 2 — PWA 본인 프로필/출금취소 + SETTLED/FAILED 자동 알림
PWA 본인 프로필:
- Profile.tsx 신규 (phone 자기수정 Dialog + 비밀번호 변경 + 로그아웃)
- TabBar 에 "내정보" 탭 추가 (총 5탭)
- 백엔드 PUT /api/mobile/me/profile/phone (MobileMeService.updateMyPhone, 토큰에서 userId 강제)
- 비밀번호 변경은 기존 /api/auth/password 재사용

PWA 출금 신청 취소:
- 백엔드 POST /api/mobile/me/withdraw-requests/{id}/cancel
  · 본인 agentId 검증 후 기존 WithdrawRequestService.cancel 호출
  · REQUESTED 상태에서만 가능 (기존 검증 재사용)
- WithdrawList 행을 SwipeAction 으로 감싸 "취소" 액션 노출
  · REQUESTED 상태 행만 SwipeAction 활성

출금 SETTLED/FAILED 본인 알림 자동 발행:
- WithdrawBankSettleService 가 SETTLED/FAILED 전이 시 user_notification INSERT
- 신청자(requestedBy) 에게 RefType=WITHDRAW, refId=requestId 로 발행
- 알림 발행 실패해도 동기화 흐름 비차단 (warn 로그만)
- PWA NoticeList 의 "내 알림" 탭에서 즉시 확인 가능

검증:
- ga-api compileJava + ga-frontend/ga-mobile-pwa tsc PASS
- PUT /api/mobile/me/profile/phone (admin) → 200, GET /api/auth/me 의 phone 변경 확인
- POST /api/mobile/me/withdraw-requests/1/cancel (admin) → E403 (agentId null 보안 강제)
- POST /api/internal/bank-settle → 200 data=0 (이번엔 ACCEPTED 잔여 없음, 알림 발행 경로 코드상 검증)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:18:39 +09:00
GA Pro 2c9bf710bc feat: P6 후속 — PWA 출금폼/명세상세/토큰refresh + 펌뱅킹 SETTLED 동기화 + user phone UI
외부 SDK 차단 항목 외 P6 잔여 운영성 작업을 일괄 완료. DB 변경 없음(v85 유지).

PWA:
- 출금 신청 작성 폼 (WithdrawCreate.tsx + POST /api/mobile/me/withdraw-requests + GET /settle-masters)
  · 클라이언트가 보낸 agentId/userId 무시, 토큰에서 강제 결정
- 명세서 상세 + 엑셀 다운로드 (StatementDetail.tsx + GET /api/mobile/me/statements/{id}[/download])
  · 본인 agentId 검증 후 기존 CommissionStatementService.download() 재사용
- 토큰 자동 refresh (request.ts interceptor + authStore.refreshToken)
  · 401 시 단일 refresh 호출로 합치고 broadcast, login/refresh 자체 401 은 무한루프 차단

펌뱅킹 SETTLED 동기화:
- WithdrawBankSettleService + POST /api/internal/bank-settle (BankSettleController)
- Mapper: selectAcceptedTransfers / markSettled / markSettleFailed
- 어댑터 queryStatus 폴링 결과로 SETTLED→COMPLETED, FAILED→APPROVED(재시도풀) 전이
- app.bank-settle.cron 옵션 (기본 비활성)

사용자 phone 등록 UI:
- PUT /api/system/users/{id}/phone — NotBlank 검증 없는 단일 패치 endpoint
- UserList 에 phone 컬럼 + "전화번호" 액션 → Antd Modal 인라인 편집
- 결재 SMS 발신 전제 충족

검증 (9/9 PASS, 라이브):
- ga-common/ga-core/ga-external-adapter/ga-api compileJava BUILD SUCCESSFUL
- ga-frontend / ga-mobile-pwa tsc --noEmit PASS
- POST /api/internal/bank-settle → data=3 (3건 COMPLETED 전이 확인)
- PUT /api/system/users/1/phone → 200, 이후 GET 으로 phone 노출 확인
- 보안 강제: admin(agentId=null)으로 /api/mobile/me/{statements,settle-masters,withdraw-requests} → E403
- POST /api/auth/refresh → 200, 새 accessToken 발급
- 3개 서비스 라이브: ga-api :8082 / ga-frontend :3000 / ga-mobile-pwa :3001

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:53:59 +09:00
GA Pro ee3e0d40c5 feat: P6 모바일 PWA 보안 강화 — /api/mobile/me/* 서버측 본인 필터
스캐폴드의 클라이언트 agentId 쿼리 필터(변조 가능)를
서버측 강제 필터로 교체. 토큰 → user → agentId 자동 추출.

백엔드 (ga-api):
- MobileMeService — 클라이언트 param 의 agentId/userId 는 무시·덮어쓰기.
  agentId 미연결 시 BizException(FORBIDDEN). PageHelper 로 정확한 total 산출.
- MobileMeController — GET /api/mobile/me/{summary,statements,
  withdraw-requests,notifications} + POST /notifications/{id}/read.
  별도 메뉴 권한 없이 인증만 통과 (PWA 사용자 전체 대상).

ga-mobile-pwa:
- api/statement.ts, api/withdraw.ts → /api/mobile/me/* 호출, agentId 제거
- api/notification.ts 신규 — list/markRead/summary
- Home.tsx — summary() 단일 호출로 3카운트 + 안 읽음 Badge
- NoticeList.tsx — Tabs(내 알림 / 공지). 알림 클릭 markRead + summary 무효화
- Statement/WithdrawList — E403(설계사 미연결) 시 ErrorBlock 안내

라이브 검증:
- ga-api 컴파일 BUILD SUCCESSFUL · 재기동 OK
- PWA tsc PASS · vite build PASS (precache 538KB, gzip 176KB)
- admin(agentId=null): /summary → 200 OK 0/0/0,
  /statements → E403 차단 정상, /notifications → 200 OK total=0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:32:19 +09:00
GA Pro 23fdd8474b feat: P6 모바일 셀프 조회 PWA 스캐폴드 (ga-mobile-pwa)
설계사 본인 정산명세서/출금/공지를 모바일에서 조회하는 PWA 신규 모듈.
데스크탑 admin UI(ga-frontend)와 별도. 백엔드 무변경 — 기존 ga-api(8082)
재사용. 토큰 키도 mobile.accessToken 으로 분리.

스택:
- Vite + React 18 + TS + antd-mobile@5
- vite-plugin-pwa (manifest + service worker autoUpdate)
- React Query + Zustand (persist) + react-router-dom
- 포트 3001, /api → 8082 dev 프록시

화면 5종 (NavBar + List + PullToRefresh + TabBar):
- /login         — POST /api/auth/login + GET /api/auth/me
- /              — 홈 (요약 카드 + 메뉴, 로그아웃)
- /statement     — GET /api/statements?agentId=…  (본인 명세서)
- /withdraw      — GET /api/withdraw-requests?agentId=…  (본인 출금이력)
- /notice        — GET /api/notices/active

PWA:
- manifest: name/icon/theme/standalone, ko, /
- /api/** NetworkFirst 5s 타임아웃 / 5분 캐시 (오프라인 시 직전 응답)
- 아이콘은 SVG placeholder (192/512) — 본 배포 시 PNG 권장

검증:
- npm install 407 packages
- tsc --noEmit PASS
- npm run build PASS — manifest.webmanifest + sw.js + workbox-*.js
  자동 생성, precache 8 entries 528KB, 번들 gzip 173KB
- vite dev :3001 LISTENING + ga-api :8082 정상
- /api/statements total=112, /api/withdraw-requests total=3,
  /api/notices/active 0건, /api/auth/me 정상

잔여 (스캐폴드 이후):
- 백엔드 /api/mobile/me/* 전용 endpoint (서버측 본인 필터 강제)
- 출금 신청 작성 폼, 명세서 상세, 본인 알림(user_notification)
- 토큰 만료 자동 refresh, 푸시 알림 (FCM)
- 실 아이콘 PNG, manualChunks 분리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:22:18 +09:00
GA Pro 4a7a4eeb7a chore: 검증용 토큰/임시 파일 제거 + .gitignore 보강
이전 커밋(5ee8321)에 검증 중 만든 login.json/token.txt 가 실수로 포함되어
JWT 토큰이 히스토리에 노출됨. 토큰은 이미 expired 되었지만(7200s) 즉시 제거.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:58:05 +09:00
GA Pro 5ee8321ef2 feat: P6 외부연동 호출 감사 이력 (V85)
펌뱅킹/SMS·카카오 어댑터 모든 호출을 external_call_log 테이블에 AOP 자동 영속화.
외부 SDK 통합 후에도 그대로 재사용. 운영 감사·장애 추적·SDK 검증 용도.

- V85: external_call_log 테이블 + 인덱스 4종 (called_at desc / type / target / failures)
       + SYSTEM_EXTERNAL_CALL 메뉴 + READ 권한 (SUPER_ADMIN/ADMIN)
- ga-core: ExternalCallLogVO/Resp/SearchParam + Mapper (insertOne / selectList / selectById)
- ga-api/aop: ExternalCallLoggingAspect — BankTransferAdapter+/MessageAdapter+
  모든 메서드 @Around 영속화. 호출자 트랜잭션과 분리(REQUIRES_NEW),
  request/response 는 PII 마스킹된 JSON 한 줄 요약(계좌·전화 뒤 4자리, 이름 첫글자만),
  영속화 자체가 실패해도 비즈니스 흐름 비차단(warn 로그만), 예외는 그대로 propagate.
- ga-api: ExternalCallLogService + Controller GET /api/external-call-logs[/{id}]
- ga-frontend: ExternalCallLogList.tsx + api/externalCallLog.ts + App.tsx 라우트
  /system/external-call-log (ProTable + Drawer 상세)

라이브 검증:
- Flyway V85 자동 적용 → 운영 DB schema v85
- 5모듈 컴파일 BUILD SUCCESSFUL + ga-frontend tsc --noEmit 통과
- 시나리오: withdraw#3 신규 → 결재 advance ×2 → 펌뱅킹 호출
  external_call_log #1: BANK/MockBankTransferAdapter/requestTransfer/
  success=true/durMs=3/target=WITHDRAW#3/accountNoMasked="****0123"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:56:27 +09:00
70 changed files with 10602 additions and 27 deletions
+3
View File
@@ -45,3 +45,6 @@ target/
# vite/tsc 빌드 산출물 # vite/tsc 빌드 산출물
ga-frontend/tsconfig.tsbuildinfo ga-frontend/tsconfig.tsbuildinfo
ga-frontend/dist/ ga-frontend/dist/
login.json
token.txt
__pycache__/
+228 -18
View File
@@ -191,23 +191,229 @@
운영 DB 최신 상태: ga schema V73 적용 완료. 운영 DB 최신 상태: ga schema V73 적용 완료.
## 4. 잔여 작업 (P6) ## 3-11. P6 PoC — 외부 연동 어댑터 모듈 (V81~V84, 2026-05-24 라이브 적용)
P5-W2 / P5-W3(메뉴·권한 보강 V74 + 본론 V75~V78) 모두 완료. 잔여는 P6뿐. P6 본 작업의 PoC가 완료되어 어댑터 추상화 + Mock 구현 + SDK 분기 + 운영 잔여 기능까지 들어감.
실제 KFTC/Toast SDK 통합 시 어댑터 구현체만 바꾸면 ga-api/ga-common 무변경.
### P6 — 외부 연동 (P5 이후) | 항목 | 마이그레이션 | 상태 |
| 항목 | 설명 | |---|---|---|
| 분급 비율 회차(월) 단위 등록 지원 | V81 | 완료 |
| withdraw_request 펌뱅킹 추적 컬럼 (transfer_tx_id/transfer_status/transfer_at) | V82 | 완료 |
| WITHDRAW_REQUESTS EXECUTE 권한 보강 (오기 — 0건 적용) | V83 | 완료(no-op) |
| WITHDRAW 메뉴 EXECUTE 권한 (V83 보정본) | V84 | 완료 |
핵심 산출물:
- **신규 모듈 `ga-external-adapter`** — 외부 SDK 의존성 격리, settings.gradle 등록
- ga-common.adapter.bank: `BankTransferAdapter` 인터페이스 + Request/Response/Status
- ga-common.adapter.message: `MessageAdapter` 인터페이스 + Channel(SMS/KAKAO)/Request/Response
- `MockBankTransferAdapter` / `MockMessageAdapter` (로그 + 계좌·전화 마스킹, 기본 활성)
- `KftcBankTransferAdapter` / `ToastMessageAdapter` 스켈레톤 (UnsupportedOperationException, SDK 통합 시 구현)
- `@ConditionalOnProperty` 프로파일 분기: `app.adapter.bank=mock|kftc`, `app.adapter.message=mock|toast`
- `ApprovalService.handleApprovalCallback(WITHDRAW)` — 최종 승인 시 펌뱅킹 어댑터 호출 → 성공 시 APPROVED→SENT 전이 + transfer_tx_id 영속화, 실패 시 APPROVED 유지 + fail_reason 기록
- `ApprovalService.sendStepNotification` — DB 알림 외 SMS 어댑터 호출 (UserMapper.selectFirstActiveContactByRoleCode 로 실제 결재자 phone 매핑, phone 미확보 시 SMS skip)
- `WithdrawBankRetryService` + `BankRetryController` (`POST /api/internal/bank-retry`) — 실패 transfer 재시도 배치 + `@EnableScheduling` + cron 옵션(기본 비활성) + `app.bank-retry.limit`
- `WithdrawRequestMapper.selectFailedTransfers` / `updateTransferResult` 추가
- `WithdrawRequestVO/Resp` + `WithdrawRequestMapper.xml` + 화면 `WithdrawRequest.tsx` 에 transfer_tx_id/transfer_status/transfer_at 노출
설정값(application.yml):
```yaml
app:
adapter:
bank: ${ADAPTER_BANK:mock} # mock | kftc
message: ${ADAPTER_MESSAGE:mock} # mock | toast
bank-retry:
limit: ${BANK_RETRY_LIMIT:50}
cron: ${BANK_RETRY_CRON:0 0 0 1 1 ?} # 사실상 비활성. 운영 활성화 예 "0 */10 * * * *"
```
통합 검증 결과 (2026-05-24):
- 5모듈 컴파일 BUILD SUCCESSFUL (ga-common/ga-core/ga-external-adapter/ga-api/ga-batch)
- 운영 DB v82~v84 적용 완료, ga-api 8082 가동
- POST /api/auth/login → 200 OK, accessToken 발급
- GET /api/withdraw-requests → 200 OK, 2건 모두 status=SENT / transfer_status=ACCEPTED / transferTxId="MOCK-…" 영속화
- POST /api/internal/bank-retry → 200 OK, data=0 (실패 건 없음 — 정상)
- GET /api/approvals/requests/mine → 200 OK, WITHDRAW APPROVED 이력 정상
- GET /api/forecast-kpi → 200 OK
- GET /api/year-end-statements → 200 OK
보정 사항:
- V83 작성 시 menu_code 오기로 0건 적용 → V84 로 정정 보강
## 4. 잔여 작업 (P6 실연동)
P5 전체 + P6 PoC(어댑터 모듈 + Mock 구현 + SDK 분기 + 재시도 배치 + DB 추적 컬럼) 완료.
잔여는 **외부 스펙 확정 후 적용** 가능한 항목 + 모바일.
### P6 실연동
| 항목 | 설명 | 선행 조건 |
|---|---|---|
| KFTC 펌뱅킹 실 SDK 통합 | `KftcBankTransferAdapter` 본체 구현 + 인증·엔드포인트 주입 | KFTC API 키·계약·테스트망 |
| Toast(NHN) SMS 실 SDK 통합 | `ToastMessageAdapter` 본체 구현 | 계정·App Key·발신번호·템플릿 코드 |
| 카카오 알림톡 실 SDK 통합 | 동일 (채널만 KAKAO) | 비즈 채널·템플릿 승인 |
| 펌뱅킹 SETTLED 동기화 | ACCEPTED→SETTLED 폴링 스케줄 (queryStatus 사용) | 실 SDK 통합 이후 |
| 펌뱅킹/메시지 호출 감사 이력 | `external_call_log` 테이블 + 영속화 + 조회 화면 | 독립 진행 가능 |
| 모바일 셀프 조회 PWA | 설계사 전용. 정산 명세서/출금 신청/공지 등 | 화면 우선순위 협의 |
## 3-12. P6 PoC — 외부연동 호출 감사 이력 (V85, 2026-05-24 라이브 적용)
P6 PoC 의 운영성 보강. 펌뱅킹/SMS·카카오 어댑터 모든 호출이 `external_call_log` 테이블에 AOP 로 자동 영속화. 외부 SDK 통합 후에도 그대로 재사용.
| 항목 | 위치 | 상태 |
|---|---|---|
| external_call_log 테이블 + 인덱스 + SYSTEM_EXTERNAL_CALL 메뉴/권한 | V85 | 완료 |
| ExternalCallLogVO / Resp / SearchParam | ga-core | 완료 |
| ExternalCallLogMapper(.java/.xml) — insertOne + selectList + selectById | ga-core | 완료 |
| ExternalCallLogService — recordCall @Transactional(REQUIRES_NEW) | ga-api | 완료 |
| ExternalCallLogController — GET /api/external-call-logs[/{id}] | ga-api | 완료 |
| ExternalCallLoggingAspect — BankTransferAdapter+/MessageAdapter+ 모든 메서드 @Around | ga-api/aop | 완료 |
| ExternalCallLogList.tsx + api/externalCallLog.ts + App.tsx 라우트 | ga-frontend | 완료 |
핵심 동작:
- 어댑터 호출 전/후·예외 모두 `external_call_log` INSERT (호출자 트랜잭션과 분리 — REQUIRES_NEW)
- request/response 는 PII 마스킹된 JSON 한 줄로 요약 (계좌·전화 뒤 4자리, 이름 첫글자만)
- 영속화 자체가 실패해도 비즈니스 흐름은 절대 깨지 않음 (warn 로그만)
- `target_ref_type/id` 자동 추출: WITHDRAW(BankTransferRequest.withdrawRequestId), MessageRequest.refType/refId
통합 검증 결과 (2026-05-24):
- Flyway: V85 자동 적용 → 운영 DB schema **v85**
- 5모듈 컴파일 BUILD SUCCESSFUL + ga-frontend TS 타입 검증 통과
- 라이브 시나리오: 신규 withdraw#3 → 결재 1단계 + 2단계 APPROVE → 최종 승인 → 펌뱅킹 호출
- withdraw#3 status=SENT, transferTxId=MOCK-…, transferStatus=ACCEPTED
- GET /api/external-call-logs → total=1, log#1: BANK/MockBankTransferAdapter/requestTransfer/success=true/durMs=3/target=WITHDRAW#3
- 상세: requestSummary 의 accountNoMasked="****0123" — PII 마스킹 정상 (평문 비노출)
## 4. 잔여 작업 (P6 실연동)
P5 전체 + P6 PoC(어댑터 모듈 + Mock 구현 + SDK 분기 + 재시도 배치 + DB 추적 컬럼 + **호출 감사 이력**) 완료.
잔여는 **외부 스펙 확정 후 적용** 가능한 항목 + 모바일.
### P6 실연동
| 항목 | 설명 | 선행 조건 |
|---|---|---|
| KFTC 펌뱅킹 실 SDK 통합 | `KftcBankTransferAdapter` 본체 구현 + 인증·엔드포인트 주입 | KFTC API 키·계약·테스트망 |
| Toast(NHN) SMS 실 SDK 통합 | `ToastMessageAdapter` 본체 구현 | 계정·App Key·발신번호·템플릿 코드 |
| 카카오 알림톡 실 SDK 통합 | 동일 (채널만 KAKAO) | 비즈 채널·템플릿 승인 |
| 펌뱅킹 SETTLED 동기화 | ACCEPTED→SETTLED 폴링 스케줄 (queryStatus 사용) | 실 SDK 통합 이후 |
| 모바일 셀프 조회 PWA | 설계사 전용. 정산 명세서/출금 신청/공지 등 | 화면 우선순위 협의 |
## 3-13. P6 — 모바일 셀프 조회 PWA 스캐폴드 (ga-mobile-pwa, 2026-05-24)
설계사 본인 정산명세서·출금·공지를 모바일에서 조회하는 PWA 신규 모듈. 데스크탑 admin UI(`ga-frontend`)와 별도. **백엔드 무변경** — 기존 `ga-api`(8082) 그대로 재사용.
### 구성
| 항목 | 위치 | 비고 |
|---|---|---|
| 신규 모듈 | `ga-mobile-pwa/` | Vite + React 18 + TS + antd-mobile@5 |
| PWA 인프라 | `vite-plugin-pwa` | manifest + service worker autoUpdate, `/api/**` NetworkFirst(5s/5min) |
| 인증 | `src/stores/authStore.ts` (zustand persist) | 토큰 키: `mobile.accessToken` (`ga-frontend` 와 분리) |
| API 클라이언트 | `src/api/{request,auth,statement,withdraw,notice}.ts` | axios + 401 자동 리다이렉트 |
| 화면 5종 | `src/pages/{Login,Home,StatementList,WithdrawList,NoticeList}.tsx` | NavBar + List/Card + PullToRefresh + TabBar |
| 라우팅 | `src/App.tsx` + `RequireAuth` 가드 + `MobileTabBar` | `/login` 외 모두 보호 |
### 화면 ↔ 백엔드 API
| 경로 | 백엔드 |
|---|---| |---|---|
| 펌뱅킹 실연동 | withdraw_request 결재 완료 → 은행 API 직접 호출 | | `/login` | POST `/api/auth/login` + GET `/api/auth/me` |
| SMS/카카오 발송 | user_notification 채널 실연동 | | `/` (홈) | 세 도메인 카운트 조회 |
| 모바일 셀프 조회 | 설계사 전용 모바일 앱 or PWA | | `/statement` | GET `/api/statements?agentId={me.agentId}` |
| `/withdraw` | GET `/api/withdraw-requests?agentId={me.agentId}` |
| `/notice` | GET `/api/notices/active` |
본인 데이터 필터: 클라이언트에서 `profile.agentId` 를 쿼리 파라미터로 전달. 본격 운영에는 **백엔드 `/api/mobile/me/*` 전용 endpoint** (토큰 → agentId 서버측 강제 필터) 권장 — 잔여 항목.
### 빌드 / 실행
```bash
cd ga-mobile-pwa
npm install
npm run dev # http://localhost:3001 (vite dev — /api → 8082 proxy)
npm run build # tsc --noEmit + vite build → dist/ (manifest + sw 자동 생성)
npm run preview # 3001 에서 production 번들 서빙
```
### 통합 검증 (2026-05-24)
- `npm install` 407 packages 성공
- `tsc --noEmit` PASS (`@types/node` 추가)
- `npm run build` PASS — `manifest.webmanifest` + `sw.js` + `workbox-*.js` 자동 생성, precache 8 entries (528 KiB), 번들 516KB(gzip 173KB)
- 라이브: vite dev :3001 LISTENING, ga-api :8082 정상
- API 응답 확인: `/api/statements` total=112, `/api/withdraw-requests` total=3, `/api/notices/active` 0건(정상), `/api/auth/me` 정상
### 잔여 (스캐폴드 이후)
- ~~백엔드 `/api/mobile/me/*` 전용 endpoint~~ → §3-14 에서 완료
- ~~본인 알림(`user_notification`) 표시~~ → §3-14 에서 완료
- 출금 신청 작성 폼 (현재 이력 조회만)
- 정산 명세서 상세 + PDF 다운로드
- 토큰 만료 자동 refresh
- 푸시 알림 (FCM 등) — 외부 채널 결정 후
- 실 아이콘 PNG (192/512/512 maskable)
- 번들 코드 스플리팅 (manualChunks)
## 3-14. P6 — 모바일 PWA 보안 강화: `/api/mobile/me/*` 전용 endpoint (2026-05-24)
PWA 스캐폴드에서 클라이언트가 `agentId` 를 쿼리 파라미터로 보내 본인 데이터를 필터하던 변조 가능 구조를 **서버측 강제 필터**로 교체. 토큰 → user → agentId 자동 추출.
### 백엔드 신규
| 위치 | 내용 |
|---|---|
| `ga-api/service/mobile/MobileMeService` | 토큰에서 userId 추출, `userMapper.selectById` 로 agentId 결정. 클라이언트 param 의 agentId/userId 는 **무시하고 덮어쓰기**. agentId 미연결 시 `BizException(FORBIDDEN)` |
| `ga-api/controller/mobile/MobileMeController` | `GET /api/mobile/me/{summary,statements,withdraw-requests,notifications}` + `POST /api/mobile/me/notifications/{id}/read`. 별도 메뉴 권한 없이 인증만 통과 |
### PWA 측 전환
- `src/api/statement.ts` / `withdraw.ts``/api/mobile/me/*` 호출, `agentId` 인터페이스에서 제거
- `src/api/notification.ts` 신규 — list/markRead/summary
- `Home.tsx``notificationApi.summary()` 한 번으로 3 카운트 동시 표시, 안 읽음 뱃지
- `NoticeList.tsx``Tabs``내 알림` / `공지사항` 두 탭. 알림 클릭 시 markRead + summary 무효화
- `StatementList.tsx` / `WithdrawList.tsx` — agentId 의존 제거, E403(설계사 미연결) 시 ErrorBlock 안내
### 라이브 검증 (2026-05-24)
- ga-api 컴파일 BUILD SUCCESSFUL · 재기동 OK
- PWA `tsc --noEmit` PASS · `npm run build` PASS (precache 8 entries 538 KiB, gzip 176KB)
- `GET /api/mobile/me/summary` (admin, agentId=null) → 200 OK, 세 카운트 모두 0
- `GET /api/mobile/me/statements` (admin) → **E403** 차단 (보안 강제 동작 확인)
- `GET /api/mobile/me/notifications` (admin) → 200 OK total=0
## 4. 잔여 작업 (P6 실연동)
P5 전체 + P6 PoC + **P6 후속(2026-05-27)** 완료. 잔여는 외부 SDK 차단 항목만.
### 운영 DB 마이그레이션 상태 ### 운영 DB 마이그레이션 상태
운영 DB `192.168.0.60:55432/trading_ai/ga` 현재 **V78 적용 완료** (2026-05-21). 운영 DB `192.168.0.60:55432/trading_ai/ga` 현재 **V85 적용 완료** (변경 없음).
V74~V78 Flyway 자동 적용 확인. ga-api 8082 가동 중. ga-api 8082 / ga-frontend 3000 / ga-mobile-pwa 3001 모두 라이브.
단위/통합 테스트는 SqlSessionFactory 환경 설정 문제로 아직 미작성 상태입니다. 단위/통합 테스트는 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 라이브 적용) ## 3-9. P5-W3 메뉴/권한 전수 정합 보강 (V74 — 완료, 2026-05-21 라이브 적용)
| 항목 | 상태 | | 항목 | 상태 |
@@ -252,16 +458,20 @@ V74~V78 Flyway 자동 적용 확인. ga-api 8082 가동 중.
- 스모크: 신규 4 + 회귀 4 = **GET 8/8 PASS**, 쓰기(regenerate/detect/draft) 4/4 PASS - 스모크: 신규 4 + 회귀 4 = **GET 8/8 PASS**, 쓰기(regenerate/detect/draft) 4/4 PASS
- 보정: YearEndStatementMapper.xml 의 `a.agent_code` 미존재 컬럼 참조 제거 (agent 테이블에 agent_code 없음) - 보정: YearEndStatementMapper.xml 의 `a.agent_code` 미존재 컬럼 참조 제거 (agent 테이블에 agent_code 없음)
## 5. 재개 절차 (다음 세션 — P6 라운드) ## 5. 재개 절차 (다음 세션)
1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro` (현재 세션과 동일) 1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro`
2. **Agent Teams 활성화 확인**: `.claude/settings.json` 커밋되어 있음 2. **HANDOFF.md 읽기**: 본 문서 §3-15(P6 후속) + 최근 git log
3. **HANDOFF.md 읽기**: 본 문서 + 최근 git log로 컨텍스트 확보 3. **운영 DB 상태**: **V85 적용 완료**, 변경 없음. ga-api 8082 + ga-frontend 3000 + ga-mobile-pwa 3001 가동 중
4. **운영 DB 상태**: **V78 적용 완료**. ga-api 8082 가동 중 (필요 시 `python verify_v74.py` / `smoke_v78.py` 재실행) 4. **재기동이 필요한 경우**:
5. **권장 시작 지점**: P6 외부 연동 — 펌뱅킹 실연동 / SMS·카카오 발송 / 모바일 셀프 조회 - `cd ga-commission-system && nohup ./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai' > ../ga-api-bootrun.log 2>&1 & disown`
6. **사용자 결정 필요 항목**: - `cd ga-commission-system/ga-frontend && npm run dev` (3000)
- P2-1 마감 도메인의 마감 후 수정 차단 AOP 구현 방식 (전역 AOP vs Service-level 가드) - `cd ga-commission-system/ga-mobile-pwa && npm run dev` (3001)
- P6 펌뱅킹/메시징 외부 연동처 실제 스펙 (현재 미정 → 착수 전 확인 필요) 5. **PWA 테스트 계정**: `agent01` / `agent1234!` (userId=4, agentId=1, AGENT role)
6. **사용자 결정 필요 항목 (외부 SDK 차단 사유)**:
- KFTC 펌뱅킹 API 계약·인증 정보 → `KftcBankTransferAdapter` 본체 구현
- NHN Toast / 카카오 비즈채널 계정 + 템플릿 코드 → `ToastMessageAdapter` 본체
- 푸시 알림 채널 (FCM 등) 결정 → PWA 통합
## 6. PL 가이드 (다음 세션 유의사항) ## 6. PL 가이드 (다음 세션 유의사항)
@@ -0,0 +1,179 @@
package com.ga.api.aop;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.api.service.external.ExternalCallLogService;
import com.ga.common.adapter.bank.BankTransferRequest;
import com.ga.common.adapter.bank.BankTransferResponse;
import com.ga.common.adapter.message.MessageRequest;
import com.ga.common.adapter.message.MessageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.vo.external.ExternalCallLogVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 외부 연동 어댑터 호출 감사 이력 자동 영속화 (V85 external_call_log).
*
* <p>대상: {@link com.ga.common.adapter.bank.BankTransferAdapter},
* {@link com.ga.common.adapter.message.MessageAdapter} 모든 메서드.
*
* <p>정책:
* <ul>
* <li>호출 전/후 (정상·예외) 모두 영속화. 예외는 그대로 propagate.
* <li>request/response 는 마스킹된 JSON 요약만 저장 (계좌·전화 뒤 4자리만).
* <li>영속화는 별도 트랜잭션({@link com.ga.api.service.external.ExternalCallLogService}#recordCall = REQUIRES_NEW).
* <li>영속화 자체가 실패해도 비즈니스 흐름을 깨지 않는다 (warn 로그만).
* </ul>
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ExternalCallLoggingAspect {
private final ExternalCallLogService logService;
private final ObjectMapper om;
@Around("execution(* com.ga.common.adapter.bank.BankTransferAdapter+.*(..))")
public Object aroundBank(ProceedingJoinPoint jp) throws Throwable {
return around(jp, "BANK");
}
@Around("execution(* com.ga.common.adapter.message.MessageAdapter+.*(..))")
public Object aroundMessage(ProceedingJoinPoint jp) throws Throwable {
return around(jp, "MESSAGE");
}
private Object around(ProceedingJoinPoint jp, String callType) throws Throwable {
long start = System.currentTimeMillis();
ExternalCallLogVO vo = new ExternalCallLogVO();
vo.setCallType(callType);
vo.setAdapterClass(simpleName(jp.getTarget()));
vo.setMethodName(jp.getSignature().getName());
vo.setCalledAt(LocalDateTime.now());
try {
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
} catch (Exception ignore) {
// 스케줄러/배치 컨텍스트에서는 인증 정보 없을 수 있음
}
applyRequest(vo, jp.getArgs());
try {
Object result = jp.proceed();
vo.setSuccess(true);
applyResponse(vo, result);
return result;
} catch (Throwable t) {
vo.setSuccess(false);
vo.setErrorClass(t.getClass().getName());
vo.setErrorMessage(truncate(t.getMessage(), 4000));
throw t;
} finally {
vo.setDurationMs((int) (System.currentTimeMillis() - start));
try {
logService.recordCall(vo);
} catch (Exception persistEx) {
log.warn("[external_call_log] persist failed: {}", persistEx.getMessage());
}
}
}
private void applyRequest(ExternalCallLogVO vo, Object[] args) {
if (args == null || args.length == 0) return;
Object a0 = args[0];
if (a0 instanceof BankTransferRequest btr) {
vo.setTargetRefType("WITHDRAW");
vo.setTargetRefId(btr.getWithdrawRequestId());
Map<String, Object> m = new LinkedHashMap<>();
m.put("withdrawRequestId", btr.getWithdrawRequestId());
m.put("bankCode", btr.getBankCode());
m.put("accountNoMasked", maskAccount(btr.getAccountNo()));
m.put("holder", maskName(btr.getAccountHolderName()));
m.put("amount", btr.getAmount());
m.put("memo", btr.getMemo());
vo.setRequestSummary(toJson(m));
} else if (a0 instanceof MessageRequest mr) {
vo.setTargetRefType(mr.getRefType());
vo.setTargetRefId(mr.getRefId());
Map<String, Object> m = new LinkedHashMap<>();
m.put("channel", mr.getChannel());
m.put("phoneMasked", maskPhone(mr.getPhoneNumber()));
m.put("title", mr.getTitle());
m.put("templateCode", mr.getTemplateCode());
m.put("refUserId", mr.getRefUserId());
m.put("contentPreview", truncate(mr.getContent(), 80));
vo.setRequestSummary(toJson(m));
} else if (a0 instanceof String s) {
// BankTransferAdapter.queryStatus(txId)
vo.setRequestSummary("{\"arg\":\"" + safeJsonString(s) + "\"}");
}
}
private void applyResponse(ExternalCallLogVO vo, Object result) {
if (result instanceof BankTransferResponse btr) {
vo.setResultCode(btr.getResultCode());
vo.setResultMessage(truncate(btr.getResultMessage(), 1000));
Map<String, Object> m = new LinkedHashMap<>();
m.put("txId", btr.getTxId());
m.put("status", btr.getStatus());
m.put("processedAt", btr.getProcessedAt());
vo.setResponseSummary(toJson(m));
} else if (result instanceof MessageResponse mr) {
vo.setResultCode(mr.getResultCode());
vo.setResultMessage(truncate(mr.getResultMessage(), 1000));
Map<String, Object> m = new LinkedHashMap<>();
m.put("messageId", mr.getMessageId());
m.put("sent", mr.isSent());
m.put("sentAt", mr.getSentAt());
vo.setResponseSummary(toJson(m));
}
}
private String simpleName(Object o) {
return o == null ? null : o.getClass().getSimpleName();
}
private String toJson(Object o) {
try {
return om.writeValueAsString(o);
} catch (Exception e) {
return "{}";
}
}
private String maskAccount(String accountNo) {
if (accountNo == null) return null;
String d = accountNo.replaceAll("[^0-9]", "");
if (d.length() <= 4) return "****";
return "****" + d.substring(d.length() - 4);
}
private String maskPhone(String phone) {
if (phone == null) return null;
String d = phone.replaceAll("[^0-9]", "");
if (d.length() <= 4) return "****";
return "***-****-" + d.substring(d.length() - 4);
}
private String maskName(String name) {
if (name == null || name.isEmpty()) return null;
return name.charAt(0) + "*".repeat(Math.max(0, name.length() - 1));
}
private String truncate(String s, int max) {
if (s == null) return null;
return s.length() <= max ? s : s.substring(0, max);
}
private String safeJsonString(String s) {
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
@@ -0,0 +1,31 @@
package com.ga.api.controller.external;
import com.ga.api.service.external.ExternalCallLogService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.external.ExternalCallLogResp;
import com.ga.core.vo.external.ExternalCallLogSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "외부연동 호출이력")
@RestController
@RequiredArgsConstructor
public class ExternalCallLogController {
private final ExternalCallLogService service;
@GetMapping("/api/external-call-logs")
@RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ")
public ApiResponse<PageResponse<ExternalCallLogResp>> list(@ModelAttribute ExternalCallLogSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/api/external-call-logs/{logId}")
@RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ")
public ApiResponse<ExternalCallLogResp> detail(@PathVariable Long logId) {
return ApiResponse.ok(service.detail(logId));
}
}
@@ -0,0 +1,29 @@
package com.ga.api.controller.internal;
import com.ga.api.service.withdraw.WithdrawBankSettleService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 펌뱅킹 SETTLED 동기화 수동 트리거.
* cron 활성화 환경에서는 자동 실행되지만 운영 중 즉시 입금 확정 동기화가 필요할 때 사용.
*/
@Tag(name = "내부 트리거")
@RestController
@RequestMapping("/api/internal/bank-settle")
@RequiredArgsConstructor
public class BankSettleController {
private final WithdrawBankSettleService bankSettleService;
@PostMapping
@RequirePermission(menu = "WITHDRAW", perm = "EXECUTE")
public ApiResponse<Integer> sync() {
return ApiResponse.ok(bankSettleService.syncOnce());
}
}
@@ -0,0 +1,122 @@
package com.ga.api.controller.mobile;
import com.ga.api.service.mobile.MobileMeService;
import com.ga.api.service.mobile.MobileMeService.MeSummary;
import com.ga.api.service.mobile.MobileMeService.MobileWithdrawSaveReq;
import com.ga.api.service.mobile.MobileMeService.WithdrawableBalance;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.notice.UserNotificationResp;
import com.ga.core.vo.notice.UserNotificationSearchParam;
import com.ga.core.vo.settle.CommissionStatementResp;
import com.ga.core.vo.settle.CommissionStatementSearchParam;
import com.ga.core.vo.settle.SettleMasterResp;
import com.ga.core.vo.withdraw.WithdrawRequestResp;
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* 모바일 PWA 전용 — 토큰의 사용자 본인 데이터만 반환.
*
* <p>모든 endpoint 는 인증만 통과하면 사용 가능 (별도 메뉴 권한 없음).
* agentId / userId 는 서버측에서 토큰 → user 조회로 강제 결정. 클라이언트 파라미터는 무시한다.
*/
@Tag(name = "모바일 셀프 조회")
@RestController
@RequestMapping("/api/mobile/me")
@RequiredArgsConstructor
public class MobileMeController {
private final MobileMeService service;
@GetMapping("/summary")
public ApiResponse<MeSummary> summary() {
return ApiResponse.ok(service.summary());
}
@GetMapping("/statements")
public ApiResponse<PageResponse<CommissionStatementResp>> statements(
@ModelAttribute CommissionStatementSearchParam param) {
return ApiResponse.ok(service.statements(param));
}
@GetMapping("/statements/{statementId}")
public ApiResponse<CommissionStatementResp> statementDetail(@PathVariable Long statementId) {
return ApiResponse.ok(service.statementDetail(statementId));
}
@GetMapping("/statements/{statementId}/download")
public void downloadStatement(@PathVariable Long statementId, HttpServletResponse response) throws IOException {
byte[] content = service.downloadStatement(statementId);
String encoded = URLEncoder.encode("수수료명세서_" + statementId + ".xlsx", StandardCharsets.UTF_8)
.replace("+", "%20");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded);
response.setContentLength(content.length);
response.getOutputStream().write(content);
}
@GetMapping("/settle-masters")
public ApiResponse<List<SettleMasterResp>> withdrawableSettleMasters() {
return ApiResponse.ok(service.withdrawableSettleMasters());
}
@GetMapping("/settle-masters/{settleMasterId}/withdrawable-balance")
public ApiResponse<WithdrawableBalance> withdrawableBalance(@PathVariable Long settleMasterId) {
return ApiResponse.ok(service.withdrawableBalance(settleMasterId));
}
@GetMapping("/withdraw-requests")
public ApiResponse<PageResponse<WithdrawRequestResp>> withdrawRequests(
@ModelAttribute WithdrawRequestSearchParam param) {
return ApiResponse.ok(service.withdrawRequests(param));
}
@PostMapping("/withdraw-requests")
public ApiResponse<Long> createWithdrawRequest(@Valid @RequestBody MobileWithdrawSaveReq req) {
return ApiResponse.ok(service.createWithdrawRequest(req));
}
@PostMapping("/withdraw-requests/{requestId}/cancel")
public ApiResponse<Void> cancelWithdrawRequest(@PathVariable Long requestId) {
service.cancelMyWithdrawRequest(requestId);
return ApiResponse.ok();
}
@PutMapping("/profile/phone")
public ApiResponse<Void> updateMyPhone(@RequestBody PhoneReq req) {
service.updateMyPhone(req.getPhone());
return ApiResponse.ok();
}
@lombok.Data
public static class PhoneReq {
private String phone;
}
@GetMapping("/notifications")
public ApiResponse<PageResponse<UserNotificationResp>> notifications(
@ModelAttribute UserNotificationSearchParam param) {
return ApiResponse.ok(service.notifications(param));
}
@PostMapping("/notifications/{notificationId}/read")
public ApiResponse<Void> markRead(@PathVariable Long notificationId) {
service.markNotificationRead(notificationId);
return ApiResponse.ok();
}
@PostMapping("/notifications/read-all")
public ApiResponse<Integer> markAllRead() {
return ApiResponse.ok(service.markAllNotificationsRead());
}
}
@@ -0,0 +1,59 @@
package com.ga.api.controller.succession;
import com.ga.api.service.succession.ContractSuccessionService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.succession.ContractSuccessionResp;
import com.ga.core.vo.succession.ContractSuccessionSaveReq;
import com.ga.core.vo.succession.ContractSuccessionSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "계약 승계")
@RestController
@RequestMapping("/api/contract-successions")
@RequiredArgsConstructor
public class ContractSuccessionController {
private final ContractSuccessionService service;
@GetMapping
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "READ")
public ApiResponse<PageResponse<ContractSuccessionResp>> list(
@ModelAttribute ContractSuccessionSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{successionId}")
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "READ")
public ApiResponse<ContractSuccessionResp> detail(@PathVariable Long successionId) {
return ApiResponse.ok(service.detail(successionId));
}
@PostMapping
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "CREATE")
@DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession")
public ApiResponse<Long> create(@Valid @RequestBody ContractSuccessionSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{successionId}/cancel")
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "UPDATE")
@DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession")
public ApiResponse<Void> cancel(@PathVariable Long successionId) {
service.cancel(successionId);
return ApiResponse.ok();
}
@DeleteMapping("/{successionId}")
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "DELETE")
@DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession")
public ApiResponse<Void> delete(@PathVariable Long successionId) {
service.delete(successionId);
return ApiResponse.ok();
}
}
@@ -61,4 +61,21 @@ public class UserController {
service.unlock(userId); service.unlock(userId);
return ApiResponse.ok(); return ApiResponse.ok();
} }
/**
* 전화번호 단독 패치. SMS 알림 발신을 위한 phone 등록·정정 경로.
* loginId/userName 같은 NotBlank 필드를 강제하지 않는다.
*/
@PutMapping("/{userId}/phone")
@RequirePermission(menu = "SYSTEM_USER", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_USER", table = "users")
public ApiResponse<Void> updatePhone(@PathVariable Long userId, @RequestBody PhoneReq req) {
service.updatePhone(userId, req.getPhone());
return ApiResponse.ok();
}
@lombok.Data
public static class PhoneReq {
private String phone;
}
} }
@@ -0,0 +1,43 @@
package com.ga.api.service.external;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.external.ExternalCallLogMapper;
import com.ga.core.vo.external.ExternalCallLogResp;
import com.ga.core.vo.external.ExternalCallLogSearchParam;
import com.ga.core.vo.external.ExternalCallLogVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* 외부 연동 호출 감사 이력.
*
* <p>{@link #recordCall} 은 AOP({@link com.ga.api.aop.ExternalCallLoggingAspect})에서 호출된다.
* 호출자 트랜잭션 롤백과 무관하게 영속화돼야 하므로 {@link Propagation#REQUIRES_NEW}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ExternalCallLogService {
private final ExternalCallLogMapper mapper;
/** AOP에서 호출. 호출자 트랜잭션과 분리(REQUIRES_NEW). */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordCall(ExternalCallLogVO vo) {
mapper.insertOne(vo);
}
@Transactional(readOnly = true)
public PageResponse<ExternalCallLogResp> list(ExternalCallLogSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
@Transactional(readOnly = true)
public ExternalCallLogResp detail(Long logId) {
return mapper.selectById(logId);
}
}
@@ -0,0 +1,274 @@
package com.ga.api.service.mobile;
import com.ga.api.service.statement.CommissionStatementService;
import com.ga.api.service.withdraw.WithdrawRequestService;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.notice.UserNotificationMapper;
import com.ga.core.mapper.settle.CommissionStatementMapper;
import com.ga.core.mapper.settle.SettleMasterMapper;
import com.ga.core.mapper.user.UserMapper;
import com.ga.core.mapper.withdraw.WithdrawRequestMapper;
import com.ga.core.vo.notice.UserNotificationResp;
import com.ga.core.vo.notice.UserNotificationSearchParam;
import com.ga.core.vo.settle.CommissionStatementResp;
import com.ga.core.vo.settle.CommissionStatementSearchParam;
import com.ga.core.vo.settle.SettleMasterResp;
import com.ga.core.vo.settle.SettleSearchParam;
import com.ga.core.vo.user.UserVO;
import com.ga.core.vo.withdraw.WithdrawRequestResp;
import com.ga.core.vo.withdraw.WithdrawRequestSaveReq;
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Builder;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
/**
* 모바일 PWA 전용 — 본인 데이터만 조회. agentId/userId 는 토큰에서 강제 추출.
*
* <p>클라이언트가 보낸 search param 의 agentId/userId 는 무시하고 SecurityContext 의 사용자로 덮어쓴다.
* 본 서비스 외부에서는 본인 데이터 임의 조회가 절대 불가능하도록 보장.
*/
@Service
@RequiredArgsConstructor
public class MobileMeService {
private final UserMapper userMapper;
private final CommissionStatementMapper statementMapper;
private final WithdrawRequestMapper withdrawMapper;
private final UserNotificationMapper notificationMapper;
private final SettleMasterMapper settleMasterMapper;
private final WithdrawRequestService withdrawRequestService;
private final CommissionStatementService statementService;
@Transactional(readOnly = true)
public MeSummary summary() {
Long userId = currentUserId();
Long agentId = resolveAgentId(userId);
long stTotal = 0L;
long wdTotal = 0L;
if (agentId != null) {
CommissionStatementSearchParam sp = new CommissionStatementSearchParam();
sp.setAgentId(agentId);
sp.setPageNum(1);
sp.setPageSize(1);
sp.startPage();
stTotal = PageResponse.of(statementMapper.selectList(sp)).getTotal();
WithdrawRequestSearchParam wp = new WithdrawRequestSearchParam();
wp.setAgentId(agentId);
wp.setPageNum(1);
wp.setPageSize(1);
wp.startPage();
wdTotal = PageResponse.of(withdrawMapper.selectList(wp)).getTotal();
}
int unreadCount = notificationMapper.selectUnreadCount(userId);
return MeSummary.builder()
.userId(userId)
.agentId(agentId)
.statementCount(stTotal)
.withdrawRequestCount(wdTotal)
.unreadNotificationCount(unreadCount)
.build();
}
@Transactional(readOnly = true)
public PageResponse<CommissionStatementResp> statements(CommissionStatementSearchParam param) {
Long agentId = requireAgentId();
param.setAgentId(agentId); // 강제 덮어쓰기
param.startPage();
return PageResponse.of(statementMapper.selectList(param));
}
@Transactional(readOnly = true)
public PageResponse<WithdrawRequestResp> withdrawRequests(WithdrawRequestSearchParam param) {
Long agentId = requireAgentId();
param.setAgentId(agentId); // 강제 덮어쓰기
param.startPage();
return PageResponse.of(withdrawMapper.selectList(param));
}
@Transactional(readOnly = true)
public PageResponse<UserNotificationResp> notifications(UserNotificationSearchParam param) {
Long userId = currentUserId();
param.setUserId(userId); // 강제 덮어쓰기
param.startPage();
return PageResponse.of(notificationMapper.selectList(param));
}
@Transactional
public void markNotificationRead(Long notificationId) {
Long userId = currentUserId();
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) {
Long agentId = requireAgentId();
CommissionStatementResp resp = statementMapper.selectDetailById(statementId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!agentId.equals(resp.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN);
return resp;
}
/** 본인 명세서 엑셀 다운로드. 본인 소유 검증 후 기존 다운로드 흐름 재사용. */
@Transactional
public byte[] downloadStatement(Long statementId) {
statementDetail(statementId); // 권한 검증 (FORBIDDEN/NOT_FOUND 던짐)
return statementService.download(statementId);
}
/** 본인 phone 자기 수정 — 관리자 거치지 않고 본인이 직접 등록·정정. */
@Transactional
public void updateMyPhone(String phone) {
Long userId = currentUserId();
String trimmed = phone == null || phone.isBlank() ? null : phone.trim();
userMapper.updatePhone(userId, trimmed);
}
/** 본인 출금 가능 정산 마스터 목록. 출금 신청 폼의 settleMasterId 드롭다운 용. */
@Transactional(readOnly = true)
public List<SettleMasterResp> withdrawableSettleMasters() {
Long agentId = requireAgentId();
SettleSearchParam param = new SettleSearchParam();
param.setAgentId(agentId);
param.setPageNum(1);
param.setPageSize(50);
param.startPage();
return settleMasterMapper.selectList(param);
}
/** 본인 출금 신청 취소. 본인 소유 검증 후 기존 WithdrawRequestService.cancel 호출. */
@Transactional
public void cancelMyWithdrawRequest(Long requestId) {
Long agentId = requireAgentId();
com.ga.core.vo.withdraw.WithdrawRequestResp existing = withdrawMapper.selectById(requestId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!agentId.equals(existing.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN);
withdrawRequestService.cancel(requestId);
}
/**
* 본인 정산 마스터의 출금 가능 잔액 조회 — 출금 신청 폼 미리 표시용.
* 본인 소유가 아니면 FORBIDDEN, 없으면 NOT_FOUND.
*/
@Transactional(readOnly = true)
public WithdrawableBalance withdrawableBalance(Long settleMasterId) {
Long agentId = requireAgentId();
var master = settleMasterMapper.selectById(settleMasterId);
if (master == null) throw new BizException(ErrorCode.NOT_FOUND, "정산 마스터를 찾을 수 없습니다");
if (!agentId.equals(master.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN);
BigDecimal netAmount = master.getNetAmount() != null ? master.getNetAmount() : BigDecimal.ZERO;
BigDecimal used = withdrawMapper.sumActiveAmountByMaster(settleMasterId);
if (used == null) used = BigDecimal.ZERO;
return WithdrawableBalance.builder()
.settleId(settleMasterId)
.netAmount(netAmount)
.usedAmount(used)
.remainingAmount(netAmount.subtract(used))
.build();
}
/**
* 본인 출금 신청 등록. agentId / requestedBy 는 토큰에서 강제 결정.
* 클라이언트가 보낸 값은 무시한다.
*/
@Transactional
public Long createWithdrawRequest(MobileWithdrawSaveReq req) {
Long userId = currentUserId();
// 본인 소유 검증 + 잔여 한도 = net_amount - 활성 출금합계 (조회 로직 재사용)
WithdrawableBalance balance = withdrawableBalance(req.getSettleMasterId());
if (req.getAmount().compareTo(balance.getRemainingAmount()) > 0) {
throw new BizException(ErrorCode.INVALID_PARAMETER,
"출금 가능 잔액(" + balance.getRemainingAmount() + ")을 초과합니다");
}
Long agentId = requireAgentId();
WithdrawRequestSaveReq inner = new WithdrawRequestSaveReq();
inner.setSettleMasterId(req.getSettleMasterId());
inner.setAgentId(agentId); // 강제
inner.setRequestedBy(userId); // 강제
inner.setAmount(req.getAmount());
inner.setBankCode(req.getBankCode());
inner.setAccountNo(req.getAccountNo());
return withdrawRequestService.create(inner);
}
// ── private ───────────────────────────────────────────────────────────────
private Long currentUserId() {
Long uid = SecurityUtil.getCurrentUserId();
if (uid == null) throw new BizException(ErrorCode.UNAUTHORIZED);
return uid;
}
private Long resolveAgentId(Long userId) {
UserVO user = userMapper.selectById(userId);
return user != null ? user.getAgentId() : null;
}
/** 본인 agent 가 연결돼 있어야만 통과. 미연결이면 FORBIDDEN. */
private Long requireAgentId() {
Long uid = currentUserId();
Long agentId = resolveAgentId(uid);
if (agentId == null) throw new BizException(ErrorCode.FORBIDDEN);
return agentId;
}
@Data
@Builder
public static class MeSummary {
private Long userId;
private Long agentId;
private long statementCount;
private long withdrawRequestCount;
private int unreadNotificationCount;
}
/** 출금 가능 잔액 — 폼 미리 표시용. */
@Data
@Builder
public static class WithdrawableBalance {
private Long settleId;
private BigDecimal netAmount;
private BigDecimal usedAmount;
private BigDecimal remainingAmount;
}
/** 모바일 출금 신청 — 토큰에서 agentId/requestedBy 강제. */
@Data
public static class MobileWithdrawSaveReq {
@NotNull
private Long settleMasterId;
@NotNull
@Positive
private BigDecimal amount;
@NotBlank
private String bankCode;
@NotBlank
private String accountNo;
}
}
@@ -0,0 +1,65 @@
package com.ga.api.service.succession;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.succession.ContractSuccessionMapper;
import com.ga.core.vo.succession.ContractSuccessionResp;
import com.ga.core.vo.succession.ContractSuccessionSaveReq;
import com.ga.core.vo.succession.ContractSuccessionSearchParam;
import com.ga.core.vo.succession.ContractSuccessionVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ContractSuccessionService {
private final ContractSuccessionMapper mapper;
public PageResponse<ContractSuccessionResp> list(ContractSuccessionSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public ContractSuccessionResp detail(Long successionId) {
ContractSuccessionResp resp = mapper.selectById(successionId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(ContractSuccessionSaveReq req) {
if (req.getFromAgentId().equals(req.getToAgentId())) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "전임과 후임 설계사가 같을 수 없습니다");
}
ContractSuccessionVO vo = new ContractSuccessionVO();
vo.setContractId(req.getContractId());
vo.setFromAgentId(req.getFromAgentId());
vo.setToAgentId(req.getToAgentId());
vo.setSuccessionDate(req.getSuccessionDate());
vo.setReason(req.getReason());
vo.setMemo(req.getMemo());
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
mapper.insert(vo);
return vo.getSuccessionId();
}
/** 승계 취소 — status=CANCELED. 이후 정산월부터 유지수수료는 다시 직전 귀속 설계사에게. */
@Transactional
public void cancel(Long successionId) {
ContractSuccessionResp existing = mapper.selectById(successionId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.cancel(successionId, SecurityUtil.getCurrentUserId());
}
@Transactional
public void delete(Long successionId) {
ContractSuccessionResp existing = mapper.selectById(successionId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.delete(successionId);
}
}
@@ -89,6 +89,15 @@ public class UserService {
mapper.updateStatus(userId, UserStatus.ACTIVE.name()); mapper.updateStatus(userId, UserStatus.ACTIVE.name());
} }
@Transactional
public void updatePhone(Long userId, String phone) {
if (mapper.selectById(userId) == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
String trimmed = phone == null || phone.isBlank() ? null : phone.trim();
mapper.updatePhone(userId, trimmed);
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<String> roleCodes(Long userId) { public List<String> roleCodes(Long userId) {
return mapper.selectRoleCodes(userId); return mapper.selectRoleCodes(userId);
@@ -0,0 +1,125 @@
package com.ga.api.service.withdraw;
import com.ga.common.adapter.bank.BankTransferAdapter;
import com.ga.common.adapter.bank.BankTransferResponse;
import com.ga.common.adapter.bank.BankTransferStatus;
import com.ga.core.mapper.notice.UserNotificationMapper;
import com.ga.core.mapper.withdraw.WithdrawRequestMapper;
import com.ga.core.vo.notice.NotificationRefType;
import com.ga.core.vo.notice.UserNotificationVO;
import com.ga.core.vo.withdraw.WithdrawRequestVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 펌뱅킹 SETTLED 동기화 서비스.
* <p>
* 어댑터가 ACCEPTED(접수)만 반환한 출금 건들에 대해 {@code queryStatus} 폴링으로 입금 확정 여부를 확인.
* <ul>
* <li>SETTLED → status=COMPLETED, transfer_status=SETTLED, completed_at=NOW()</li>
* <li>FAILED → status=APPROVED, transfer_status=FAILED (재시도 풀로 회귀)</li>
* <li>ACCEPTED 유지 → no-op (다음 사이클에서 다시 폴링)</li>
* </ul>
* <p>운영: {@code POST /api/internal/bank-settle} 수동 트리거 + cron 옵션(기본 비활성).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WithdrawBankSettleService {
private static final int DEFAULT_BATCH_LIMIT = 50;
private final WithdrawRequestMapper mapper;
private final BankTransferAdapter bankTransferAdapter;
private final UserNotificationMapper notificationMapper;
@Value("${app.bank-settle.limit:50}")
private int batchLimit;
/** 동기화 1회 — 상태 전이된 건수 반환(SETTLED + FAILED 합산). */
@Transactional
public int syncOnce() {
int limit = batchLimit > 0 ? batchLimit : DEFAULT_BATCH_LIMIT;
List<WithdrawRequestVO> targets = mapper.selectAcceptedTransfers(limit);
if (targets.isEmpty()) return 0;
log.info("[BANK-SETTLE] 대상 {}건 동기화 시작", targets.size());
int changed = 0;
for (WithdrawRequestVO vo : targets) {
String txId = vo.getTransferTxId();
if (txId == null) continue;
try {
BankTransferResponse resp = bankTransferAdapter.queryStatus(txId);
BankTransferStatus status = resp != null ? resp.getStatus() : null;
if (status == BankTransferStatus.SETTLED) {
int n = mapper.markSettled(vo.getRequestId());
if (n > 0) {
changed++;
notify(vo, true, null);
}
} else if (status == BankTransferStatus.FAILED) {
String reason = resp.getResultMessage() != null ? resp.getResultMessage() : "settle failed";
int n = mapper.markSettleFailed(vo.getRequestId(), reason);
if (n > 0) {
changed++;
notify(vo, false, reason);
}
}
// ACCEPTED 유지 시 no-op
} catch (Exception e) {
log.error("[BANK-SETTLE] queryStatus 어댑터 예외: requestId={} txId={}",
vo.getRequestId(), txId, e);
}
}
log.info("[BANK-SETTLE] 완료 — 상태전이 {}/{}건", changed, targets.size());
return changed;
}
/**
* 신청자에게 결과 알림. 본인 알림이 실패해도 동기화 흐름은 비차단.
*/
private void notify(WithdrawRequestVO vo, boolean settled, String reason) {
Long userId = vo.getRequestedBy();
if (userId == null) return;
try {
UserNotificationVO n = new UserNotificationVO();
n.setUserId(userId);
n.setRefType(NotificationRefType.WITHDRAW.name());
n.setRefId(vo.getRequestId());
n.setIsRead(false);
if (settled) {
n.setTitle("출금 입금 완료");
n.setContent("#" + vo.getRequestId() + " 출금 신청이 입금 완료되었습니다 ("
+ vo.getAmount() + "원).");
} else {
n.setTitle("출금 입금 실패");
n.setContent("#" + vo.getRequestId() + " 출금이 실패하였습니다"
+ (reason != null ? "" + reason : "")
+ ". 재시도 처리됩니다.");
}
notificationMapper.insert(n);
} catch (Exception e) {
log.warn("[BANK-SETTLE] 알림 발행 실패 requestId={}: {}", vo.getRequestId(), e.getMessage());
}
}
/**
* cron 기반 자동 동기화.
* yml 에 {@code app.bank-settle.cron} 미설정 시 매년 1월 1일 0시(=사실상 비활성)로 작동.
*/
@Scheduled(cron = "${app.bank-settle.cron:0 0 0 1 1 ?}")
public void scheduled() {
try {
syncOnce();
} catch (Exception e) {
log.error("[BANK-SETTLE] 스케줄러 실패", e);
}
}
}
@@ -66,6 +66,11 @@ app:
limit: ${BANK_RETRY_LIMIT:50} limit: ${BANK_RETRY_LIMIT:50}
# 미설정 시 사실상 비활성 (매년 1/1 0시). 운영 활성화 예: "0 */10 * * * *" # 미설정 시 사실상 비활성 (매년 1/1 0시). 운영 활성화 예: "0 */10 * * * *"
cron: ${BANK_RETRY_CRON:0 0 0 1 1 ?} cron: ${BANK_RETRY_CRON:0 0 0 1 1 ?}
# 펌뱅킹 SETTLED 동기화 배치 — 어댑터 queryStatus 폴링
bank-settle:
limit: ${BANK_SETTLE_LIMIT:50}
# 미설정 시 사실상 비활성. 운영 활성화 예: "0 */5 * * * *"
cron: ${BANK_SETTLE_CRON:0 0 0 1 1 ?}
logging: logging:
level: level:
@@ -26,15 +26,25 @@ public class CommissionCalculator {
private final AgentMapper agentMapper; private final AgentMapper agentMapper;
public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) { public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) {
return buildLedger(c, settleMonth, 1); return buildLedger(c, settleMonth, 1, c.getAgentId());
} }
public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear) { public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear) {
return buildLedger(c, settleMonth, commissionYear); return buildLedger(c, settleMonth, commissionYear, c.getAgentId());
} }
private LedgerVO buildLedger(ContractResp c, String settleMonth, int year) { /**
AgentVO agent = agentMapper.selectById(c.getAgentId()); * 계약 승계 반영 유지수수료 계산.
* effectiveAgentId 가 후임 설계사이면 해당 설계사 등급의 payout 비율로 계산되고
* 원장 agent_id 도 후임으로 기록된다. null 이면 원 모집설계사로 동작.
*/
public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear, Long effectiveAgentId) {
return buildLedger(c, settleMonth, commissionYear,
effectiveAgentId != null ? effectiveAgentId : c.getAgentId());
}
private LedgerVO buildLedger(ContractResp c, String settleMonth, int year, Long agentId) {
AgentVO agent = agentMapper.selectById(agentId);
if (agent == null) return null; if (agent == null) return null;
LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now(); LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
@@ -52,7 +62,7 @@ public class CommissionCalculator {
LedgerVO led = new LedgerVO(); LedgerVO led = new LedgerVO();
led.setContractId(c.getContractId()); led.setContractId(c.getContractId());
led.setAgentId(c.getAgentId()); led.setAgentId(agentId);
led.setPolicyNo(c.getPolicyNo()); led.setPolicyNo(c.getPolicyNo());
led.setCompanyCode(c.getCompanyCode()); led.setCompanyCode(c.getCompanyCode());
led.setProductCode(c.getProductCode()); led.setProductCode(c.getProductCode());
@@ -6,9 +6,11 @@ import com.ga.common.util.DateUtil;
import com.ga.common.util.MoneyUtil; import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.ledger.MaintainLedgerMapper; import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.product.ContractMapper; import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.mapper.succession.ContractSuccessionMapper;
import com.ga.core.vo.ledger.LedgerVO; import com.ga.core.vo.ledger.LedgerVO;
import com.ga.core.vo.product.ContractResp; import com.ga.core.vo.product.ContractResp;
import com.ga.core.vo.product.ContractSearchParam; import com.ga.core.vo.product.ContractSearchParam;
import com.ga.core.vo.succession.ContractSuccessionVO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.StepContribution;
@@ -20,7 +22,9 @@ import org.springframework.stereotype.Component;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* Step 5 — calcMaintain: 유지수수료 계산 (2회차~) * Step 5 — calcMaintain: 유지수수료 계산 (2회차~)
@@ -37,6 +41,7 @@ public class CalcMaintainStep implements Tasklet {
private final SettlementContext ctx; private final SettlementContext ctx;
private final ContractMapper contractMapper; private final ContractMapper contractMapper;
private final MaintainLedgerMapper maintainMapper; private final MaintainLedgerMapper maintainMapper;
private final ContractSuccessionMapper successionMapper;
private final CommissionCalculator calculator; private final CommissionCalculator calculator;
private static final int BATCH_SIZE = 1000; private static final int BATCH_SIZE = 1000;
@@ -52,6 +57,15 @@ public class CalcMaintainStep implements Tasklet {
maintainMapper.deleteBySettleMonth(ctx.getSettleMonth()); maintainMapper.deleteBySettleMonth(ctx.getSettleMonth());
// 계약 승계 반영: 정산월 기준 유효 승계(계약→후임설계사) 사전 로드 (N+1 회피)
Map<Long, Long> successorByContract = new HashMap<>();
for (ContractSuccessionVO s : successionMapper.selectEffectiveSuccessions(ctx.getSettleMonth())) {
successorByContract.put(s.getContractId(), s.getToAgentId());
}
if (!successorByContract.isEmpty()) {
log.info("[Step5] active successions applied: {}", successorByContract.size());
}
List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE); List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE);
int total = 0; int total = 0;
@@ -66,7 +80,9 @@ public class CalcMaintainStep implements Tasklet {
// 납입주기 매칭 (월납이면 매월, 분기납이면 3개월마다 ...) // 납입주기 매칭 (월납이면 매월, 분기납이면 3개월마다 ...)
if (!matchesPayCycle(c.getPayCycle(), monthsElapsed)) continue; if (!matchesPayCycle(c.getPayCycle(), monthsElapsed)) continue;
LedgerVO led = calculator.calcMaintainLedger(c, ctx.getSettleMonth(), year); // 승계된 계약이면 유지수수료 귀속을 후임 설계사로 (없으면 원 모집설계사)
Long effectiveAgentId = successorByContract.getOrDefault(c.getContractId(), c.getAgentId());
LedgerVO led = calculator.calcMaintainLedger(c, ctx.getSettleMonth(), year, effectiveAgentId);
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue; if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
batch.add(led); batch.add(led);
if (batch.size() >= BATCH_SIZE) { if (batch.size() >= BATCH_SIZE) {
@@ -0,0 +1,85 @@
-- V85: 외부 연동(펌뱅킹/SMS·카카오) 호출 감사 이력
--
-- 목적: BankTransferAdapter / MessageAdapter 모든 호출을 자동 영속화.
-- 운영 감사·장애 추적·외부 SDK 통합 검증에 사용. AOP(@Around)가 INSERT.
-- PII 는 요약 필드에 어댑터에서 이미 마스킹된 값만 들어간다 (계좌·전화 뒤 4자리만).
CREATE TABLE IF NOT EXISTS external_call_log (
log_id BIGSERIAL PRIMARY KEY,
call_type VARCHAR(20) NOT NULL, -- BANK / MESSAGE
adapter_class VARCHAR(200) NOT NULL, -- 실제 호출된 구현체 (Mock/Kftc/Toast)
method_name VARCHAR(50) NOT NULL, -- requestTransfer / queryStatus / send
target_ref_type VARCHAR(30), -- WITHDRAW / APPROVAL 등 (가능 시)
target_ref_id BIGINT, -- withdrawRequestId / refId 등
request_summary TEXT, -- 요청 요약 (마스킹된 JSON 한 줄)
response_summary TEXT, -- 응답 요약
result_code VARCHAR(20), -- 외부 응답 코드 (정상 시)
result_message TEXT,
success BOOLEAN NOT NULL DEFAULT TRUE,
duration_ms INTEGER, -- 호출 소요 시간 (ms)
error_class VARCHAR(200), -- 예외 발생 시 클래스명
error_message TEXT, -- 예외 메시지
called_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by BIGINT -- 시도자 (가능 시)
);
COMMENT ON TABLE external_call_log IS '외부 연동 호출 감사 이력 (AOP 자동 영속화)';
COMMENT ON COLUMN external_call_log.call_type IS 'BANK / MESSAGE';
COMMENT ON COLUMN external_call_log.adapter_class IS '실제 호출된 어댑터 클래스 (Mock/Kftc/Toast)';
COMMENT ON COLUMN external_call_log.method_name IS 'requestTransfer / queryStatus / send';
COMMENT ON COLUMN external_call_log.success IS 'true=정상응답, false=예외/실패';
CREATE INDEX IF NOT EXISTS idx_ecl_called_at_desc
ON external_call_log(called_at DESC);
CREATE INDEX IF NOT EXISTS idx_ecl_type_called_at
ON external_call_log(call_type, called_at DESC);
CREATE INDEX IF NOT EXISTS idx_ecl_target
ON external_call_log(target_ref_type, target_ref_id)
WHERE target_ref_type IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_ecl_failures
ON external_call_log(called_at DESC)
WHERE success = FALSE;
-- ============================================================
-- 메뉴 등록 (시스템 그룹 하위)
-- ============================================================
INSERT INTO menu (menu_code, menu_name, menu_type, menu_path, component_path, menu_icon,
menu_level, sort_order, is_visible, is_active, parent_menu_id, description)
SELECT
'SYSTEM_EXTERNAL_CALL',
'외부연동 호출이력',
'PAGE',
'/system/external-call-log',
'system/ExternalCallLogList',
'api',
2,
20,
'Y',
'Y',
(SELECT menu_id FROM menu WHERE menu_code = 'GRP_SYSTEM'),
'펌뱅킹/SMS·카카오 어댑터 호출 감사 이력 (AOP 자동 영속화)'
WHERE EXISTS (SELECT 1 FROM menu WHERE menu_code = 'GRP_SYSTEM')
ON CONFLICT (menu_code) DO NOTHING;
-- ============================================================
-- 권한 정의 (READ 만 — 감사 로그는 수동 등록 없음)
-- ============================================================
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code = 'SYSTEM_EXTERNAL_CALL'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 역할 부여 (SUPER_ADMIN / ADMIN)
-- ============================================================
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, p.perm_code, 'Y'
FROM role r
CROSS JOIN menu m
JOIN menu_permission p ON p.menu_id = m.menu_id
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND m.menu_code = 'SYSTEM_EXTERNAL_CALL'
AND p.perm_code = 'READ'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -0,0 +1,112 @@
-- V86: 계약 승계 (contract_succession)
-- 설계사 해촉/이관 시 보험계약의 "유지수수료" 귀속 설계사를 후임에게 전환.
-- - 모집수수료(1회차)는 원 모집설계사 귀속이므로 승계 대상 아님.
-- - 유지수수료는 승계월(succession_date의 YYYYMM) 이후 정산월부터 후임 설계사에게 귀속.
-- - 후임 설계사는 본인 등급의 payout 비율로 수령(CommissionCalculator 가 effectiveAgentId 등급 조회).
-- - 한 계약에 시차 승계(A→B→C) 가능: 정산월 기준 가장 최근 ACTIVE 승계가 유효.
CREATE TABLE contract_succession (
succession_id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
from_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
to_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
succession_date DATE NOT NULL,
reason VARCHAR(20) NOT NULL DEFAULT 'TERMINATION',
memo VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
CHECK (reason IN ('TERMINATION','TRANSFER','REASSIGN')),
CHECK (status IN ('ACTIVE','CANCELED')),
CHECK (from_agent_id <> to_agent_id)
);
COMMENT ON TABLE contract_succession IS '계약 승계 — 유지수수료 귀속 설계사 전환 이력';
COMMENT ON COLUMN contract_succession.contract_id IS '대상 보험계약 FK';
COMMENT ON COLUMN contract_succession.from_agent_id IS '전임 설계사 (관두거나 이관한 설계사)';
COMMENT ON COLUMN contract_succession.to_agent_id IS '후임 설계사 (유지수수료를 이어받는 설계사)';
COMMENT ON COLUMN contract_succession.succession_date IS '승계 효력일 (이 날짜의 YYYYMM 이상 정산월부터 후임 귀속)';
COMMENT ON COLUMN contract_succession.reason IS 'TERMINATION=해촉 / TRANSFER=이관 / REASSIGN=재배정';
COMMENT ON COLUMN contract_succession.status IS 'ACTIVE=유효 / CANCELED=취소';
-- 배치 effective 조회용: contract_id + 효력일 역순
CREATE INDEX idx_cs_contract ON contract_succession(contract_id, status, succession_date DESC);
CREATE INDEX idx_cs_to_agent ON contract_succession(to_agent_id, status);
CREATE INDEX idx_cs_from_agent ON contract_succession(from_agent_id, status);
-- ============================================================
-- 메뉴 (GRP_PRD 하위, ENDORSE 옆)
-- ============================================================
INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active)
SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort, 'Y', 'Y'
FROM menu p
JOIN (VALUES
('CONTRACT_SUCCESSION', '계약 승계', '/contracts/successions', 'contracts/ContractSuccessions', 50)
) AS x(code, name, path, cmpt, sort) ON p.menu_code = 'GRP_PRD'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- 권한 항목 (READ/CREATE/UPDATE/DELETE)
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT m.menu_id, p.code, p.name, p.sort
FROM menu m
CROSS JOIN (VALUES
('READ', '조회', 10),
('CREATE', '등록', 20),
('UPDATE', '취소', 30),
('DELETE', '삭제', 40)
) AS p(code, name, sort)
WHERE m.menu_code = 'CONTRACT_SUCCESSION'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- 역할 부여: SUPER_ADMIN/ADMIN 전체
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND m.menu_code = 'CONTRACT_SUCCESSION'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER: READ 만
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code = 'MANAGER'
AND mp.perm_code = 'READ'
AND m.menu_code = 'CONTRACT_SUCCESSION'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 공통코드
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('SUCCESSION_REASON', '승계사유', 'TERMINATION/TRANSFER/REASSIGN', 'Y', 470),
('SUCCESSION_STATUS', '승계상태', 'ACTIVE/CANCELED', 'Y', 480)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, sort_order, is_active) VALUES
('SUCCESSION_REASON', 'TERMINATION', '해촉', 10, 'Y'),
('SUCCESSION_REASON', 'TRANSFER', '이관', 20, 'Y'),
('SUCCESSION_REASON', 'REASSIGN', '재배정', 30, 'Y'),
('SUCCESSION_STATUS', 'ACTIVE', '유효', 10, 'Y'),
('SUCCESSION_STATUS', 'CANCELED', '취소', 20, 'Y')
ON CONFLICT (group_code, code) DO NOTHING;
-- ============================================================
-- 테스트 데이터 (소량) — 테이블이 비어있고 활성 계약/타 설계사가 존재할 때만 1건
-- ============================================================
INSERT INTO contract_succession (contract_id, from_agent_id, to_agent_id, succession_date, reason, memo, status, created_at)
SELECT c.contract_id, c.agent_id, b.agent_id, DATE '2025-01-01', 'TERMINATION', '테스트 승계 데이터', 'ACTIVE', NOW()
FROM contract c
CROSS JOIN LATERAL (
SELECT agent_id FROM agent WHERE agent_id <> c.agent_id AND status = 'ACTIVE' ORDER BY agent_id LIMIT 1
) b
WHERE c.status = 'ACTIVE'
AND NOT EXISTS (SELECT 1 FROM contract_succession)
ORDER BY c.contract_id
LIMIT 1;
@@ -0,0 +1,20 @@
package com.ga.core.mapper.external;
import com.ga.core.vo.external.ExternalCallLogResp;
import com.ga.core.vo.external.ExternalCallLogSearchParam;
import com.ga.core.vo.external.ExternalCallLogVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ExternalCallLogMapper {
/** AOP @Around 영속화. selectKey 로 logId 채움. */
int insertOne(ExternalCallLogVO vo);
ExternalCallLogResp selectById(@Param("logId") Long logId);
List<ExternalCallLogResp> selectList(ExternalCallLogSearchParam param);
}
@@ -0,0 +1,25 @@
package com.ga.core.mapper.succession;
import com.ga.core.vo.succession.ContractSuccessionResp;
import com.ga.core.vo.succession.ContractSuccessionSearchParam;
import com.ga.core.vo.succession.ContractSuccessionVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ContractSuccessionMapper {
ContractSuccessionResp selectById(@Param("successionId") Long successionId);
List<ContractSuccessionResp> selectList(ContractSuccessionSearchParam param);
/**
* 배치 유지수수료 계산용 정산월 기준 유효한 승계의 (계약, 후임설계사) 매핑.
* 계약별로 succession_date YYYYMM 정산월 이하인 가장 최근 ACTIVE 승계 1건만.
*/
List<ContractSuccessionVO> selectEffectiveSuccessions(@Param("settleMonth") String settleMonth);
int insert(ContractSuccessionVO vo);
int cancel(@Param("successionId") Long successionId, @Param("updatedBy") Long updatedBy);
int delete(@Param("successionId") Long successionId);
}
@@ -26,6 +26,7 @@ public interface UserMapper {
int incrementFailCount(@Param("userId") Long userId); int incrementFailCount(@Param("userId") Long userId);
int resetFailCount(@Param("userId") Long userId); int resetFailCount(@Param("userId") Long userId);
int updateStatus(@Param("userId") Long userId, @Param("status") String status); int updateStatus(@Param("userId") Long userId, @Param("status") String status);
int updatePhone(@Param("userId") Long userId, @Param("phone") String phone);
List<String> selectRoleCodes(@Param("userId") Long userId); List<String> selectRoleCodes(@Param("userId") Long userId);
@@ -33,4 +33,23 @@ public interface WithdrawRequestMapper {
* account_no 복호화되어 반환. * account_no 복호화되어 반환.
*/ */
List<WithdrawRequestVO> selectFailedTransfers(@Param("limit") int limit); List<WithdrawRequestVO> selectFailedTransfers(@Param("limit") int limit);
/**
* SETTLED 동기화 대상 transfer_status='ACCEPTED' 이고 status='SENT'.
* queryStatus 폴링 결과로 입금 확정/실패를 판정한다.
*/
List<WithdrawRequestVO> selectAcceptedTransfers(@Param("limit") int limit);
/** queryStatus 가 SETTLED 응답: status='COMPLETED', transfer_status='SETTLED', completed_at=NOW(). */
int markSettled(@Param("requestId") Long requestId);
/** 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);
} }
@@ -0,0 +1,25 @@
package com.ga.core.vo.external;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ExternalCallLogResp {
private Long logId;
private String callType;
private String adapterClass;
private String methodName;
private String targetRefType;
private Long targetRefId;
private String requestSummary;
private String responseSummary;
private String resultCode;
private String resultMessage;
private Boolean success;
private Integer durationMs;
private String errorClass;
private String errorMessage;
private LocalDateTime calledAt;
private Long createdBy;
}
@@ -0,0 +1,17 @@
package com.ga.core.vo.external;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ExternalCallLogSearchParam extends SearchParam {
/** BANK / MESSAGE */
private String callType;
private String methodName;
/** true 만 / false 만 (실패 조회) — null 이면 전체 */
private Boolean success;
private String targetRefType;
private Long targetRefId;
}
@@ -0,0 +1,34 @@
package com.ga.core.vo.external;
import lombok.Data;
import java.time.LocalDateTime;
/**
* external_call_log 외부 연동(펌뱅킹/SMS·카카오) 호출 감사 이력 (V85).
* AOP @Around INSERT. 운영 감사·장애 추적용. PII 이미 어댑터에서 마스킹된 값만 들어간다.
*/
@Data
public class ExternalCallLogVO {
private Long logId;
/** BANK / MESSAGE */
private String callType;
/** 실제 호출된 구현체 (Mock/Kftc/Toast) */
private String adapterClass;
/** requestTransfer / queryStatus / send */
private String methodName;
/** WITHDRAW / APPROVAL 등 (가능 시) */
private String targetRefType;
private Long targetRefId;
/** 요청 요약 (마스킹된 JSON 한 줄) */
private String requestSummary;
private String responseSummary;
private String resultCode;
private String resultMessage;
private Boolean success;
private Integer durationMs;
private String errorClass;
private String errorMessage;
private LocalDateTime calledAt;
private Long createdBy;
}
@@ -0,0 +1,24 @@
package com.ga.core.vo.succession;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
public class ContractSuccessionResp {
private Long successionId;
private Long contractId;
private String policyNo;
private Long fromAgentId;
private String fromAgentName;
private Long toAgentId;
private String toAgentName;
private LocalDate successionDate;
private String reason;
private String reasonName;
private String memo;
private String status;
private String statusName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.ga.core.vo.succession;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
@Data
public class ContractSuccessionSaveReq {
@NotNull
private Long contractId;
@NotNull
private Long fromAgentId;
@NotNull
private Long toAgentId;
@NotNull
private LocalDate successionDate;
/** TERMINATION/TRANSFER/REASSIGN (기본 TERMINATION) */
private String reason;
private String memo;
}
@@ -0,0 +1,15 @@
package com.ga.core.vo.succession;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ContractSuccessionSearchParam extends SearchParam {
private Long contractId;
private Long fromAgentId;
private Long toAgentId;
private String reason;
private String status;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.succession;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* contract_succession 계약 승계 (V86)
* 유지수수료 귀속 설계사를 승계일 기준으로 전임후임 전환.
*/
@Data
public class ContractSuccessionVO {
private Long successionId;
private Long contractId;
private Long fromAgentId;
private Long toAgentId;
private LocalDate successionDate;
/** SUCCESSION_REASON: TERMINATION/TRANSFER/REASSIGN */
private String reason;
private String memo;
/** SUCCESSION_STATUS: ACTIVE/CANCELED */
private String status;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.external.ExternalCallLogMapper">
<resultMap id="RespMap" type="com.ga.core.vo.external.ExternalCallLogResp">
<id property="logId" column="log_id"/>
<result property="callType" column="call_type"/>
<result property="adapterClass" column="adapter_class"/>
<result property="methodName" column="method_name"/>
<result property="targetRefType" column="target_ref_type"/>
<result property="targetRefId" column="target_ref_id"/>
<result property="requestSummary" column="request_summary"/>
<result property="responseSummary" column="response_summary"/>
<result property="resultCode" column="result_code"/>
<result property="resultMessage" column="result_message"/>
<result property="success" column="success"/>
<result property="durationMs" column="duration_ms"/>
<result property="errorClass" column="error_class"/>
<result property="errorMessage" column="error_message"/>
<result property="calledAt" column="called_at"/>
<result property="createdBy" column="created_by"/>
</resultMap>
<sql id="cols">
log_id, call_type, adapter_class, method_name,
target_ref_type, target_ref_id,
request_summary, response_summary,
result_code, result_message, success, duration_ms,
error_class, error_message, called_at, created_by
</sql>
<insert id="insertOne" parameterType="com.ga.core.vo.external.ExternalCallLogVO"
useGeneratedKeys="true" keyProperty="logId" keyColumn="log_id">
INSERT INTO external_call_log (
call_type, adapter_class, method_name,
target_ref_type, target_ref_id,
request_summary, response_summary,
result_code, result_message, success, duration_ms,
error_class, error_message, called_at, created_by
) VALUES (
#{callType},
#{adapterClass},
#{methodName},
#{targetRefType, jdbcType=VARCHAR},
#{targetRefId, jdbcType=BIGINT},
#{requestSummary, jdbcType=LONGVARCHAR},
#{responseSummary, jdbcType=LONGVARCHAR},
#{resultCode, jdbcType=VARCHAR},
#{resultMessage, jdbcType=LONGVARCHAR},
#{success, jdbcType=BOOLEAN},
#{durationMs, jdbcType=INTEGER},
#{errorClass, jdbcType=VARCHAR},
#{errorMessage, jdbcType=LONGVARCHAR},
COALESCE(#{calledAt,jdbcType=TIMESTAMP}, CURRENT_TIMESTAMP),
#{createdBy, jdbcType=BIGINT}
)
</insert>
<select id="selectById" resultMap="RespMap">
SELECT <include refid="cols"/>
FROM external_call_log
WHERE log_id = #{logId}
</select>
<select id="selectList" resultMap="RespMap"
parameterType="com.ga.core.vo.external.ExternalCallLogSearchParam">
SELECT <include refid="cols"/>
FROM external_call_log
<where>
<if test="callType != null and callType != ''">
AND call_type = #{callType}
</if>
<if test="methodName != null and methodName != ''">
AND method_name = #{methodName}
</if>
<if test="success != null">
AND success = #{success}
</if>
<if test="targetRefType != null and targetRefType != ''">
AND target_ref_type = #{targetRefType}
</if>
<if test="targetRefId != null">
AND target_ref_id = #{targetRefId}
</if>
<if test="startDate != null and startDate != ''">
AND called_at &gt;= (#{startDate}::date)
</if>
<if test="endDate != null and endDate != ''">
AND called_at &lt; (#{endDate}::date + INTERVAL '1 day')
</if>
</where>
ORDER BY called_at DESC, log_id DESC
</select>
</mapper>
@@ -150,4 +150,13 @@
WHERE deduction_id = #{deductionId} WHERE deduction_id = #{deductionId}
</update> </update>
<update id="updateStatusBatch">
UPDATE agent_deduction
SET status = #{status}, updated_at = NOW()
WHERE deduction_id IN
<foreach item="id" collection="deductionIds" open="(" separator="," close=")">
#{id}
</foreach>
</update>
</mapper> </mapper>
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.succession.ContractSuccessionMapper">
<resultMap id="VOMap" type="com.ga.core.vo.succession.ContractSuccessionVO"/>
<resultMap id="RespMap" type="com.ga.core.vo.succession.ContractSuccessionResp"/>
<sql id="joinCols">
cs.succession_id, cs.contract_id, ct.policy_no,
cs.from_agent_id, fa.agent_name AS from_agent_name,
cs.to_agent_id, ta.agent_name AS to_agent_name,
cs.succession_date,
cs.reason,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'SUCCESSION_REASON' AND cc.code = cs.reason) AS reason_name,
cs.memo,
cs.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'SUCCESSION_STATUS' AND cc.code = cs.status) AS status_name,
cs.created_at
</sql>
<select id="selectById" resultMap="RespMap">
SELECT <include refid="joinCols"/>
FROM contract_succession cs
LEFT JOIN contract ct ON ct.contract_id = cs.contract_id
LEFT JOIN agent fa ON fa.agent_id = cs.from_agent_id
LEFT JOIN agent ta ON ta.agent_id = cs.to_agent_id
WHERE cs.succession_id = #{successionId}
</select>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="joinCols"/>
FROM contract_succession cs
LEFT JOIN contract ct ON ct.contract_id = cs.contract_id
LEFT JOIN agent fa ON fa.agent_id = cs.from_agent_id
LEFT JOIN agent ta ON ta.agent_id = cs.to_agent_id
<where>
<if test="contractId != null">AND cs.contract_id = #{contractId}</if>
<if test="fromAgentId != null">AND cs.from_agent_id = #{fromAgentId}</if>
<if test="toAgentId != null">AND cs.to_agent_id = #{toAgentId}</if>
<if test="reason != null and reason != ''">AND cs.reason = #{reason}</if>
<if test="status != null and status != ''">AND cs.status = #{status}</if>
</where>
ORDER BY cs.succession_date DESC, cs.succession_id DESC
</select>
<!-- 정산월 기준 계약별 유효 승계 (가장 최근 ACTIVE 1건) -->
<select id="selectEffectiveSuccessions" resultMap="VOMap">
SELECT DISTINCT ON (cs.contract_id)
cs.contract_id, cs.to_agent_id, cs.from_agent_id, cs.succession_date, cs.status
FROM contract_succession cs
WHERE cs.status = 'ACTIVE'
AND TO_CHAR(cs.succession_date, 'YYYYMM') &lt;= #{settleMonth}
ORDER BY cs.contract_id, cs.succession_date DESC, cs.succession_id DESC
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="successionId">
INSERT INTO contract_succession
(contract_id, from_agent_id, to_agent_id, succession_date, reason, memo, status, created_by)
VALUES
(#{contractId}, #{fromAgentId}, #{toAgentId}, #{successionDate},
COALESCE(#{reason}, 'TERMINATION'), #{memo}, 'ACTIVE', #{createdBy})
</insert>
<update id="cancel">
UPDATE contract_succession
SET status = 'CANCELED',
updated_at = NOW(),
updated_by = #{updatedBy}
WHERE succession_id = #{successionId}
</update>
<delete id="delete">
DELETE FROM contract_succession WHERE succession_id = #{successionId}
</delete>
</mapper>
@@ -110,6 +110,10 @@
UPDATE users SET status = #{status} WHERE user_id = #{userId} UPDATE users SET status = #{status} WHERE user_id = #{userId}
</update> </update>
<update id="updatePhone">
UPDATE users SET phone = #{phone,jdbcType=VARCHAR} WHERE user_id = #{userId}
</update>
<select id="selectRoleCodes" resultType="string"> <select id="selectRoleCodes" resultType="string">
SELECT r.role_code FROM role r SELECT r.role_code FROM role r
JOIN user_role ur ON ur.role_id = r.role_id JOIN user_role ur ON ur.role_id = r.role_id
@@ -176,4 +176,51 @@
WHERE request_id = #{requestId} WHERE request_id = #{requestId}
</update> </update>
<!-- SETTLED 동기화 대상 — 펌뱅킹 어댑터로 보냈고(SENT) 외부 응답이 접수만(ACCEPTED) 된 건 -->
<select id="selectAcceptedTransfers" resultMap="VOMap">
SELECT wr.request_id, wr.settle_master_id, wr.agent_id, wr.amount,
wr.requested_by, wr.requested_at, wr.approval_request_id,
wr.bank_code, wr.account_no, wr.status,
wr.sent_at, wr.completed_at, wr.fail_reason,
wr.transfer_tx_id, wr.transfer_status, wr.transfer_at,
wr.created_at, wr.updated_at
FROM withdraw_request wr
WHERE wr.transfer_status = 'ACCEPTED'
AND wr.status = 'SENT'
AND wr.transfer_tx_id IS NOT NULL
ORDER BY wr.transfer_at NULLS FIRST, wr.request_id
LIMIT #{limit}
</select>
<!-- 입금 확정 — SETTLED -->
<update id="markSettled">
UPDATE withdraw_request
SET transfer_status = 'SETTLED',
status = 'COMPLETED',
completed_at = NOW(),
fail_reason = NULL,
updated_at = NOW()
WHERE request_id = #{requestId}
AND status = 'SENT'
</update>
<!-- 입금 실패 동기화 — 재시도 풀(APPROVED + FAILED) 로 회귀 -->
<update id="markSettleFailed">
UPDATE withdraw_request
SET transfer_status = 'FAILED',
status = 'APPROVED',
fail_reason = #{failReason,jdbcType=VARCHAR},
updated_at = NOW()
WHERE request_id = #{requestId}
AND status = 'SENT'
</update>
<!-- 잔여 한도 검증용: 특정 settle_master 의 활성(CANCELLED 외) 출금 amount 합계 -->
<select id="sumActiveAmountByMaster" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(amount), 0)
FROM withdraw_request
WHERE settle_master_id = #{settleMasterId}
AND status != 'CANCELLED'
</select>
</mapper> </mapper>
+4
View File
@@ -32,6 +32,7 @@ import AgentTrainings from '@/pages/agent/AgentTrainings';
// ── P4: 계약 운영 ──────────────────────────────────── // ── P4: 계약 운영 ────────────────────────────────────
import ContractEndorsements from '@/pages/contracts/ContractEndorsements'; import ContractEndorsements from '@/pages/contracts/ContractEndorsements';
import ContractSuccessions from '@/pages/contracts/ContractSuccessions';
import Complaints from '@/pages/contracts/Complaints'; import Complaints from '@/pages/contracts/Complaints';
// ── P4: 시스템 ────────────────────────────────────── // ── P4: 시스템 ──────────────────────────────────────
@@ -94,6 +95,7 @@ import SystemLog from '@/pages/system/SystemLog';
import UploadTemplateManager from '@/pages/system/UploadTemplateManager'; import UploadTemplateManager from '@/pages/system/UploadTemplateManager';
import DataDomainList from '@/pages/system/DataDomainList'; import DataDomainList from '@/pages/system/DataDomainList';
import DataDictionary from '@/pages/system/DataDictionary'; import DataDictionary from '@/pages/system/DataDictionary';
import ExternalCallLogList from '@/pages/system/ExternalCallLogList';
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('accessToken'); const token = localStorage.getItem('accessToken');
@@ -190,6 +192,7 @@ export default function App() {
{/* P4: 계약 운영 */} {/* P4: 계약 운영 */}
<Route path="contracts/endorsements" element={<ContractEndorsements />} /> <Route path="contracts/endorsements" element={<ContractEndorsements />} />
<Route path="contracts/successions" element={<ContractSuccessions />} />
<Route path="contracts/complaints" element={<Complaints />} /> <Route path="contracts/complaints" element={<Complaints />} />
{/* P4: 시스템 */} {/* P4: 시스템 */}
@@ -209,6 +212,7 @@ export default function App() {
<Route path="system/upload-templates" element={<UploadTemplateManager />} /> <Route path="system/upload-templates" element={<UploadTemplateManager />} />
<Route path="system/data-domains" element={<DataDomainList />} /> <Route path="system/data-domains" element={<DataDomainList />} />
<Route path="system/data-dictionary" element={<DataDictionary />} /> <Route path="system/data-dictionary" element={<DataDictionary />} />
<Route path="system/external-call-log" element={<ExternalCallLogList />} />
</Route> </Route>
</Routes> </Routes>
); );
+41
View File
@@ -0,0 +1,41 @@
import api, { unwrap } from './request';
export interface ExternalCallLogResp extends Record<string, unknown> {
logId: number;
callType: string; // BANK / MESSAGE
adapterClass: string; // Mock / Kftc / Toast
methodName: string; // requestTransfer / queryStatus / send
targetRefType?: string;
targetRefId?: number;
requestSummary?: string;
responseSummary?: string;
resultCode?: string;
resultMessage?: string;
success: boolean;
durationMs?: number;
errorClass?: string;
errorMessage?: string;
calledAt: string;
createdBy?: number;
}
export interface ExternalCallLogSearchParam {
callType?: string;
methodName?: string;
success?: boolean;
targetRefType?: string;
targetRefId?: number;
startDate?: string;
endDate?: string;
pageNum?: number;
pageSize?: number;
}
export const externalCallLogApi = {
list: (params?: ExternalCallLogSearchParam) =>
unwrap<{ list: ExternalCallLogResp[]; total: number }>(
api.get('/api/external-call-logs', { params })
),
detail: (id: number) =>
unwrap<ExternalCallLogResp>(api.get(`/api/external-call-logs/${id}`)),
};
+50
View File
@@ -0,0 +1,50 @@
import api, { PageResponse, unwrap } from './request';
export interface ContractSuccessionRow extends Record<string, unknown> {
successionId: number;
contractId: number;
policyNo: string;
fromAgentId: number;
fromAgentName: string;
toAgentId: number;
toAgentName: string;
successionDate: string;
reason: string; // TERMINATION / TRANSFER / REASSIGN
reasonName: string;
memo?: string;
status: string; // ACTIVE / CANCELED
statusName: string;
createdAt: string;
}
export interface ContractSuccessionSaveReq {
contractId: number;
fromAgentId: number;
toAgentId: number;
successionDate: string;
reason?: string;
memo?: string;
}
export interface SuccessionSearchParam {
contractId?: number;
fromAgentId?: number;
toAgentId?: number;
reason?: string;
status?: string;
pageNum?: number;
pageSize?: number;
}
export const successionApi = {
list: (p: SuccessionSearchParam) =>
unwrap<PageResponse<ContractSuccessionRow>>(
api.get('/api/contract-successions', { params: p }),
),
create: (body: ContractSuccessionSaveReq) =>
unwrap<number>(api.post('/api/contract-successions', body)),
cancel: (id: number) =>
unwrap<void>(api.post(`/api/contract-successions/${id}/cancel`)),
delete: (id: number) =>
unwrap<void>(api.delete(`/api/contract-successions/${id}`)),
};
+2
View File
@@ -35,6 +35,8 @@ export const userApi = {
update: (id: number, req: UserSaveReq) => unwrap<void>(api.put(`/api/system/users/${id}`, req)), update: (id: number, req: UserSaveReq) => unwrap<void>(api.put(`/api/system/users/${id}`, req)),
resetPassword: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/reset-password`)), resetPassword: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/reset-password`)),
unlock: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/unlock`)), unlock: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/unlock`)),
updatePhone: (id: number, phone: string) =>
unwrap<void>(api.put(`/api/system/users/${id}/phone`, { phone })),
}; };
export interface RoleVO { export interface RoleVO {
@@ -0,0 +1,148 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, Select, Tag, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import CodeBadge from '@/components/common/CodeBadge';
import { successionApi, ContractSuccessionRow, ContractSuccessionSaveReq, SuccessionSearchParam } from '@/api/succession';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const COLUMNS: ColDef<ContractSuccessionRow>[] = [
{ field: 'policyNo', headerName: '증권번호', width: 150, pinned: 'left' },
{ field: 'fromAgentName', headerName: '전임 설계사', width: 130 },
{ field: 'toAgentName', headerName: '후임 설계사', width: 130 },
{ field: 'successionDate', headerName: '승계일', width: 120 },
{
field: 'reason', headerName: '사유', width: 110,
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="SUCCESSION_REASON" value={p.value} />,
},
{ field: 'memo', headerName: '메모', flex: 1 },
{
field: 'status', headerName: '상태', width: 100,
cellRenderer: (p: { value: string }) =>
p.value === 'CANCELED'
? <Tag color="default" style={{ margin: 0 }}></Tag>
: <Tag color="green" style={{ margin: 0, fontWeight: 700 }}></Tag>,
},
{ field: 'createdAt', headerName: '등록일', width: 120 },
];
export default function ContractSuccessions() {
const qc = useQueryClient();
const [params, setParams] = useState<SuccessionSearchParam>({ pageSize: 100 });
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['successions', params],
queryFn: () => successionApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const handleCreate = async () => {
const values = await form.validateFields() as ContractSuccessionSaveReq;
try {
await successionApi.create(values);
message.success('계약 승계 등록 완료');
qc.invalidateQueries({ queryKey: ['successions'] });
setCreateOpen(false);
form.resetFields();
} catch { /* handled by interceptor */ }
};
const handleCancel = (id: number) => Modal.confirm({
title: '승계 취소',
content: '이 승계를 취소합니다. 이후 정산월부터 유지수수료는 다시 직전 귀속 설계사에게 계산됩니다.',
okText: '취소 처리',
cancelText: '닫기',
onOk: async () => {
await successionApi.cancel(id);
message.success('승계 취소 완료');
qc.invalidateQueries({ queryKey: ['successions'] });
},
});
return (
<PageContainer
title="계약 승계 관리"
description="설계사 해촉·이관 시 보험계약의 유지수수료 귀속 설계사를 후임으로 전환 (모집수수료는 원 설계사 귀속 유지)"
extra={
<PermissionButton menuCode="CONTRACT_SUCCESSION" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
+
</PermissionButton>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/contract-successions 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'code', name: 'reason', label: '사유', groupCode: 'SUCCESSION_REASON', span: 6 },
{ type: 'code', name: 'status', label: '상태', groupCode: 'SUCCESSION_STATUS', span: 6 },
]}
onSearch={(v) => setParams({ ...v as SuccessionSearchParam, pageSize: 100 })}
onReset={() => setParams({ pageSize: 100 })}
/>
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
<DataGrid<ContractSuccessionRow>
rows={rows}
columns={[
...COLUMNS,
{
headerName: '액션', width: 110, pinned: 'right',
cellRenderer: (p: { data: ContractSuccessionRow }) => (
<PermissionButton
menuCode="CONTRACT_SUCCESSION" permCode="UPDATE" size="small" danger
disabled={p.data.status === 'CANCELED'}
onClick={() => handleCancel(p.data.successionId)}
>
</PermissionButton>
),
},
]}
loading={isLoading}
height={600}
rowKey="successionId"
/>
</ProCard>
<Modal title="계약 승계 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
<Form form={form} layout="vertical" initialValues={{ reason: 'TERMINATION' }}>
<Form.Item name="contractId" label="계약 ID" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} placeholder="contract_id" />
</Form.Item>
<Form.Item name="fromAgentId" label="전임 설계사 ID" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="toAgentId" label="후임 설계사 ID" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="successionDate" label="승계일" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="reason" label="사유" rules={[{ required: true }]}>
<Select
options={[
{ value: 'TERMINATION', label: '해촉' },
{ value: 'TRANSFER', label: '이관' },
{ value: 'REASSIGN', label: '재배정' },
]}
/>
</Form.Item>
<Form.Item name="memo" label="메모">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,113 @@
import { useState } from 'react';
import { Tag, Drawer, Descriptions } from 'antd';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import { externalCallLogApi, ExternalCallLogResp } from '@/api/externalCallLog';
export default function ExternalCallLogList() {
const [detail, setDetail] = useState<ExternalCallLogResp | null>(null);
const columns: ProColumns<ExternalCallLogResp>[] = [
{ title: '호출시각', dataIndex: 'calledAt', width: 170, valueType: 'dateTime' },
{
title: '종류', dataIndex: 'callType', width: 90, valueType: 'select',
valueEnum: { BANK: { text: '펌뱅킹' }, MESSAGE: { text: 'SMS/카카오' } },
render: (_, r) => (
<Tag color={r.callType === 'BANK' ? 'geekblue' : 'magenta'}>{r.callType}</Tag>
),
},
{ title: '구현체', dataIndex: 'adapterClass', width: 200, search: false, ellipsis: true },
{ title: '메서드', dataIndex: 'methodName', width: 140 },
{
title: '대상', dataIndex: 'targetRefType', width: 160, search: false,
render: (_, r) =>
r.targetRefType ? `${r.targetRefType}#${r.targetRefId ?? ''}` : '-',
},
{
title: '결과', dataIndex: 'success', width: 80,
valueType: 'select',
valueEnum: { true: { text: '성공' }, false: { text: '실패' } },
render: (_, r) => (
<Tag color={r.success ? 'success' : 'error'}>{r.success ? 'SUCCESS' : 'FAIL'}</Tag>
),
},
{ title: '코드', dataIndex: 'resultCode', width: 90, search: false },
{
title: '소요(ms)', dataIndex: 'durationMs', width: 90, align: 'right', search: false,
},
{
title: '메시지', dataIndex: 'resultMessage', ellipsis: true, search: false,
render: (_, r) => r.errorMessage ?? r.resultMessage ?? '-',
},
];
return (
<PageContainer
title="외부연동 호출이력"
description="펌뱅킹·SMS·카카오 어댑터 호출 감사 (AOP 자동 영속화)"
>
<ProTable<ExternalCallLogResp>
rowKey="logId"
columns={columns}
dateFormatter="string"
scroll={{ x: 1300 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await externalCallLogApi.list({
...rest, pageNum: current, pageSize,
});
return { data: res.list, total: res.total, success: true };
}}
onRow={(record) => ({ onClick: () => setDetail(record), style: { cursor: 'pointer' } })}
pagination={{ pageSize: 50, showTotal: (t) => `${t.toLocaleString()}` }}
search={{
labelWidth: 'auto', searchText: '검색', resetText: '초기화',
collapsed: false, collapseRender: false,
}}
/>
<Drawer
title={`호출 #${detail?.logId ?? ''}`}
width={640}
open={!!detail}
onClose={() => setDetail(null)}
destroyOnClose
>
{detail && (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="호출시각">{detail.calledAt}</Descriptions.Item>
<Descriptions.Item label="종류">{detail.callType}</Descriptions.Item>
<Descriptions.Item label="구현체">{detail.adapterClass}</Descriptions.Item>
<Descriptions.Item label="메서드">{detail.methodName}</Descriptions.Item>
<Descriptions.Item label="대상">
{detail.targetRefType ? `${detail.targetRefType}#${detail.targetRefId ?? ''}` : '-'}
</Descriptions.Item>
<Descriptions.Item label="결과">
<Tag color={detail.success ? 'success' : 'error'}>
{detail.success ? 'SUCCESS' : 'FAIL'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="코드">{detail.resultCode ?? '-'}</Descriptions.Item>
<Descriptions.Item label="메시지">{detail.resultMessage ?? '-'}</Descriptions.Item>
<Descriptions.Item label="소요(ms)">{detail.durationMs ?? '-'}</Descriptions.Item>
<Descriptions.Item label="요청">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{detail.requestSummary ?? '-'}
</pre>
</Descriptions.Item>
<Descriptions.Item label="응답">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{detail.responseSummary ?? '-'}
</pre>
</Descriptions.Item>
{detail.errorClass && (
<Descriptions.Item label="예외">
{detail.errorClass}: {detail.errorMessage ?? ''}
</Descriptions.Item>
)}
</Descriptions>
)}
</Drawer>
</PageContainer>
);
}
+39 -3
View File
@@ -1,5 +1,5 @@
import { useRef } from 'react'; import { useRef, useState } from 'react';
import { Modal, Space, message } from 'antd'; import { Input, Modal, Space, message } from 'antd';
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components'; import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer'; import PageContainer from '@/components/common/PageContainer';
@@ -31,6 +31,15 @@ export default function UserList() {
onOk: async () => { await userApi.unlock(id); message.success('해제 완료'); refresh(); }, onOk: async () => { await userApi.unlock(id); message.success('해제 완료'); refresh(); },
}); });
const [phoneEdit, setPhoneEdit] = useState<{ id: number; loginId: string; phone: string } | null>(null);
const submitPhone = async () => {
if (!phoneEdit) return;
await userApi.updatePhone(phoneEdit.id, phoneEdit.phone);
message.success('전화번호 저장 완료');
setPhoneEdit(null);
refresh();
};
const columns: ProColumns<UserResp>[] = [ const columns: ProColumns<UserResp>[] = [
{ {
title: '검색어', dataIndex: 'searchKeyword', hideInTable: true, title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
@@ -44,14 +53,20 @@ export default function UserList() {
{ title: '로그인 ID', dataIndex: 'loginId', width: 120, search: false }, { title: '로그인 ID', dataIndex: 'loginId', width: 120, search: false },
{ title: '이름', dataIndex: 'userName', width: 120, search: false }, { title: '이름', dataIndex: 'userName', width: 120, search: false },
{ title: '이메일', dataIndex: 'email', width: 200, search: false }, { title: '이메일', dataIndex: 'email', width: 200, search: false },
{ title: '전화번호', dataIndex: 'phone', width: 140, search: false,
render: (_, r) => r.phone ?? <span style={{ color: '#bbb' }}></span> },
{ title: '소속', dataIndex: 'orgName', width: 160, search: false }, { title: '소속', dataIndex: 'orgName', width: 160, search: false },
{ title: '연결 설계사', dataIndex: 'agentName', width: 100, search: false }, { title: '연결 설계사', dataIndex: 'agentName', width: 100, search: false },
{ title: '실패횟수', dataIndex: 'failCount', width: 90, align: 'right', search: false }, { title: '실패횟수', dataIndex: 'failCount', width: 90, align: 'right', search: false },
{ title: '마지막 로그인', dataIndex: 'lastLoginAt', width: 160, search: false }, { title: '마지막 로그인', dataIndex: 'lastLoginAt', width: 160, search: false },
{ {
title: '액션', width: 220, fixed: 'right', search: false, title: '액션', width: 300, fixed: 'right', search: false,
render: (_, r) => ( render: (_, r) => (
<Space> <Space>
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small"
onClick={() => setPhoneEdit({ id: r.userId, loginId: r.loginId, phone: r.phone ?? '' })}>
</PermissionButton>
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small" <PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small"
onClick={() => onReset(r.userId)}></PermissionButton> onClick={() => onReset(r.userId)}></PermissionButton>
{r.status === 'LOCKED' && ( {r.status === 'LOCKED' && (
@@ -83,6 +98,27 @@ export default function UserList() {
options={{ density: false, fullScreen: true, reload: true, setting: true }} options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string" dateFormatter="string"
/> />
<Modal
title={`전화번호 등록${phoneEdit ? `${phoneEdit.loginId}` : ''}`}
open={!!phoneEdit}
onCancel={() => setPhoneEdit(null)}
onOk={submitPhone}
okText="저장"
cancelText="취소"
destroyOnClose
>
<Input
autoFocus
placeholder="010-1234-5678"
value={phoneEdit?.phone ?? ''}
onChange={(e) => setPhoneEdit((p) => (p ? { ...p, phone: e.target.value } : p))}
onPressEnter={submitPhone}
/>
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
SMS . SMS .
</div>
</Modal>
</PageContainer> </PageContainer>
); );
} }
+50
View File
@@ -0,0 +1,50 @@
# ga-mobile-pwa
설계사 전용 모바일 셀프 조회 PWA 스캐폴드.
## 스택
- Vite + React 18 + TypeScript
- antd-mobile@5 (모바일 UI)
- vite-plugin-pwa (manifest + service worker autoUpdate)
- React Query (서버 상태) + Zustand (인증)
- 백엔드: 기존 `ga-api`(8082) 그대로 재사용. 로그인 후 `/api/auth/me``agentId` 로 본인 데이터 필터링.
## 화면
| 경로 | 화면 | 백엔드 API |
|---|---|---|
| `/login` | 로그인 | `POST /api/auth/login`, `GET /api/auth/me` |
| `/` | 홈 (요약 카드 + 메뉴) | 위 3개 카운트 |
| `/statement` | 정산 명세서 | `GET /api/statements?agentId=…` |
| `/withdraw` | 출금 신청 이력 | `GET /api/withdraw-requests?agentId=…` |
| `/notice` | 공지사항 | `GET /api/notices/active` |
## 실행
```bash
cd ga-mobile-pwa
npm install
npm run dev # http://localhost:3001
```
`vite.config.ts``/api` 프록시가 `localhost:8082` 로 전달하므로 ga-api 가 가동 중이어야 한다.
## 빌드 / 프리뷰
```bash
npm run build # tsc --noEmit + vite build (dist/)
npm run preview # 3001 에서 production 번들 서빙
```
## PWA
- `manifest.webmanifest` 자동 생성 (`vite-plugin-pwa`)
- 서비스워커: `registerType=autoUpdate`. 신규 빌드 시 사용자 새로고침 시점에 갱신.
- 런타임 캐시: `/api/**` NetworkFirst, 5초 타임아웃, 5분 유효 (오프라인 시 직전 응답 반환)
- 아이콘: `public/pwa-{192,512}.svg` (스캐폴드 — 본 배포 시 PNG 권장)
## 잔여 작업 (스캐폴드 이후)
- [ ] 로그인 응답에서 직접 profile 추출 (현재 `/me` 추가 호출)
- [ ] 백엔드 `/api/mobile/me/*` 전용 endpoint — 본인 agentId 자동 필터 (현 클라이언트 필터는 변조 가능)
- [ ] 출금 신청 작성 화면 (현재 이력 조회만)
- [ ] 정산 명세서 상세 + PDF 다운로드
- [ ] 공지 상세 + 본인 알림(`user_notification`) 표시
- [ ] 토큰 만료 자동 refresh (`/api/auth/refresh`)
- [ ] 푸시 알림 (FCM 등) — 외부 채널 결정 후
- [ ] 실 아이콘 PNG (192/512/512 maskable)
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#1677ff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>GA 설계사 셀프 조회</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6749
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "ga-mobile-pwa",
"private": true,
"version": "0.1.0-alpha",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 3001"
},
"dependencies": {
"@tanstack/react-query": "^5.40.0",
"antd-mobile": "^5.37.1",
"antd-mobile-icons": "^0.3.0",
"axios": "^1.7.2",
"dayjs": "^1.11.11",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/node": "^20.12.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vite-plugin-pwa": "^0.20.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1677ff"/>
<text x="16" y="22" font-family="Arial, sans-serif" font-size="14" font-weight="700" text-anchor="middle" fill="#fff">GA</text>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" rx="36" fill="#1677ff"/>
<text x="96" y="120" font-family="Arial, sans-serif" font-size="78" font-weight="700" text-anchor="middle" fill="#fff">GA</text>
</svg>

After

Width:  |  Height:  |  Size: 259 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#1677ff"/>
<text x="256" y="320" font-family="Arial, sans-serif" font-size="208" font-weight="700" text-anchor="middle" fill="#fff">GA</text>
</svg>

After

Width:  |  Height:  |  Size: 261 B

+84
View File
@@ -0,0 +1,84 @@
import { Route, Routes } from 'react-router-dom';
import RequireAuth from '@/components/RequireAuth';
import MobileTabBar from '@/components/TabBar';
import Login from '@/pages/Login';
import Home from '@/pages/Home';
import StatementList from '@/pages/StatementList';
import StatementDetail from '@/pages/StatementDetail';
import WithdrawList from '@/pages/WithdrawList';
import WithdrawCreate from '@/pages/WithdrawCreate';
import NoticeList from '@/pages/NoticeList';
import Profile from '@/pages/Profile';
function AuthedLayout({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<MobileTabBar />
</>
);
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<RequireAuth>
<AuthedLayout><Home /></AuthedLayout>
</RequireAuth>
}
/>
<Route
path="/statement"
element={
<RequireAuth>
<AuthedLayout><StatementList /></AuthedLayout>
</RequireAuth>
}
/>
<Route
path="/statement/:statementId"
element={
<RequireAuth>
<StatementDetail />
</RequireAuth>
}
/>
<Route
path="/withdraw"
element={
<RequireAuth>
<AuthedLayout><WithdrawList /></AuthedLayout>
</RequireAuth>
}
/>
<Route
path="/withdraw/new"
element={
<RequireAuth>
<WithdrawCreate />
</RequireAuth>
}
/>
<Route
path="/notice"
element={
<RequireAuth>
<AuthedLayout><NoticeList /></AuthedLayout>
</RequireAuth>
}
/>
<Route
path="/profile"
element={
<RequireAuth>
<AuthedLayout><Profile /></AuthedLayout>
</RequireAuth>
}
/>
</Routes>
);
}
+45
View File
@@ -0,0 +1,45 @@
import api, { unwrap } from './request';
export interface LoginReq {
loginId: string;
password: string;
}
export interface LoginResp {
accessToken: string;
refreshToken: string;
user?: {
userId: number;
loginId: string;
userName?: string;
agentId?: number;
roles?: string[];
};
}
export interface MeResp {
userId: number;
loginId: string;
userName?: string;
email?: string;
phone?: string;
agentId?: number;
agentName?: string;
orgName?: string;
status?: string;
roleCodes?: string[];
roleNames?: string[];
}
export const authApi = {
login: (req: LoginReq) =>
unwrap<LoginResp>(api.post('/api/auth/login', req)),
me: () => unwrap<MeResp>(api.get('/api/auth/me')),
refresh: (refreshToken: string) =>
unwrap<LoginResp>(api.post('/api/auth/refresh', { refreshToken })),
changePassword: (oldPassword: string, newPassword: string) =>
unwrap<void>(api.post('/api/auth/password', { oldPassword, newPassword })),
updateMyPhone: (phone: string) =>
unwrap<void>(api.put('/api/mobile/me/profile/phone', { phone })),
logout: () => api.post('/api/auth/logout').catch(() => undefined),
};
+18
View File
@@ -0,0 +1,18 @@
import api, { unwrap } from './request';
export interface NoticeResp extends Record<string, unknown> {
noticeId: number;
title: string;
content?: string;
noticeType?: string;
startAt?: string;
endAt?: string;
isActive: boolean;
createdAt?: string;
createdByName?: string;
}
export const noticeApi = {
active: () => unwrap<NoticeResp[]>(api.get('/api/notices/active')),
detail: (id: number) => unwrap<NoticeResp>(api.get(`/api/notices/${id}`)),
};
+38
View File
@@ -0,0 +1,38 @@
import api, { unwrap, PageResponse } from './request';
export interface NotificationResp extends Record<string, unknown> {
notificationId: number;
userId: number;
title: string;
content?: string;
refType?: string;
refId?: number;
isRead: boolean;
readAt?: string;
createdAt?: string;
}
export interface NotificationSearchParam {
isRead?: boolean;
refType?: string;
pageNum?: number;
pageSize?: number;
}
export interface MeSummary {
userId: number;
agentId?: number;
statementCount: number;
withdrawRequestCount: number;
unreadNotificationCount: number;
}
export const notificationApi = {
list: (params: NotificationSearchParam = {}) =>
unwrap<PageResponse<NotificationResp>>(api.get('/api/mobile/me/notifications', { params })),
markRead: (id: number) =>
unwrap<void>(api.post(`/api/mobile/me/notifications/${id}/read`, {})),
markAllRead: () =>
unwrap<number>(api.post('/api/mobile/me/notifications/read-all', {})),
summary: () => unwrap<MeSummary>(api.get('/api/mobile/me/summary')),
};
+116
View File
@@ -0,0 +1,116 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { Toast } from 'antd-mobile';
export interface ApiResponse<T> {
success: boolean;
code: string;
message: string;
data: T;
timestamp: string;
}
export interface PageResponse<T> {
list: T[];
pageNum: number;
pageSize: number;
total: number;
pages: number;
}
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '',
timeout: 30_000,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('mobile.accessToken');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// ── 401 → refreshToken 으로 access 갱신 후 원 요청 재시도 ──
// 동시 401 다발 시 단일 refresh 호출로 합치고, 그 결과를 모두에 broadcast.
let refreshing: Promise<string | null> | null = null;
async function refreshAccessToken(): Promise<string | null> {
const refreshToken = localStorage.getItem('mobile.refreshToken');
if (!refreshToken) return null;
try {
const res = await axios.post<ApiResponse<{ accessToken: string }>>(
'/api/auth/refresh',
{ refreshToken },
{ timeout: 10_000 },
);
const body = res.data;
if (!body?.success || !body.data?.accessToken) return null;
const next = body.data.accessToken;
localStorage.setItem('mobile.accessToken', next);
return next;
} catch {
return null;
}
}
function clearSessionAndRedirect() {
localStorage.removeItem('mobile.accessToken');
localStorage.removeItem('mobile.refreshToken');
localStorage.removeItem('mobile.profile');
if (!window.location.pathname.startsWith('/login')) {
window.location.href = '/login';
}
}
api.interceptors.response.use(
(res) => {
const body = res.data;
if (body && typeof body === 'object' && 'success' in body && body.success === false) {
Toast.show({ icon: 'fail', content: body.message ?? '오류가 발생했습니다' });
return Promise.reject(body);
}
return res;
},
async (err: AxiosError<ApiResponse<unknown>>) => {
const status = err.response?.status;
const body = err.response?.data;
const config = err.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined;
if (status === 401 && config && !config._retried) {
// 무한 재시도 차단 + refresh 호출 자체의 401 차단
if (config.url?.includes('/api/auth/refresh') || config.url?.includes('/api/auth/login')) {
clearSessionAndRedirect();
return Promise.reject(err);
}
config._retried = true;
if (!refreshing) {
refreshing = refreshAccessToken().finally(() => {
refreshing = null;
});
}
const newAccess = await refreshing;
if (newAccess) {
config.headers = config.headers ?? ({} as any);
(config.headers as Record<string, string>).Authorization = `Bearer ${newAccess}`;
return api.request(config);
}
clearSessionAndRedirect();
return Promise.reject(err);
}
if (status === 401) {
clearSessionAndRedirect();
} else if (status === 403) {
Toast.show({ icon: 'fail', content: body?.message ?? '권한이 없습니다' });
} else {
Toast.show({ icon: 'fail', content: body?.message ?? err.message ?? '서버 오류' });
}
return Promise.reject(err);
},
);
export default api;
export async function unwrap<T>(p: Promise<{ data: ApiResponse<T> }>): Promise<T> {
const res = await p;
return res.data.data;
}
+45
View File
@@ -0,0 +1,45 @@
import api, { unwrap, PageResponse } from './request';
export interface StatementResp extends Record<string, unknown> {
statementId: number;
agentId: number;
agentName?: string;
settleMonth: string;
statementType: string;
statementTypeName?: string;
issueStatus?: string;
issueStatusName?: string;
totalCommission?: number;
totalTax?: number;
netAmount?: number;
issuedAt?: string;
}
export interface StatementSearchParam {
settleMonth?: string;
statementType?: string;
issueStatus?: string;
pageNum?: number;
pageSize?: number;
}
export interface StatementDetailResp extends StatementResp {
grossAmount?: number;
incomeTaxAmount?: number;
localTaxAmount?: number;
vatAmount?: number;
deductionAmount?: number;
filePath?: string;
fileFormat?: string;
issuedBy?: string;
}
export const statementApi = {
/** 본인 명세서 — 서버측이 토큰 → agentId 강제 필터. */
list: (params: StatementSearchParam = {}) =>
unwrap<PageResponse<StatementResp>>(api.get('/api/mobile/me/statements', { params })),
/** 본인 명세서 단건 — 서버측이 본인 agentId 가 아니면 E403. */
detail: (statementId: number) =>
unwrap<StatementDetailResp>(api.get(`/api/mobile/me/statements/${statementId}`)),
};
+73
View File
@@ -0,0 +1,73 @@
import api, { unwrap, PageResponse } from './request';
export interface WithdrawResp extends Record<string, unknown> {
requestId: number;
settleMasterId?: number;
settleMonth?: string;
agentId: number;
agentName?: string;
amount: number;
bankCode?: string;
accountNo?: string;
status: string;
statusName?: string;
requestedAt?: string;
sentAt?: string;
completedAt?: string;
failReason?: string;
transferTxId?: string;
transferStatus?: string;
}
export interface WithdrawSearchParam {
settleMasterId?: number;
pageNum?: number;
pageSize?: number;
}
export interface SettleMasterResp {
settleId: number;
settleMonth: string;
netAmount?: number;
grossAmount?: number;
status?: string;
statusName?: string;
}
export interface WithdrawCreateReq {
settleMasterId: number;
amount: number;
bankCode: string;
accountNo: string;
}
export interface WithdrawableBalance {
settleId: number;
netAmount: number;
usedAmount: number;
remainingAmount: number;
}
export const withdrawApi = {
/** 본인 출금 신청 이력 — 서버측이 토큰 → agentId 강제 필터. */
list: (params: WithdrawSearchParam = {}) =>
unwrap<PageResponse<WithdrawResp>>(api.get('/api/mobile/me/withdraw-requests', { params })),
/** 출금 신청 가능 정산 마스터 (본인 agentId 기준). */
settleMasters: () =>
unwrap<SettleMasterResp[]>(api.get('/api/mobile/me/settle-masters')),
/** 선택한 정산 마스터의 출금 가능 잔액 (net - 활성 출금합계). */
withdrawableBalance: (settleMasterId: number) =>
unwrap<WithdrawableBalance>(
api.get(`/api/mobile/me/settle-masters/${settleMasterId}/withdrawable-balance`),
),
/** 본인 출금 신청 등록. */
create: (req: WithdrawCreateReq) =>
unwrap<number>(api.post('/api/mobile/me/withdraw-requests', req)),
/** 본인 출금 신청 취소 (REQUESTED 상태만). */
cancel: (requestId: number) =>
unwrap<void>(api.post(`/api/mobile/me/withdraw-requests/${requestId}/cancel`)),
};
@@ -0,0 +1,20 @@
import { ReactNode, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/stores/authStore';
import { authApi } from '@/api/auth';
export default function RequireAuth({ children }: { children: ReactNode }) {
const { accessToken, profile, setProfile, clear } = useAuthStore();
const location = useLocation();
useEffect(() => {
if (accessToken && !profile) {
authApi.me().then(setProfile).catch(() => clear());
}
}, [accessToken, profile, setProfile, clear]);
if (!accessToken) {
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
}
return <>{children}</>;
}
+37
View File
@@ -0,0 +1,37 @@
import { TabBar } from 'antd-mobile';
import { AppOutline, FileOutline, PayCircleOutline, SoundOutline, UserOutline } from 'antd-mobile-icons';
import { useLocation, useNavigate } from 'react-router-dom';
const tabs = [
{ key: '/', title: '홈', icon: <AppOutline /> },
{ key: '/statement', title: '명세서', icon: <FileOutline /> },
{ key: '/withdraw', title: '출금', icon: <PayCircleOutline /> },
{ key: '/notice', title: '공지', icon: <SoundOutline /> },
{ key: '/profile', title: '내정보', icon: <UserOutline /> },
];
export default function MobileTabBar() {
const navigate = useNavigate();
const location = useLocation();
const activeKey = tabs
.map((t) => t.key)
.reduce((acc, k) => (location.pathname === k || (k !== '/' && location.pathname.startsWith(k)) ? k : acc), '/');
return (
<div
style={{
position: 'fixed', bottom: 0, left: 0, right: 0,
background: '#fff', borderTop: '1px solid #eee',
paddingBottom: 'env(safe-area-inset-bottom, 0)',
zIndex: 50,
}}
>
<TabBar activeKey={activeKey} onChange={(k) => navigate(k)}>
{tabs.map((t) => (
<TabBar.Item key={t.key} icon={t.icon} title={t.title} />
))}
</TabBar>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './styles.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false },
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);
+83
View File
@@ -0,0 +1,83 @@
import { Card, List, NavBar, Space, Tag } from 'antd-mobile';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/stores/authStore';
import { notificationApi } from '@/api/notification';
export default function Home() {
const navigate = useNavigate();
const profile = useAuthStore((s) => s.profile);
const clear = useAuthStore((s) => s.clear);
const summary = useQuery({
queryKey: ['mobile.me.summary'],
queryFn: () => notificationApi.summary(),
staleTime: 10_000,
});
const s = summary.data;
const loading = summary.isLoading;
return (
<div className="page">
<NavBar back={null} right={<a onClick={() => { clear(); navigate('/login'); }} style={{ fontSize: 14 }}></a>}>
</NavBar>
<div style={{ padding: 16 }}>
<Card>
<div style={{ fontSize: 18, fontWeight: 700 }}>
{profile?.userName ?? profile?.loginId ?? '사용자'}
</div>
<div style={{ color: '#888', fontSize: 13, marginTop: 4 }}>
{profile?.agentName ? `${profile.agentName} · ${profile.orgName ?? ''}` : '설계사 정보 없음'}
</div>
{profile?.roleCodes && profile.roleCodes.length > 0 && (
<div style={{ marginTop: 8 }}>
<Space wrap>
{profile.roleCodes.map((r) => <Tag key={r} color="primary">{r}</Tag>)}
</Space>
</div>
)}
</Card>
<div style={{ marginTop: 16 }}>
<List header="요약">
<List.Item
extra={loading ? '...' : `${s?.statementCount ?? 0}`}
onClick={() => navigate('/statement')}
clickable
>
</List.Item>
<List.Item
extra={loading ? '...' : `${s?.withdrawRequestCount ?? 0}`}
onClick={() => navigate('/withdraw')}
clickable
>
</List.Item>
<List.Item
extra={
loading
? '...'
: (s?.unreadNotificationCount ?? 0) > 0
? <Tag color="danger">{s?.unreadNotificationCount} </Tag>
: '모두 확인'
}
onClick={() => navigate('/notice')}
clickable
>
/
</List.Item>
</List>
</div>
{s && !s.agentId && (
<div style={{ marginTop: 24, fontSize: 12, color: '#f96', textAlign: 'center' }}>
(agent) . · .
</div>
)}
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { Button, Form, Input, NavBar, Toast } from 'antd-mobile';
import { useLocation, useNavigate } from 'react-router-dom';
import { authApi } from '@/api/auth';
import { useAuthStore } from '@/stores/authStore';
export default function Login() {
const navigate = useNavigate();
const location = useLocation() as { state?: { from?: string } };
const setSession = useAuthStore((s) => s.setSession);
const [submitting, setSubmitting] = useState(false);
const onFinish = async (values: { loginId: string; password: string }) => {
setSubmitting(true);
try {
const r = await authApi.login(values);
const me = await authApi.me().catch(() => null);
setSession(r.accessToken, r.refreshToken, me ?? {
userId: 0, loginId: values.loginId,
});
Toast.show({ icon: 'success', content: '로그인 성공' });
navigate(location.state?.from ?? '/', { replace: true });
} catch {
// request interceptor 가 토스트 처리
} finally {
setSubmitting(false);
}
};
return (
<div className="page" style={{ paddingBottom: 0 }}>
<NavBar back={null}>GA </NavBar>
<div style={{ padding: 24 }}>
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}></div>
<div style={{ color: '#999', marginBottom: 24, fontSize: 13 }}>
· · .
</div>
<Form
layout="vertical"
onFinish={onFinish}
footer={
<Button block type="submit" color="primary" size="large" loading={submitting}>
</Button>
}
>
<Form.Item label="아이디" name="loginId" rules={[{ required: true, message: '아이디를 입력하세요' }]}>
<Input placeholder="loginId" clearable />
</Form.Item>
<Form.Item label="비밀번호" name="password" rules={[{ required: true, message: '비밀번호를 입력하세요' }]}>
<Input placeholder="********" type="password" clearable />
</Form.Item>
</Form>
<div style={{ marginTop: 16, fontSize: 12, color: '#bbb', textAlign: 'center' }}>
PWA · .
</div>
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
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';
import { NotificationResp, notificationApi } from '@/api/notification';
export default function NoticeList() {
const [tab, setTab] = useState('notifications');
return (
<div className="page">
<NavBar back={null}> / </NavBar>
<Tabs activeKey={tab} onChange={setTab}>
<Tabs.Tab title="내 알림" key="notifications">
<Notifications />
</Tabs.Tab>
<Tabs.Tab title="공지사항" key="notices">
<Notices />
</Tabs.Tab>
</Tabs>
</div>
);
}
function Notifications() {
const qc = useQueryClient();
const { data, isLoading, refetch } = useQuery({
queryKey: ['mobile.me.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) {
try {
await notificationApi.markRead(n.notificationId);
await qc.invalidateQueries({ queryKey: ['mobile.me.notifications'] });
await qc.invalidateQueries({ queryKey: ['mobile.me.summary'] });
} catch {
// interceptor 가 토스트 처리
}
}
};
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 (
<PullToRefresh onRefresh={async () => { await refetch(); }}>
<div style={{ paddingTop: 4 }}>
{unreadCount > 0 && (
<div style={{ padding: '8px 12px', textAlign: 'right' }}>
<Button size="mini" onClick={onMarkAllRead}> ({unreadCount})</Button>
</div>
)}
{!isLoading && items.length === 0 && (
<ErrorBlock status="empty" title="알림이 없습니다" />
)}
{items.length > 0 && (
<List>
{items.map((n: NotificationResp) => (
<List.Item
key={n.notificationId}
onClick={() => onItemClick(n)}
clickable
title={
<span>
{!n.isRead && <Badge color="#ff3141" style={{ marginRight: 6 }} />}
{n.title}
</span>
}
description={
<span style={{ fontSize: 12 }}>
{n.refType && <Tag color="default" style={{ marginRight: 6 }}>{n.refType}</Tag>}
{n.content ?? ''}
</span>
}
extra={<span style={{ fontSize: 11, color: '#aaa' }}>{n.createdAt ?? ''}</span>}
/>
))}
</List>
)}
</div>
</PullToRefresh>
);
}
function Notices() {
const { data, isLoading, refetch } = useQuery({
queryKey: ['mobile.notices.active'],
queryFn: () => noticeApi.active(),
});
const items = data ?? [];
return (
<PullToRefresh onRefresh={async () => { await refetch(); }}>
<div style={{ paddingTop: 4 }}>
{!isLoading && items.length === 0 && (
<ErrorBlock status="empty" title="활성 공지가 없습니다" />
)}
{items.length > 0 && (
<List>
{items.map((n: NoticeResp) => (
<List.Item
key={n.noticeId}
title={n.title}
description={
<span style={{ fontSize: 12 }}>
{n.startAt ?? ''}{' '}
{n.noticeType && <Tag style={{ marginLeft: 6 }} color="default">{n.noticeType}</Tag>}
</span>
}
/>
))}
</List>
)}
</div>
</PullToRefresh>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { Button, Card, Dialog, Form, Input, List, NavBar, Toast } from 'antd-mobile';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { authApi } from '@/api/auth';
import { useAuthStore } from '@/stores/authStore';
export default function Profile() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const clear = useAuthStore((s) => s.clear);
const setProfile = useAuthStore((s) => s.setProfile);
const [phoneOpen, setPhoneOpen] = useState(false);
const [phoneInput, setPhoneInput] = useState('');
const [pwOpen, setPwOpen] = useState(false);
const [pwForm] = Form.useForm<{ oldPassword: string; newPassword: string; confirm: string }>();
const { data: me, refetch } = useQuery({
queryKey: ['mobile.me.profile'],
queryFn: () => authApi.me(),
});
const submitPhone = async () => {
try {
await authApi.updateMyPhone(phoneInput.trim());
Toast.show({ icon: 'success', content: '전화번호 저장 완료' });
setPhoneOpen(false);
await refetch();
const fresh = await authApi.me().catch(() => null);
if (fresh) setProfile(fresh);
} catch {
// interceptor 처리
}
};
const submitPassword = async () => {
try {
const v = await pwForm.validateFields();
if (v.newPassword !== v.confirm) {
Toast.show({ icon: 'fail', content: '새 비밀번호 확인이 일치하지 않습니다' });
return;
}
await authApi.changePassword(v.oldPassword, v.newPassword);
Toast.show({ icon: 'success', content: '비밀번호 변경 완료' });
setPwOpen(false);
pwForm.resetFields();
} catch {
// 검증 실패 또는 서버 오류
}
};
const onLogout = () => {
Dialog.confirm({
content: '로그아웃 하시겠습니까?',
onConfirm: async () => {
await authApi.logout();
queryClient.clear();
clear();
navigate('/login', { replace: true });
},
});
};
return (
<div className="page">
<NavBar back={null}> </NavBar>
<div style={{ padding: 12 }}>
<Card title={me?.userName ?? me?.loginId ?? '-'}>
<List>
<List.Item extra={me?.loginId ?? '-'}></List.Item>
<List.Item extra={me?.userName ?? '-'}></List.Item>
<List.Item extra={me?.email ?? '-'}></List.Item>
<List.Item
extra={me?.phone ?? <span style={{ color: '#bbb' }}></span>}
clickable
onClick={() => { setPhoneInput(me?.phone ?? ''); setPhoneOpen(true); }}
>
</List.Item>
<List.Item extra={me?.agentName ?? <span style={{ color: '#bbb' }}></span>}></List.Item>
<List.Item extra={me?.orgName ?? '-'}></List.Item>
</List>
</Card>
<Card style={{ marginTop: 12 }}>
<List>
<List.Item clickable onClick={() => setPwOpen(true)}> </List.Item>
<List.Item clickable onClick={onLogout} style={{ color: '#ff3141' }}></List.Item>
</List>
</Card>
<Dialog
visible={phoneOpen}
title="전화번호 등록"
content={
<div>
<Input placeholder="010-1234-5678" value={phoneInput} onChange={setPhoneInput} autoFocus />
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
SMS .
</div>
</div>
}
actions={[
[
{ key: 'cancel', text: '취소', onClick: () => setPhoneOpen(false) },
{ key: 'save', text: '저장', bold: true, onClick: submitPhone },
],
]}
/>
<Dialog
visible={pwOpen}
title="비밀번호 변경"
content={
<Form form={pwForm} layout="vertical">
<Form.Item name="oldPassword" label="현재 비밀번호" rules={[{ required: true, message: '필수' }]}>
<Input type="password" />
</Form.Item>
<Form.Item name="newPassword" label="새 비밀번호" rules={[{ required: true, min: 8, message: '8자 이상' }]}>
<Input type="password" />
</Form.Item>
<Form.Item name="confirm" label="새 비밀번호 확인" rules={[{ required: true, message: '필수' }]}>
<Input type="password" />
</Form.Item>
</Form>
}
actions={[
[
{ key: 'cancel', text: '취소', onClick: () => { setPwOpen(false); pwForm.resetFields(); } },
{ key: 'save', text: '변경', bold: true, onClick: submitPassword },
],
]}
/>
<Button block style={{ marginTop: 16 }} onClick={onLogout}></Button>
</div>
</div>
);
}
@@ -0,0 +1,94 @@
import { Button, Card, ErrorBlock, NavBar, Skeleton, Tag, Toast } from 'antd-mobile';
import { DownlandOutline } from 'antd-mobile-icons';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { statementApi } from '@/api/statement';
export default function StatementDetail() {
const navigate = useNavigate();
const { statementId } = useParams<{ statementId: string }>();
const id = Number(statementId);
const { data, isLoading, error } = useQuery({
queryKey: ['mobile.me.statement', id],
queryFn: () => statementApi.detail(id),
enabled: !isNaN(id),
retry: false,
});
const forbidden = (error as { code?: string } | undefined)?.code === 'E403';
const notFound = (error as { code?: string } | undefined)?.code === 'E404';
const onDownload = async () => {
try {
const token = localStorage.getItem('mobile.accessToken');
const res = await fetch(`/api/mobile/me/statements/${id}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!res.ok) {
Toast.show({ icon: 'fail', content: `다운로드 실패 (${res.status})` });
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `수수료명세서_${id}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
Toast.show({ icon: 'fail', content: '다운로드 중 오류가 발생했습니다' });
}
};
return (
<div className="page">
<NavBar onBack={() => navigate(-1)}> </NavBar>
<div style={{ padding: 12 }}>
{isLoading && <Skeleton.Paragraph lineCount={6} animated />}
{forbidden && <ErrorBlock status="empty" title="권한이 없습니다" description="본인 명세서가 아닙니다" />}
{notFound && <ErrorBlock status="empty" title="명세서를 찾을 수 없습니다" />}
{data && (
<>
<Card title={`${data.settleMonth} · ${data.statementTypeName ?? data.statementType}`}
extra={<Tag color={data.issueStatus === 'ISSUED' ? 'success' : 'warning'}>{data.issueStatusName ?? data.issueStatus ?? '-'}</Tag>}>
<Row label="설계사" value={data.agentName ?? `#${data.agentId}`} />
<Row label="발급일" value={data.issuedAt ?? '-'} />
</Card>
<Card title="금액 내역" style={{ marginTop: 12 }}>
<Row label="총수수료" value={fmt(data.grossAmount)} />
<Row label="소득세" value={fmt(data.incomeTaxAmount)} />
<Row label="지방소득세" value={fmt(data.localTaxAmount)} />
<Row label="부가세" value={fmt(data.vatAmount)} />
<Row label="차감" value={fmt(data.deductionAmount)} />
<Row label="실수령액" value={fmt(data.netAmount)} bold />
</Card>
{data.filePath && (
<Button block color="primary" style={{ marginTop: 16 }} onClick={onDownload}>
<DownlandOutline />
</Button>
)}
</>
)}
</div>
</div>
);
}
function Row({ label, value, bold }: { label: string; value: string | number; bold?: boolean }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0' }}>
<span style={{ color: '#666' }}>{label}</span>
<span style={{ fontWeight: bold ? 700 : 400 }}>{value}</span>
</div>
);
}
function fmt(n?: number) {
if (n == null) return '-';
return `${n.toLocaleString()}`;
}
+60
View File
@@ -0,0 +1,60 @@
import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { StatementResp, statementApi } from '@/api/statement';
export default function StatementList() {
const navigate = useNavigate();
const { data, isLoading, refetch, error } = useQuery({
queryKey: ['mobile.me.statements'],
queryFn: () => statementApi.list({ pageNum: 1, pageSize: 50 }),
retry: false,
});
const items = data?.list ?? [];
const forbidden = (error as { code?: string } | undefined)?.code === 'E403';
return (
<div className="page">
<NavBar back={null}> </NavBar>
<PullToRefresh onRefresh={async () => { await refetch(); }}>
<div style={{ paddingTop: 8 }}>
{forbidden && (
<ErrorBlock status="empty" title="설계사 미연결" description="본 계정에는 설계사가 연결되어 있지 않습니다." />
)}
{!forbidden && !isLoading && items.length === 0 && (
<ErrorBlock status="empty" title="명세서가 없습니다" description="발급된 정산 명세서가 아직 없습니다." />
)}
{items.length > 0 && (
<List>
{items.map((s: StatementResp) => (
<List.Item
key={s.statementId}
clickable
onClick={() => navigate(`/statement/${s.statementId}`)}
title={
<span>
{s.settleMonth ?? '-'}{' '}
<Tag color="default" style={{ marginLeft: 6 }}>{s.statementTypeName ?? s.statementType}</Tag>
</span>
}
description={
<span style={{ fontSize: 12 }}>
{fmt(s.totalCommission)} · {fmt(s.totalTax)} · {fmt(s.netAmount)}
</span>
}
extra={<Tag color={s.issueStatus === 'ISSUED' ? 'success' : 'warning'}>{s.issueStatusName ?? s.issueStatus ?? '-'}</Tag>}
/>
))}
</List>
)}
</div>
</PullToRefresh>
</div>
);
}
function fmt(n?: number) {
if (n == null) return '-';
return n.toLocaleString();
}
+153
View File
@@ -0,0 +1,153 @@
import { Button, Form, Input, NavBar, Picker, Toast } from 'antd-mobile';
import type { PickerColumn } from 'antd-mobile/es/components/picker';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { SettleMasterResp, withdrawApi } from '@/api/withdraw';
interface FormValues {
settleMasterId?: number;
amount?: string;
bankCode?: string;
accountNo?: string;
}
export default function WithdrawCreate() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [form] = Form.useForm<FormValues>();
const [pickerVisible, setPickerVisible] = useState(false);
const [pickedLabel, setPickedLabel] = useState<string>('');
const [selectedId, setSelectedId] = useState<number | undefined>(undefined);
const { data: settleMasters = [], isLoading } = useQuery({
queryKey: ['mobile.me.settle-masters'],
queryFn: () => withdrawApi.settleMasters(),
retry: false,
});
const { data: balance } = useQuery({
queryKey: ['mobile.me.withdrawable-balance', selectedId],
queryFn: () => withdrawApi.withdrawableBalance(selectedId as number),
enabled: selectedId != null,
retry: false,
});
const pickerColumns: PickerColumn[] = useMemo(
() => [
settleMasters.map((s: SettleMasterResp) => ({
label: `${s.settleMonth} · 실수령 ${fmt(s.netAmount)}`,
value: String(s.settleId),
})),
],
[settleMasters],
);
const create = useMutation({
mutationFn: (req: { settleMasterId: number; amount: number; bankCode: string; accountNo: string }) =>
withdrawApi.create(req),
onSuccess: () => {
Toast.show({ icon: 'success', content: '출금 신청이 접수되었습니다' });
queryClient.invalidateQueries({ queryKey: ['mobile.me.withdraws'] });
queryClient.invalidateQueries({ queryKey: ['mobile.me.summary'] });
navigate('/withdraw', { replace: true });
},
});
const onSubmit = (values: FormValues) => {
if (!values.settleMasterId) {
Toast.show({ icon: 'fail', content: '정산 마스터를 선택하세요' });
return;
}
const amount = Number(values.amount);
if (!amount || amount <= 0) {
Toast.show({ icon: 'fail', content: '금액을 입력하세요' });
return;
}
if (balance && amount > balance.remainingAmount) {
Toast.show({ icon: 'fail', content: `출금 가능 잔액(${fmt(balance.remainingAmount)}원)을 초과합니다` });
return;
}
create.mutate({
settleMasterId: Number(values.settleMasterId),
amount,
bankCode: values.bankCode ?? '',
accountNo: values.accountNo ?? '',
});
};
return (
<div className="page">
<NavBar onBack={() => navigate(-1)}> </NavBar>
{balance && (
<div
style={{
margin: '12px',
padding: '12px 16px',
background: '#e6f4ff',
borderRadius: 8,
fontSize: 14,
}}
>
<div style={{ color: '#666' }}> </div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#1677ff' }}>
{fmt(balance.remainingAmount)}
</div>
<div style={{ color: '#999', fontSize: 12, marginTop: 4 }}>
{fmt(balance.netAmount)} · {fmt(balance.usedAmount)}
</div>
</div>
)}
<Form
form={form}
layout="horizontal"
mode="card"
onFinish={onSubmit}
footer={
<Button block type="submit" color="primary" loading={create.isPending} disabled={isLoading}>
</Button>
}
>
<Form.Item
name="settleMasterId"
label="정산 월"
trigger="onConfirm"
onClick={() => setPickerVisible(true)}
rules={[{ required: true, message: '정산 마스터를 선택하세요' }]}
>
<div style={{ color: pickedLabel ? '#000' : '#bbb' }}>
{pickedLabel || '선택하세요'}
</div>
</Form.Item>
<Form.Item name="amount" label="금액" rules={[{ required: true, message: '금액을 입력하세요' }]}>
<Input placeholder="예: 1500000" type="number" inputMode="numeric" />
</Form.Item>
<Form.Item name="bankCode" label="은행 코드" rules={[{ required: true, message: '은행 코드' }]}>
<Input placeholder="예: 088" />
</Form.Item>
<Form.Item name="accountNo" label="계좌번호" rules={[{ required: true, message: '계좌번호' }]}>
<Input placeholder="-없이 입력" />
</Form.Item>
</Form>
<Picker
columns={pickerColumns}
visible={pickerVisible}
onClose={() => setPickerVisible(false)}
onConfirm={(val) => {
const id = val[0] != null ? Number(val[0]) : undefined;
form.setFieldValue('settleMasterId', id);
setSelectedId(id);
const picked = settleMasters.find((s) => String(s.settleId) === val[0]);
if (picked) setPickedLabel(`${picked.settleMonth} · 실수령 ${fmt(picked.netAmount)}`);
}}
/>
</div>
);
}
function fmt(n?: number) {
if (n == null) return '-';
return n.toLocaleString();
}
+91
View File
@@ -0,0 +1,91 @@
import { Button, Dialog, ErrorBlock, List, NavBar, PullToRefresh, SwipeAction, Tag, Toast } from 'antd-mobile';
import { AddOutline } from 'antd-mobile-icons';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { WithdrawResp, withdrawApi } from '@/api/withdraw';
const STATUS_COLOR: Record<string, string> = {
REQUESTED: 'default',
APPROVED: 'primary',
SENT: 'success',
COMPLETED: 'success',
FAILED: 'danger',
CANCELLED: 'default',
};
export default function WithdrawList() {
const navigate = useNavigate();
const { data, isLoading, refetch, error } = useQuery({
queryKey: ['mobile.me.withdraws'],
queryFn: () => withdrawApi.list({ pageNum: 1, pageSize: 50 }),
retry: false,
});
const items = data?.list ?? [];
const forbidden = (error as { code?: string } | undefined)?.code === 'E403';
return (
<div className="page">
<NavBar
back={null}
right={
!forbidden && (
<Button size="mini" color="primary" onClick={() => navigate('/withdraw/new')}>
<AddOutline />
</Button>
)
}
>
</NavBar>
<PullToRefresh onRefresh={async () => { await refetch(); }}>
<div style={{ paddingTop: 8 }}>
{forbidden && (
<ErrorBlock status="empty" title="설계사 미연결" description="본 계정에는 설계사가 연결되어 있지 않습니다." />
)}
{!forbidden && !isLoading && items.length === 0 && (
<ErrorBlock status="empty" title="출금 신청 내역이 없습니다" />
)}
{items.length > 0 && (
<List>
{items.map((w: WithdrawResp) => {
const cancellable = w.status === 'REQUESTED';
const row = (
<List.Item
key={w.requestId}
title={<span>#{w.requestId} {w.settleMonth ?? ''}</span>}
description={
<span style={{ fontSize: 12 }}>
{w.bankCode ?? '-'} · {w.accountNo ?? '-'} · {w.amount.toLocaleString()}
{w.transferTxId && <> · txId={w.transferTxId}</>}
</span>
}
extra={<Tag color={STATUS_COLOR[w.status] ?? 'default'}>{w.statusName ?? w.status}</Tag>}
/>
);
if (!cancellable) return row;
return (
<SwipeAction
key={w.requestId}
rightActions={[{
key: 'cancel', text: '취소', color: 'danger',
onClick: async () => {
const ok = await Dialog.confirm({ content: `#${w.requestId} 출금 신청을 취소하시겠습니까?` });
if (!ok) return;
await withdrawApi.cancel(w.requestId);
Toast.show({ icon: 'success', content: '취소되었습니다' });
await refetch();
},
}]}
>
{row}
</SwipeAction>
);
})}
</List>
)}
</div>
</PullToRefresh>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { MeResp } from '../api/auth';
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
profile: MeResp | null;
setSession: (access: string, refresh: string, profile: MeResp) => void;
setAccessToken: (access: string) => void;
setProfile: (profile: MeResp) => void;
clear: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
profile: null,
setSession: (access, refresh, profile) => {
localStorage.setItem('mobile.accessToken', access);
localStorage.setItem('mobile.refreshToken', refresh);
set({ accessToken: access, refreshToken: refresh, profile });
},
setAccessToken: (access) => {
localStorage.setItem('mobile.accessToken', access);
set({ accessToken: access });
},
setProfile: (profile) => set({ profile }),
clear: () => {
localStorage.removeItem('mobile.accessToken');
localStorage.removeItem('mobile.refreshToken');
set({ accessToken: null, refreshToken: null, profile: null });
},
}),
{ name: 'mobile.profile' },
),
);
+23
View File
@@ -0,0 +1,23 @@
:root {
--safe-top: env(safe-area-inset-top, 0);
--safe-bottom: env(safe-area-inset-bottom, 0);
}
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
background: #f5f5f7;
-webkit-text-size-adjust: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo",
"Malgun Gothic", sans-serif;
}
.page {
padding-bottom: calc(56px + var(--safe-bottom));
min-height: 100vh;
box-sizing: border-box;
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "vite-plugin-pwa/client"],
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src", "vite.config.ts"]
}
+73
View File
@@ -0,0 +1,73 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'node:path';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg'],
manifest: {
name: 'GA 설계사 셀프 조회',
short_name: 'GA Mobile',
description: '설계사 본인 정산 명세서 / 출금 신청 / 공지 모바일 조회',
theme_color: '#1677ff',
background_color: '#ffffff',
display: 'standalone',
start_url: '/',
lang: 'ko',
icons: [
{
src: 'pwa-192x192.svg',
sizes: '192x192',
type: 'image/svg+xml',
purpose: 'any',
},
{
src: 'pwa-512x512.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
},
workbox: {
navigateFallback: '/index.html',
runtimeCaching: [
{
urlPattern: /\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 5 },
},
},
],
},
devOptions: {
enabled: false,
},
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:8082',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
});