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:
@@ -0,0 +1,59 @@
|
||||
package com.ga.api.controller.mobile;
|
||||
|
||||
import com.ga.api.service.mobile.MobileMeService;
|
||||
import com.ga.api.service.mobile.MobileMeService.MeSummary;
|
||||
import com.ga.common.model.ApiResponse;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.core.vo.notice.UserNotificationResp;
|
||||
import com.ga.core.vo.notice.UserNotificationSearchParam;
|
||||
import com.ga.core.vo.settle.CommissionStatementResp;
|
||||
import com.ga.core.vo.settle.CommissionStatementSearchParam;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestResp;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 모바일 PWA 전용 — 토큰의 사용자 본인 데이터만 반환.
|
||||
*
|
||||
* <p>모든 endpoint 는 인증만 통과하면 사용 가능 (별도 메뉴 권한 없음).
|
||||
* agentId / userId 는 서버측에서 토큰 → user 조회로 강제 결정. 클라이언트 파라미터는 무시한다.
|
||||
*/
|
||||
@Tag(name = "모바일 셀프 조회")
|
||||
@RestController
|
||||
@RequestMapping("/api/mobile/me")
|
||||
@RequiredArgsConstructor
|
||||
public class MobileMeController {
|
||||
|
||||
private final MobileMeService service;
|
||||
|
||||
@GetMapping("/summary")
|
||||
public ApiResponse<MeSummary> summary() {
|
||||
return ApiResponse.ok(service.summary());
|
||||
}
|
||||
|
||||
@GetMapping("/statements")
|
||||
public ApiResponse<PageResponse<CommissionStatementResp>> statements(
|
||||
@ModelAttribute CommissionStatementSearchParam param) {
|
||||
return ApiResponse.ok(service.statements(param));
|
||||
}
|
||||
|
||||
@GetMapping("/withdraw-requests")
|
||||
public ApiResponse<PageResponse<WithdrawRequestResp>> withdrawRequests(
|
||||
@ModelAttribute WithdrawRequestSearchParam param) {
|
||||
return ApiResponse.ok(service.withdrawRequests(param));
|
||||
}
|
||||
|
||||
@GetMapping("/notifications")
|
||||
public ApiResponse<PageResponse<UserNotificationResp>> notifications(
|
||||
@ModelAttribute UserNotificationSearchParam param) {
|
||||
return ApiResponse.ok(service.notifications(param));
|
||||
}
|
||||
|
||||
@PostMapping("/notifications/{notificationId}/read")
|
||||
public ApiResponse<Void> markRead(@PathVariable Long notificationId) {
|
||||
service.markNotificationRead(notificationId);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.ga.api.service.mobile;
|
||||
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.common.util.SecurityUtil;
|
||||
import com.ga.core.mapper.notice.UserNotificationMapper;
|
||||
import com.ga.core.mapper.settle.CommissionStatementMapper;
|
||||
import com.ga.core.mapper.user.UserMapper;
|
||||
import com.ga.core.mapper.withdraw.WithdrawRequestMapper;
|
||||
import com.ga.core.vo.notice.UserNotificationResp;
|
||||
import com.ga.core.vo.notice.UserNotificationSearchParam;
|
||||
import com.ga.core.vo.settle.CommissionStatementResp;
|
||||
import com.ga.core.vo.settle.CommissionStatementSearchParam;
|
||||
import com.ga.core.vo.user.UserVO;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestResp;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 모바일 PWA 전용 — 본인 데이터만 조회. agentId/userId 는 토큰에서 강제 추출.
|
||||
*
|
||||
* <p>클라이언트가 보낸 search param 의 agentId/userId 는 무시하고 SecurityContext 의 사용자로 덮어쓴다.
|
||||
* 본 서비스 외부에서는 본인 데이터 임의 조회가 절대 불가능하도록 보장.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MobileMeService {
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final CommissionStatementMapper statementMapper;
|
||||
private final WithdrawRequestMapper withdrawMapper;
|
||||
private final UserNotificationMapper notificationMapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MeSummary summary() {
|
||||
Long userId = currentUserId();
|
||||
Long agentId = resolveAgentId(userId);
|
||||
|
||||
long stTotal = 0L;
|
||||
long wdTotal = 0L;
|
||||
if (agentId != null) {
|
||||
CommissionStatementSearchParam sp = new CommissionStatementSearchParam();
|
||||
sp.setAgentId(agentId);
|
||||
sp.setPageNum(1);
|
||||
sp.setPageSize(1);
|
||||
sp.startPage();
|
||||
stTotal = PageResponse.of(statementMapper.selectList(sp)).getTotal();
|
||||
|
||||
WithdrawRequestSearchParam wp = new WithdrawRequestSearchParam();
|
||||
wp.setAgentId(agentId);
|
||||
wp.setPageNum(1);
|
||||
wp.setPageSize(1);
|
||||
wp.startPage();
|
||||
wdTotal = PageResponse.of(withdrawMapper.selectList(wp)).getTotal();
|
||||
}
|
||||
int unreadCount = notificationMapper.selectUnreadCount(userId);
|
||||
|
||||
return MeSummary.builder()
|
||||
.userId(userId)
|
||||
.agentId(agentId)
|
||||
.statementCount(stTotal)
|
||||
.withdrawRequestCount(wdTotal)
|
||||
.unreadNotificationCount(unreadCount)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<CommissionStatementResp> statements(CommissionStatementSearchParam param) {
|
||||
Long agentId = requireAgentId();
|
||||
param.setAgentId(agentId); // 강제 덮어쓰기
|
||||
param.startPage();
|
||||
return PageResponse.of(statementMapper.selectList(param));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<WithdrawRequestResp> withdrawRequests(WithdrawRequestSearchParam param) {
|
||||
Long agentId = requireAgentId();
|
||||
param.setAgentId(agentId); // 강제 덮어쓰기
|
||||
param.startPage();
|
||||
return PageResponse.of(withdrawMapper.selectList(param));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<UserNotificationResp> notifications(UserNotificationSearchParam param) {
|
||||
Long userId = currentUserId();
|
||||
param.setUserId(userId); // 강제 덮어쓰기
|
||||
param.startPage();
|
||||
return PageResponse.of(notificationMapper.selectList(param));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markNotificationRead(Long notificationId) {
|
||||
Long userId = currentUserId();
|
||||
notificationMapper.markRead(notificationId, userId);
|
||||
}
|
||||
|
||||
// ── private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private Long currentUserId() {
|
||||
Long uid = SecurityUtil.getCurrentUserId();
|
||||
if (uid == null) throw new BizException(ErrorCode.UNAUTHORIZED);
|
||||
return uid;
|
||||
}
|
||||
|
||||
private Long resolveAgentId(Long userId) {
|
||||
UserVO user = userMapper.selectById(userId);
|
||||
return user != null ? user.getAgentId() : null;
|
||||
}
|
||||
|
||||
/** 본인 agent 가 연결돼 있어야만 통과. 미연결이면 FORBIDDEN. */
|
||||
private Long requireAgentId() {
|
||||
Long uid = currentUserId();
|
||||
Long agentId = resolveAgentId(uid);
|
||||
if (agentId == null) throw new BizException(ErrorCode.FORBIDDEN);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public static class MeSummary {
|
||||
private Long userId;
|
||||
private Long agentId;
|
||||
private long statementCount;
|
||||
private long withdrawRequestCount;
|
||||
private int unreadNotificationCount;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user