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:
GA Pro
2026-05-27 22:53:59 +09:00
parent ee3e0d40c5
commit 2c9bf710bc
24 changed files with 727 additions and 18 deletions
+39 -3
View File
@@ -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<UserResp>[] = [
{
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 ?? <span style={{ color: '#bbb' }}></span> },
{ 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) => (
<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"
onClick={() => onReset(r.userId)}></PermissionButton>
{r.status === 'LOCKED' && (
@@ -83,6 +98,27 @@ export default function UserList() {
options={{ density: false, fullScreen: true, reload: true, setting: true }}
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>
);
}