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시(=사실상 비활성)로 작동.
+9
View File
@@ -8,6 +8,7 @@ import StatementDetail from '@/pages/StatementDetail';
import WithdrawList from '@/pages/WithdrawList';
import WithdrawCreate from '@/pages/WithdrawCreate';
import NoticeList from '@/pages/NoticeList';
import Profile from '@/pages/Profile';
function AuthedLayout({ children }: { children: React.ReactNode }) {
return (
@@ -70,6 +71,14 @@ export default function App() {
</RequireAuth>
}
/>
<Route
path="/profile"
element={
<RequireAuth>
<AuthedLayout><Profile /></AuthedLayout>
</RequireAuth>
}
/>
</Routes>
);
}
+4
View File
@@ -37,5 +37,9 @@ export const authApi = {
me: () => unwrap<MeResp>(api.get('/api/auth/me')),
refresh: (refreshToken: string) =>
unwrap<LoginResp>(api.post('/api/auth/refresh', { refreshToken })),
changePassword: (oldPassword: string, newPassword: string) =>
unwrap<void>(api.post('/api/auth/password', { oldPassword, newPassword })),
updateMyPhone: (phone: string) =>
unwrap<void>(api.put('/api/mobile/me/profile/phone', { phone })),
logout: () => api.post('/api/auth/logout').catch(() => undefined),
};
+4
View File
@@ -53,4 +53,8 @@ export const withdrawApi = {
/** 본인 출금 신청 등록. */
create: (req: WithdrawCreateReq) =>
unwrap<number>(api.post('/api/mobile/me/withdraw-requests', req)),
/** 본인 출금 신청 취소 (REQUESTED 상태만). */
cancel: (requestId: number) =>
unwrap<void>(api.post(`/api/mobile/me/withdraw-requests/${requestId}/cancel`)),
};
+2 -1
View File
@@ -1,5 +1,5 @@
import { TabBar } from 'antd-mobile';
import { AppOutline, FileOutline, PayCircleOutline, SoundOutline } from 'antd-mobile-icons';
import { AppOutline, FileOutline, PayCircleOutline, SoundOutline, UserOutline } from 'antd-mobile-icons';
import { useLocation, useNavigate } from 'react-router-dom';
const tabs = [
@@ -7,6 +7,7 @@ const tabs = [
{ key: '/statement', title: '명세서', icon: <FileOutline /> },
{ key: '/withdraw', title: '출금', icon: <PayCircleOutline /> },
{ key: '/notice', title: '공지', icon: <SoundOutline /> },
{ key: '/profile', title: '내정보', icon: <UserOutline /> },
];
export default function MobileTabBar() {
+139
View File
@@ -0,0 +1,139 @@
import { Button, Card, Dialog, Form, Input, List, NavBar, Toast } from 'antd-mobile';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { authApi } from '@/api/auth';
import { useAuthStore } from '@/stores/authStore';
export default function Profile() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const clear = useAuthStore((s) => s.clear);
const setProfile = useAuthStore((s) => s.setProfile);
const [phoneOpen, setPhoneOpen] = useState(false);
const [phoneInput, setPhoneInput] = useState('');
const [pwOpen, setPwOpen] = useState(false);
const [pwForm] = Form.useForm<{ oldPassword: string; newPassword: string; confirm: string }>();
const { data: me, refetch } = useQuery({
queryKey: ['mobile.me.profile'],
queryFn: () => authApi.me(),
});
const submitPhone = async () => {
try {
await authApi.updateMyPhone(phoneInput.trim());
Toast.show({ icon: 'success', content: '전화번호 저장 완료' });
setPhoneOpen(false);
await refetch();
const fresh = await authApi.me().catch(() => null);
if (fresh) setProfile(fresh);
} catch {
// interceptor 처리
}
};
const submitPassword = async () => {
try {
const v = await pwForm.validateFields();
if (v.newPassword !== v.confirm) {
Toast.show({ icon: 'fail', content: '새 비밀번호 확인이 일치하지 않습니다' });
return;
}
await authApi.changePassword(v.oldPassword, v.newPassword);
Toast.show({ icon: 'success', content: '비밀번호 변경 완료' });
setPwOpen(false);
pwForm.resetFields();
} catch {
// 검증 실패 또는 서버 오류
}
};
const onLogout = () => {
Dialog.confirm({
content: '로그아웃 하시겠습니까?',
onConfirm: async () => {
await authApi.logout();
queryClient.clear();
clear();
navigate('/login', { replace: true });
},
});
};
return (
<div className="page">
<NavBar back={null}> </NavBar>
<div style={{ padding: 12 }}>
<Card title={me?.userName ?? me?.loginId ?? '-'}>
<List>
<List.Item extra={me?.loginId ?? '-'}></List.Item>
<List.Item extra={me?.userName ?? '-'}></List.Item>
<List.Item extra={me?.email ?? '-'}></List.Item>
<List.Item
extra={me?.phone ?? <span style={{ color: '#bbb' }}></span>}
clickable
onClick={() => { setPhoneInput(me?.phone ?? ''); setPhoneOpen(true); }}
>
</List.Item>
<List.Item extra={me?.agentName ?? <span style={{ color: '#bbb' }}></span>}></List.Item>
<List.Item extra={me?.orgName ?? '-'}></List.Item>
</List>
</Card>
<Card style={{ marginTop: 12 }}>
<List>
<List.Item clickable onClick={() => setPwOpen(true)}> </List.Item>
<List.Item clickable onClick={onLogout} style={{ color: '#ff3141' }}></List.Item>
</List>
</Card>
<Dialog
visible={phoneOpen}
title="전화번호 등록"
content={
<div>
<Input placeholder="010-1234-5678" value={phoneInput} onChange={setPhoneInput} autoFocus />
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
SMS .
</div>
</div>
}
actions={[
[
{ key: 'cancel', text: '취소', onClick: () => setPhoneOpen(false) },
{ key: 'save', text: '저장', bold: true, onClick: submitPhone },
],
]}
/>
<Dialog
visible={pwOpen}
title="비밀번호 변경"
content={
<Form form={pwForm} layout="vertical">
<Form.Item name="oldPassword" label="현재 비밀번호" rules={[{ required: true, message: '필수' }]}>
<Input type="password" />
</Form.Item>
<Form.Item name="newPassword" label="새 비밀번호" rules={[{ required: true, min: 8, message: '8자 이상' }]}>
<Input type="password" />
</Form.Item>
<Form.Item name="confirm" label="새 비밀번호 확인" rules={[{ required: true, message: '필수' }]}>
<Input type="password" />
</Form.Item>
</Form>
}
actions={[
[
{ key: 'cancel', text: '취소', onClick: () => { setPwOpen(false); pwForm.resetFields(); } },
{ key: 'save', text: '변경', bold: true, onClick: submitPassword },
],
]}
/>
<Button block style={{ marginTop: 16 }} onClick={onLogout}></Button>
</div>
</div>
);
}
+35 -14
View File
@@ -1,4 +1,4 @@
import { Button, ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
import { Button, Dialog, ErrorBlock, List, NavBar, PullToRefresh, SwipeAction, Tag, Toast } from 'antd-mobile';
import { AddOutline } from 'antd-mobile-icons';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
@@ -48,19 +48,40 @@ export default function WithdrawList() {
)}
{items.length > 0 && (
<List>
{items.map((w: WithdrawResp) => (
<List.Item
key={w.requestId}
title={<span>#{w.requestId} {w.settleMonth ?? ''}</span>}
description={
<span style={{ fontSize: 12 }}>
{w.bankCode ?? '-'} · {w.accountNo ?? '-'} · {w.amount.toLocaleString()}
{w.transferTxId && <> · txId={w.transferTxId}</>}
</span>
}
extra={<Tag color={STATUS_COLOR[w.status] ?? 'default'}>{w.statusName ?? w.status}</Tag>}
/>
))}
{items.map((w: WithdrawResp) => {
const cancellable = w.status === 'REQUESTED';
const row = (
<List.Item
key={w.requestId}
title={<span>#{w.requestId} {w.settleMonth ?? ''}</span>}
description={
<span style={{ fontSize: 12 }}>
{w.bankCode ?? '-'} · {w.accountNo ?? '-'} · {w.amount.toLocaleString()}
{w.transferTxId && <> · txId={w.transferTxId}</>}
</span>
}
extra={<Tag color={STATUS_COLOR[w.status] ?? 'default'}>{w.statusName ?? w.status}</Tag>}
/>
);
if (!cancellable) return row;
return (
<SwipeAction
key={w.requestId}
rightActions={[{
key: 'cancel', text: '취소', color: 'danger',
onClick: async () => {
const ok = await Dialog.confirm({ content: `#${w.requestId} 출금 신청을 취소하시겠습니까?` });
if (!ok) return;
await withdrawApi.cancel(w.requestId);
Toast.show({ icon: 'success', content: '취소되었습니다' });
await refetch();
},
}]}
>
{row}
</SwipeAction>
);
})}
</List>
)}
</div>