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:
@@ -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시(=사실상 비활성)로 작동.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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`)),
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user