diff --git a/ga-api/src/main/java/com/ga/api/controller/internal/BankSettleController.java b/ga-api/src/main/java/com/ga/api/controller/internal/BankSettleController.java new file mode 100644 index 0000000..390717d --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/internal/BankSettleController.java @@ -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 sync() { + return ApiResponse.ok(bankSettleService.syncOnce()); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java index d2fecb8..27343e9 100644 --- a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java +++ b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java @@ -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 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> withdrawableSettleMasters() { + return ApiResponse.ok(service.withdrawableSettleMasters()); + } + @GetMapping("/withdraw-requests") public ApiResponse> withdrawRequests( @ModelAttribute WithdrawRequestSearchParam param) { return ApiResponse.ok(service.withdrawRequests(param)); } + @PostMapping("/withdraw-requests") + public ApiResponse createWithdrawRequest(@Valid @RequestBody MobileWithdrawSaveReq req) { + return ApiResponse.ok(service.createWithdrawRequest(req)); + } + @GetMapping("/notifications") public ApiResponse> notifications( @ModelAttribute UserNotificationSearchParam param) { diff --git a/ga-api/src/main/java/com/ga/api/controller/system/UserController.java b/ga-api/src/main/java/com/ga/api/controller/system/UserController.java index 5939fd5..2fe9ae6 100644 --- a/ga-api/src/main/java/com/ga/api/controller/system/UserController.java +++ b/ga-api/src/main/java/com/ga/api/controller/system/UserController.java @@ -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 updatePhone(@PathVariable Long userId, @RequestBody PhoneReq req) { + service.updatePhone(userId, req.getPhone()); + return ApiResponse.ok(); + } + + @lombok.Data + public static class PhoneReq { + private String phone; + } } diff --git a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java index 48a3bad..d473ad7 100644 --- a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java +++ b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java @@ -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 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; + } } diff --git a/ga-api/src/main/java/com/ga/api/service/system/UserService.java b/ga-api/src/main/java/com/ga/api/service/system/UserService.java index 74f2b26..dd38d00 100644 --- a/ga-api/src/main/java/com/ga/api/service/system/UserService.java +++ b/ga-api/src/main/java/com/ga/api/service/system/UserService.java @@ -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 roleCodes(Long userId) { return mapper.selectRoleCodes(userId); diff --git a/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankSettleService.java b/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankSettleService.java new file mode 100644 index 0000000..5eb1428 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankSettleService.java @@ -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 동기화 서비스. + *

+ * 어댑터가 ACCEPTED(접수)만 반환한 출금 건들에 대해 {@code queryStatus} 폴링으로 입금 확정 여부를 확인. + *

    + *
  • SETTLED → status=COMPLETED, transfer_status=SETTLED, completed_at=NOW()
  • + *
  • FAILED → status=APPROVED, transfer_status=FAILED (재시도 풀로 회귀)
  • + *
  • ACCEPTED 유지 → no-op (다음 사이클에서 다시 폴링)
  • + *
+ *

운영: {@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 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); + } + } +} diff --git a/ga-api/src/main/resources/application.yml b/ga-api/src/main/resources/application.yml index 6466180..fa1153c 100644 --- a/ga-api/src/main/resources/application.yml +++ b/ga-api/src/main/resources/application.yml @@ -66,6 +66,11 @@ app: limit: ${BANK_RETRY_LIMIT:50} # 미설정 시 사실상 비활성 (매년 1/1 0시). 운영 활성화 예: "0 */10 * * * *" 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: level: diff --git a/ga-core/src/main/java/com/ga/core/mapper/user/UserMapper.java b/ga-core/src/main/java/com/ga/core/mapper/user/UserMapper.java index ff28ad2..d144df6 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/user/UserMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/user/UserMapper.java @@ -26,6 +26,7 @@ public interface UserMapper { int incrementFailCount(@Param("userId") Long userId); int resetFailCount(@Param("userId") Long userId); int updateStatus(@Param("userId") Long userId, @Param("status") String status); + int updatePhone(@Param("userId") Long userId, @Param("phone") String phone); List selectRoleCodes(@Param("userId") Long userId); diff --git a/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java b/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java index f3d6654..4aa7c3b 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java @@ -33,4 +33,17 @@ public interface WithdrawRequestMapper { * account_no 는 복호화되어 반환. */ List selectFailedTransfers(@Param("limit") int limit); + + /** + * SETTLED 동기화 대상 — transfer_status='ACCEPTED' 이고 status='SENT'. + * queryStatus 폴링 결과로 입금 확정/실패를 판정한다. + */ + List 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); } diff --git a/ga-core/src/main/resources/mapper/user/UserMapper.xml b/ga-core/src/main/resources/mapper/user/UserMapper.xml index a74c867..2e23307 100644 --- a/ga-core/src/main/resources/mapper/user/UserMapper.xml +++ b/ga-core/src/main/resources/mapper/user/UserMapper.xml @@ -110,6 +110,10 @@ UPDATE users SET status = #{status} WHERE user_id = #{userId} + + UPDATE users SET phone = #{phone,jdbcType=VARCHAR} WHERE user_id = #{userId} + + + 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} + + + + + 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 withdraw_request + SET transfer_status = 'FAILED', + status = 'APPROVED', + fail_reason = #{failReason,jdbcType=VARCHAR}, + updated_at = NOW() + WHERE request_id = #{requestId} + AND status = 'SENT' + + diff --git a/ga-frontend/src/api/user.ts b/ga-frontend/src/api/user.ts index 4612a0f..b558cc7 100644 --- a/ga-frontend/src/api/user.ts +++ b/ga-frontend/src/api/user.ts @@ -35,6 +35,8 @@ export const userApi = { update: (id: number, req: UserSaveReq) => unwrap(api.put(`/api/system/users/${id}`, req)), resetPassword: (id: number) => unwrap(api.put(`/api/system/users/${id}/reset-password`)), unlock: (id: number) => unwrap(api.put(`/api/system/users/${id}/unlock`)), + updatePhone: (id: number, phone: string) => + unwrap(api.put(`/api/system/users/${id}/phone`, { phone })), }; export interface RoleVO { diff --git a/ga-frontend/src/pages/system/UserList.tsx b/ga-frontend/src/pages/system/UserList.tsx index b2869a4..7f8a766 100644 --- a/ga-frontend/src/pages/system/UserList.tsx +++ b/ga-frontend/src/pages/system/UserList.tsx @@ -1,5 +1,5 @@ -import { useRef } from 'react'; -import { Modal, Space, message } from 'antd'; +import { useRef, useState } from 'react'; +import { Input, Modal, Space, message } from 'antd'; import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components'; import { useQueryClient } from '@tanstack/react-query'; import PageContainer from '@/components/common/PageContainer'; @@ -31,6 +31,15 @@ export default function UserList() { 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[] = [ { title: '검색어', dataIndex: 'searchKeyword', hideInTable: true, @@ -44,14 +53,20 @@ export default function UserList() { { title: '로그인 ID', dataIndex: 'loginId', width: 120, search: false }, { title: '이름', dataIndex: 'userName', width: 120, search: false }, { title: '이메일', dataIndex: 'email', width: 200, search: false }, + { title: '전화번호', dataIndex: 'phone', width: 140, search: false, + render: (_, r) => r.phone ?? 미등록 }, { title: '소속', dataIndex: 'orgName', width: 160, search: false }, { title: '연결 설계사', dataIndex: 'agentName', width: 100, search: false }, { title: '실패횟수', dataIndex: 'failCount', width: 90, align: 'right', 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) => ( + setPhoneEdit({ id: r.userId, loginId: r.loginId, phone: r.phone ?? '' })}> + 전화번호 + onReset(r.userId)}>비번초기화 {r.status === 'LOCKED' && ( @@ -83,6 +98,27 @@ export default function UserList() { options={{ density: false, fullScreen: true, reload: true, setting: true }} dateFormatter="string" /> + + setPhoneEdit(null)} + onOk={submitPhone} + okText="저장" + cancelText="취소" + destroyOnClose + > + setPhoneEdit((p) => (p ? { ...p, phone: e.target.value } : p))} + onPressEnter={submitPhone} + /> +

+ SMS 결재 알림 발송 시 사용됩니다. 비워두면 SMS 발송이 스킵됩니다. +
+ ); } diff --git a/ga-mobile-pwa/src/App.tsx b/ga-mobile-pwa/src/App.tsx index c15aaed..55121ac 100644 --- a/ga-mobile-pwa/src/App.tsx +++ b/ga-mobile-pwa/src/App.tsx @@ -4,7 +4,9 @@ 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'; function AuthedLayout({ children }: { children: React.ReactNode }) { @@ -36,6 +38,14 @@ export default function App() { } /> + + + + } + /> } /> + + + + } + /> unwrap(api.post('/api/auth/login', req)), me: () => unwrap(api.get('/api/auth/me')), + refresh: (refreshToken: string) => + unwrap(api.post('/api/auth/refresh', { refreshToken })), logout: () => api.post('/api/auth/logout').catch(() => undefined), }; diff --git a/ga-mobile-pwa/src/api/request.ts b/ga-mobile-pwa/src/api/request.ts index 8ae63e4..3cbc8d2 100644 --- a/ga-mobile-pwa/src/api/request.ts +++ b/ga-mobile-pwa/src/api/request.ts @@ -1,4 +1,4 @@ -import axios, { AxiosError } from 'axios'; +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; import { Toast } from 'antd-mobile'; export interface ApiResponse { @@ -28,6 +28,38 @@ api.interceptors.request.use((config) => { return config; }); +// ── 401 → refreshToken 으로 access 갱신 후 원 요청 재시도 ── +// 동시 401 다발 시 단일 refresh 호출로 합치고, 그 결과를 모두에 broadcast. +let refreshing: Promise | null = null; + +async function refreshAccessToken(): Promise { + const refreshToken = localStorage.getItem('mobile.refreshToken'); + if (!refreshToken) return null; + try { + const res = await axios.post>( + '/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; @@ -37,15 +69,36 @@ api.interceptors.response.use( } return res; }, - (err: AxiosError>) => { + async (err: AxiosError>) => { const status = err.response?.status; const body = err.response?.data; - if (status === 401) { - localStorage.removeItem('mobile.accessToken'); - localStorage.removeItem('mobile.profile'); - if (!window.location.pathname.startsWith('/login')) { - window.location.href = '/login'; + 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).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 { diff --git a/ga-mobile-pwa/src/api/statement.ts b/ga-mobile-pwa/src/api/statement.ts index fe03921..3a176fb 100644 --- a/ga-mobile-pwa/src/api/statement.ts +++ b/ga-mobile-pwa/src/api/statement.ts @@ -23,8 +23,23 @@ export interface StatementSearchParam { 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>(api.get('/api/mobile/me/statements', { params })), + + /** 본인 명세서 단건 — 서버측이 본인 agentId 가 아니면 E403. */ + detail: (statementId: number) => + unwrap(api.get(`/api/mobile/me/statements/${statementId}`)), }; diff --git a/ga-mobile-pwa/src/api/withdraw.ts b/ga-mobile-pwa/src/api/withdraw.ts index 0985802..b387357 100644 --- a/ga-mobile-pwa/src/api/withdraw.ts +++ b/ga-mobile-pwa/src/api/withdraw.ts @@ -25,8 +25,32 @@ export interface WithdrawSearchParam { 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 const withdrawApi = { /** 본인 출금 신청 이력 — 서버측이 토큰 → agentId 강제 필터. */ list: (params: WithdrawSearchParam = {}) => unwrap>(api.get('/api/mobile/me/withdraw-requests', { params })), + + /** 출금 신청 가능 정산 마스터 (본인 agentId 기준). */ + settleMasters: () => + unwrap(api.get('/api/mobile/me/settle-masters')), + + /** 본인 출금 신청 등록. */ + create: (req: WithdrawCreateReq) => + unwrap(api.post('/api/mobile/me/withdraw-requests', req)), }; diff --git a/ga-mobile-pwa/src/pages/Login.tsx b/ga-mobile-pwa/src/pages/Login.tsx index 5f524d4..c6ca2d6 100644 --- a/ga-mobile-pwa/src/pages/Login.tsx +++ b/ga-mobile-pwa/src/pages/Login.tsx @@ -15,7 +15,7 @@ export default function Login() { try { const r = await authApi.login(values); const me = await authApi.me().catch(() => null); - setSession(r.accessToken, me ?? { + setSession(r.accessToken, r.refreshToken, me ?? { userId: 0, loginId: values.loginId, }); Toast.show({ icon: 'success', content: '로그인 성공' }); diff --git a/ga-mobile-pwa/src/pages/StatementDetail.tsx b/ga-mobile-pwa/src/pages/StatementDetail.tsx new file mode 100644 index 0000000..622fbb0 --- /dev/null +++ b/ga-mobile-pwa/src/pages/StatementDetail.tsx @@ -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 ( +
+ navigate(-1)}>명세서 상세 +
+ {isLoading && } + {forbidden && } + {notFound && } + {data && ( + <> + {data.issueStatusName ?? data.issueStatus ?? '-'}}> + + + + + + + + + + + + + + {data.filePath && ( + + )} + + )} +
+
+ ); +} + +function Row({ label, value, bold }: { label: string; value: string | number; bold?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +function fmt(n?: number) { + if (n == null) return '-'; + return `${n.toLocaleString()}원`; +} diff --git a/ga-mobile-pwa/src/pages/StatementList.tsx b/ga-mobile-pwa/src/pages/StatementList.tsx index 6d67823..b441b8a 100644 --- a/ga-mobile-pwa/src/pages/StatementList.tsx +++ b/ga-mobile-pwa/src/pages/StatementList.tsx @@ -1,8 +1,10 @@ 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 }), @@ -28,6 +30,8 @@ export default function StatementList() { {items.map((s: StatementResp) => ( navigate(`/statement/${s.statementId}`)} title={ {s.settleMonth ?? '-'}{' '} diff --git a/ga-mobile-pwa/src/pages/WithdrawCreate.tsx b/ga-mobile-pwa/src/pages/WithdrawCreate.tsx new file mode 100644 index 0000000..c5b1294 --- /dev/null +++ b/ga-mobile-pwa/src/pages/WithdrawCreate.tsx @@ -0,0 +1,121 @@ +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(); + const [pickerVisible, setPickerVisible] = useState(false); + const [pickedLabel, setPickedLabel] = useState(''); + + const { data: settleMasters = [], isLoading } = useQuery({ + queryKey: ['mobile.me.settle-masters'], + queryFn: () => withdrawApi.settleMasters(), + 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; + } + create.mutate({ + settleMasterId: Number(values.settleMasterId), + amount, + bankCode: values.bankCode ?? '', + accountNo: values.accountNo ?? '', + }); + }; + + return ( +
+ navigate(-1)}>출금 신청 +
+ 신청 + + } + > + setPickerVisible(true)} + rules={[{ required: true, message: '정산 마스터를 선택하세요' }]} + > +
+ {pickedLabel || '선택하세요'} +
+
+ + + + + + + + + +
+ + setPickerVisible(false)} + onConfirm={(val) => { + const id = val[0] != null ? Number(val[0]) : undefined; + form.setFieldValue('settleMasterId', id); + const picked = settleMasters.find((s) => String(s.settleId) === val[0]); + if (picked) setPickedLabel(`${picked.settleMonth} · 실수령 ${fmt(picked.netAmount)}원`); + }} + /> +
+ ); +} + +function fmt(n?: number) { + if (n == null) return '-'; + return n.toLocaleString(); +} diff --git a/ga-mobile-pwa/src/pages/WithdrawList.tsx b/ga-mobile-pwa/src/pages/WithdrawList.tsx index 3d265fb..48c31d0 100644 --- a/ga-mobile-pwa/src/pages/WithdrawList.tsx +++ b/ga-mobile-pwa/src/pages/WithdrawList.tsx @@ -1,5 +1,7 @@ -import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile'; +import { Button, ErrorBlock, List, NavBar, PullToRefresh, Tag } 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 = { @@ -12,6 +14,7 @@ const STATUS_COLOR: Record = { }; export default function WithdrawList() { + const navigate = useNavigate(); const { data, isLoading, refetch, error } = useQuery({ queryKey: ['mobile.me.withdraws'], queryFn: () => withdrawApi.list({ pageNum: 1, pageSize: 50 }), @@ -23,7 +26,18 @@ export default function WithdrawList() { return (
- 출금 신청 + navigate('/withdraw/new')}> + 신청 + + ) + } + > + 출금 신청 + { await refetch(); }}>
{forbidden && ( diff --git a/ga-mobile-pwa/src/stores/authStore.ts b/ga-mobile-pwa/src/stores/authStore.ts index 93da4d0..ae448db 100644 --- a/ga-mobile-pwa/src/stores/authStore.ts +++ b/ga-mobile-pwa/src/stores/authStore.ts @@ -4,8 +4,10 @@ import { MeResp } from '../api/auth'; interface AuthState { accessToken: string | null; + refreshToken: string | null; profile: MeResp | null; - setSession: (token: string, profile: MeResp) => void; + setSession: (access: string, refresh: string, profile: MeResp) => void; + setAccessToken: (access: string) => void; setProfile: (profile: MeResp) => void; clear: () => void; } @@ -14,15 +16,22 @@ export const useAuthStore = create()( persist( (set) => ({ accessToken: null, + refreshToken: null, profile: null, - setSession: (token, profile) => { - localStorage.setItem('mobile.accessToken', token); - set({ accessToken: token, profile }); + 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'); - set({ accessToken: null, profile: null }); + localStorage.removeItem('mobile.refreshToken'); + set({ accessToken: null, refreshToken: null, profile: null }); }, }), { name: 'mobile.profile' },