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