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>
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,27 @@ 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.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 전용 — 토큰의 사용자 본인 데이터만 반환.
|
||||
*
|
||||
@@ -39,12 +48,38 @@ public class MobileMeController {
|
||||
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("/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));
|
||||
}
|
||||
|
||||
@GetMapping("/notifications")
|
||||
public ApiResponse<PageResponse<UserNotificationResp>> notifications(
|
||||
@ModelAttribute UserNotificationSearchParam param) {
|
||||
|
||||
@@ -61,4 +61,21 @@ public class UserController {
|
||||
service.unlock(userId);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
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 는 토큰에서 강제 추출.
|
||||
*
|
||||
@@ -35,6 +47,9 @@ public class MobileMeService {
|
||||
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() {
|
||||
@@ -99,6 +114,55 @@ public class MobileMeService {
|
||||
notificationMapper.markRead(notificationId, 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);
|
||||
}
|
||||
|
||||
/** 본인 출금 가능 정산 마스터 목록. 출금 신청 폼의 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 본인 출금 신청 등록. agentId / requestedBy 는 토큰에서 강제 결정.
|
||||
* 클라이언트가 보낸 값은 무시한다.
|
||||
*/
|
||||
@Transactional
|
||||
public Long createWithdrawRequest(MobileWithdrawSaveReq req) {
|
||||
Long userId = currentUserId();
|
||||
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() {
|
||||
@@ -129,4 +193,18 @@ public class MobileMeService {
|
||||
private long withdrawRequestCount;
|
||||
private int unreadNotificationCount;
|
||||
}
|
||||
|
||||
/** 모바일 출금 신청 — 토큰에서 agentId/requestedBy 강제. */
|
||||
@Data
|
||||
public static class MobileWithdrawSaveReq {
|
||||
@NotNull
|
||||
private Long settleMasterId;
|
||||
@NotNull
|
||||
@Positive
|
||||
private BigDecimal amount;
|
||||
@NotBlank
|
||||
private String bankCode;
|
||||
@NotBlank
|
||||
private String accountNo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,15 @@ public class UserService {
|
||||
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)
|
||||
public List<String> roleCodes(Long userId) {
|
||||
return mapper.selectRoleCodes(userId);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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.withdraw.WithdrawRequestMapper;
|
||||
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;
|
||||
|
||||
@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++;
|
||||
} 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++;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user