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>
This commit is contained in:
GA Pro
2026-05-27 23:18:39 +09:00
parent 2c9bf710bc
commit dfce9e5c02
9 changed files with 268 additions and 17 deletions
@@ -80,6 +80,23 @@ public class MobileMeController {
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) {
@@ -131,6 +131,14 @@ public class MobileMeService {
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() {
@@ -143,6 +151,16 @@ public class MobileMeService {
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);
}
/**
* 본인 출금 신청 등록. agentId / requestedBy 는 토큰에서 강제 결정.
* 클라이언트가 보낸 값은 무시한다.
@@ -3,7 +3,10 @@ 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;
@@ -34,6 +37,7 @@ public class WithdrawBankSettleService {
private final WithdrawRequestMapper mapper;
private final BankTransferAdapter bankTransferAdapter;
private final UserNotificationMapper notificationMapper;
@Value("${app.bank-settle.limit:50}")
private int batchLimit;
@@ -56,11 +60,17 @@ public class WithdrawBankSettleService {
if (status == BankTransferStatus.SETTLED) {
int n = mapper.markSettled(vo.getRequestId());
if (n > 0) changed++;
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++;
if (n > 0) {
changed++;
notify(vo, false, reason);
}
}
// ACCEPTED 유지 시 no-op
} catch (Exception e) {
@@ -72,6 +82,34 @@ public class WithdrawBankSettleService {
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시(=사실상 비활성)로 작동.