feat: P6 모바일 PWA 보안 강화 — /api/mobile/me/* 서버측 본인 필터

스캐폴드의 클라이언트 agentId 쿼리 필터(변조 가능)를
서버측 강제 필터로 교체. 토큰 → user → agentId 자동 추출.

백엔드 (ga-api):
- MobileMeService — 클라이언트 param 의 agentId/userId 는 무시·덮어쓰기.
  agentId 미연결 시 BizException(FORBIDDEN). PageHelper 로 정확한 total 산출.
- MobileMeController — GET /api/mobile/me/{summary,statements,
  withdraw-requests,notifications} + POST /notifications/{id}/read.
  별도 메뉴 권한 없이 인증만 통과 (PWA 사용자 전체 대상).

ga-mobile-pwa:
- api/statement.ts, api/withdraw.ts → /api/mobile/me/* 호출, agentId 제거
- api/notification.ts 신규 — list/markRead/summary
- Home.tsx — summary() 단일 호출로 3카운트 + 안 읽음 Badge
- NoticeList.tsx — Tabs(내 알림 / 공지). 알림 클릭 markRead + summary 무효화
- Statement/WithdrawList — E403(설계사 미연결) 시 ErrorBlock 안내

라이브 검증:
- ga-api 컴파일 BUILD SUCCESSFUL · 재기동 OK
- PWA tsc PASS · vite build PASS (precache 538KB, gzip 176KB)
- admin(agentId=null): /summary → 200 OK 0/0/0,
  /statements → E403 차단 정상, /notifications → 200 OK total=0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-24 23:32:19 +09:00
parent 23fdd8474b
commit ee3e0d40c5
10 changed files with 399 additions and 101 deletions
+36
View File
@@ -0,0 +1,36 @@
import api, { unwrap, PageResponse } from './request';
export interface NotificationResp extends Record<string, unknown> {
notificationId: number;
userId: number;
title: string;
content?: string;
refType?: string;
refId?: number;
isRead: boolean;
readAt?: string;
createdAt?: string;
}
export interface NotificationSearchParam {
isRead?: boolean;
refType?: string;
pageNum?: number;
pageSize?: number;
}
export interface MeSummary {
userId: number;
agentId?: number;
statementCount: number;
withdrawRequestCount: number;
unreadNotificationCount: number;
}
export const notificationApi = {
list: (params: NotificationSearchParam = {}) =>
unwrap<PageResponse<NotificationResp>>(api.get('/api/mobile/me/notifications', { params })),
markRead: (id: number) =>
unwrap<void>(api.post(`/api/mobile/me/notifications/${id}/read`, {})),
summary: () => unwrap<MeSummary>(api.get('/api/mobile/me/summary')),
};
+2 -4
View File
@@ -16,7 +16,6 @@ export interface StatementResp extends Record<string, unknown> {
}
export interface StatementSearchParam {
agentId?: number;
settleMonth?: string;
statementType?: string;
issueStatus?: string;
@@ -25,8 +24,7 @@ export interface StatementSearchParam {
}
export const statementApi = {
/** 본인 명세서 — 서버측이 토큰 → agentId 강제 필터. */
list: (params: StatementSearchParam = {}) =>
unwrap<PageResponse<StatementResp>>(api.get('/api/statements', { params })),
detail: (id: number) =>
unwrap<StatementResp>(api.get(`/api/statements/${id}`)),
unwrap<PageResponse<StatementResp>>(api.get('/api/mobile/me/statements', { params })),
};
+2 -17
View File
@@ -20,28 +20,13 @@ export interface WithdrawResp extends Record<string, unknown> {
}
export interface WithdrawSearchParam {
agentId?: number;
settleMasterId?: number;
pageNum?: number;
pageSize?: number;
}
export interface WithdrawSaveReq {
settleMasterId: number;
agentId: number;
amount: number;
requestedBy: number;
bankCode: string;
accountNo: string;
}
export const withdrawApi = {
/** 본인 출금 신청 이력 — 서버측이 토큰 → agentId 강제 필터. */
list: (params: WithdrawSearchParam = {}) =>
unwrap<PageResponse<WithdrawResp>>(api.get('/api/withdraw-requests', { params })),
detail: (id: number) =>
unwrap<WithdrawResp>(api.get(`/api/withdraw-requests/${id}`)),
create: (req: WithdrawSaveReq) =>
unwrap<number>(api.post('/api/withdraw-requests', req)),
cancel: (id: number) =>
unwrap<void>(api.post(`/api/withdraw-requests/${id}/cancel`, {})),
unwrap<PageResponse<WithdrawResp>>(api.get('/api/mobile/me/withdraw-requests', { params })),
};
+21 -24
View File
@@ -2,31 +2,22 @@ import { Card, List, NavBar, Space, Tag } from 'antd-mobile';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/stores/authStore';
import { statementApi } from '@/api/statement';
import { withdrawApi } from '@/api/withdraw';
import { noticeApi } from '@/api/notice';
import { notificationApi } from '@/api/notification';
export default function Home() {
const navigate = useNavigate();
const profile = useAuthStore((s) => s.profile);
const clear = useAuthStore((s) => s.clear);
const agentId = profile?.agentId;
const statements = useQuery({
queryKey: ['home.statements', agentId],
enabled: !!agentId,
queryFn: () => statementApi.list({ agentId, pageNum: 1, pageSize: 1 }),
});
const withdraws = useQuery({
queryKey: ['home.withdraws', agentId],
enabled: !!agentId,
queryFn: () => withdrawApi.list({ agentId, pageNum: 1, pageSize: 1 }),
});
const notices = useQuery({
queryKey: ['home.notices'],
queryFn: () => noticeApi.active(),
const summary = useQuery({
queryKey: ['mobile.me.summary'],
queryFn: () => notificationApi.summary(),
staleTime: 10_000,
});
const s = summary.data;
const loading = summary.isLoading;
return (
<div className="page">
<NavBar back={null} right={<a onClick={() => { clear(); navigate('/login'); }} style={{ fontSize: 14 }}></a>}>
@@ -40,7 +31,7 @@ export default function Home() {
<div style={{ color: '#888', fontSize: 13, marginTop: 4 }}>
{profile?.agentName ? `${profile.agentName} · ${profile.orgName ?? ''}` : '설계사 정보 없음'}
</div>
{profile?.roleCodes && (
{profile?.roleCodes && profile.roleCodes.length > 0 && (
<div style={{ marginTop: 8 }}>
<Space wrap>
{profile.roleCodes.map((r) => <Tag key={r} color="primary">{r}</Tag>)}
@@ -52,32 +43,38 @@ export default function Home() {
<div style={{ marginTop: 16 }}>
<List header="요약">
<List.Item
extra={statements.isLoading ? '...' : `${statements.data?.total ?? 0}`}
extra={loading ? '...' : `${s?.statementCount ?? 0}`}
onClick={() => navigate('/statement')}
clickable
>
</List.Item>
<List.Item
extra={withdraws.isLoading ? '...' : `${withdraws.data?.total ?? 0}`}
extra={loading ? '...' : `${s?.withdrawRequestCount ?? 0}`}
onClick={() => navigate('/withdraw')}
clickable
>
</List.Item>
<List.Item
extra={notices.isLoading ? '...' : `${notices.data?.length ?? 0}`}
extra={
loading
? '...'
: (s?.unreadNotificationCount ?? 0) > 0
? <Tag color="danger">{s?.unreadNotificationCount} </Tag>
: '모두 확인'
}
onClick={() => navigate('/notice')}
clickable
>
/
</List.Item>
</List>
</div>
{!agentId && (
{s && !s.agentId && (
<div style={{ marginTop: 24, fontSize: 12, color: '#f96', textAlign: 'center' }}>
(agent) . .
(agent) . · .
</div>
)}
</div>
+103 -31
View File
@@ -1,40 +1,112 @@
import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
import { useQuery } from '@tanstack/react-query';
import { Badge, ErrorBlock, List, NavBar, PullToRefresh, Tabs, Tag } from 'antd-mobile';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { NoticeResp, noticeApi } from '@/api/notice';
import { NotificationResp, notificationApi } from '@/api/notification';
export default function NoticeList() {
const { data, isLoading, refetch } = useQuery({
queryKey: ['notices.active'],
queryFn: () => noticeApi.active(),
});
const items = data ?? [];
const [tab, setTab] = useState('notifications');
return (
<div className="page">
<NavBar back={null}></NavBar>
<PullToRefresh onRefresh={async () => { await refetch(); }}>
<div style={{ padding: 0, paddingTop: 8 }}>
{!isLoading && items.length === 0 && (
<ErrorBlock status="empty" title="활성 공지가 없습니다" />
)}
{items.length > 0 && (
<List>
{items.map((n: NoticeResp) => (
<List.Item
key={n.noticeId}
title={n.title}
description={
<span style={{ fontSize: 12 }}>
{n.startAt ?? ''} {n.noticeType && <Tag style={{ marginLeft: 6 }} color="default">{n.noticeType}</Tag>}
</span>
}
/>
))}
</List>
)}
</div>
</PullToRefresh>
<NavBar back={null}> / </NavBar>
<Tabs activeKey={tab} onChange={setTab}>
<Tabs.Tab title="내 알림" key="notifications">
<Notifications />
</Tabs.Tab>
<Tabs.Tab title="공지사항" key="notices">
<Notices />
</Tabs.Tab>
</Tabs>
</div>
);
}
function Notifications() {
const qc = useQueryClient();
const { data, isLoading, refetch } = useQuery({
queryKey: ['mobile.me.notifications'],
queryFn: () => notificationApi.list({ pageNum: 1, pageSize: 50 }),
});
const items = data?.list ?? [];
const onItemClick = async (n: NotificationResp) => {
if (!n.isRead) {
try {
await notificationApi.markRead(n.notificationId);
await qc.invalidateQueries({ queryKey: ['mobile.me.notifications'] });
await qc.invalidateQueries({ queryKey: ['mobile.me.summary'] });
} catch {
// interceptor 가 토스트 처리
}
}
};
return (
<PullToRefresh onRefresh={async () => { await refetch(); }}>
<div style={{ paddingTop: 4 }}>
{!isLoading && items.length === 0 && (
<ErrorBlock status="empty" title="알림이 없습니다" />
)}
{items.length > 0 && (
<List>
{items.map((n: NotificationResp) => (
<List.Item
key={n.notificationId}
onClick={() => onItemClick(n)}
clickable
title={
<span>
{!n.isRead && <Badge color="#ff3141" style={{ marginRight: 6 }} />}
{n.title}
</span>
}
description={
<span style={{ fontSize: 12 }}>
{n.refType && <Tag color="default" style={{ marginRight: 6 }}>{n.refType}</Tag>}
{n.content ?? ''}
</span>
}
extra={<span style={{ fontSize: 11, color: '#aaa' }}>{n.createdAt ?? ''}</span>}
/>
))}
</List>
)}
</div>
</PullToRefresh>
);
}
function Notices() {
const { data, isLoading, refetch } = useQuery({
queryKey: ['mobile.notices.active'],
queryFn: () => noticeApi.active(),
});
const items = data ?? [];
return (
<PullToRefresh onRefresh={async () => { await refetch(); }}>
<div style={{ paddingTop: 4 }}>
{!isLoading && items.length === 0 && (
<ErrorBlock status="empty" title="활성 공지가 없습니다" />
)}
{items.length > 0 && (
<List>
{items.map((n: NoticeResp) => (
<List.Item
key={n.noticeId}
title={n.title}
description={
<span style={{ fontSize: 12 }}>
{n.startAt ?? ''}{' '}
{n.noticeType && <Tag style={{ marginLeft: 6 }} color="default">{n.noticeType}</Tag>}
</span>
}
/>
))}
</List>
)}
</div>
</PullToRefresh>
);
}
+8 -9
View File
@@ -1,27 +1,26 @@
import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/stores/authStore';
import { StatementResp, statementApi } from '@/api/statement';
export default function StatementList() {
const agentId = useAuthStore((s) => s.profile?.agentId);
const { data, isLoading, refetch } = useQuery({
queryKey: ['statements', agentId],
enabled: !!agentId,
queryFn: () => statementApi.list({ agentId, pageNum: 1, pageSize: 50 }),
const { data, isLoading, refetch, error } = useQuery({
queryKey: ['mobile.me.statements'],
queryFn: () => statementApi.list({ pageNum: 1, pageSize: 50 }),
retry: false,
});
const items = data?.list ?? [];
const forbidden = (error as { code?: string } | undefined)?.code === 'E403';
return (
<div className="page">
<NavBar back={null}> </NavBar>
<PullToRefresh onRefresh={async () => { await refetch(); }}>
<div style={{ padding: 0, paddingTop: 8 }}>
{!agentId && (
<div style={{ paddingTop: 8 }}>
{forbidden && (
<ErrorBlock status="empty" title="설계사 미연결" description="본 계정에는 설계사가 연결되어 있지 않습니다." />
)}
{agentId && !isLoading && items.length === 0 && (
{!forbidden && !isLoading && items.length === 0 && (
<ErrorBlock status="empty" title="명세서가 없습니다" description="발급된 정산 명세서가 아직 없습니다." />
)}
{items.length > 0 && (
+9 -14
View File
@@ -1,6 +1,5 @@
import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/stores/authStore';
import { WithdrawResp, withdrawApi } from '@/api/withdraw';
const STATUS_COLOR: Record<string, string> = {
@@ -13,24 +12,24 @@ const STATUS_COLOR: Record<string, string> = {
};
export default function WithdrawList() {
const agentId = useAuthStore((s) => s.profile?.agentId);
const { data, isLoading, refetch } = useQuery({
queryKey: ['withdraws', agentId],
enabled: !!agentId,
queryFn: () => withdrawApi.list({ agentId, pageNum: 1, pageSize: 50 }),
const { data, isLoading, refetch, error } = useQuery({
queryKey: ['mobile.me.withdraws'],
queryFn: () => withdrawApi.list({ pageNum: 1, pageSize: 50 }),
retry: false,
});
const items = data?.list ?? [];
const forbidden = (error as { code?: string } | undefined)?.code === 'E403';
return (
<div className="page">
<NavBar back={null}> </NavBar>
<PullToRefresh onRefresh={async () => { await refetch(); }}>
<div style={{ padding: 0, paddingTop: 8 }}>
{!agentId && (
<div style={{ paddingTop: 8 }}>
{forbidden && (
<ErrorBlock status="empty" title="설계사 미연결" description="본 계정에는 설계사가 연결되어 있지 않습니다." />
)}
{agentId && !isLoading && items.length === 0 && (
{!forbidden && !isLoading && items.length === 0 && (
<ErrorBlock status="empty" title="출금 신청 내역이 없습니다" />
)}
{items.length > 0 && (
@@ -38,11 +37,7 @@ export default function WithdrawList() {
{items.map((w: WithdrawResp) => (
<List.Item
key={w.requestId}
title={
<span>
#{w.requestId} {w.settleMonth ?? ''}
</span>
}
title={<span>#{w.requestId} {w.settleMonth ?? ''}</span>}
description={
<span style={{ fontSize: 12 }}>
{w.bankCode ?? '-'} · {w.accountNo ?? '-'} · {w.amount.toLocaleString()}