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
+18
View File
@@ -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() {
</RequireAuth>
}
/>
<Route
path="/statement/:statementId"
element={
<RequireAuth>
<StatementDetail />
</RequireAuth>
}
/>
<Route
path="/withdraw"
element={
@@ -44,6 +54,14 @@ export default function App() {
</RequireAuth>
}
/>
<Route
path="/withdraw/new"
element={
<RequireAuth>
<WithdrawCreate />
</RequireAuth>
}
/>
<Route
path="/notice"
element={
+2
View File
@@ -35,5 +35,7 @@ export const authApi = {
login: (req: LoginReq) =>
unwrap<LoginResp>(api.post('/api/auth/login', req)),
me: () => unwrap<MeResp>(api.get('/api/auth/me')),
refresh: (refreshToken: string) =>
unwrap<LoginResp>(api.post('/api/auth/refresh', { refreshToken })),
logout: () => api.post('/api/auth/logout').catch(() => undefined),
};
+60 -7
View File
@@ -1,4 +1,4 @@
import axios, { AxiosError } from 'axios';
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { Toast } from 'antd-mobile';
export interface ApiResponse<T> {
@@ -28,6 +28,38 @@ api.interceptors.request.use((config) => {
return config;
});
// ── 401 → refreshToken 으로 access 갱신 후 원 요청 재시도 ──
// 동시 401 다발 시 단일 refresh 호출로 합치고, 그 결과를 모두에 broadcast.
let refreshing: Promise<string | null> | null = null;
async function refreshAccessToken(): Promise<string | null> {
const refreshToken = localStorage.getItem('mobile.refreshToken');
if (!refreshToken) return null;
try {
const res = await axios.post<ApiResponse<{ accessToken: string }>>(
'/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<ApiResponse<unknown>>) => {
async (err: AxiosError<ApiResponse<unknown>>) => {
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<string, string>).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 {
+15
View File
@@ -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<PageResponse<StatementResp>>(api.get('/api/mobile/me/statements', { params })),
/** 본인 명세서 단건 — 서버측이 본인 agentId 가 아니면 E403. */
detail: (statementId: number) =>
unwrap<StatementDetailResp>(api.get(`/api/mobile/me/statements/${statementId}`)),
};
+24
View File
@@ -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<PageResponse<WithdrawResp>>(api.get('/api/mobile/me/withdraw-requests', { params })),
/** 출금 신청 가능 정산 마스터 (본인 agentId 기준). */
settleMasters: () =>
unwrap<SettleMasterResp[]>(api.get('/api/mobile/me/settle-masters')),
/** 본인 출금 신청 등록. */
create: (req: WithdrawCreateReq) =>
unwrap<number>(api.post('/api/mobile/me/withdraw-requests', req)),
};
+1 -1
View File
@@ -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: '로그인 성공' });
@@ -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 (
<div className="page">
<NavBar onBack={() => navigate(-1)}> </NavBar>
<div style={{ padding: 12 }}>
{isLoading && <Skeleton.Paragraph lineCount={6} animated />}
{forbidden && <ErrorBlock status="empty" title="권한이 없습니다" description="본인 명세서가 아닙니다" />}
{notFound && <ErrorBlock status="empty" title="명세서를 찾을 수 없습니다" />}
{data && (
<>
<Card title={`${data.settleMonth} · ${data.statementTypeName ?? data.statementType}`}
extra={<Tag color={data.issueStatus === 'ISSUED' ? 'success' : 'warning'}>{data.issueStatusName ?? data.issueStatus ?? '-'}</Tag>}>
<Row label="설계사" value={data.agentName ?? `#${data.agentId}`} />
<Row label="발급일" value={data.issuedAt ?? '-'} />
</Card>
<Card title="금액 내역" style={{ marginTop: 12 }}>
<Row label="총수수료" value={fmt(data.grossAmount)} />
<Row label="소득세" value={fmt(data.incomeTaxAmount)} />
<Row label="지방소득세" value={fmt(data.localTaxAmount)} />
<Row label="부가세" value={fmt(data.vatAmount)} />
<Row label="차감" value={fmt(data.deductionAmount)} />
<Row label="실수령액" value={fmt(data.netAmount)} bold />
</Card>
{data.filePath && (
<Button block color="primary" style={{ marginTop: 16 }} onClick={onDownload}>
<DownlandOutline />
</Button>
)}
</>
)}
</div>
</div>
);
}
function Row({ label, value, bold }: { label: string; value: string | number; bold?: boolean }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0' }}>
<span style={{ color: '#666' }}>{label}</span>
<span style={{ fontWeight: bold ? 700 : 400 }}>{value}</span>
</div>
);
}
function fmt(n?: number) {
if (n == null) return '-';
return `${n.toLocaleString()}`;
}
@@ -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) => (
<List.Item
key={s.statementId}
clickable
onClick={() => navigate(`/statement/${s.statementId}`)}
title={
<span>
{s.settleMonth ?? '-'}{' '}
+121
View File
@@ -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<FormValues>();
const [pickerVisible, setPickerVisible] = useState(false);
const [pickedLabel, setPickedLabel] = useState<string>('');
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 (
<div className="page">
<NavBar onBack={() => navigate(-1)}> </NavBar>
<Form
form={form}
layout="horizontal"
mode="card"
onFinish={onSubmit}
footer={
<Button block type="submit" color="primary" loading={create.isPending} disabled={isLoading}>
</Button>
}
>
<Form.Item
name="settleMasterId"
label="정산 월"
trigger="onConfirm"
onClick={() => setPickerVisible(true)}
rules={[{ required: true, message: '정산 마스터를 선택하세요' }]}
>
<div style={{ color: pickedLabel ? '#000' : '#bbb' }}>
{pickedLabel || '선택하세요'}
</div>
</Form.Item>
<Form.Item name="amount" label="금액" rules={[{ required: true, message: '금액을 입력하세요' }]}>
<Input placeholder="예: 1500000" type="number" inputMode="numeric" />
</Form.Item>
<Form.Item name="bankCode" label="은행 코드" rules={[{ required: true, message: '은행 코드' }]}>
<Input placeholder="예: 088" />
</Form.Item>
<Form.Item name="accountNo" label="계좌번호" rules={[{ required: true, message: '계좌번호' }]}>
<Input placeholder="-없이 입력" />
</Form.Item>
</Form>
<Picker
columns={pickerColumns}
visible={pickerVisible}
onClose={() => 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)}`);
}}
/>
</div>
);
}
function fmt(n?: number) {
if (n == null) return '-';
return n.toLocaleString();
}
+16 -2
View File
@@ -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<string, string> = {
@@ -12,6 +14,7 @@ const STATUS_COLOR: Record<string, string> = {
};
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 (
<div className="page">
<NavBar back={null}> </NavBar>
<NavBar
back={null}
right={
!forbidden && (
<Button size="mini" color="primary" onClick={() => navigate('/withdraw/new')}>
<AddOutline />
</Button>
)
}
>
</NavBar>
<PullToRefresh onRefresh={async () => { await refetch(); }}>
<div style={{ paddingTop: 8 }}>
{forbidden && (
+14 -5
View File
@@ -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<AuthState>()(
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' },