diff --git a/.gitignore b/.gitignore index 5d995f8..ae6efa0 100644 --- a/.gitignore +++ b/.gitignore @@ -53,14 +53,8 @@ src/main/resources/application-secret.properties src/main/resources/application-prod.properties *.secret.properties -keys -keys/** - src/main/resources/keys/private.pem **/private.pem -gateway-config -init - src/main/resources/firebase/firebase-adminsdk.json docker-compose.yml diff --git a/Dockerfile b/Dockerfile index 838322d..f172afb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,54 @@ -# Debian/Ubuntu 계열 JRE (amd64/arm64 모두 제공) -FROM eclipse-temurin:17-jre-jammy +############################ +# 1) Build stage (JDK) +############################ +FROM eclipse-temurin:17-jdk-jammy AS build +WORKDIR /workspace -# spring 사용자/그룹 생성 (Debian 표준 명령) +# Gradle wrapper & 설정 먼저 복사(캐시 잘 쓰기 위함) +COPY gradlew gradle/ /workspace/ +COPY build.gradle* settings.gradle* /workspace/ +RUN chmod +x gradlew + +# 의존성 워밍업 (빌드캐시) +RUN --mount=type=cache,target=/root/.gradle \ + ./gradlew --no-daemon help || true + +# 나머지 소스 복사 후 빌드 +COPY . /workspace +RUN --mount=type=cache,target=/root/.gradle \ + ./gradlew --no-daemon clean bootJar -x test + +# 산출물 꺼내두기 +RUN JAR="$(ls build/libs/*.jar | head -n1)" && \ + install -D "$JAR" /out/app.jar + +# ✅ 가드: user 레포의 메인 클래스가 JAR에 반드시 존재해야 함 +ARG MAIN_CLASS_PATH=com/tekcit/festival/FestivalServiceApplication.class +RUN jar tf /out/app.jar | grep -q "$MAIN_CLASS_PATH" \ + || (echo "ERROR: missing $MAIN_CLASS_PATH in built JAR; check project structure" >&2; exit 1) + + +############################ +# 2) Runtime stage (JRE) +############################ +FROM eclipse-temurin:17-jre-jammy AS runtime + +# 비루트 사용자 생성 RUN groupadd -r spring \ && useradd -r -g spring -d /home/spring -s /usr/sbin/nologin spring \ - && mkdir -p /app /home/spring + && mkdir -p /app /home/spring /etc/keys /etc/firebase \ + && chown -R spring:spring /app /home/spring /etc/keys /etc/firebase WORKDIR /app -# 빌드 산출물 복사 (권한도 함께 설정) -ARG JAR_FILE=build/libs/*.jar -COPY --chown=spring:spring ${JAR_FILE} /app/app.jar +# 빌드 산출물 복사 +COPY --from=build --chown=spring:spring /out/app.jar /app/app.jar -# 비루트 실행 USER spring - EXPOSE 8080 + +# 컨테이너 메모리 친화 옵션 ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75" + +# 실행 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 4d1d672..55865a9 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-aop' // Slf4j 로그용 추가 implementation 'org.springframework.boot:spring-boot-starter-webflux' + // prometheus + implementation 'io.micrometer:micrometer-registry-prometheus' + // MariaDB runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' diff --git a/src/main/java/com/tekcit/festival/FestivalServiceApplication.java b/src/main/java/com/tekcit/festival/FestivalServiceApplication.java index 4c4efbe..a82a31a 100644 --- a/src/main/java/com/tekcit/festival/FestivalServiceApplication.java +++ b/src/main/java/com/tekcit/festival/FestivalServiceApplication.java @@ -6,14 +6,14 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; +import java.util.UUID; + @SpringBootApplication @Slf4j @EnableJpaAuditing @EnableScheduling public class FestivalServiceApplication { - public static void main(String[] args) { SpringApplication.run(FestivalServiceApplication.class, args); } - } diff --git a/src/main/java/com/tekcit/festival/config/security/AuthDetails.java b/src/main/java/com/tekcit/festival/config/security/AuthDetails.java new file mode 100644 index 0000000..eed9c82 --- /dev/null +++ b/src/main/java/com/tekcit/festival/config/security/AuthDetails.java @@ -0,0 +1,16 @@ +package com.tekcit.festival.config.security; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.web.authentication.WebAuthenticationDetails; + +public class AuthDetails extends WebAuthenticationDetails { + private final String userName; + + public AuthDetails(HttpServletRequest request, String userName) { + super(request); + this.userName = userName; + } + public String getUserName() { + return userName; + } +} diff --git a/src/main/java/com/tekcit/festival/config/security/HeaderAuthenticationFilter.java b/src/main/java/com/tekcit/festival/config/security/HeaderAuthenticationFilter.java index 49c2aed..07c8c87 100644 --- a/src/main/java/com/tekcit/festival/config/security/HeaderAuthenticationFilter.java +++ b/src/main/java/com/tekcit/festival/config/security/HeaderAuthenticationFilter.java @@ -4,36 +4,19 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.stereotype.Component; import java.io.IOException; import java.util.Arrays; import java.util.List; -import java.util.Set; import java.util.stream.Collectors; // --- HeaderAuthenticationFilter를 SecurityConfig 내부에 정의하여 관리합니다. --- @@ -55,7 +38,14 @@ protected void doFilterInternal( final String userIdHeader = trimToNull(request.getHeader("X-User-Id")); final String rolesHdr = trimToNull(request.getHeader("X-User-Role")); - + final String userNameHeader = trimToNull(request.getHeader("X-User-Name")); + String userName = ""; + if(userNameHeader != null) { + userName = new String( + java.util.Base64.getUrlDecoder().decode(userNameHeader), + java.nio.charset.StandardCharsets.UTF_8 + ); + } Authentication current = SecurityContextHolder.getContext().getAuthentication(); boolean isAnonymous = (current instanceof AnonymousAuthenticationToken); boolean canSetAuth = (current == null) || isAnonymous; @@ -83,7 +73,7 @@ protected void doFilterInternal( UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(String.valueOf(userId), null, authorities); - auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + auth.setDetails(new AuthDetails(request, userName)); SecurityContextHolder.getContext().setAuthentication(auth); // principal을 **Long**으로 세팅 @@ -107,6 +97,9 @@ private static String trimToNull(String s) { protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); String method = request.getMethod(); - return "OPTIONS".equals(method) || path.startsWith("/actuator"); + + return "OPTIONS".equals(method) + || path.startsWith("/actuator") + || path.startsWith("/api/users/statisticsList"); } } \ No newline at end of file diff --git a/src/main/java/com/tekcit/festival/config/security/SecurityConfig.java b/src/main/java/com/tekcit/festival/config/security/SecurityConfig.java index 458b231..8a51124 100644 --- a/src/main/java/com/tekcit/festival/config/security/SecurityConfig.java +++ b/src/main/java/com/tekcit/festival/config/security/SecurityConfig.java @@ -52,6 +52,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .logout(logout -> logout.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health/**", "/actuator/prometheus").permitAll() .requestMatchers( "/api/users/signupUser", "/api/users/signupAdmin", @@ -59,6 +60,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/users/findRegisteredEmail", "/api/users/resetPasswordEmail", "/api/users/login", + "/api/users/login/confirm", "/api/auth/kakao/signupUser" ).anonymous() .requestMatchers( diff --git a/src/main/java/com/tekcit/festival/config/security/WebClientConfig.java b/src/main/java/com/tekcit/festival/config/security/WebClientConfig.java index 22e34b1..46a1fd9 100644 --- a/src/main/java/com/tekcit/festival/config/security/WebClientConfig.java +++ b/src/main/java/com/tekcit/festival/config/security/WebClientConfig.java @@ -1,5 +1,6 @@ package com.tekcit.festival.config.security; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.client.WebClient; @@ -7,18 +8,13 @@ @Configuration public class WebClientConfig { + @Value("${booking.base.service.url}") + private String bookingBaseServiceUrl; + @Bean public WebClient bookingWebClient() { return WebClient.builder() - .baseUrl("http://localhost:8082") + .baseUrl(bookingBaseServiceUrl) .build(); } - - // 다른 외부 서비스 호출을 할 수 있음 - // @Bean - // public WebClient anotherWebClient() { - // return WebClient.builder() - // .baseUrl("http://another-service-url") - // .build(); - // } } \ No newline at end of file diff --git a/src/main/java/com/tekcit/festival/config/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/tekcit/festival/config/security/filter/JwtAuthenticationFilter.java deleted file mode 100644 index bd409a1..0000000 --- a/src/main/java/com/tekcit/festival/config/security/filter/JwtAuthenticationFilter.java +++ /dev/null @@ -1,64 +0,0 @@ -/*package com.tekcit.festival.config.security.filter; - -import com.tekcit.festival.config.security.userdetails.CustomUserDetailsService; -import com.tekcit.festival.config.security.token.JwtTokenProvider; -import com.tekcit.festival.utils.TokenParseUtil; -import io.jsonwebtoken.ExpiredJwtException; // ✅ 추가 -import jakarta.servlet.*; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; // ✅ 추가 -import org.springframework.security.authentication.AnonymousAuthenticationToken; // ✅ 추가 -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Component; // ✅ 추가 -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -@Slf4j // ✅ 추가 -@Component // ✅ 추가 -@RequiredArgsConstructor -public class JwtAuthenticationFilter extends OncePerRequestFilter{ - private final JwtTokenProvider jwtTokenProvider; - private final CustomUserDetailsService userDetailsService; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String path = request.getRequestURI(); - String method = request.getMethod(); - - // ✅ 인증이 필요 없는 경로(OPTIONS 요청, FCM 토큰 저장)를 필터링하지 않음 - if (path.startsWith("/api/users/fcm-token") || "OPTIONS".equalsIgnoreCase(method)) { - filterChain.doFilter(request, response); - return; - } - - // ... 기존 JWT 토큰 검증 로직 ... - String token = TokenParseUtil.parseToken(request); - - if (token != null && jwtTokenProvider.validateToken(token)) { - Long userId = jwtTokenProvider.getUserId(token); - UserDetails userDetails = userDetailsService.loadUserByUserId(userId); - if (!userDetails.isEnabled()) { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403 - response.getWriter().write("정지된 계정입니다."); - return; - } - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); - - SecurityContextHolder.getContext().setAuthentication(authentication); - - } - filterChain.doFilter(request, response); - - } - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { - return false; - } -}*/ \ No newline at end of file diff --git a/src/main/java/com/tekcit/festival/config/security/token/JwtTokenProvider.java b/src/main/java/com/tekcit/festival/config/security/token/JwtTokenProvider.java index 43fcd79..26da9ea 100644 --- a/src/main/java/com/tekcit/festival/config/security/token/JwtTokenProvider.java +++ b/src/main/java/com/tekcit/festival/config/security/token/JwtTokenProvider.java @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; import org.springframework.stereotype.Component; import com.tekcit.festival.domain.user.entity.User; import io.jsonwebtoken.jackson.io.JacksonSerializer; // jjwt-jackson @@ -29,10 +30,10 @@ @Slf4j public class JwtTokenProvider { @Value("${jwt.private-pem-path}") - private org.springframework.core.io.Resource privatePemPath; + private Resource privatePemPath; @Value("${jwt.public-pem-path}") - private org.springframework.core.io.Resource publicPemPath; + private Resource publicPemPath; @Value("${jwt.access-valid-ms}") private long accessValidMs; @@ -46,6 +47,9 @@ public class JwtTokenProvider { @Value("${signup.ticket.valid-ms}") // 기본 10분 private long signupTicketValidMs; + @Value("${login.confirm.valid-ms}") + private long loginConfirmTicketValidMs; + private PrivateKey privateKey; private PublicKey publicKey; private Serializer> jsonSerializer; @@ -86,13 +90,14 @@ public String createAccessToken(User user) { } // 리프레시 토큰 생성 - public String createRefreshToken(User user) { + public String createRefreshToken(User user, String sid) { Date now = new Date(); Date expiration = new Date(now.getTime() + refreshValidMs); return Jwts.builder() .setIssuer(issuer) .setSubject(String.valueOf(user.getUserId())) + .claim("sid", sid) .setIssuedAt(now) .setExpiration(expiration) .serializeToJsonWith(jsonSerializer) // ★ 여기! @@ -116,6 +121,22 @@ public String createSignupTicket(String kakaoId, String email) { .compact(); } + //아무나 특정 사용자의 기존 세션을 끊는 DoS 방지를 위해 + public String createLoginConfirmTicket(Long userId){ + Date now = new Date(); + Date exp = new Date(now.getTime() + loginConfirmTicketValidMs); + + return Jwts.builder() + .setIssuer(issuer) + .setSubject("login-confirm") + .claim("userId", userId) + .setIssuedAt(now) + .setExpiration(exp) + .serializeToJsonWith(jsonSerializer) + .signWith(privateKey, SignatureAlgorithm.RS256) + .compact(); + } + public boolean validateToken(String token) { try { Jwts.parserBuilder() @@ -195,6 +216,30 @@ public SignupTicketClaims parseSignupTicket(String token) { } } + public Long parseLoginConfirmTicket(String token){ + try { + Claims c = Jwts.parserBuilder() + .setSigningKey(publicKey) + .setAllowedClockSkewSeconds(30) + .build() + .parseClaimsJws(token) + .getBody(); + + if (!"login-confirm".equals(c.getSubject())) { + throw new BusinessException(ErrorCode.LOGIN_CONFIRM_MISMATCH, "잘못된 로그인 확인 티켓입니다."); + } + Long userId = c.get("userId", Long.class); + if(userId == null){ + throw new BusinessException(ErrorCode.LOGIN_CONFIRM_INVALID, "userId 값이 없습니다."); + } + return userId; + } catch (ExpiredJwtException e) { + throw new BusinessException(ErrorCode.LOGIN_CONFIRM_EXPIRED, "로그인 확인이 만료되었습니다."); + } catch (JwtException | IllegalArgumentException e) { + throw new BusinessException(ErrorCode.LOGIN_CONFIRM_INVALID, "로그인 확인 티켓이 유효하지 않습니다."); + } + } + // ===== PEM 로더들 private static PrivateKey loadPrivateKeyFromPem(String pem) { try { diff --git a/src/main/java/com/tekcit/festival/config/security/userdetails/CustomUserDetails.java b/src/main/java/com/tekcit/festival/config/security/userdetails/CustomUserDetails.java index c4a5d67..3e251c6 100644 --- a/src/main/java/com/tekcit/festival/config/security/userdetails/CustomUserDetails.java +++ b/src/main/java/com/tekcit/festival/config/security/userdetails/CustomUserDetails.java @@ -49,19 +49,4 @@ public boolean isCredentialsNonExpired() { return true; // 자격 만료 여부 } - @Override - public boolean isEnabled() { - // 일반 사용자일 경우 - if (user.getRole() == UserRole.USER && user.getUserProfile() != null) { - return user.getUserProfile().isActive(); - } - - // 축제 주최측일 경우 - if (user.getRole() == UserRole.HOST && user.getHostProfile() != null) { - return user.getHostProfile().isActive(); - } - - // 운영관리자는 항상 활성 - return true; - } } diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/controller/FcmTokenController.java b/src/main/java/com/tekcit/festival/domain/host_admin/controller/FcmTokenController.java index 0e45601..2b91449 100644 --- a/src/main/java/com/tekcit/festival/domain/host_admin/controller/FcmTokenController.java +++ b/src/main/java/com/tekcit/festival/domain/host_admin/controller/FcmTokenController.java @@ -9,7 +9,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -23,25 +25,23 @@ public class FcmTokenController { private final UserRepository userRepository; private final FcmService fcmService; // FcmService 의존성 주입 + // 공통 로직을 별도의 메서드로 분리 + private Long getUserIdFromSecurityContext() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return Long.parseLong(authentication.getName()); + } + // FCM 토큰 저장/갱신 @PostMapping("/fcm-token") - public ResponseEntity receiveToken( - @RequestBody FcmTokenRequestDTO requestDto, - Authentication authentication) { - - if (authentication == null || authentication.getPrincipal() == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "인증 정보가 없습니다."); - } - - String userIdStr = (String) authentication.getPrincipal(); - Long userId = Long.valueOf(userIdStr); + @PreAuthorize("hasRole('USER')") + public ResponseEntity receiveToken(@RequestBody FcmTokenRequestDTO requestDto) { + Long userId = getUserIdFromSecurityContext(); User user = userRepository.findById(userId) .orElseThrow(() -> new ResponseStatusException( HttpStatus.BAD_REQUEST, "존재하지 않는 사용자 ID: " + userId)); fcmService.saveToken(user, requestDto.getToken()); - return ResponseEntity.ok("토큰 저장 완료"); } diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/controller/NotificationScheduleController.java b/src/main/java/com/tekcit/festival/domain/host_admin/controller/NotificationScheduleController.java index 1bf9fad..df502d4 100644 --- a/src/main/java/com/tekcit/festival/domain/host_admin/controller/NotificationScheduleController.java +++ b/src/main/java/com/tekcit/festival/domain/host_admin/controller/NotificationScheduleController.java @@ -52,16 +52,21 @@ public ResponseEntity> update( return ResponseEntity.ok(new SuccessResponse<>(true, data, "✏️ 공지 알림 수정 완료")); } - @Operation(summary = "공지 알림 삭제", description = "실행되지 않는 등록된 알림에 한해 알림을 삭제합니다. (HOST만 가능, 본인 소유만)") + @Operation(summary = "공지 알림 삭제", description = "공지 스케줄은 삭제 하지만, 유저에서 발송된 히스토리는 삭제 X. (HOST는 본인 소유만, ADMIN은 전체 )") @DeleteMapping("/{id}") - @PreAuthorize("hasRole('HOST')") + @PreAuthorize("hasAnyRole('HOST', 'ADMIN')") public ResponseEntity> delete(@PathVariable Long id) { Long userId = getUserIdFromSecurityContext(); - scheduleService.delete(id, userId); - return ResponseEntity.ok(new SuccessResponse<>(true, null, "🗑️ 예약 삭제 완료")); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + boolean isAdmin = authentication.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + + scheduleService.delete(id, userId, isAdmin); + return ResponseEntity.ok(new SuccessResponse<>(true, null, "🗑️ 공지 알림 삭제 완료")); } - @Operation(summary = "전체 공지 알림 조회", description = "모든 공지 알림 알림을 조회합니다. (HOST는 본인 소유만, ADMIN은 전체 )") + @Operation(summary = "전체 공지 알림 조회", description = "모든 공지 알림을 조회합니다. (HOST는 본인 소유만, ADMIN은 전체 )") @GetMapping @PreAuthorize("hasAnyRole('HOST', 'ADMIN')") public ResponseEntity>> getAll() { diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/dto/response/NotificationListDTO.java b/src/main/java/com/tekcit/festival/domain/host_admin/dto/response/NotificationListDTO.java index c2599fa..3456909 100644 --- a/src/main/java/com/tekcit/festival/domain/host_admin/dto/response/NotificationListDTO.java +++ b/src/main/java/com/tekcit/festival/domain/host_admin/dto/response/NotificationListDTO.java @@ -12,6 +12,9 @@ @AllArgsConstructor @Schema(description = "알림 목록 조회 응답 DTO") public class NotificationListDTO { + @Schema(description = "알림 ID", example = "1") + private Long nid; + @Schema(description = "알림 제목", example = "공연 시작 10분 전!") private String title; @@ -26,6 +29,7 @@ public class NotificationListDTO { public static NotificationListDTO fromEntity(Notification notification) { return new NotificationListDTO( + notification.getNid(), notification.getTitle(), notification.getSentAt(), notification.getFname(), diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/repository/FcmTokenRepository.java b/src/main/java/com/tekcit/festival/domain/host_admin/repository/FcmTokenRepository.java index 76e1461..09c9d8d 100644 --- a/src/main/java/com/tekcit/festival/domain/host_admin/repository/FcmTokenRepository.java +++ b/src/main/java/com/tekcit/festival/domain/host_admin/repository/FcmTokenRepository.java @@ -27,4 +27,6 @@ public interface FcmTokenRepository extends JpaRepository { @Query("SELECT f.token FROM FcmToken f WHERE f.user.userId IN :userIds") List findTokensByUserIds(@Param("userIds") List userIds); -} \ No newline at end of file + + void deleteByUser_UserId(Long userId); + } \ No newline at end of file diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/service/FcmSender.java b/src/main/java/com/tekcit/festival/domain/host_admin/service/FcmSender.java deleted file mode 100644 index 28277b9..0000000 --- a/src/main/java/com/tekcit/festival/domain/host_admin/service/FcmSender.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.tekcit.festival.domain.host_admin.service; - -// Mock 처리용 (나중에 삭제 예정) -public interface FcmSender { - void send(String targetToken, String title, String body); -} \ No newline at end of file diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/service/FcmService.java b/src/main/java/com/tekcit/festival/domain/host_admin/service/FcmService.java index b622dbd..437edb7 100644 --- a/src/main/java/com/tekcit/festival/domain/host_admin/service/FcmService.java +++ b/src/main/java/com/tekcit/festival/domain/host_admin/service/FcmService.java @@ -4,16 +4,21 @@ import com.tekcit.festival.domain.host_admin.entity.FcmToken; import com.tekcit.festival.domain.host_admin.repository.FcmTokenRepository; import com.tekcit.festival.domain.user.entity.User; +import com.tekcit.festival.exception.BusinessException; +import com.tekcit.festival.exception.ErrorCode; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; + @Data @Slf4j @Service @@ -22,7 +27,6 @@ public class FcmService { private final FcmTokenRepository fcmTokenRepository; - // 발송 실패 시 유효하지 않은 토큰을 DB에서 삭제합니다. @Transactional public void sendMessageToUsers(List userIds, String title, String body) { List tokens = fcmTokenRepository.findTokensByUserIds(userIds); @@ -33,7 +37,14 @@ public void sendMessageToUsers(List userIds, String title, String body) { } MulticastMessage multicastMessage = MulticastMessage.builder() - .setNotification(Notification.builder().setTitle(title).setBody(body).build()) + // data payload (웹에서 사용) + .putData("title", title) + .putData("body", body) + // notification payload (모바일 크롬 OS 알림에 사용) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body) + .build()) .addAllTokens(tokens) .build(); @@ -41,26 +52,16 @@ public void sendMessageToUsers(List userIds, String title, String body) { BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage); log.info("FCM 멀티캐스트 메시지 전송 결과: 총 {}개, 성공 {}개, 실패 {}개", response.getResponses().size(), response.getSuccessCount(), response.getFailureCount()); - - // 유효하지 않은 토큰들을 데이터베이스에서 삭제 - if (response.getFailureCount() > 0) { - Set failedTokens = response.getResponses().stream() - .filter(r -> !r.isSuccessful()) - .map(r -> r.getException().getMessage().split(" ")[0]) - .collect(Collectors.toSet()); - - log.warn("전송 실패한 유효하지 않은 토큰들을 DB에서 삭제합니다: {}", failedTokens); - fcmTokenRepository.deleteAllByTokenIn(failedTokens); - } } catch (FirebaseMessagingException e) { log.error("FCM 멀티캐스트 메시지 전송 실패", e); + throw new BusinessException(ErrorCode.FCM_SEND_FAILED); } } - // FCM 토큰을 저장하거나 이미 존재하는 경우 갱신합니다. + // FCM 토큰을 저장하거나 이미 존재하는 경우 갱신 @Transactional public void saveToken(User user, String token) { - // 기존 토큰을 찾고, 없다면 새로운 엔티티를 생성합니다. + // 기존 토큰을 찾고, 없다면 새로운 엔티티를 생성 FcmToken fcmTokenToSave = fcmTokenRepository.findByUser(user) .map(existing -> { // 기존 토큰이 존재하면 값만 업데이트하고 반환 @@ -73,7 +74,7 @@ public void saveToken(User user, String token) { fcmTokenRepository.save(fcmTokenToSave); } - // 특정 FCM 토큰이 유효한지 테스트하기 위해 단일 알림을 전송합니다. + // 특정 FCM 토큰이 유효한지 테스트하기 위해 단일 알림을 전송 public void validateTokenAndSend(String targetToken) { try { Message message = Message.builder() diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/service/FirebaseFcmSender.java b/src/main/java/com/tekcit/festival/domain/host_admin/service/FirebaseFcmSender.java deleted file mode 100644 index d612ee2..0000000 --- a/src/main/java/com/tekcit/festival/domain/host_admin/service/FirebaseFcmSender.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.tekcit.festival.domain.host_admin.service; - -import com.google.firebase.messaging.*; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -public class FirebaseFcmSender implements FcmSender { - - @Override - public void send(String targetToken, String title, String body) { - try { - AndroidNotification androidNotification = AndroidNotification.builder() - .setSound("default") - .setDefaultSound(true) - .setDefaultVibrateTimings(true) - .setPriority(AndroidNotification.Priority.HIGH) - .build(); - - AndroidConfig androidConfig = AndroidConfig.builder() - .setNotification(androidNotification) - .build(); - - Message message = Message.builder() - .setToken(targetToken) - .setNotification(Notification.builder().setTitle(title).setBody(body).build()) - .setAndroidConfig(androidConfig) - .build(); - - String response = FirebaseMessaging.getInstance().send(message); - log.info("✅ FCM 메시지 전송 성공: title={}, response={}", title, response); - } catch (Exception e) { - log.error("❌ FCM 메시지 전송 실패", e); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/service/MockFcmSender.java b/src/main/java/com/tekcit/festival/domain/host_admin/service/MockFcmSender.java deleted file mode 100644 index 9696423..0000000 --- a/src/main/java/com/tekcit/festival/domain/host_admin/service/MockFcmSender.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.tekcit.festival.domain.host_admin.service; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -// Mock 처리용 (나중에 삭제 예정) -@Slf4j -@Component -@Profile("test") // 테스트 환경에서만 활성화 -public class MockFcmSender implements FcmSender { - @Override - public void send(String targetToken, String title, String body) { - log.info("📦 [MOCK] FCM 메시지 전송됨: {}, {}, {}", targetToken, title, body); - } -} diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationScheduleService.java b/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationScheduleService.java index d938f7a..c91a62c 100644 --- a/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationScheduleService.java +++ b/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationScheduleService.java @@ -9,7 +9,7 @@ public interface NotificationScheduleService { NotificationScheduleResponseDTO create(NotificationScheduleDTO req, Long userId); NotificationScheduleResponseDTO update(Long id, NotificationUpdateScheduleDTO req, Long userId); - void delete(Long id, Long userId); + void delete(Long id, Long userId, boolean isAdmin); NotificationScheduleResponseDTO getById(Long id); List getByFestival(String fid); List getAll(); diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationScheduleServiceImpl.java b/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationScheduleServiceImpl.java index ec28c48..db87ba9 100644 --- a/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationScheduleServiceImpl.java +++ b/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationScheduleServiceImpl.java @@ -72,7 +72,7 @@ public NotificationScheduleResponseDTO update(Long id, NotificationUpdateSchedul // 1. 이미 발송된 알림인지 검증 if (e.isSent()) { - throw new BusinessException(ErrorCode.BAD_REQUEST); + throw new BusinessException(ErrorCode.ALREADY_SENT_NOTIFICATION); } // 2. 소유권 검증 if (!e.getUserId().equals(userId)) { @@ -95,16 +95,12 @@ public NotificationScheduleResponseDTO update(Long id, NotificationUpdateSchedul @Override @Transactional - public void delete(Long id, Long userId) { + public void delete(Long id, Long userId, boolean isAdmin) { NotificationSchedule e = scheduleRepository.findById(id) .orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND)); - // 1. 이미 발송된 알림인지 검증 - if (e.isSent()) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } - // 2. 소유권 검증 - if (!e.getUserId().equals(userId)) { + // 1. 소유권 검증 (ADMIN은 예외) + if (!isAdmin && !e.getUserId().equals(userId)) { throw new BusinessException(ErrorCode.FORBIDDEN_RESOURCE); } diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationSchedulerService.java b/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationSchedulerService.java index fbe8c31..440bebe 100644 --- a/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationSchedulerService.java +++ b/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationSchedulerService.java @@ -5,6 +5,8 @@ import com.tekcit.festival.domain.host_admin.entity.NotificationSchedule; import com.tekcit.festival.domain.host_admin.repository.NotificationRepository; import com.tekcit.festival.domain.host_admin.repository.NotificationScheduleRepository; +import com.tekcit.festival.exception.BusinessException; +import com.tekcit.festival.exception.ErrorCode; import com.tekcit.festival.exception.global.SuccessResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -62,7 +64,7 @@ public void scheduleNotifications() { try { final ZoneId KST = ZoneId.of("Asia/Seoul"); - log.info("스케줄러 실행 시각: {}", LocalDateTime.now(KST)); + //log.info("스케줄러 실행 시각: {}", LocalDateTime.now(KST)); // 현재 시각으로부터 1분 이내의 발송 예정 스케줄을 조회 LocalDateTime now = LocalDateTime.now(KST).truncatedTo(ChronoUnit.MINUTES); @@ -71,7 +73,7 @@ public void scheduleNotifications() { // 아직 발송되지 않은 알림 스케줄만 DB에서 가져옴 List schedules = scheduleRepository.findBySendTimeBetweenAndIsSentFalse(now, endOfWindow); if (schedules.isEmpty()) { - log.info("이번 분에 발송될 알림이 없습니다."); + //log.info("이번 분에 발송될 알림이 없습니다."); return; } @@ -100,14 +102,25 @@ public void scheduleNotifications() { // API 호출 성공 시 처리 로직 if (response.isSuccess() && response.getData() != null && !response.getData().isEmpty()) { log.info("스케줄 ID {}에 대한 API 호출 성공. {}명의 사용자를 찾았습니다.", scheduleId, response.getData().size()); + + // Add this line to log the list of user IDs. + log.info("스케줄 ID {}에 대한 예매자 리스트: {}", scheduleId, response.getData()); + // 알림 발송 및 DB 저장 (트랜잭션으로 처리) sendAndSaveNotifications(schedule, response.getData()); } else { log.warn("스케줄 ID {}에 대한 API 응답 실패 또는 사용자 없음. 메시지: {}", scheduleId, response.getMessage()); // 사용자가 0명이라도 스케줄 상태를 '발송 완료'로 업데이트하여 재처리 방지 - updateScheduleStatus(schedule, true); + updateScheduleStatus(schedule, true);; } }) + .doOnError(error -> { + // API 호출 실패 시 처리 로직 + log.error("스케줄 ID {} 알림 처리 중 API 호출 실패. 원인: {}", scheduleId, error.getMessage(), error); + // WebClient 통신 실패 시에도 알림 스케줄의 상태를 '실패'로 업데이트하는 로직 추가 가능 + // 현재는 doFinally에서 inFlight 제거만 하고, 스케줄 상태는 변경하지 않음 + throw new BusinessException(ErrorCode.API_CALL_FAILED); + }) .doFinally(signal -> inFlight.remove(scheduleId)) // 작업 완료 후 Set에서 ID 제거 .subscribe( result -> log.info("스케줄 ID {} 알림 처리가 완료되었습니다.", scheduleId), @@ -117,7 +130,7 @@ public void scheduleNotifications() { } finally { // 스케줄러 락 해제 schedulerLock.unlock(); - log.info("스케줄러 락이 해제되었습니다."); + //log.info("스케줄러 락이 해제되었습니다."); } } @@ -125,7 +138,10 @@ public void scheduleNotifications() { @Transactional public void sendAndSaveNotifications(NotificationSchedule schedule, List userIds) { // FCM을 통해 알림 발송 - fcmService.sendMessageToUsers(userIds, schedule.getTitle(), schedule.getBody()); + String finalTitle = String.format("[%s] %s", schedule.getFname(), schedule.getTitle()); + log.info("FCM 발송 직전 제목: '{}', 내용: '{}'", finalTitle, schedule.getBody()); + + fcmService.sendMessageToUsers(userIds, finalTitle, schedule.getBody()); // 알림 내역을 Notification 테이블에 저장 List notificationsToSave = userIds.stream() diff --git a/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationService.java b/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationService.java index 0f8553b..b1d37ba 100644 --- a/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationService.java +++ b/src/main/java/com/tekcit/festival/domain/host_admin/service/NotificationService.java @@ -20,9 +20,7 @@ @Transactional(readOnly = true) @Slf4j public class NotificationService { - private final NotificationRepository notificationRepository; - private final FcmService fcmService; // 사용자 알림 히스토리 조회 public List getUserNotifications(Long userId) { @@ -35,40 +33,12 @@ public List getUserNotifications(Long userId) { // 알림 단건 상세 조회 public NotificationResponseDTO getNotificationDetail(Long nid, Long userId) { Notification notification = notificationRepository.findById(nid) - .orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found.")); + .orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND)); if (!notification.getUserId().equals(userId)) { - throw new BusinessException(ErrorCode.FORBIDDEN_RESOURCE, "Access denied."); + throw new BusinessException(ErrorCode.FORBIDDEN_RESOURCE); } return NotificationResponseDTO.fromEntity(notification); } - - @Transactional - public void sendNotifications(List bookingInfos) { - log.info("총 {}명에게 정해진 시각에 알림 발송을 합니다.", bookingInfos.size()); - - List userIds = bookingInfos.stream() - .map(BookingInfoDTO::getUserId) - .collect(Collectors.toList()); - - if (!bookingInfos.isEmpty()) { - BookingInfoDTO firstInfo = bookingInfos.get(0); - fcmService.sendMessageToUsers(userIds, firstInfo.getNotificationTitle(), firstInfo.getNotificationBody()); - } - - List newNotifications = bookingInfos.stream() - .map(info -> Notification.builder() - .userId(info.getUserId()) - .title(info.getNotificationTitle()) - .body(info.getNotificationBody()) - .isRead(false) - .fname(info.getFname()) - .build()) - .collect(Collectors.toList()); - - notificationRepository.saveAll(newNotifications); - - log.info("알림 발송 확정 및 DB 저장이 완료되었습니다. {}건.", newNotifications.size()); - } } \ No newline at end of file diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/AddressController.java b/src/main/java/com/tekcit/festival/domain/user/controller/AddressController.java index 9b10cd3..8e241ff 100644 --- a/src/main/java/com/tekcit/festival/domain/user/controller/AddressController.java +++ b/src/main/java/com/tekcit/festival/domain/user/controller/AddressController.java @@ -1,11 +1,11 @@ package com.tekcit.festival.domain.user.controller; +import com.tekcit.festival.domain.user.controller.api.AddressApiSpecification; import com.tekcit.festival.domain.user.dto.request.AddressRequestDTO; import com.tekcit.festival.domain.user.dto.response.AddressDTO; import com.tekcit.festival.domain.user.service.AddressService; import com.tekcit.festival.exception.global.SuccessResponse; import com.tekcit.festival.utils.ApiResponseUtil; -import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -20,12 +20,10 @@ @RequestMapping("/api/addresses") @RequiredArgsConstructor @Tag(name = "주소 API", description = "주소 조회, 추가, 수정, 삭제, 기본 배송지 수정") -public class AddressController { +public class AddressController implements AddressApiSpecification { private final AddressService addressService; @PostMapping - @Operation(summary = "회원 주소 정보 추가", - description = "회원 주소 정보 추가, AddressRequestDTO를 포함해야 합니다. ex) POST /api/addresses") @PreAuthorize("hasRole('USER')") public ResponseEntity> addAddress(@Valid @RequestBody AddressRequestDTO addressRequestDTO, @AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); @@ -34,8 +32,6 @@ public ResponseEntity> addAddress(@Valid @RequestBod } @PatchMapping(value="/updateAddress/{addressId}") - @Operation(summary = "회원 주소 정보 수정", - description = "회원 주소 정보 수정, AddressRequestDTO를 포함해야 합니다. ex) PATCH /api/addresses/updateAddress/{addressId}") @PreAuthorize("hasRole('USER')") public ResponseEntity> updateAddress(@PathVariable Long addressId, @Valid @RequestBody AddressRequestDTO addressRequestDTO, @AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); @@ -44,8 +40,6 @@ public ResponseEntity> updateAddress(@PathVariable L } @PatchMapping(value="/changeDefault/{addressId}") - @Operation(summary = "회원 주소 기본 배송지 수정", - description = "회원 주소 기본 배송지 수정, ex) PATCH /api/addresses/changeDefault/{addressId}") @PreAuthorize("hasRole('USER')") public ResponseEntity> updateDefault(@PathVariable Long addressId, @AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); @@ -54,8 +48,6 @@ public ResponseEntity> updateDefault(@PathVariable L } @DeleteMapping(value="/{addressId}") - @Operation(summary = "회원 주소 삭제", - description = "회원 주소 삭제, ex) DELETE /api/addresses/deleteAddress/{addressId}") @PreAuthorize("hasRole('USER')") public ResponseEntity deleteAddress(@PathVariable Long addressId, @AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); @@ -64,8 +56,6 @@ public ResponseEntity deleteAddress(@PathVariable Long addressId, @Authent } @GetMapping(value="/allAddress") - @Operation(summary = "회원 주소 정보 전체 조회", - description = "회원 주소 정보 전체 조회 ex) GET /api/addresses/allAddress") @PreAuthorize("hasAnyRole('USER')") public ResponseEntity>> getAllAddresses(@AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); @@ -74,8 +64,6 @@ public ResponseEntity>> getAllAddresses(@Authen } @GetMapping(value="/defaultAddress") - @Operation(summary = "회원 주소 기본 배송지 조회", - description = "회원 주소 기본 배송지 정보 조회 ex) GET /api/addresses/defaultAddress") @PreAuthorize("hasAnyRole('USER')") public ResponseEntity> getDefaultAddress(@AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); @@ -83,4 +71,12 @@ public ResponseEntity> getDefaultAddress(@Authentica return ApiResponseUtil.success(addressDTOS); } + @GetMapping(value="/{addressId}") + @PreAuthorize("hasAnyRole('USER')") + public ResponseEntity> getAddress(@AuthenticationPrincipal String principal, @PathVariable Long addressId){ + Long userId = Long.parseLong(principal); + AddressDTO addressDTO = addressService.getAddress(userId, addressId); + return ApiResponseUtil.success(addressDTO); + } + } diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/AdminController.java b/src/main/java/com/tekcit/festival/domain/user/controller/AdminController.java index e7a4471..427beaa 100644 --- a/src/main/java/com/tekcit/festival/domain/user/controller/AdminController.java +++ b/src/main/java/com/tekcit/festival/domain/user/controller/AdminController.java @@ -1,14 +1,12 @@ package com.tekcit.festival.domain.user.controller; +import com.tekcit.festival.domain.user.controller.api.AdminApiSpecification; import com.tekcit.festival.domain.user.dto.response.AddressDTO; import com.tekcit.festival.domain.user.dto.response.AdminHostListDTO; import com.tekcit.festival.domain.user.dto.response.AdminUserListDTO; import com.tekcit.festival.domain.user.service.AdminService; import com.tekcit.festival.exception.global.SuccessResponse; import com.tekcit.festival.utils.ApiResponseUtil; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -22,13 +20,11 @@ @RequestMapping("/api/admin") @RequiredArgsConstructor @Tag(name = "운영 관리자 api", description = "전체 회원 조회, 전체 주최자 조회") -public class AdminController { +public class AdminController implements AdminApiSpecification { private final AdminService adminService; @GetMapping(value="/userList") - @Operation(summary = "사용자 전체 목록 조회", - description = "사용자 전체 목록 조회(user), ex) GET /api/admin/userList") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity>> getAllUser(@AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); @@ -37,8 +33,6 @@ public ResponseEntity>> getAllUser(@Authe } @GetMapping(value="/hostList") - @Operation(summary = "주최자 전체 목록 조회", - description = "주최자 전체 목록 조회(host), ex) GET /api/admin/hostList") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity>> getAllHostList(@AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); @@ -47,13 +41,6 @@ public ResponseEntity>> getAllHostList(@A } @PatchMapping(value="/{userId}/state") - @Operation(summary = "회원 상태 변경 (활성화 / 비활성화)", - description = "운영관리자는 userId를 기준으로 회원의 활성 상태(active)를 true/false로 변경할 수 있습니다. ex) PATCH /api/admin/{userId}/state?active=false") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "회원 상태(active) 조정 완료"), - @ApiResponse(responseCode = "403", description = "회원 상태(active) 조정 실패(운영 관리자는 불가능)"), - @ApiResponse(responseCode = "404", description = "회원 상태(active) 조정 실패(해당 유저를 찾을 수 없거나 운영 관리자만 상태 관리를 할 수 있습니다.)") - }) @PreAuthorize("hasRole('ADMIN')") public ResponseEntity> changeState(@PathVariable Long userId, @RequestParam boolean active, @AuthenticationPrincipal String principal){ Long adminId = Long.parseLong(principal); @@ -62,8 +49,6 @@ public ResponseEntity> changeState(@PathVariable Long user } @DeleteMapping(value="/{userId}") - @Operation(summary = "주최자 탈퇴(삭제)", - description = "운영관리자가 주최자 탈퇴(host), ex) DELETE /api/admin/{userId}") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity> deleteHost(@AuthenticationPrincipal String principal, @PathVariable Long userId){ Long adminId = Long.parseLong(principal); @@ -73,8 +58,6 @@ public ResponseEntity> deleteHost(@AuthenticationPrincipal } @GetMapping - @Operation(summary = "전체 회원 주소 정보 조회", - description = "회원 주소 정보 조회 ex) GET /api/admin/addresses") @PreAuthorize("hasAnyRole('ADMIN')") public ResponseEntity>> getAllAddresses(@AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/AuthController.java b/src/main/java/com/tekcit/festival/domain/user/controller/AuthController.java index 5a474ef..756642a 100644 --- a/src/main/java/com/tekcit/festival/domain/user/controller/AuthController.java +++ b/src/main/java/com/tekcit/festival/domain/user/controller/AuthController.java @@ -1,6 +1,7 @@ package com.tekcit.festival.domain.user.controller; import com.tekcit.festival.config.security.token.JwtTokenProvider; +import com.tekcit.festival.domain.user.controller.api.AuthApiSpecification; import com.tekcit.festival.domain.user.dto.response.AccessTokenInfoDTO; import com.tekcit.festival.domain.user.dto.request.LoginRequestDTO; import com.tekcit.festival.domain.user.dto.response.LoginResponseDTO; @@ -13,14 +14,10 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -29,47 +26,35 @@ @RequestMapping("/api/users") @RequiredArgsConstructor @Tag(name = "로그인 API", description = "회원 로그인, 로그아웃, 토큰 재발급") -public class AuthController { +public class AuthController implements AuthApiSpecification { private final JwtTokenProvider jwtTokenProvider; private final AuthService authService; @PostMapping("/login") - @Operation(summary = "로그인", - description = "로그인 기능, LoginRequestDTO를 포함해야 합니다. ex) POST /api/users/login") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그인 성공", - content = @Content(schema = @Schema(implementation = SuccessResponse.class)))}) - public ResponseEntity> login(@RequestBody LoginRequestDTO request, HttpServletResponse response) { - LoginResponseDTO loginResult = authService.login(request, response); + public ResponseEntity> login(@Valid @RequestBody LoginRequestDTO request, HttpServletResponse response) { + Object loginResult = authService.tryLogin(request, response); return ApiResponseUtil.success(loginResult); } + @PostMapping("/login/confirm") + public ResponseEntity> confirmLogin(@RequestParam("ticket") String ticket, HttpServletResponse response) { + LoginResponseDTO confirmLoginResult = authService.confirmLogin(ticket, response); + return ApiResponseUtil.success(confirmLoginResult); + } + @PostMapping("/logout") - @Operation(summary = "로그아웃", - description = "로그아웃 기능 ex) POST /api/users/logout") - @ApiResponse(responseCode = "200", description = "로그아웃 성공", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))) public ResponseEntity> logout(HttpServletRequest request, HttpServletResponse response) { authService.logout(request, response); return ApiResponseUtil.success(null, "로그아웃 성공"); } @PostMapping("/reissue") - @Operation(summary = "accessToken 재발급", - description = "accessToken 재발급 기능 ex) POST /api/users/reissue") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "재발급 성공", - content = @Content(schema = @Schema(implementation = SuccessResponse.class)))}) public ResponseEntity> reissue(HttpServletRequest request, HttpServletResponse response) { LoginResponseDTO newToken = authService.reissue(request, response); return ApiResponseUtil.success(newToken); } @GetMapping("/token/parse") - @Operation( - summary = "Access Token 파싱", - description = "Authorization: Bearer {token} 헤더로 전달된 Access Token을 검증하고, 포함된 클레임 정보를 반환합니다." - ) public ResponseEntity> parseToken(HttpServletRequest request) { String raw = TokenParseUtil.parseToken(request); diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/EmailController.java b/src/main/java/com/tekcit/festival/domain/user/controller/EmailController.java index ca32a61..a77abd5 100644 --- a/src/main/java/com/tekcit/festival/domain/user/controller/EmailController.java +++ b/src/main/java/com/tekcit/festival/domain/user/controller/EmailController.java @@ -1,12 +1,12 @@ package com.tekcit.festival.domain.user.controller; +import com.tekcit.festival.domain.user.controller.api.EmailApiSpecification; import com.tekcit.festival.domain.user.dto.request.EmailSendDTO; import com.tekcit.festival.domain.user.dto.request.EmailVerifyDTO; import com.tekcit.festival.domain.user.dto.response.EmailResponseDTO; import com.tekcit.festival.domain.user.service.EmailService; import com.tekcit.festival.exception.global.SuccessResponse; import com.tekcit.festival.utils.ApiResponseUtil; -import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -17,21 +17,17 @@ @RequiredArgsConstructor @RequestMapping("/api/mail") @Tag(name = "이메일 인증 API", description = "이메일 인증 코드 전송, 검증") -public class EmailController { +public class EmailController implements EmailApiSpecification { private final EmailService emailService; @PostMapping("/sendCode") - @Operation(summary = "이메일 인증 코드 전송", - description = "이메일 인증 코드 전송, 인증 코드 5분 이후 만료, emailSendDTO를 포함해야 합니다. ex) POST /api/mail/sendCode") public ResponseEntity> sendCode(@Valid @RequestBody EmailSendDTO emailSendDTO) { EmailResponseDTO sendCode = emailService.sendVerificationCode(emailSendDTO); return ApiResponseUtil.success(sendCode); } @PostMapping("/verifyCode") - @Operation(summary = "이메일 인증 코드 검증", - description = "이메일 인증 코드 검증, emailVerifyDTO를 포함해야 합니다. ex) POST /api/mail/verify") public ResponseEntity> verifyCode(@Valid @RequestBody EmailVerifyDTO emailVerifyDTO) { EmailResponseDTO verifyCode = emailService.verifyCode(emailVerifyDTO); return ApiResponseUtil.success(verifyCode); diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/KakaoAuthController.java b/src/main/java/com/tekcit/festival/domain/user/controller/KakaoAuthController.java index 8414806..06c0fbf 100644 --- a/src/main/java/com/tekcit/festival/domain/user/controller/KakaoAuthController.java +++ b/src/main/java/com/tekcit/festival/domain/user/controller/KakaoAuthController.java @@ -1,19 +1,14 @@ package com.tekcit.festival.domain.user.controller; +import com.tekcit.festival.domain.user.controller.api.KakaoAuthApiSpecification; import com.tekcit.festival.domain.user.dto.request.KakaoSignupDTO; import com.tekcit.festival.domain.user.dto.response.UserResponseDTO; import com.tekcit.festival.domain.user.service.KakaoService; import com.tekcit.festival.exception.BusinessException; import com.tekcit.festival.exception.ErrorCode; -import com.tekcit.festival.exception.global.ErrorResponse; import com.tekcit.festival.exception.global.SuccessResponse; import com.tekcit.festival.utils.ApiResponseUtil; import com.tekcit.festival.utils.CookieUtil; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -29,8 +24,8 @@ @RequestMapping("/api/auth/kakao") @RequiredArgsConstructor @Tag(name = "카카오 회원가입, 로그인 API", description = "카카오 회원가입, 로그인, 로그아웃, 토큰 재발급") -public class KakaoAuthController { - @Value("${kakao.client-id}") +public class KakaoAuthController implements KakaoAuthApiSpecification { + @Value("${kakao.restapi-key}") private String clientId; @Value("${kakao.redirect-uri}") @@ -81,23 +76,16 @@ public void callback(@RequestParam("code") String code, HttpServletResponse resp return; } else { - kakaoService.login(result.kakaoId(), response); + boolean duplicateLogin = kakaoService.login(result.kakaoId(), response); response.addHeader("Set-Cookie", cookieUtil.deleteKakaoSignupCookie().toString()); - response.sendRedirect(frontendLoginUrl); + if(duplicateLogin) + response.sendRedirect(frontendLoginUrl+"?duplicate=true"); + else + response.sendRedirect(frontendLoginUrl); } } @PostMapping(value="/signupUser") - @Operation(summary = "회원 가입(일반 유저), 카카오 회원가입", - description = "일반 유저 회원 가입, SignupUserDTO를 포함해야 합니다. ex) POST /api/auth/kakao/signupUser") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "회원 가입 성공(일반 유저)", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))), - @ApiResponse(responseCode = "400", description = "회원 가입 실패 (잘못된 데이터, 필수 필드 누락)", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "409", description = "회원 가입 실패 (중복된 ID, Email로 인한 conflict)", - content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - }) public ResponseEntity> signupUser(@Valid @RequestBody KakaoSignupDTO kakaoSignupDTO, @CookieValue(value = "kakao_signup", required = false) String ticket, HttpServletResponse res) { diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/MyPageController.java b/src/main/java/com/tekcit/festival/domain/user/controller/MyPageController.java index 36f24d1..ef19ba7 100644 --- a/src/main/java/com/tekcit/festival/domain/user/controller/MyPageController.java +++ b/src/main/java/com/tekcit/festival/domain/user/controller/MyPageController.java @@ -1,21 +1,13 @@ package com.tekcit.festival.domain.user.controller; -import com.tekcit.festival.config.security.userdetails.CustomUserDetails; +import com.tekcit.festival.domain.user.controller.api.MyPageApiSpecification; import com.tekcit.festival.domain.user.dto.request.CheckPwDTO; import com.tekcit.festival.domain.user.dto.request.ResetPwDTO; import com.tekcit.festival.domain.user.dto.request.UpdateUserRequestDTO; -import com.tekcit.festival.domain.user.dto.response.MyPageCommonDTO; -import com.tekcit.festival.domain.user.dto.response.MyPageHostDTO; -import com.tekcit.festival.domain.user.dto.response.MyPageUserDTO; import com.tekcit.festival.domain.user.dto.response.UpdateUserResponseDTO; import com.tekcit.festival.domain.user.service.MyPageService; import com.tekcit.festival.exception.global.SuccessResponse; import com.tekcit.festival.utils.ApiResponseUtil; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -27,16 +19,10 @@ @RequestMapping("/api/myPage") @RequiredArgsConstructor @Tag(name = "마이 페이지 API", description = "회원 생성, 조회, 탈퇴") -public class MyPageController { +public class MyPageController implements MyPageApiSpecification { private final MyPageService myPageService; @GetMapping(value="/userInfo") - @Operation(summary = "마이페이지 회원 정보 조회", - description = "마이페이지 회원 정보 조회, MyPageUserDTO(USER), MyPageHostDTO(HOST), MyPageCommonDTO(ADMIN) Role에 따라 return 값이 달라집니다." + - "ex) GET /api/myPage/userInfo") - @ApiResponse(responseCode = "200", content = @Content( - schema = @Schema(oneOf = { MyPageUserDTO.class, MyPageHostDTO.class, MyPageCommonDTO.class }) - )) public ResponseEntity> myPageUserInfo(@AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); Object myPageDto = myPageService.getUserInfo(userId); @@ -44,8 +30,6 @@ public ResponseEntity> myPageUserInfo(@AuthenticationPri } @PatchMapping(value="/updateUser") - @Operation(summary = "마이페이지 회원 정보 수정", - description = "마이페이지 회원 정보 수정, UpdateUserRequestDTO를 포함해야 합니다. ex) PATCH /api/myPage/updateUser") public ResponseEntity> updateUser(@Valid @RequestBody UpdateUserRequestDTO updateUserRequestDTO, @AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); UpdateUserResponseDTO updateUserDTO = myPageService.updateUser(updateUserRequestDTO, userId); @@ -53,12 +37,6 @@ public ResponseEntity> updateUser(@Valid } @PostMapping(value="/checkPassword") - @Operation(summary = "마이페이지 기존 비밀번호 일치 여부 확인", - description = "마이페이지에서 기존 비밀번호 일치 여부를 확인할 수 있습니다. CheckPwDTO(기존 비밀번호)를 포함해야 합니다. ex) POST /api/myPage/checkPassword") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "비밀번호가 일치합니다.", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))), - }) public ResponseEntity> checkPassword(@AuthenticationPrincipal String principal, @Valid @RequestBody CheckPwDTO checkPwDTO){ Long userId = Long.parseLong(principal); myPageService.checkPassword(userId, checkPwDTO); @@ -66,12 +44,6 @@ public ResponseEntity> checkPassword(@AuthenticationPrinci } @PatchMapping(value="/resetPassword") - @Operation(summary = "마이페이지 비밀번호 재설정", - description = "마이페이지에서 비밀번호를 변경할 수 있습니다. ResetPwDTO(새로운 비밀번호)를 포함해야 합니다. ex) PATCH /api/myPage/resetPassword") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "새로운 비밀번호 재설정", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))), - }) public ResponseEntity> resetPassword(@AuthenticationPrincipal String principal, @Valid @RequestBody ResetPwDTO resetPwDTO){ Long userId = Long.parseLong(principal); myPageService.resetPassword(userId, resetPwDTO); diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/UserController.java b/src/main/java/com/tekcit/festival/domain/user/controller/UserController.java index 0a71345..e801d99 100644 --- a/src/main/java/com/tekcit/festival/domain/user/controller/UserController.java +++ b/src/main/java/com/tekcit/festival/domain/user/controller/UserController.java @@ -1,16 +1,12 @@ package com.tekcit.festival.domain.user.controller; +import com.tekcit.festival.domain.user.controller.api.UserApiSpecification; import com.tekcit.festival.domain.user.dto.request.*; import com.tekcit.festival.domain.user.dto.response.*; import com.tekcit.festival.domain.user.service.UserService; import com.tekcit.festival.exception.global.SuccessResponse; import com.tekcit.festival.utils.ApiResponseUtil; import com.tekcit.festival.utils.CookieUtil; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -25,30 +21,18 @@ @RequestMapping("/api/users") @RequiredArgsConstructor @Tag(name = "회원 API", description = "회원 생성, 조회, 탈퇴") -public class UserController { +public class UserController implements UserApiSpecification { private final UserService userService; private final CookieUtil cookieUtil; @PostMapping(value="/signupUser") - @Operation(summary = "회원 가입(일반 유저)", - description = "일반 유저 회원 가입, SignupUserDTO를 포함해야 합니다. ex) POST /api/users/signupUser") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "회원 가입 성공(일반 유저)", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))) - }) public ResponseEntity> signupUser(@Valid @RequestBody SignupUserDTO signupUserDTO){ UserResponseDTO signupUser = userService.signupUser(signupUserDTO); return ApiResponseUtil.success(signupUser); } @PostMapping(value="/signupHost") - @Operation(summary = "회원 가입(축제 주최측)", - description = "축제 주최측 회원 가입, SignupUserDTO를 포함해야 합니다. ex) POST /api/users/signupHost") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "회원 가입 성공(축제 주최측)", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))) - }) @PreAuthorize("hasRole('ADMIN')") public ResponseEntity> signupHost(@Valid @RequestBody SignupUserDTO signupUserDTO){ UserResponseDTO signupHost = userService.signupHost(signupUserDTO); @@ -56,48 +40,24 @@ public ResponseEntity> signupHost(@Valid @Reque } @PostMapping(value="/signupAdmin") - @Operation(summary = "회원 가입(운영 관리자)", - description = "운영 관리자 회원 가입, SignupUserDTO를 포함해야 합니다. ex) POST /api/users/signupAdmin") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "회원 가입 성공(운영 관리자)", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))) - }) public ResponseEntity> signupAdmin(@Valid @RequestBody SignupUserDTO signupUserDTO){ UserResponseDTO signupAdmin = userService.signupAdmin(signupUserDTO); return ApiResponseUtil.success(signupAdmin); } @GetMapping(value="/checkLoginId") - @Operation(summary = "로그인 아이디 중복 확인", - description = "로그인 아이디 중복 확인, ex) GET /api/users/checkLoginId?loginId=test") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "로그인 아이디 중복 체크(true면 중복 아님, false면 중복)", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))) - }) public ResponseEntity> checkLoginId(@RequestParam String loginId){ boolean isLoginIdAvailable = userService.checkLoginId(loginId); return ApiResponseUtil.success(isLoginIdAvailable); } @GetMapping(value="/checkEmail") - @Operation(summary = "이메일 주소 중복 확인", - description = "이메일 주소 중복 확인, ex) GET /api/users/checkEmail?email=test@test.com") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "이메일 주소 중복 체크(true면 중복 아님, false면 중복)", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))) - }) public ResponseEntity> checkEmail(@RequestParam String email){ boolean isEmailAvailable = userService.checkEmail(email); return ApiResponseUtil.success(isEmailAvailable); } @DeleteMapping - @Operation(summary = "일반 회원 탈퇴", - description = "일반 회원 탈퇴, ex) DELETE /api/users") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))) - }) @PreAuthorize("hasRole('USER')") public ResponseEntity deleteUser(@AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); @@ -110,36 +70,18 @@ public ResponseEntity deleteUser(@AuthenticationPrincipal String principal } @PostMapping(value="/findLoginId") - @Operation(summary = "아이디 찾기", - description = "로그인 아이디 찾기, FindLoginIdDTO를 포함해야 합니다. ex) POST /api/users/findLoginId") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "아이디 찾기 성공", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))), - }) public ResponseEntity> findLoginId(@Valid @RequestBody FindLoginIdDTO findLoginIdDTO){ String loginId = userService.findLoginId(findLoginIdDTO); return ApiResponseUtil.success(loginId); } @PostMapping(value="/findRegisteredEmail") - @Operation(summary = "비밀번호 찾기", - description = "로그인 비밀번호 찾기 1단계, FindLoginPwDTO(로그인아이디, 이름)을 포함해야 합니다. ex) POST /api/users/findRegisteredEmail") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "인증 성공 이메일 주소 return", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))), - }) public ResponseEntity> findRegisteredEmail(@Valid @RequestBody FindPwEmailDTO findPwEmailDTO){ String email = userService.findRegisteredEmail(findPwEmailDTO); return ApiResponseUtil.success(email); } @PatchMapping(value="/resetPasswordEmail") - @Operation(summary = "비밀번호 재설정", - description = "로그인 비밀번호 찾기 2단계, FindPwResetDTO(로그인아이디, 이메일, 새로운 비밀번호)를 포함해야 합니다. ex) PATCH /api/users/resetPasswordEmail") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "이메일 인증번호 검증 후 새로운 비밀번호 재설정", - content = @Content(schema = @Schema(implementation = SuccessResponse.class))), - }) public ResponseEntity> resetPasswordEmail(@Valid @RequestBody FindPwResetDTO findPwResetDTO){ userService.resetPasswordEmail(findPwResetDTO); return ApiResponseUtil.success(); diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/UserInfoController.java b/src/main/java/com/tekcit/festival/domain/user/controller/UserInfoController.java index fc2747f..6031d36 100644 --- a/src/main/java/com/tekcit/festival/domain/user/controller/UserInfoController.java +++ b/src/main/java/com/tekcit/festival/domain/user/controller/UserInfoController.java @@ -1,13 +1,11 @@ package com.tekcit.festival.domain.user.controller; +import com.tekcit.festival.domain.user.controller.api.UserInfoApiSpecification; -import com.tekcit.festival.config.security.userdetails.CustomUserDetails; import com.tekcit.festival.domain.user.dto.response.*; import com.tekcit.festival.domain.user.service.UserInfoService; import com.tekcit.festival.exception.global.SuccessResponse; import com.tekcit.festival.utils.ApiResponseUtil; -import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -20,37 +18,37 @@ @RequestMapping("/api/users") @RequiredArgsConstructor @Tag(name = "사용자 정보 조회 API", description = "예매, 통계 가예매자, 양수자, 양도자 정보 조회") -public class UserInfoController { +public class UserInfoController implements UserInfoApiSpecification { private final UserInfoService userInfoService; + @GetMapping(value="/checkAge") + @PreAuthorize("hasRole('USER')") + public ResponseEntity> checkUserAgeInfo(@AuthenticationPrincipal String principal){ + Long userId = Long.parseLong(principal); + CheckAgeDTO checkAgeDTO = userInfoService.checkUserAgeInfo(userId); + return ApiResponseUtil.success(checkAgeDTO); + } + @GetMapping(value="/booking-profile/{userId}") - @Operation(summary = "예매 시 사용자 정보", - description = "예매 시 사용자 정보(email), ex) GET /api/users/booking-profile/{userId}") - public ResponseEntity> bookingProfileInfo(@Valid @PathVariable Long userId){ + public ResponseEntity> bookingProfileInfo(@PathVariable Long userId){ BookingProfileDTO bookingProfile = userInfoService.bookingProfileInfo(userId); return ApiResponseUtil.success(bookingProfile); } @PostMapping(value = "/reservationList") - @Operation(summary = "예매자 정보 조회", - description = "예매자 정보 조회, 예매자 userId가 리스트로 주어져야 합니다. ex) POST /api/users/reservationList") public ResponseEntity>> getReservationUserInfo(@RequestBody List userIds){ List reservationUserDTOS = userInfoService.getReservationUserInfo(userIds); return ApiResponseUtil.success(reservationUserDTOS); } @PostMapping(value = "/statisticsList") - @Operation(summary = "통계 정보 조회", - description = "통계 정보 조회, 예매자 userId가 리스트로 주어져야 합니다. ex) POST /api/users/statisticsList") public ResponseEntity> getStatisticsInfo(@RequestBody List userIds){ List statisticsDTOS = userInfoService.getStatisticsInfo(userIds); return ResponseEntity.ok(statisticsDTOS); } @GetMapping(value = "/preReservation") - @Operation(summary = "가예매자 정보 조회", - description = "가예매자 정보 조회. ex) POST /api/users/preReservation") public ResponseEntity> getPreReservationInfo(@AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); PreReservationDTO preReservationDTO = userInfoService.getPreReservationInfo(userId); @@ -58,20 +56,24 @@ public ResponseEntity> getPreReservationInfo( } @GetMapping(value = "/transferee") - @Operation(summary = "양도 시 이메일을 통한 양수자 정보 조회", - description = "양도 시 이메일을 통한 양수자 정보 조회. ex) GET /api/users/transferee?email=test@test.com") - public ResponseEntity> transfereeInfo(@RequestParam String email){ - AssignmentDTO assignmentDTO = userInfoService.transfereeInfo(email); + public ResponseEntity> transfereeInfo(@AuthenticationPrincipal String principal, @RequestParam String email){ + Long userId = Long.parseLong(principal); + AssignmentDTO assignmentDTO = userInfoService.transfereeInfo(userId, email); return ApiResponseUtil.success(assignmentDTO); } @GetMapping(value = "/transferor") - @Operation(summary = "양도 시 현재 양도자 정보 조회", - description = "양도 시 현재 양도자 정보 조회. ex) GET /api/users/transferor?email=test@test.com") public ResponseEntity> transferorInfo(@AuthenticationPrincipal String principal){ Long userId = Long.parseLong(principal); AssignmentDTO assignmentDTO = userInfoService.transferorInfo(userId); return ApiResponseUtil.success(assignmentDTO); } + @GetMapping(value = "/geocodeInfo") + public ResponseEntity> geoCodeInfo(@AuthenticationPrincipal String principal){ + Long userId = Long.parseLong(principal); + GeoCodeInfoDTO geoCodeInfo = userInfoService.geoCodeInfo(userId); + return ApiResponseUtil.success(geoCodeInfo); + } + } diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/api/AddressApiSpecification.java b/src/main/java/com/tekcit/festival/domain/user/controller/api/AddressApiSpecification.java new file mode 100644 index 0000000..bb15780 --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/controller/api/AddressApiSpecification.java @@ -0,0 +1,255 @@ +package com.tekcit.festival.domain.user.controller.api; + +import com.tekcit.festival.domain.user.dto.request.AddressRequestDTO; +import com.tekcit.festival.domain.user.dto.response.AddressDTO; +import com.tekcit.festival.exception.global.ErrorResponse; +import com.tekcit.festival.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +public interface AddressApiSpecification { + @Operation(summary = "회원 주소 정보 추가", + description = "회원 주소 정보 추가, AddressRequestDTO를 포함해야 합니다. ex) POST /api/addresses") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "필수 입력 사항(주소, 우편번호, 이름, 전화번호) 위반", + value = """ + { + "success": false, + "code": "VALIDATION_ERROR", + "message": "%s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> addAddress(@Valid @RequestBody AddressRequestDTO addressRequestDTO, @AuthenticationPrincipal String principal); + + + @Operation(summary = "회원 주소 정보 수정", + description = "회원 주소 정보 수정, AddressRequestDTO를 포함해야 합니다. ex) PATCH /api/addresses/updateAddress/{addressId}") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "필수 입력 사항(주소, 우편번호, 이름, 전화번호) 위반", + value = """ + { + "success": false, + "code": "VALIDATION_ERROR", + "message": "%s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "403", description = "허용되지 않는 행동 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "로그인 한 user의 userId, 수정하려는 주소지 userId 정보 일치하지 않을 경우", + value = """ + { + "success": false, + "code": "ADDRESS_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다. 작성자만이 주소를 조회 또는 수정 또는 삭제할 수 있습니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패, 주소 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음 또는 수정하려는 주소를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND or ADDRESS_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s or 주소가 존재하지 않습니다." + } + """ + ) + ) + ) + } + ) + ResponseEntity> updateAddress(@PathVariable Long addressId, @Valid @RequestBody AddressRequestDTO addressRequestDTO, @AuthenticationPrincipal String principal); + + @Operation(summary = "회원 주소 기본 배송지 수정", + description = "회원 주소 기본 배송지 수정, ex) PATCH /api/addresses/changeDefault/{addressId}") + @ApiResponses(value = { + @ApiResponse(responseCode = "403", description = "허용되지 않는 행동 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "로그인 한 user의 userId, 수정하려는 주소지 userId 정보 일치하지 않을 경우", + value = """ + { + "success": false, + "code": "ADDRESS_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다. 작성자만이 주소를 조회 또는 수정 또는 삭제할 수 있습니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패, 주소 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음 또는 수정하려는 주소를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND or ADDRESS_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s or 주소가 존재하지 않습니다." + } + """ + ) + ) + ) + } + ) + ResponseEntity> updateDefault(@PathVariable Long addressId, @AuthenticationPrincipal String principal); + + @Operation(summary = "회원 주소 삭제", + description = "회원 주소 삭제, ex) DELETE /api/addresses/{addressId}") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "기본 배송지 삭제 불가 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "기본 배송지 삭제 불가", + value = """ + { + "success": false, + "code": "ADDRESS_DEFAULT_NOT_DELETED", + "message": "기본 주소지는 삭제할 수 없습니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "403", description = "허용되지 않는 행동 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "로그인 한 user의 userId, 삭제하려는 주소지 userId 정보 일치하지 않을 경우", + value = """ + { + "success": false, + "code": "ADDRESS_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다. 작성자만이 주소를 조회 또는 수정 또는 삭제할 수 있습니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패, 주소 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음 또는 삭제하려는 주소를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND or ADDRESS_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s or 주소가 존재하지 않습니다." + } + """ + ) + ) + ) + } + ) + ResponseEntity deleteAddress(@PathVariable Long addressId, @AuthenticationPrincipal String principal); + + @Operation(summary = "회원 주소 정보 전체 조회", + description = "회원 주소 정보 전체 조회 ex) GET /api/addresses/allAddress") + @ApiResponses(value = { + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity>> getAllAddresses(@AuthenticationPrincipal String principal); + + + @Operation(summary = "회원 주소 기본 배송지 조회", + description = "회원 주소 기본 배송지 정보 조회 ex) GET /api/addresses/defaultAddress") + @ApiResponses(value = { + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> getDefaultAddress(@AuthenticationPrincipal String principal); + + @Operation(summary = "회원 주소 정보 한 개 조회", + description = "회원 주소 정보 한 개 조회 ex) GET /api/addresses/{addressId}") + @ApiResponses(value = { + @ApiResponse(responseCode = "403", description = "허용되지 않는 행동 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "로그인 한 user의 userId, 조회하려는 주소지 userId 정보 일치하지 않을 경우", + value = """ + { + "success": false, + "code": "ADDRESS_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다. 작성자만이 주소를 조회 또는 수정 또는 삭제할 수 있습니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패, 주소 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음 또는 조회하려는 주소를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND or ADDRESS_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s or 주소가 존재하지 않습니다." + } + """ + ) + ) + ) + } + ) + ResponseEntity> getAddress(@AuthenticationPrincipal String principal, @PathVariable Long addressId); + + } diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/api/AdminApiSpecification.java b/src/main/java/com/tekcit/festival/domain/user/controller/api/AdminApiSpecification.java new file mode 100644 index 0000000..a248659 --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/controller/api/AdminApiSpecification.java @@ -0,0 +1,187 @@ +package com.tekcit.festival.domain.user.controller.api; + +import com.tekcit.festival.domain.user.dto.response.AddressDTO; +import com.tekcit.festival.domain.user.dto.response.AdminHostListDTO; +import com.tekcit.festival.domain.user.dto.response.AdminUserListDTO; +import com.tekcit.festival.exception.global.ErrorResponse; +import com.tekcit.festival.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +public interface AdminApiSpecification { + @Operation(summary = "사용자 전체 목록 조회", + description = "사용자 전체 목록 조회(user), ex) GET /api/admin/userList") + @ApiResponses(value = { + @ApiResponse(responseCode = "403", description = "권한 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "ROLE: ADMIN 만 전체 일반 사용자 목록 조회 가능(HOST, USER 위반)", + value = """ + { + "success": false, + "code": "AUTH_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity>> getAllUser(@AuthenticationPrincipal String principal); + + @Operation(summary = "주최자 전체 목록 조회", + description = "주최자 전체 목록 조회(host), ex) GET /api/admin/hostList") + @ApiResponses(value = { + @ApiResponse(responseCode = "403", description = "권한 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "ROLE: ADMIN 만 전체 축제 주최측 목록 조회 가능(HOST, USER 위반)", + value = """ + { + "success": false, + "code": "AUTH_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity>> getAllHostList(@AuthenticationPrincipal String principal); + + @Operation(summary = "회원 상태 변경 (활성화 / 비활성화)", + description = "운영관리자는 userId를 기준으로 회원의 활성 상태(active)를 true/false로 변경할 수 있습니다. ex) PATCH /api/admin/{userId}/state?active=false") + @ApiResponses(value = { + @ApiResponse(responseCode = "403", description = "권한 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "ROLE: ADMIN 만 회원 상태 변경 가능(HOST, USER 위반), 운영관리자(ROLE:ADMIN)은 상태를 조정할 수 없습니다.", + value = """ + { + "success": false, + "code": "AUTH_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "조정하려는 user를 찾을 수 없거나 관리자를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> changeState(@PathVariable Long userId, @RequestParam boolean active, @AuthenticationPrincipal String principal); + + @Operation(summary = "주최자 탈퇴(삭제)", + description = "운영관리자가 주최자 탈퇴(host), ex) DELETE /api/admin/{userId}") + @ApiResponses(value = { + @ApiResponse(responseCode = "403", description = "권한 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "ROLE: ADMIN 만 사용자 삭제 가능(HOST, USER 위반), 운영관리자(ROLE:ADMIN)은 삭제할 수 없습니다.", + value = """ + { + "success": false, + "code": "AUTH_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "삭제하려는 user를 찾을 수 없거나 관리자를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> deleteHost(@AuthenticationPrincipal String principal, @PathVariable Long userId); + + @Operation(summary = "전체 회원 주소 정보 조회", + description = "회원 주소 정보 조회 ex) GET /api/admin/addresses") + @ApiResponses(value = { + @ApiResponse(responseCode = "403", description = "권한 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "ROLE: ADMIN 만 전체 주소 조회 가능(HOST, USER 위반)", + value = """ + { + "success": false, + "code": "AUTH_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity>> getAllAddresses(@AuthenticationPrincipal String principal); + + } diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/api/AuthApiSpecification.java b/src/main/java/com/tekcit/festival/domain/user/controller/api/AuthApiSpecification.java new file mode 100644 index 0000000..51a8177 --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/controller/api/AuthApiSpecification.java @@ -0,0 +1,210 @@ +package com.tekcit.festival.domain.user.controller.api; + +import com.tekcit.festival.domain.user.dto.request.LoginRequestDTO; +import com.tekcit.festival.domain.user.dto.response.AccessTokenInfoDTO; +import com.tekcit.festival.domain.user.dto.response.LoginConflictDTO; +import com.tekcit.festival.domain.user.dto.response.LoginResponseDTO; +import com.tekcit.festival.exception.global.ErrorResponse; +import com.tekcit.festival.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +public interface AuthApiSpecification { + @Operation(summary = "로그인", + description = "로그인 기능, LoginRequestDTO를 포함해야 합니다. ex) POST /api/users/login") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공 또는 충돌", + content = @Content(schema = @Schema( + oneOf = { LoginResponseDTO.class, LoginConflictDTO.class } + )) + ), + @ApiResponse(responseCode = "400", description = "일치하지 않는 비밀번호 or 필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "일치하지 않는 비밀번호 or 필수 입력 사항 위반(아이디, 비밀번호)", + value = """ + { + "success": false, + "code": "AUTH_PASSWORD_NOT_EQUAL_ERROR or VALIDATION_ERROR", + "message": "일치하지 않는 비밀번호입니다. or %s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "403", description = "정지된 계정 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "정지된 계정(ROLE: USER, HOST 만 정지 가능)", + value = """ + { + "success": false, + "code": "USER_DEACTIVATED", + "message": "정지된 계정입니다. 관리자 이메일로 문의하세요." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> login(@Valid @RequestBody LoginRequestDTO request, HttpServletResponse response); + + @Operation(summary = "중복 로그인 시 로그인", + description = "로그인 기능, ticket(confirmLoginTicket: 2분 후 만료)을 포함해야 합니다. ex) POST /api/users/login") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse(responseCode = "400", description = "잘못된 로그인 확인 티켓", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "로그인 확인 티켓이 잘못된 경우 or 티켓 userId값이 null or 그 외 모든 잘못된 티켓일 경우", + value = """ + { + "success": false, + "code": "LOGIN_CONFIRM_MISMATCH or LOGIN_CONFIRM_INVALID", + "message": "잘못된 로그인 확인 티켓입니다. or userId 값이 없습니다. or 로그인 확인 티켓이 유효하지 않습니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "403", description = "정지된 계정 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "정지된 계정(ROLE: USER, HOST 만 정지 가능)", + value = """ + { + "success": false, + "code": "USER_DEACTIVATED", + "message": "정지된 계정입니다. 관리자 이메일로 문의하세요." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ), + @ApiResponse(responseCode = "410", description = "로그인 확인 티켓 만료", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "로그인 확인 티켓 만료(2분 지났을 경우)", + value = """ + { + "success": false, + "code": "LOGIN_CONFIRM_EXPIRED", + "message": "로그인 확인이 만료되었습니다." + } + """ + ) + ) + ) + } + ) + ResponseEntity> confirmLogin(@RequestParam("ticket") String ticket, HttpServletResponse response); + + @Operation(summary = "로그아웃", + description = "로그아웃 기능 ex) POST /api/users/logout") + ResponseEntity> logout(HttpServletRequest request, HttpServletResponse response); + + + @Operation(summary = "accessToken 재발급", + description = "accessToken 재발급 기능 ex) POST /api/users/reissue") + @ApiResponses(value = { + @ApiResponse(responseCode = "401", description = "유효하지 않은 RefreshToken", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "유효하지 않은 RefreshToken(만료 또는 cookie의 refreshToken과 db의 refreshToken 불일치)", + value = """ + { + "success": false, + "code": "AUTH_REFRESH_TOKEN_EXPIRED or AUTH_REFRESH_TOKEN_NOT_MATCH", + "message": "Refresh Token이 만료되었습니다. or Refresh Token이 일치하지 않습니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> reissue(HttpServletRequest request, HttpServletResponse response); + + + @Operation( + summary = "Access Token 파싱", + description = "Authorization: Bearer {token} 헤더로 전달된 Access Token을 검증하고, 포함된 클레임 정보를 반환합니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "유효하지 않은 AccessToken", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "유효하지 않은 AccessToken(토큰이 없거나 올바르지 못한 accessToken)", + value = """ + { + "success": false, + "code": "AUTH_TOKEN_MISSING or AUTH_TOKEN_INVALID", + "message": "토큰이 없습니다. or 올바르지 못한 Access Token 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "401", description = "유효하지 않은 AccessToken", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "유효하지 않은 AccessToken(만료)", + value = """ + { + "success": false, + "code": "AUTH_ACCESS_TOKEN_EXPIRED", + "message": "Access Token이 만료되었습니다." + } + """ + ) + ) + ) + } + ) + ResponseEntity> parseToken(HttpServletRequest request); + + } diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/api/EmailApiSpecification.java b/src/main/java/com/tekcit/festival/domain/user/controller/api/EmailApiSpecification.java new file mode 100644 index 0000000..90cd11c --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/controller/api/EmailApiSpecification.java @@ -0,0 +1,85 @@ +package com.tekcit.festival.domain.user.controller.api; + +import com.tekcit.festival.domain.user.dto.request.EmailSendDTO; +import com.tekcit.festival.domain.user.dto.request.EmailVerifyDTO; +import com.tekcit.festival.domain.user.dto.response.EmailResponseDTO; +import com.tekcit.festival.exception.global.ErrorResponse; +import com.tekcit.festival.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +public interface EmailApiSpecification { + @Operation(summary = "이메일 인증 코드 전송", + description = "이메일 인증 코드 전송, 인증 코드 5분 이후 만료, emailSendDTO를 포함해야 합니다. ex) POST /api/mail/sendCode") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "필수 입력 사항 위반(이메일, 검증타입)", + value = """ + { + "success": false, + "code": "VALIDATION_ERROR", + "message": "%s는 필수 입력사항 입니다." + } + """ + ) + ) + ) + }) + ResponseEntity> sendCode(@Valid @RequestBody EmailSendDTO emailSendDTO); + + + @Operation(summary = "이메일 인증 코드 검증", + description = "이메일 인증 코드 검증, emailVerifyDTO를 포함해야 합니다. ex) POST /api/mail/verify") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "인증코드 인증 실패 or 필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "인증코드 인증 실패(불일치) or 필수 입력 사항 위반(이메일, 인증 코드, 검증타입)", + value = """ + { + "success": false, + "code": "EMAIL_VERIFICATION_CODE_MISMATCH or VALIDATION_ERROR", + "message": "인증 코드가 일치하지 않습니다. or %s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "이메일 인증 요청 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "이메일 인증 요청 조회를 찾을 수 없음", + value = """ + { + "success": false, + "code": "EMAIL_VERIFICATION_NOT_FOUND", + "message": "인증 요청이 없습니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "410", description = "이메일 인증 요청 시간 만료", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "이메일 인증 요청 시간 만료", + value = """ + { + "success": false, + "code": "EMAIL_VERIFICATION_EXPIRED", + "message": "인증 코드가 만료되었습니다." + } + """ + ) + ) + ) + } + ) + ResponseEntity> verifyCode(@Valid @RequestBody EmailVerifyDTO emailVerifyDTO); + +} diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/api/KakaoAuthApiSpecification.java b/src/main/java/com/tekcit/festival/domain/user/controller/api/KakaoAuthApiSpecification.java new file mode 100644 index 0000000..3471c07 --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/controller/api/KakaoAuthApiSpecification.java @@ -0,0 +1,64 @@ +package com.tekcit.festival.domain.user.controller.api; + +import com.tekcit.festival.domain.user.dto.request.KakaoSignupDTO; +import com.tekcit.festival.domain.user.dto.response.UserResponseDTO; +import com.tekcit.festival.exception.global.ErrorResponse; +import com.tekcit.festival.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.io.IOException; + +public interface KakaoAuthApiSpecification { + + void redirectToKakao(@RequestParam(value = "force", defaultValue = "false") boolean forceLogin, HttpServletResponse response) throws IOException; + + void callback(@RequestParam("code") String code, HttpServletResponse response) throws IOException; + + @Operation(summary = "회원 가입(일반 유저), 카카오 회원가입", + description = "일반 유저 회원 가입, SignupUserDTO를 포함해야 합니다. ex) POST /api/auth/kakao/signupUser") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "가입 토큰 없거나 만료 or 필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "회원 가입 실패(가입 토큰 없음 or 필수 입력 사항 위반(이름, 전화번호, 주민번호, 주소, 우편번호))", + value = """ + { + "success": false, + "code": "KAKAO_INVALID_TICKET or VALIDATION_ERROR", + "message": "카카오 가입 토큰이 없거나 만료(10분). 다시 인증해주세요. or %s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "409", description = "회원 가입 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "회원 가입 실패 (중복된 kakao ID, Email로 인한 conflict)", + value = """ + { + "success": false, + "code": "DUPLICATE_KAKAO_ID or DUPLICATE_EMAIL_ID", + "message": "이미 존재하는 카카오 계정입니다. KAKAO_ID: %s, 이미 존재하는 이메일입니다. EMAIL: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> signupUser(@Valid @RequestBody KakaoSignupDTO kakaoSignupDTO, + @CookieValue(value = "kakao_signup", required = false) String ticket, + HttpServletResponse res); + + + } diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/api/MyPageApiSpecification.java b/src/main/java/com/tekcit/festival/domain/user/controller/api/MyPageApiSpecification.java new file mode 100644 index 0000000..9383fc6 --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/controller/api/MyPageApiSpecification.java @@ -0,0 +1,173 @@ +package com.tekcit.festival.domain.user.controller.api; + +import com.tekcit.festival.domain.user.dto.request.CheckPwDTO; +import com.tekcit.festival.domain.user.dto.request.ResetPwDTO; +import com.tekcit.festival.domain.user.dto.request.UpdateUserRequestDTO; +import com.tekcit.festival.domain.user.dto.response.MyPageCommonDTO; +import com.tekcit.festival.domain.user.dto.response.MyPageHostDTO; +import com.tekcit.festival.domain.user.dto.response.MyPageUserDTO; +import com.tekcit.festival.domain.user.dto.response.UpdateUserResponseDTO; +import com.tekcit.festival.exception.global.ErrorResponse; +import com.tekcit.festival.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; + +public interface MyPageApiSpecification { + @Operation(summary = "마이페이지 회원 정보 조회", + description = "마이페이지 회원 정보 조회, MyPageUserDTO(USER), MyPageHostDTO(HOST), MyPageCommonDTO(ADMIN) Role에 따라 return 값이 달라집니다." + + "ex) GET /api/myPage/userInfo") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", content = @Content( + schema = @Schema(oneOf = { MyPageUserDTO.class, MyPageHostDTO.class, MyPageCommonDTO.class }) + )), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> myPageUserInfo(@AuthenticationPrincipal String principal); + + @Operation(summary = "마이페이지 회원 정보 수정", + description = "마이페이지 회원 정보 수정, UpdateUserRequestDTO를 포함해야 합니다. ex) PATCH /api/myPage/updateUser") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "회원 가입 실패(필수 입력 사항 위반(이름, 전화번호, 주민번호))", + value = """ + { + "success": false, + "code": "VALIDATION_ERROR", + "message": "%s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없음", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> updateUser(@Valid @RequestBody UpdateUserRequestDTO updateUserRequestDTO, @AuthenticationPrincipal String principal); + + @Operation(summary = "마이페이지 기존 비밀번호 일치 여부 확인", + description = "마이페이지에서 기존 비밀번호 일치 여부를 확인할 수 있습니다. CheckPwDTO(기존 비밀번호)를 포함해야 합니다. ex) POST /api/myPage/checkPassword") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "일치하지 않는 비밀번호 or 필수 입력 사항 위반", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "일치하지 않는 비밀번호 or 필수 입력 사항 위반(기존 비밀번호)", + value = """ + { + "success": false, + "code": "AUTH_PASSWORD_NOT_EQUAL_ERROR or VALIDATION_ERROR", + "message": "일치하지 않는 비밀번호입니다. or %s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "403", description = "카카오 계정은 비밀번호 없음", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "카카오 가입 사용자는 비밀번호가 존재하지 않음", + value = """ + { + "success": false, + "code": "AUTH_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> checkPassword(@AuthenticationPrincipal String principal, @Valid @RequestBody CheckPwDTO checkPwDTO); + + @Operation(summary = "마이페이지 비밀번호 재설정", + description = "마이페이지에서 비밀번호를 변경할 수 있습니다. ResetPwDTO(새로운 비밀번호)를 포함해야 합니다. ex) PATCH /api/myPage/resetPassword") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "필수 입력 사항 위반", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "필수 입력 사항 위반(새로운 비밀번호)", + value = """ + { + "success": false, + "code": "VALIDATION_ERROR", + "message": "%s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "403", description = "카카오 계정은 비밀번호 없음", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "카카오 가입 사용자는 비밀번호가 존재하지 않음", + value = """ + { + "success": false, + "code": "AUTH_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> resetPassword(@AuthenticationPrincipal String principal, @Valid @RequestBody ResetPwDTO resetPwDTO); + + } diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/api/UserApiSpecification.java b/src/main/java/com/tekcit/festival/domain/user/controller/api/UserApiSpecification.java new file mode 100644 index 0000000..f31c56c --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/controller/api/UserApiSpecification.java @@ -0,0 +1,310 @@ +package com.tekcit.festival.domain.user.controller.api; + +import com.tekcit.festival.domain.user.dto.request.FindLoginIdDTO; +import com.tekcit.festival.domain.user.dto.request.FindPwEmailDTO; +import com.tekcit.festival.domain.user.dto.request.FindPwResetDTO; +import com.tekcit.festival.domain.user.dto.request.SignupUserDTO; +import com.tekcit.festival.domain.user.dto.response.UserResponseDTO; +import com.tekcit.festival.exception.global.ErrorResponse; +import com.tekcit.festival.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +public interface UserApiSpecification { + @Operation(summary = "회원 가입(일반 유저)", + description = "일반 유저 회원 가입, SignupUserDTO를 포함해야 합니다. ex) POST /api/users/signupUser") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "이메일 인증 안 됨 or 필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "회원 가입 실패(이메일 인증 안 됨 or 필수 입력 사항 위반(로그인 아이디, 로그인 비밀번호, 이름, 전화번호, 이메일, 주민번호, 주소, 우편번호))", + value = """ + { + "success": false, + "code": "USER_EMAIL_NOT_VERIFIED or VALIDATION_ERROR", + "message": "이메일이 인증되지 않았습니다. or %s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "이메일 인증 요청 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "이메일 인증 요청 조회를 찾을 수 없음", + value = """ + { + "success": false, + "code": "EMAIL_VERIFICATION_NOT_FOUND", + "message": "인증 요청이 없습니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "409", description = "회원 가입 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "회원 가입 실패 (중복된 login ID, Email로 인한 conflict)", + value = """ + { + "success": false, + "code": "DUPLICATE_LOGIN_ID or DUPLICATE_EMAIL_ID", + "message": "이미 존재하는 아이디입니다. ID: %s, 이미 존재하는 이메일입니다. EMAIL: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> signupUser(@Valid @RequestBody SignupUserDTO signupUserDTO); + + @Operation(summary = "회원 가입(축제 주최측)", + description = "축제 주최측 회원 가입, SignupUserDTO를 포함해야 합니다. ex) POST /api/users/signupHost") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "회원 가입 실패(필수 입력 사항 위반(로그인 아이디, 로그인 비밀번호, 이름, 전화번호, 이메일, 사업체명))", + value = """ + { + "success": false, + "code": "VALIDATION_ERROR", + "message": "%s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "409", description = "회원 가입 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "회원 가입 실패 (중복된 login ID, Email로 인한 conflict)", + value = """ + { + "success": false, + "code": "DUPLICATE_LOGIN_ID or DUPLICATE_EMAIL_ID", + "message": "이미 존재하는 아이디입니다. ID: %s, 이미 존재하는 이메일입니다. EMAIL: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> signupHost(@Valid @RequestBody SignupUserDTO signupUserDTO); + + @Operation(summary = "회원 가입(운영 관리자)", + description = "운영 관리자 회원 가입, SignupUserDTO를 포함해야 합니다. ex) POST /api/users/signupAdmin") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "회원 가입 실패(필수 입력 사항 위반(로그인 아이디, 로그인 비밀번호, 이름, 전화번호, 이메일))", + value = """ + { + "success": false, + "code": "VALIDATION_ERROR", + "message": "%s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "409", description = "회원 가입 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "회원 가입 실패 (중복된 login ID, Email로 인한 conflict)", + value = """ + { + "success": false, + "code": "DUPLICATE_LOGIN_ID or DUPLICATE_EMAIL_ID", + "message": "이미 존재하는 아이디입니다. ID: %s, 이미 존재하는 이메일입니다. EMAIL: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> signupAdmin(@Valid @RequestBody SignupUserDTO signupUserDTO); + + @Operation(summary = "로그인 아이디 중복 확인", + description = "로그인 아이디 중복 확인, ex) GET /api/users/checkLoginId?loginId=test") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 아이디 중복 체크(true면 중복 아님, false면 중복)", + content = @Content(schema = @Schema(implementation = SuccessResponse.class))) + } + ) + ResponseEntity> checkLoginId(@RequestParam String loginId); + + @Operation(summary = "이메일 주소 중복 확인", + description = "이메일 주소 중복 확인, ex) GET /api/users/checkEmail?email=test@test.com") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이메일 주소 중복 체크(true면 중복 아님, false면 중복)", + content = @Content(schema = @Schema(implementation = SuccessResponse.class))) + }) + ResponseEntity> checkEmail(@RequestParam String email); + + @Operation(summary = "일반 회원 탈퇴", + description = "일반 회원 탈퇴, ex) DELETE /api/users") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "kakao unlink 오류 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "kakao unlink 오류", + value = """ + { + "success": false, + "code": "KAKAO_UNLINK_FAILED", + "message": "errorMessage" + } + """ + ) + ) + ), + @ApiResponse(responseCode = "403", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "일반 사용자만 삭제(탈퇴) 가능합니다.", + value = """ + { + "success": false, + "code": "AUTH_NOT_ALLOWED", + "message": "허용되지 않는 행동입니다" + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity deleteUser(@AuthenticationPrincipal String principal); + + @Operation(summary = "아이디 찾기", + description = "로그인 아이디 찾기, FindLoginIdDTO를 포함해야 합니다. ex) POST /api/users/findLoginId") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "필수 입력 사항 위반(이름, 이메일))", + value = """ + { + "success": false, + "code": "VALIDATION_ERROR", + "message": "%s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> findLoginId(@Valid @RequestBody FindLoginIdDTO findLoginIdDTO); + + @Operation(summary = "비밀번호 찾기", + description = "로그인 비밀번호 찾기 1단계, FindLoginPwDTO(로그인아이디, 이름)을 포함해야 합니다. ex) POST /api/users/findRegisteredEmail") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "필수 입력 사항 위반(로그인아이디, 이름))", + value = """ + { + "success": false, + "code": "VALIDATION_ERROR", + "message": "%s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> findRegisteredEmail(@Valid @RequestBody FindPwEmailDTO findPwEmailDTO); + + @Operation(summary = "비밀번호 재설정", + description = "로그인 비밀번호 찾기 2단계, FindPwResetDTO(로그인아이디, 이메일, 새로운 비밀번호)를 포함해야 합니다. ex) PATCH /api/users/resetPasswordEmail") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "이메일 인증 안 됨 or 이메일 일치하지 않음 or 필수 입력 사항 위반 ", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "회원 가입 실패(이메일 인증 안 됨 or 이메일 일치하지 않음 or 필수 입력 사항 위반(로그인 아이디, 이메일, 새로운 비밀번호))", + value = """ + { + "success": false, + "code": "USER_EMAIL_NOT_VERIFIED or USER_EMAIL_NOT_MATCH or VALIDATION_ERROR", + "message": "이메일이 인증되지 않았습니다. or 이메일이 일치하지 않습니다. EMAIL: %s or %s는 필수 입력사항 입니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "이메일 인증 요청 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "이메일 인증 요청 조회를 찾을 수 없음", + value = """ + { + "success": false, + "code": "EMAIL_VERIFICATION_NOT_FOUND", + "message": "인증 요청이 없습니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> resetPasswordEmail(@Valid @RequestBody FindPwResetDTO findPwResetDTO); + + } diff --git a/src/main/java/com/tekcit/festival/domain/user/controller/api/UserInfoApiSpecification.java b/src/main/java/com/tekcit/festival/domain/user/controller/api/UserInfoApiSpecification.java new file mode 100644 index 0000000..0e438cb --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/controller/api/UserInfoApiSpecification.java @@ -0,0 +1,149 @@ +package com.tekcit.festival.domain.user.controller.api; + +import com.tekcit.festival.domain.user.dto.response.*; +import com.tekcit.festival.exception.global.ErrorResponse; +import com.tekcit.festival.exception.global.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +public interface UserInfoApiSpecification { + @Operation(summary = "사용자 나이 확인", + description = "사용자 나이 확인(age), ex) GET /api/users/checkAge") + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + ResponseEntity> checkUserAgeInfo(@AuthenticationPrincipal String principal); + + @Operation(summary = "예매 시 사용자 정보", + description = "예매 시 사용자 정보(email, 이름), ex) GET /api/users/booking-profile/{userId}") + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + ResponseEntity> bookingProfileInfo(@Valid @PathVariable Long userId); + + @Operation(summary = "예매자 정보 조회", + description = "예매자 정보 조회, 예매자 userId가 리스트로 주어져야 합니다. ex) POST /api/users/reservationList") + ResponseEntity>> getReservationUserInfo(@RequestBody List userIds); + + @Operation(summary = "통계 정보 조회", + description = "통계 정보 조회, 예매자 userId가 리스트로 주어져야 합니다. ex) POST /api/users/statisticsList") + ResponseEntity> getStatisticsInfo(@RequestBody List userIds); + + @Operation(summary = "가예매자 정보 조회", + description = "가예매자 정보 조회. ex) POST /api/users/preReservation") + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + ResponseEntity> getPreReservationInfo(@AuthenticationPrincipal String principal); + + + @Operation(summary = "양도 시 이메일을 통한 양수자 정보 조회", + description = "양도 시 이메일을 통한 양수자 정보 조회. ex) GET /api/users/transferee?email=test@test.com") + @ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "본인에게는 양도 불가능", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "본인에게는 양도 불가능", + value = """ + { + "success": false, + "code": "SELF_TRANSFER_FORBIDDEN", + "message": "본인에게는 양도를 할 수 없습니다." + } + """ + ) + ) + ), + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + } + ) + ResponseEntity> transfereeInfo(@AuthenticationPrincipal String principal, @RequestParam String email); + + @Operation(summary = "양도 시 현재 양도자 정보 조회", + description = "양도 시 현재 양도자 정보 조회. ex) GET /api/users/transferor?email=test@test.com") + @ApiResponse(responseCode = "404", description = "사용자 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s" + } + """ + ) + ) + ) + ResponseEntity> transferorInfo(@AuthenticationPrincipal String principal); + + @Operation(summary = "사용자 위도 경도 정보 조회", + description = "사용자 위도 경도 정보 조회. ex) GET /api/users/geocodeInfo") + @ApiResponse(responseCode = "404", description = "사용자 조회 실패 or 주소 조회 실패", content = @Content( + mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject( + summary = "user를 찾을 수 없습니다. or 기본 주소를 찾을 수 없습니다.", + value = """ + { + "success": false, + "code": "USER_NOT_FOUND or ADDRESS_DEFAULT_NOT_FOUND", + "message": "해당 사용자를 찾을 수 없습니다. ID: %s or 기본 주소가 존재하지 않습니다." + } + """ + ) + ) + ) + ResponseEntity> geoCodeInfo(@AuthenticationPrincipal String principal); + + } diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/request/AddressRequestDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/request/AddressRequestDTO.java index 7e3a5ef..a3c740b 100644 --- a/src/main/java/com/tekcit/festival/domain/user/dto/request/AddressRequestDTO.java +++ b/src/main/java/com/tekcit/festival/domain/user/dto/request/AddressRequestDTO.java @@ -1,15 +1,17 @@ package com.tekcit.festival.domain.user.dto.request; +import com.fasterxml.jackson.annotation.JsonProperty; import com.tekcit.festival.domain.user.entity.Address; import com.tekcit.festival.domain.user.entity.UserProfile; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -@Schema(description = "회원 배송지 추가, 수정 요청 DTO", name = "AddAddressDTO") +@Schema(description = "회원 배송지 추가, 수정 요청 DTO", name = "AddressRequestDTO") @Data @NoArgsConstructor @AllArgsConstructor @@ -29,15 +31,22 @@ public class AddressRequestDTO { @Schema(description = "수령자 전화번호") @NotBlank(message = "수령자 전화번호는 필수 입력사항 입니다.") + @Pattern(regexp = "^01[016789]-\\d{3,4}-\\d{4}$", + message = "전화번호 형식이 올바르지 않습니다. 예: 010-1234-5678") private String phone; + //isDefault로 인식하도록 + @JsonProperty("isDefault") + @Schema(description = "기본 배송지 여부") + private boolean isDefault; + public Address toAddressEntity(UserProfile userProfile){ return Address.builder() .address(address) .zipCode(zipCode) .name(name) .phone(phone) - .isDefault(false) + .isDefault(isDefault) .userProfile(userProfile) .build(); } diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/request/SignupUserDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/request/SignupUserDTO.java index baa7b38..127382c 100644 --- a/src/main/java/com/tekcit/festival/domain/user/dto/request/SignupUserDTO.java +++ b/src/main/java/com/tekcit/festival/domain/user/dto/request/SignupUserDTO.java @@ -4,6 +4,7 @@ import com.tekcit.festival.domain.user.enums.OAuthProvider; import com.tekcit.festival.domain.user.enums.UserRole; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; @@ -41,8 +42,10 @@ public class SignupUserDTO { message = "이메일 형식이 올바르지 않습니다.") private String email; + @Valid private UserProfileDTO userProfile; // USER일 때만 + @Valid private HostProfileDTO hostProfile; // HOST일 때만 public User toUserEntity(){ diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/request/UserProfileDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/request/UserProfileDTO.java index 55fbb86..6080afd 100644 --- a/src/main/java/com/tekcit/festival/domain/user/dto/request/UserProfileDTO.java +++ b/src/main/java/com/tekcit/festival/domain/user/dto/request/UserProfileDTO.java @@ -26,11 +26,9 @@ public class UserProfileDTO { private String residentNum; @Schema(description = "회원 주소") - @NotBlank(message = "주소는 필수 입력사항 입니다.") private String address; @Schema(description = "회원 주소(우편 번호)") - @NotBlank(message = "우편 번호는 필수입니다.") private String zipCode; public UserProfile toEntity(int age, UserGender gender, String birth){ diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/response/AddressDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/response/AddressDTO.java index 09e489a..3fa9083 100644 --- a/src/main/java/com/tekcit/festival/domain/user/dto/response/AddressDTO.java +++ b/src/main/java/com/tekcit/festival/domain/user/dto/response/AddressDTO.java @@ -1,5 +1,6 @@ package com.tekcit.festival.domain.user.dto.response; +import com.fasterxml.jackson.annotation.JsonProperty; import com.tekcit.festival.domain.user.entity.Address; import com.tekcit.festival.domain.user.entity.User; import io.swagger.v3.oas.annotations.media.Schema; @@ -16,6 +17,9 @@ @AllArgsConstructor @Builder public class AddressDTO { + @Schema(description = "주소 Id") + private Long id; + @Schema(description = "수령자 이름") private String name; @@ -28,12 +32,15 @@ public class AddressDTO { @Schema(description = "사용자 주소 우편번호") private String zipCode; + //isDefault로 인식하도록 + @JsonProperty("isDefault") @Schema(description = "사용자 주소 기본 배송지 여부") private boolean isDefault; @Schema(hidden = true) public static AddressDTO fromEntity(Address address) { return AddressDTO.builder() + .id(address.getId()) .name(address.getName()) .phone(address.getPhone()) .address(address.getAddress()) diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/response/AssignmentDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/response/AssignmentDTO.java index 5e58845..c745401 100644 --- a/src/main/java/com/tekcit/festival/domain/user/dto/response/AssignmentDTO.java +++ b/src/main/java/com/tekcit/festival/domain/user/dto/response/AssignmentDTO.java @@ -16,6 +16,9 @@ @AllArgsConstructor @Builder public class AssignmentDTO { + @Schema(description = "사용자 id") + private Long userId; + @Schema(description = "사용자 이름") private String name; @@ -26,6 +29,7 @@ public class AssignmentDTO { public static AssignmentDTO fromUserEntity(User user) { UserProfile userProfile = user.getUserProfile(); return AssignmentDTO.builder() + .userId(user.getUserId()) .name(user.getName()) .residentNum(userProfile.getResidentNum()) .build(); diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/response/BookingProfileDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/response/BookingProfileDTO.java index be7b31f..7a7587f 100644 --- a/src/main/java/com/tekcit/festival/domain/user/dto/response/BookingProfileDTO.java +++ b/src/main/java/com/tekcit/festival/domain/user/dto/response/BookingProfileDTO.java @@ -17,10 +17,14 @@ public class BookingProfileDTO { @Schema(description = "예매자 이메일 주소") private String email; + @Schema(description = "예매자 이름") + private String name; + @Schema(hidden = true) public static BookingProfileDTO fromEntity(User bookingUser) { return BookingProfileDTO.builder() .email(bookingUser.getEmail()) + .name(bookingUser.getName()) .build(); } } \ No newline at end of file diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/response/CheckAgeDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/response/CheckAgeDTO.java new file mode 100644 index 0000000..bc6b6ad --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/dto/response/CheckAgeDTO.java @@ -0,0 +1,25 @@ +package com.tekcit.festival.domain.user.dto.response; + +import com.tekcit.festival.domain.user.entity.UserProfile; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "나이 확인 응답 DTO", name = "CheckAgeDTO") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CheckAgeDTO { + @Schema(description = "사용자 나이") + private int age; + + @Schema(hidden = true) + public static CheckAgeDTO fromEntity(UserProfile userProfile) { + return CheckAgeDTO.builder() + .age(userProfile.getAge()) + .build(); + } +} diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/response/GeoCodeInfoDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/response/GeoCodeInfoDTO.java new file mode 100644 index 0000000..9aa12c0 --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/dto/response/GeoCodeInfoDTO.java @@ -0,0 +1,33 @@ +package com.tekcit.festival.domain.user.dto.response; + +import com.tekcit.festival.domain.user.entity.Address; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "User GeoCode 정보 DTO", name = "GeoCodeInfoDTO") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GeoCodeInfoDTO { + @Schema(description = "사용자 userId") + private Long userId; + + @Schema(description = "사용자 주소 위도") + private Double latitude; //위도 + + @Schema(description = "사용자 주소 경도") + private Double longitude;//경도 + + @Schema(hidden = true) + public static GeoCodeInfoDTO fromAddressEntity(Long userId, Address address) { + return GeoCodeInfoDTO.builder() + .userId(userId) + .latitude(address.getLatitude()) + .longitude(address.getLongitude()) + .build(); + } +} diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/response/KakaoAddressSearchDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/response/KakaoAddressSearchDTO.java new file mode 100644 index 0000000..b1f8137 --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/dto/response/KakaoAddressSearchDTO.java @@ -0,0 +1,14 @@ +package com.tekcit.festival.domain.user.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "카카오 주소 검색 전체 응답", name = "KakaoAddressSearchDTO") +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoAddressSearchDTO { + private List documents; +} \ No newline at end of file diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/response/KakaoMapResponseDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/response/KakaoMapResponseDTO.java new file mode 100644 index 0000000..83432f5 --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/dto/response/KakaoMapResponseDTO.java @@ -0,0 +1,23 @@ +package com.tekcit.festival.domain.user.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "카카오 search 주소 응답 DTO", name = "KakaoMapResponseDTO") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoMapResponseDTO { + @JsonProperty("x") + private String longitude; + + @JsonProperty("y") + private String latitude; +} diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/response/LoginConflictDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/response/LoginConflictDTO.java new file mode 100644 index 0000000..44b107e --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/dto/response/LoginConflictDTO.java @@ -0,0 +1,28 @@ +package com.tekcit.festival.domain.user.dto.response; + +import com.tekcit.festival.domain.user.enums.LoginStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "동시 로그인 응답 DTO", name = "LoginConflictDTO") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class LoginConflictDTO { + @Schema(description = "로그인 confirm ticket") + private String loginTicket; + + @Schema(description = "로그인 상태") + private LoginStatus kind; + + public static LoginConflictDTO fromTicket(String loginTicket) { + return LoginConflictDTO.builder() + .loginTicket(loginTicket) + .kind(LoginStatus.CONFLICT) + .build(); + } +} diff --git a/src/main/java/com/tekcit/festival/domain/user/dto/response/LoginResponseDTO.java b/src/main/java/com/tekcit/festival/domain/user/dto/response/LoginResponseDTO.java index 2986cae..49a11cc 100644 --- a/src/main/java/com/tekcit/festival/domain/user/dto/response/LoginResponseDTO.java +++ b/src/main/java/com/tekcit/festival/domain/user/dto/response/LoginResponseDTO.java @@ -1,5 +1,6 @@ package com.tekcit.festival.domain.user.dto.response; +import com.tekcit.festival.domain.user.enums.LoginStatus; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -16,9 +17,13 @@ public class LoginResponseDTO { @Schema(description = "JWT 액세스 토큰") private String accessToken; + @Schema(description = "로그인 상태") + private LoginStatus kind; + public static LoginResponseDTO fromToken(String accessToken) { return LoginResponseDTO.builder() .accessToken(accessToken) + .kind(LoginStatus.SUCCESS) .build(); } diff --git a/src/main/java/com/tekcit/festival/domain/user/entity/Address.java b/src/main/java/com/tekcit/festival/domain/user/entity/Address.java index 419850c..25f74a3 100644 --- a/src/main/java/com/tekcit/festival/domain/user/entity/Address.java +++ b/src/main/java/com/tekcit/festival/domain/user/entity/Address.java @@ -1,5 +1,6 @@ package com.tekcit.festival.domain.user.entity; +import com.tekcit.festival.domain.user.enums.GeocodeStatus; import jakarta.persistence.*; import lombok.*; @@ -28,6 +29,17 @@ public class Address { @Column(name = "phone", nullable = false) private String phone; + @Column(name = "latitude", columnDefinition = "DECIMAL(10,7)") + private Double latitude; //위도 + + @Column(name = "longitude", columnDefinition = "DECIMAL(10,7)") + private Double longitude;//경도 + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private GeocodeStatus isGeocoded = GeocodeStatus.PENDING;//지오코드 여부(위도, 경도) + @Builder.Default @Column(name = "is_default", nullable = false) private boolean isDefault = false; diff --git a/src/main/java/com/tekcit/festival/domain/user/entity/User.java b/src/main/java/com/tekcit/festival/domain/user/entity/User.java index 7361532..e3cc9d6 100644 --- a/src/main/java/com/tekcit/festival/domain/user/entity/User.java +++ b/src/main/java/com/tekcit/festival/domain/user/entity/User.java @@ -41,7 +41,7 @@ public class User extends BaseEntity { @Column(name = "email", nullable = false) private String email; - @Column(name = "refresh_token", length = 512) + @Column(name = "refresh_token", length = 1000) private String refreshToken; @Column(name = "is_email_verified", nullable = false) @@ -60,6 +60,9 @@ public class User extends BaseEntity { @Column(name = "oauth_provider_id", length = 100) private String oauthProviderId; // 예: 카카오 id(문자열) + @Column(name = "session_id") + private String sessionId; // 현재 세션 id(중복 로그인 확인 용도) + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private UserProfile userProfile; @@ -92,4 +95,11 @@ private void validateProviderSpecificFields() { default -> throw new BusinessException(ErrorCode.KAKAO_INVALID_FIELDS, "지원하지 않는 oauthProvider: " + oauthProvider); } } + + public void rotateSession() { + this.sessionId = java.util.UUID.randomUUID().toString(); + } + public void clearSessionOnLogout() { + this.sessionId = null; + } } diff --git a/src/main/java/com/tekcit/festival/domain/user/entity/UserProfile.java b/src/main/java/com/tekcit/festival/domain/user/entity/UserProfile.java index a834140..8a72ba7 100644 --- a/src/main/java/com/tekcit/festival/domain/user/entity/UserProfile.java +++ b/src/main/java/com/tekcit/festival/domain/user/entity/UserProfile.java @@ -1,5 +1,6 @@ package com.tekcit.festival.domain.user.entity; +import com.tekcit.festival.domain.user.enums.GeocodeStatus; import com.tekcit.festival.domain.user.enums.UserGender; import com.tekcit.festival.utils.ResidentUtil; import jakarta.persistence.*; diff --git a/src/main/java/com/tekcit/festival/domain/user/enums/GeocodeStatus.java b/src/main/java/com/tekcit/festival/domain/user/enums/GeocodeStatus.java new file mode 100644 index 0000000..45c6b92 --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/enums/GeocodeStatus.java @@ -0,0 +1,5 @@ +package com.tekcit.festival.domain.user.enums; + +public enum GeocodeStatus { + PENDING, NO_RESULT, SUCCESS, UPDATED +} diff --git a/src/main/java/com/tekcit/festival/domain/user/enums/LoginStatus.java b/src/main/java/com/tekcit/festival/domain/user/enums/LoginStatus.java new file mode 100644 index 0000000..e4fe074 --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/enums/LoginStatus.java @@ -0,0 +1,5 @@ +package com.tekcit.festival.domain.user.enums; + +public enum LoginStatus { + SUCCESS, CONFLICT +} diff --git a/src/main/java/com/tekcit/festival/domain/user/repository/AddressRepository.java b/src/main/java/com/tekcit/festival/domain/user/repository/AddressRepository.java index 387bdeb..61b462b 100644 --- a/src/main/java/com/tekcit/festival/domain/user/repository/AddressRepository.java +++ b/src/main/java/com/tekcit/festival/domain/user/repository/AddressRepository.java @@ -2,6 +2,7 @@ import com.tekcit.festival.domain.user.entity.Address; import com.tekcit.festival.domain.user.entity.UserProfile; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -11,9 +12,12 @@ public interface AddressRepository extends JpaRepository { List
findAllByUserProfile(UserProfile userProfile); - @Query("SELECT a FROM Address a WHERE a.userProfile.user.userId = :userId") - List
findAllByUserId(Long userId); - @Query("SELECT a FROM Address a WHERE a.userProfile.user.userId = :userId AND a.isDefault = true") Optional
findDefaultByUserId(Long userId); + + @Query("SELECT a FROM Address a WHERE a.userProfile = :userProfile AND a.isDefault = true") + Optional
findDefaultByUserProfile(UserProfile userProfile); + + @Query("select a from Address a where a.isGeocoded= 'PENDING' or a.isGeocoded='UPDATED'") + List
findGeocoding(Pageable pageable); } diff --git a/src/main/java/com/tekcit/festival/domain/user/repository/UserProfileRepository.java b/src/main/java/com/tekcit/festival/domain/user/repository/UserProfileRepository.java index e60c616..913d0ad 100644 --- a/src/main/java/com/tekcit/festival/domain/user/repository/UserProfileRepository.java +++ b/src/main/java/com/tekcit/festival/domain/user/repository/UserProfileRepository.java @@ -1,10 +1,8 @@ package com.tekcit.festival.domain.user.repository; -import com.tekcit.festival.domain.user.entity.User; import com.tekcit.festival.domain.user.entity.UserProfile; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; diff --git a/src/main/java/com/tekcit/festival/domain/user/scheduler/UserGeocodeScheduler.java b/src/main/java/com/tekcit/festival/domain/user/scheduler/UserGeocodeScheduler.java new file mode 100644 index 0000000..136d7cb --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/scheduler/UserGeocodeScheduler.java @@ -0,0 +1,19 @@ +package com.tekcit.festival.domain.user.scheduler; + +import com.tekcit.festival.domain.user.service.UserGeocodeService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UserGeocodeScheduler { + private final UserGeocodeService userGeocodeService; + + @Scheduled( + fixedDelayString = "${user.geocode.fixed-delay-ms}", + initialDelayString = "${user.geocode.initial-delay-ms}") + public void runGeocode() { + userGeocodeService.geocodeBatch(100); // 최대 100건 + } +} diff --git a/src/main/java/com/tekcit/festival/domain/user/service/AddressService.java b/src/main/java/com/tekcit/festival/domain/user/service/AddressService.java index 8330c41..deb7126 100644 --- a/src/main/java/com/tekcit/festival/domain/user/service/AddressService.java +++ b/src/main/java/com/tekcit/festival/domain/user/service/AddressService.java @@ -4,6 +4,7 @@ import com.tekcit.festival.domain.user.dto.response.AddressDTO; import com.tekcit.festival.domain.user.entity.Address; import com.tekcit.festival.domain.user.entity.UserProfile; +import com.tekcit.festival.domain.user.enums.GeocodeStatus; import com.tekcit.festival.domain.user.repository.AddressRepository; import com.tekcit.festival.domain.user.repository.UserProfileRepository; import com.tekcit.festival.exception.BusinessException; @@ -25,6 +26,10 @@ public class AddressService { public AddressDTO addAddress(AddressRequestDTO addressRequestDTO, Long userId){ UserProfile userProfile = userProfileRepository.findByUser_UserId(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if(addressRequestDTO.isDefault()) { + addressRepository.findDefaultByUserId(userId) + .ifPresent(Address::unsetDefault); + } Address address = addressRequestDTO.toAddressEntity(userProfile); userProfile.getAddresses().add(address); @@ -44,10 +49,17 @@ public AddressDTO updateAddress(Long addressId, AddressRequestDTO addressRequest throw new BusinessException(ErrorCode.ADDRESS_NOT_ALLOWED); } + if(addressRequestDTO.isDefault()) { + addressRepository.findDefaultByUserId(userId) + .ifPresent(Address::unsetDefault); + } + address.setAddress(addressRequestDTO.getAddress()); address.setZipCode(addressRequestDTO.getZipCode()); address.setName(addressRequestDTO.getName()); address.setPhone(addressRequestDTO.getPhone()); + address.setDefault(addressRequestDTO.isDefault()); + address.setIsGeocoded(GeocodeStatus.UPDATED); addressRepository.save(address); return AddressDTO.fromEntity(address); @@ -92,7 +104,10 @@ public void deleteAddress(Long addressId, Long userId){ } public List getAllAddresses(Long userId){ - List
addresses = addressRepository.findAllByUserId(userId); + UserProfile userProfile = userProfileRepository.findByUser_UserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + List
addresses = addressRepository.findAllByUserProfile(userProfile); List addressDTOS = addresses.stream() .map(address->AddressDTO.fromEntity(address)) @@ -102,6 +117,9 @@ public List getAllAddresses(Long userId){ } public AddressDTO getDefaultAddress(Long userId){ + userProfileRepository.findByUser_UserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + Address address = addressRepository.findDefaultByUserId(userId) .orElse(null); @@ -112,4 +130,19 @@ public AddressDTO getDefaultAddress(Long userId){ return addressDTO; } + public AddressDTO getAddress(Long userId, Long addressId){ + UserProfile userProfile = userProfileRepository.findByUser_UserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Address address = addressRepository.findById(addressId) + .orElseThrow(() -> new BusinessException(ErrorCode.ADDRESS_NOT_FOUND)); + + if (!address.getUserProfile().getUId().equals(userProfile.getUId())) { + throw new BusinessException(ErrorCode.ADDRESS_NOT_ALLOWED); + } + + AddressDTO addressDTO = AddressDTO.fromEntity(address); + return addressDTO; + } + } diff --git a/src/main/java/com/tekcit/festival/domain/user/service/AdminService.java b/src/main/java/com/tekcit/festival/domain/user/service/AdminService.java index db06dc5..1f95392 100644 --- a/src/main/java/com/tekcit/festival/domain/user/service/AdminService.java +++ b/src/main/java/com/tekcit/festival/domain/user/service/AdminService.java @@ -110,6 +110,11 @@ public List getAllAddresses(Long userId){ User adminUser = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + // 운영자 권한 확인 + if (adminUser.getRole() != UserRole.ADMIN) { + throw new BusinessException(ErrorCode.AUTH_NOT_ALLOWED); + } + List
addresses = addressRepository.findAll(); List addressDTOS = addresses.stream() diff --git a/src/main/java/com/tekcit/festival/domain/user/service/AuthService.java b/src/main/java/com/tekcit/festival/domain/user/service/AuthService.java index d5ed76e..91c3ceb 100644 --- a/src/main/java/com/tekcit/festival/domain/user/service/AuthService.java +++ b/src/main/java/com/tekcit/festival/domain/user/service/AuthService.java @@ -3,6 +3,7 @@ import com.tekcit.festival.config.security.userdetails.CustomUserDetails; import com.tekcit.festival.domain.user.dto.response.AccessTokenInfoDTO; import com.tekcit.festival.domain.user.dto.request.LoginRequestDTO; +import com.tekcit.festival.domain.user.dto.response.LoginConflictDTO; import com.tekcit.festival.domain.user.dto.response.LoginResponseDTO; import com.tekcit.festival.domain.user.entity.User; import com.tekcit.festival.domain.user.enums.UserRole; @@ -26,6 +27,7 @@ import com.tekcit.festival.exception.ErrorCode; import java.util.Date; +import java.util.Objects; @Service @RequiredArgsConstructor @@ -38,7 +40,7 @@ public class AuthService { // private final UserEventProducer userEventProducer; @Transactional - public LoginResponseDTO login(LoginRequestDTO loginRequestDTO, HttpServletResponse response) { + public Object tryLogin(LoginRequestDTO loginRequestDTO, HttpServletResponse response) { Authentication authentication; try { @@ -52,11 +54,33 @@ public LoginResponseDTO login(LoginRequestDTO loginRequestDTO, HttpServletRespon CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); User user = userDetails.getUser(); + checkState(user); + + if(user.getSessionId() != null) { //이미 로그인하고 있는 사용자 + String ticket = jwtTokenProvider.createLoginConfirmTicket(user.getUserId()); + return LoginConflictDTO.fromTicket(ticket); + } + + return login(user, response); + } + + //confirmLoginTicket 유효 여부 확인(보안 위해) 2분 후 만료 + @Transactional + public LoginResponseDTO confirmLogin(String ticket, HttpServletResponse response) { + Long userId = jwtTokenProvider.parseLoginConfirmTicket(ticket); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); checkState(user); + return login(user, response); + } + @Transactional + public LoginResponseDTO login(User user, HttpServletResponse response) { + user.rotateSession(); String accessToken = jwtTokenProvider.createAccessToken(user); - String refreshToken = jwtTokenProvider.createRefreshToken(user); + String refreshToken = jwtTokenProvider.createRefreshToken(user, user.getSessionId()); user.updateRefreshToken(refreshToken); userRepository.save(user); @@ -69,17 +93,17 @@ public LoginResponseDTO login(LoginRequestDTO loginRequestDTO, HttpServletRespon @Transactional public void logout(HttpServletRequest request, HttpServletResponse response) { + //cookie에서 refreshToken삭제 + ResponseCookie cookie = cookieUtil.deleteRefreshTokenCookie(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + //refreshToken cookie에서 가져옴 String refreshToken = cookieUtil.resolveRefreshToken(request); // refreshToken이 아예 없는 경우 처리 - if (refreshToken == null) + if (refreshToken == null || refreshToken.isBlank()) return; - //cookie에서 refreshToken삭제 - ResponseCookie cookie = cookieUtil.deleteRefreshTokenCookie(); - response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); - Long userId = jwtTokenProvider.getUserId(refreshToken); if (userId == null) @@ -87,6 +111,7 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { userRepository.findById(userId).ifPresent(user -> { user.updateRefreshToken(null); + user.clearSessionOnLogout(); userRepository.save(user); }); } @@ -115,6 +140,12 @@ public LoginResponseDTO reissue(HttpServletRequest request, HttpServletResponse throw new BusinessException(ErrorCode.AUTH_REFRESH_TOKEN_NOT_MATCH); } + Claims c = jwtTokenProvider.getAllClaims(refreshToken); + String sessionId = c.get("sid", String.class); + if (!(sessionId.equals(user.getSessionId()))) { + throw new BusinessException(ErrorCode.AUTH_REFRESH_TOKEN_NOT_MATCH); + } + //새로운 accessToken 생성 String newAccessToken = jwtTokenProvider.createAccessToken(user); diff --git a/src/main/java/com/tekcit/festival/domain/user/service/KakaoOAuthService.java b/src/main/java/com/tekcit/festival/domain/user/service/KakaoOAuthService.java index 0236d40..5beeeb6 100644 --- a/src/main/java/com/tekcit/festival/domain/user/service/KakaoOAuthService.java +++ b/src/main/java/com/tekcit/festival/domain/user/service/KakaoOAuthService.java @@ -17,7 +17,7 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class KakaoOAuthService { - @Value("${kakao.client-id}") + @Value("${kakao.restapi-key}") private String clientId; @Value("${kakao.redirect-uri}") diff --git a/src/main/java/com/tekcit/festival/domain/user/service/KakaoSearchService.java b/src/main/java/com/tekcit/festival/domain/user/service/KakaoSearchService.java new file mode 100644 index 0000000..3a73f5f --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/service/KakaoSearchService.java @@ -0,0 +1,48 @@ +package com.tekcit.festival.domain.user.service; + +import com.tekcit.festival.domain.user.dto.response.KakaoAddressSearchDTO; +import com.tekcit.festival.domain.user.dto.response.KakaoMapResponseDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KakaoSearchService { + @Value("${kakao.restapi-key}") + private String restApiKey; + + @Value("${kakao.search-base-url}") + private String baseUrl; + + private final WebClient webClient = WebClient.builder().build(); + + public Optional geocodeAddress(String address) { + log.info("address: {}", address); + if (address == null || address.isBlank()) + return Optional.empty(); + + KakaoAddressSearchDTO response = webClient.get() + .uri(baseUrl + "/v2/local/search/address.json?query={query}&size={size}", address, 1) + .header(HttpHeaders.AUTHORIZATION, "KakaoAK " + restApiKey) + .header(HttpHeaders.ACCEPT, "application/json") + .retrieve() + .bodyToMono(KakaoAddressSearchDTO.class) + .block(); + + log.info("kakao response={}", response); + + if (response == null || response.getDocuments() == null || response.getDocuments().isEmpty()) { + return Optional.empty(); + } + + KakaoMapResponseDTO kakaoMapResponseDTO = response.getDocuments().get(0); + return Optional.of(kakaoMapResponseDTO); + } +} diff --git a/src/main/java/com/tekcit/festival/domain/user/service/KakaoService.java b/src/main/java/com/tekcit/festival/domain/user/service/KakaoService.java index e9c32f5..9ba4ebb 100644 --- a/src/main/java/com/tekcit/festival/domain/user/service/KakaoService.java +++ b/src/main/java/com/tekcit/festival/domain/user/service/KakaoService.java @@ -103,7 +103,7 @@ public UserResponseDTO signupUser(KakaoSignupDTO kakaoSignupDTO, String signupTi } @Transactional - public void login(String kakaoId, HttpServletResponse response) { + public boolean login(String kakaoId, HttpServletResponse response) { User user = userRepository.findByOauthProviderAndOauthProviderId(OAuthProvider.KAKAO, kakaoId) .orElseThrow(()-> new BusinessException(ErrorCode.USER_NOT_FOUND)); @@ -111,13 +111,19 @@ public void login(String kakaoId, HttpServletResponse response) { throw new BusinessException(ErrorCode.USER_DEACTIVATED); } - String refreshToken = jwtTokenProvider.createRefreshToken(user); + boolean duplicateLogin = false; + if(user.getSessionId() != null) { + duplicateLogin = true; + } + user.rotateSession(); + String refreshToken = jwtTokenProvider.createRefreshToken(user, user.getSessionId()); user.updateRefreshToken(refreshToken); userRepository.save(user); ResponseCookie cookie = cookieUtil.createRefreshTokenCookie(refreshToken); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + return duplicateLogin; } public record KakaoCallbackResult(boolean isNew, String signupTicket, String kakaoId) {} diff --git a/src/main/java/com/tekcit/festival/domain/user/service/UserGeocodeService.java b/src/main/java/com/tekcit/festival/domain/user/service/UserGeocodeService.java new file mode 100644 index 0000000..9460ab4 --- /dev/null +++ b/src/main/java/com/tekcit/festival/domain/user/service/UserGeocodeService.java @@ -0,0 +1,76 @@ +package com.tekcit.festival.domain.user.service; + +import com.tekcit.festival.domain.user.dto.response.KakaoMapResponseDTO; +import com.tekcit.festival.domain.user.entity.Address; +import com.tekcit.festival.domain.user.enums.GeocodeStatus; +import com.tekcit.festival.domain.user.repository.AddressRepository; +import com.tekcit.festival.domain.user.repository.UserProfileRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserGeocodeService { + private final AddressRepository addressRepository; + private final KakaoSearchService kakaoSearchService; + + public String normalizeAddress(String address) { + if (address == null) + return null; + return address.split(",")[0].trim(); + } + + @Transactional + public boolean geocode(Address address){ + log.info("geocode 시작", address.getId()); + + String searchAddress = normalizeAddress(address.getAddress()); + log.info("searchAddress:{}", searchAddress); + KakaoMapResponseDTO response = kakaoSearchService.geocodeAddress(searchAddress) + .orElse(null); + + if(response != null) { + address.setLongitude(Double.parseDouble(response.getLongitude())); + address.setLatitude(Double.parseDouble(response.getLatitude())); + address.setIsGeocoded(GeocodeStatus.SUCCESS); + addressRepository.save(address); + return true; + } + else + return false; + } + + @Transactional + public int geocodeBatch(int size){ + List
addresses = addressRepository.findGeocoding(PageRequest.of(0, size, Sort.by(Sort.Direction.ASC, "id"))); + log.info("[GEOCODE] picked targets={}", addresses.size()); + + int success = 0; + for(Address address: addresses){ + log.info("[GEOCODE] start id={}, address={}", address.getId(), address.getAddress()); + try { + if (geocode(address)) + success++; + else { + log.info("geocode 실패 (결과 없음)"); + address.setIsGeocoded(GeocodeStatus.NO_RESULT); + addressRepository.save(address); + } + Thread.sleep(200); + } catch (Exception e) { + log.error("geocode 실패(오류 발생)", e); + } + } + + log.info("success: {}", success); + return success; + } +} diff --git a/src/main/java/com/tekcit/festival/domain/user/service/UserInfoService.java b/src/main/java/com/tekcit/festival/domain/user/service/UserInfoService.java index d99e1d6..5e248f0 100644 --- a/src/main/java/com/tekcit/festival/domain/user/service/UserInfoService.java +++ b/src/main/java/com/tekcit/festival/domain/user/service/UserInfoService.java @@ -1,25 +1,39 @@ package com.tekcit.festival.domain.user.service; import com.tekcit.festival.domain.user.dto.response.*; +import com.tekcit.festival.domain.user.entity.Address; import com.tekcit.festival.domain.user.entity.User; import com.tekcit.festival.domain.user.entity.UserProfile; +import com.tekcit.festival.domain.user.enums.GeocodeStatus; +import com.tekcit.festival.domain.user.repository.AddressRepository; import com.tekcit.festival.domain.user.repository.UserProfileRepository; import com.tekcit.festival.domain.user.repository.UserRepository; import com.tekcit.festival.exception.BusinessException; import com.tekcit.festival.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class UserInfoService { private final UserRepository userRepository; private final UserProfileRepository userProfileRepository; + private final AddressRepository addressRepository; + private final UserGeocodeService userGeocodeService; + + public CheckAgeDTO checkUserAgeInfo(Long userId) { + UserProfile userProfile = userProfileRepository.findByUser_UserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + return CheckAgeDTO.fromEntity(userProfile); + } public BookingProfileDTO bookingProfileInfo(Long userId) { User bookingUser = userRepository.findById(userId) @@ -51,11 +65,17 @@ public PreReservationDTO getPreReservationInfo(Long userId){ return PreReservationDTO.fromUserEntity(findUser); } - public AssignmentDTO transfereeInfo(String email){ - User findUser = userRepository.findByEmail(email) + public AssignmentDTO transfereeInfo(Long userId, String email){ + User transferor = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - return AssignmentDTO.fromUserEntity(findUser); + if(transferor.getEmail().equals(email)) + throw new BusinessException(ErrorCode.SELF_TRANSFER_FORBIDDEN); + + User transferee = userRepository.findByEmail(email) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + return AssignmentDTO.fromUserEntity(transferee); } public AssignmentDTO transferorInfo(Long userId){ @@ -65,4 +85,23 @@ public AssignmentDTO transferorInfo(Long userId){ return AssignmentDTO.fromUserEntity(findUser); } + public GeoCodeInfoDTO geoCodeInfo(Long userId){ + User findUser = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Address address = addressRepository.findDefaultByUserId(userId) + .orElseThrow(()-> new BusinessException(ErrorCode.ADDRESS_DEFAULT_NOT_FOUND)); + + if(address.getIsGeocoded() == GeocodeStatus.PENDING || address.getIsGeocoded() == GeocodeStatus.UPDATED){ + boolean result = userGeocodeService.geocode(address); + if(!result) { + log.info("geocode 실패 (결과 없음)"); + address.setIsGeocoded(GeocodeStatus.NO_RESULT); + addressRepository.save(address); + } + } + + return GeoCodeInfoDTO.fromAddressEntity(userId, address); + } + } diff --git a/src/main/java/com/tekcit/festival/domain/user/service/UserService.java b/src/main/java/com/tekcit/festival/domain/user/service/UserService.java index 55c9d7e..4a7cf37 100644 --- a/src/main/java/com/tekcit/festival/domain/user/service/UserService.java +++ b/src/main/java/com/tekcit/festival/domain/user/service/UserService.java @@ -1,5 +1,6 @@ package com.tekcit.festival.domain.user.service; +import com.tekcit.festival.domain.host_admin.repository.FcmTokenRepository; import com.tekcit.festival.domain.user.dto.request.*; import com.tekcit.festival.domain.user.dto.response.*; import com.tekcit.festival.domain.user.entity.*; @@ -15,6 +16,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; @Service @RequiredArgsConstructor @@ -24,6 +26,7 @@ public class UserService { private final PasswordEncoder passwordEncoder; private final EmailVerificationRepository emailVerificationRepository; private final KakaoOAuthService kakaoOAuthService; + private final FcmTokenRepository fcmTokenRepository; @Transactional public UserResponseDTO signupUser(@Valid SignupUserDTO signupUserDTO){ @@ -46,8 +49,10 @@ public UserResponseDTO signupUser(@Valid SignupUserDTO signupUserDTO){ String rNum = userProfileDTO.getResidentNum(); UserProfile userProfile = userProfileDTO.toEntity(ResidentUtil.calcAge(rNum), ResidentUtil.extractGender(rNum), ResidentUtil.calcBirth(rNum)); - Address address = userProfileDTO.toAddressEntity(user, userProfile); - userProfile.getAddresses().add(address); + if(StringUtils.hasText(userProfileDTO.getAddress()) && StringUtils.hasText(userProfileDTO.getZipCode())) { + Address address = userProfileDTO.toAddressEntity(user, userProfile); + userProfile.getAddresses().add(address); + } userProfile.setUser(user); user.setUserProfile(userProfile); @@ -97,7 +102,7 @@ public void deleteUser(Long userId){ throw new BusinessException(ErrorCode.KAKAO_UNLINK_FAILED, e.getMessage()); } } - + fcmTokenRepository.deleteByUser_UserId(userId); userRepository.delete(deleteUser); } diff --git a/src/main/java/com/tekcit/festival/exception/ErrorCode.java b/src/main/java/com/tekcit/festival/exception/ErrorCode.java index edde27a..674d25a 100644 --- a/src/main/java/com/tekcit/festival/exception/ErrorCode.java +++ b/src/main/java/com/tekcit/festival/exception/ErrorCode.java @@ -15,10 +15,13 @@ public enum ErrorCode { DUPLICATE_KAKAO_ID("U006", "이미 존재하는 카카오 계정입니다. KAKAO_ID: %s", HttpStatus.CONFLICT), USER_EMAIL_NOT_MATCH("U007", "이메일이 일치하지 않습니다. EMAIL: %s", HttpStatus.BAD_REQUEST), ADDRESS_NOT_FOUND("U008", "주소가 존재하지 않습니다.", HttpStatus.NOT_FOUND), - ADDRESS_NOT_ALLOWED("U009", "허용되지 않는 행동입니다. 작성자만이 주소를 수정 또는 삭제할 수 있습니다.", HttpStatus.FORBIDDEN), - ADDRESS_DEFAULT_NOT_DELETED("U010", "기본 주소지는 삭제할 수 없습니다.", HttpStatus.BAD_REQUEST), + ADDRESS_DEFAULT_NOT_FOUND("U009", "기본 주소가 존재하지 않습니다.", HttpStatus.NOT_FOUND), + ADDRESS_NOT_ALLOWED("U010", "허용되지 않는 행동입니다. 작성자만이 주소를 조회 또는 수정 또는 삭제할 수 있습니다.", HttpStatus.FORBIDDEN), + ADDRESS_DEFAULT_NOT_DELETED("U011", "기본 주소지는 삭제할 수 없습니다.", HttpStatus.BAD_REQUEST), + INVALID_RESIDENT_NUMBER("U012", "올바르지 못한 주민번호입니다.", HttpStatus.BAD_REQUEST), + SELF_TRANSFER_FORBIDDEN("U013", "본인에게는 양도를 할 수 없습니다.", HttpStatus.BAD_REQUEST), - // AUTH 관련 에러입니다. + // AUTH 관련 에러입니다. AUTH_PASSWORD_NOT_EQUAL_ERROR("A001","일치하지 않는 비밀번호입니다.",HttpStatus.BAD_REQUEST), AUTH_REFRESH_TOKEN_EXPIRED("A002", "Refresh Token이 만료되었습니다.", HttpStatus.UNAUTHORIZED), AUTH_ACCESS_TOKEN_EXPIRED("A003", "Access Token이 만료되었습니다.", HttpStatus.UNAUTHORIZED), @@ -26,38 +29,41 @@ public enum ErrorCode { AUTH_REFRESH_TOKEN_NOT_MATCH("A005", "Refresh Token이 일치하지 않습니다.", HttpStatus.UNAUTHORIZED), AUTH_NOT_ALLOWED("A006", "허용되지 않는 행동입니다.", HttpStatus.FORBIDDEN), AUTH_TOKEN_MISSING("A007", "토큰이 없습니다.", HttpStatus.BAD_REQUEST), + LOGIN_CONFIRM_MISMATCH("A008", "%s", HttpStatus.BAD_REQUEST), + LOGIN_CONFIRM_INVALID("A009", "%s", HttpStatus.BAD_REQUEST), + LOGIN_CONFIRM_EXPIRED("A011", "%s", HttpStatus.GONE), - //이메일 인증 에러 + // 이메일 인증 에러 EMAIL_VERIFICATION_NOT_FOUND("E001", "인증 요청이 없습니다.", HttpStatus.NOT_FOUND), EMAIL_VERIFICATION_EXPIRED("E002","인증 코드가 만료되었습니다.", HttpStatus.GONE), EMAIL_VERIFICATION_CODE_MISMATCH("E003","인증 코드가 일치하지 않습니다.",HttpStatus.BAD_REQUEST), - //카카오 에러 + // 카카오 에러 KAKAO_INVALID_FIELDS("K001", "%s", HttpStatus.BAD_REQUEST), KAKAO_INVALID_TICKET("K002", "%s", HttpStatus.BAD_REQUEST), KAKAO_UNLINK_FAILED("K003", "%s", HttpStatus.BAD_REQUEST), - VERIFICATION_NOT_FOUND("U0010", "인증 요청이 없습니다.", HttpStatus.NOT_FOUND), - VERIFICATION_EXPIRED("U0011","인증 코드가 만료되었습니다.", HttpStatus.GONE), - VERIFICATION_CODE_MISMATCH("U0012","인증 코드가 일치하지 않습니다.",HttpStatus.BAD_REQUEST), - - NOT_FOUND("R001", "요청한 리소스를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - FORBIDDEN("A001", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN), - + // 알림 관련 에러코드 NOTIFICATION_NOT_FOUND("N001", "예약된 알림을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), DUPLICATE_RESOURCE("N002", "이미 예약된 알림이 존재합니다.", HttpStatus.CONFLICT), REQUEST_LIMIT_EXCEEDED("N003", "하루 알림 예약 횟수 제한을 초과했습니다.", HttpStatus.TOO_MANY_REQUESTS), FORBIDDEN_RESOURCE("N004", "해당 알림에 대한 접근 권한이 없습니다.", HttpStatus.FORBIDDEN), - BAD_REQUEST("N005", "잘못된 요청입니다. 이미 전송된 알림은 수정하거나 삭제할 수 없습니다.", HttpStatus.BAD_REQUEST), - INVALID_SEND_TIME("N006", "과거 시점에 알림을 발송할 수 없습니다.", HttpStatus.BAD_REQUEST); + INVALID_SEND_TIME("N005", "과거 시점에 알림을 발송할 수 없습니다.", HttpStatus.BAD_REQUEST), + ALREADY_SENT_NOTIFICATION("N006", "이미 전송된 알림은 수정하거나 삭제할 수 없습니다.", HttpStatus.BAD_REQUEST), + FCM_SEND_FAILED("N007", "알림 전송에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + API_CALL_FAILED("N008", "외부 API 호출에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + + // 기타 에러코드 + NOT_FOUND("G001", "요청한 리소스를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + FORBIDDEN("G002", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN); - private final String code; // A001, A002 등 - private final String message; // 사용자에게 보여줄 메시지 - private final HttpStatus status; //http status 코드 + private final String code; + private final String message; + private final HttpStatus status; ErrorCode(String code, String message, HttpStatus status) { this.code = code; this.message = message; this.status = status; } -} + } \ No newline at end of file diff --git a/src/main/java/com/tekcit/festival/exception/global/GlobalExceptionHandler.java b/src/main/java/com/tekcit/festival/exception/global/GlobalExceptionHandler.java index 6852c69..cd7252b 100644 --- a/src/main/java/com/tekcit/festival/exception/global/GlobalExceptionHandler.java +++ b/src/main/java/com/tekcit/festival/exception/global/GlobalExceptionHandler.java @@ -10,13 +10,30 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; + +import java.util.Map; @ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(EmailSendException.class) - public ResponseEntity handleEmailSendFailed(EmailSendException ex) { - return ResponseEntity.status(500).body("이메일 실패: " + ex.getMessage()); + public ResponseEntity handleEmailSendFailed(EmailSendException ex) { + ErrorResponse response = new ErrorResponse(false, "EMAIL_ERROR", "이메일 실패: " + ex.getMessage()); + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex) { + ErrorResponse response = new ErrorResponse(false, "AUTHORIZATION_ERROR", "접근 권한이 없습니다."); + return new ResponseEntity<>(response, HttpStatus.FORBIDDEN); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthentication(AuthenticationException ex) { + ErrorResponse response = new ErrorResponse(false, "AUTHENTICATION_ERROR", "로그인이 필요합니다."); + return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); } /** diff --git a/src/main/java/com/tekcit/festival/global/init/DataInitRunner.java b/src/main/java/com/tekcit/festival/global/init/DataInitRunner.java new file mode 100644 index 0000000..8b771a1 --- /dev/null +++ b/src/main/java/com/tekcit/festival/global/init/DataInitRunner.java @@ -0,0 +1,94 @@ +package com.tekcit.festival.global.init; + +import com.tekcit.festival.domain.user.dto.request.HostProfileDTO; +import com.tekcit.festival.domain.user.dto.request.SignupUserDTO; +import com.tekcit.festival.domain.user.dto.request.UserProfileDTO; +import com.tekcit.festival.domain.user.entity.HostProfile; +import com.tekcit.festival.domain.user.entity.User; +import com.tekcit.festival.domain.user.enums.GeocodeStatus; +import com.tekcit.festival.domain.user.enums.OAuthProvider; +import com.tekcit.festival.domain.user.enums.UserRole; +import com.tekcit.festival.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Profile({"dev"}) +@RequiredArgsConstructor +public class DataInitRunner implements ApplicationRunner { + + private final JdbcTemplate jdbcTemplate; + private final BCryptPasswordEncoder passwordEncoder; + + @Override + @Transactional + public void run(ApplicationArguments args) { + insertAdmin(); + insertHost(); + insertUser(); + } + + private boolean existsUser(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM users WHERE user_id = ?", Integer.class, id); + return count != null && count > 0; + } + + private void insertAdmin() { + if (existsUser(1L)) return; + + jdbcTemplate.update(""" + INSERT INTO users + (user_id, login_id, login_pw, name, phone, email, role, oauth_provider, oauth_provider_id, is_email_verified, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'ADMIN', 'LOCAL', NULL, TRUE, NOW(), NOW()) + """, 1L, "adminId", passwordEncoder.encode("adminPassword"), + "관리자", "010-1234-5678", "admin@example.com"); + } + + private void insertHost() { + if (existsUser(2L)) return; + + jdbcTemplate.update(""" + INSERT INTO users + (user_id, login_id, login_pw, name, phone, email, role, oauth_provider, oauth_provider_id, is_email_verified, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'HOST', 'LOCAL', NULL, TRUE, NOW(), NOW()) + """, 2L, "host1", passwordEncoder.encode("hostPassword"), + "호스트1", "010-1111-2222", "host1@example.com"); + + jdbcTemplate.update(""" + INSERT INTO host_profiles (b_name, is_active, user_id) + VALUES (?, TRUE, ?) + """, "테킷 엔터프라이즈", 2L); + } + + private void insertUser() { + if (existsUser(3L)) return; + + jdbcTemplate.update(""" + INSERT INTO users + (user_id, login_id, login_pw, name, phone, email, role, oauth_provider, oauth_provider_id, is_email_verified, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'USER', 'LOCAL', NULL, TRUE, NOW(), NOW()) + """, 3L, "user1", passwordEncoder.encode("userPassword"), + "홍길동", "010-3333-4444", "user1@example.com"); + + jdbcTemplate.update(""" + INSERT INTO user_profiles (resident_num, age, gender, birth, is_active, user_id) + VALUES (?, ?, ?, ?, TRUE, ?) + """, "990101-1", 27, "MALE", "1999-01-01", 3L); + + Long profileId = jdbcTemplate.queryForObject( + "SELECT u_id FROM user_profiles WHERE user_id = ?", + Long.class, 3L); + + jdbcTemplate.update(""" + INSERT INTO addresses (address, zip_code, name, phone, is_default, u_id, is_geocoded) + VALUES (?, ?, ?, ?, TRUE, ?, "PENDING") + """, "서울 강남구 봉은사로81길 8", "06086", "홍길동", "010-3333-4444", profileId); + } +} \ No newline at end of file diff --git a/src/main/java/com/tekcit/festival/utils/ResidentUtil.java b/src/main/java/com/tekcit/festival/utils/ResidentUtil.java index eb5b001..90b82c8 100644 --- a/src/main/java/com/tekcit/festival/utils/ResidentUtil.java +++ b/src/main/java/com/tekcit/festival/utils/ResidentUtil.java @@ -1,6 +1,8 @@ package com.tekcit.festival.utils; import com.tekcit.festival.domain.user.enums.UserGender; +import com.tekcit.festival.exception.BusinessException; +import com.tekcit.festival.exception.ErrorCode; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -8,12 +10,14 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class ResidentUtil { + private static final int MAX_AGE = 120; + public static int calcAge(String residentNum){ if (residentNum == null || !residentNum.contains("-")) { - throw new IllegalArgumentException("올바르지 않은 주민번호 형식입니다."); + throw new BusinessException(ErrorCode.INVALID_RESIDENT_NUMBER); } - String[] rArray = residentNum.split("-"); + String[] rArray = residentNum.split("-", 2); String birth = rArray[0]; char gender = rArray[1].charAt(0); @@ -26,19 +30,24 @@ public static int calcAge(String residentNum){ case '3': case '4': case '7': case '8': year += 2000; break; - case '9': case '0': - year += 1800; - break; default: - throw new IllegalArgumentException("올바르지 않은 성별 코드입니다."); + throw new BusinessException(ErrorCode.INVALID_RESIDENT_NUMBER); } + int currentYear = LocalDate.now().getYear(); - return currentYear-year+1; + if(currentYearMAX_AGE) + throw new BusinessException(ErrorCode.INVALID_RESIDENT_NUMBER); + + return age; } public static String calcBirth(String residentNum){ if (residentNum == null || !residentNum.contains("-")) { - throw new IllegalArgumentException("올바르지 않은 주민번호 형식입니다."); + throw new BusinessException(ErrorCode.INVALID_RESIDENT_NUMBER); } String[] rArray = residentNum.split("-"); diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index ec9ad62..6475210 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -45,16 +45,22 @@ jwt.public-pem-path=classpath:keys/public.pem jwt.access-valid-ms=1800000 #jwt.access-valid-ms=180000000000000 +#3m +#jwt.access-valid-ms=180000 + #refresh 15? jwt.refresh-valid-ms=1296000000 jwt.issuer=festival-user-service +#2m +login.confirm.valid-ms = 120000 + #kakao signup ticket signup.ticket.valid-ms=600000 # Kakao OAuth -kakao.client-id=${KAKAO_REST_API_KEY} +kakao.restapi-key=${KAKAO_REST_API_KEY} kakao.admin-key=${KAKAO_ADMIN_KEY} kakao.redirect-uri=${KAKAO_REDIRECT_URL:http://localhost:10000}/api/auth/kakao/callback kakao.authorize-uri=https://kauth.kakao.com/oauth/authorize @@ -62,6 +68,14 @@ kakao.token-uri=https://kauth.kakao.com/oauth/token kakao.userinfo-uri=https://kapi.kakao.com/v2/user/me kakao.scope=account_email +# Kakao Map +kakao.search-base-url=https://dapi.kakao.com + +#10s +user.geocode.initial-delay-ms=10000 +#60m +user.geocode.fixed-delay-ms=3600000 + #app.frontend.signup-url=/mock/signup.html #app.frontend.login-url=/mock/login.html @@ -70,11 +84,27 @@ app.frontend.login-url=${FRONTEND_URL:http://localhost:5173} spring.config.import=optional:file:.env[.properties] -service.reservation.url=http://localhost:8082 +service.reservation.url=http://localhost:8082 firebase.key-path=${FIREBASE_KEY_PATH:classpath:firebase/firebase-adminsdk.json} -#api -base.service.url=${BASE_API} +# API base (게이트웨이 기본값 제공) +#base.service.url=${BASE_API:http://localhost:10000} booking.base.service.url=${BOOKING_BASE_API} -booking.user.list.service.url=${BOOKING_USER_LIST_API} \ No newline at end of file +#booking.user.list.service.url=${BOOKING_USER_LIST_API} + +# ===== Actuator/Prometheus & 메트릭 히스토그램 ===== +management.endpoints.web.exposure.include=health,info,prometheus +management.endpoint.health.show-details=always +management.endpoint.health.probes.enabled=true +management.metrics.tags.application=${spring.application.name} + +# HTTP 서버 지연 히스토그램(p95 계산용) +management.metrics.distribution.percentiles-histogram.http.server.requests=true + +# Hikari 커넥션 획득 지연 히스토그램(p95) +management.metrics.distribution.percentiles-histogram.hikaricp.connections.acquire=true +management.metrics.distribution.sla.hikaricp.connections.acquire=5ms,10ms,20ms,50ms,100ms,200ms,500ms + +# Tomcat 메트릭(MBean) 활성화 +server.tomcat.mbeanregistry.enabled=true \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ac8db3f..029110c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,9 +1,6 @@ spring.application.name=festival-service spring.profiles.active= dev -#log file -logging.file.path=logs - springdoc.api-docs.enabled=true springdoc.swagger-ui.enabled=true springdoc.swagger-ui.path=/swagger-ui.html diff --git a/src/main/resources/firebase-messaging-sw.js b/src/main/resources/firebase-messaging-sw.js deleted file mode 100644 index f9d559f..0000000 --- a/src/main/resources/firebase-messaging-sw.js +++ /dev/null @@ -1,44 +0,0 @@ -importScripts('https://www.gstatic.com/firebasejs/10.7.0/firebase-app-compat.js'); -importScripts('https://www.gstatic.com/firebasejs/10.7.0/firebase-messaging-compat.js'); - -// 1. Firebase 설정 (메인 HTML 파일의 설정과 동일) -const firebaseConfig = { - apiKey: "AIzaSyBJ9T7mt7CtwZm1E89qLK-1XeRitcwV-Es", - authDomain: "fcmtest-bd402.firebaseapp.com", - projectId: "fcmtest-bd402", - storageBucket: "fcmtest-bd402.firebasestorage.app", - messagingSenderId: "603915203012", - appId: "1:603915203012:web:fb00e2ef0dab3fb51ef491" -}; - -// 2. Firebase 앱 초기화 -firebase.initializeApp(firebaseConfig); -const messaging = firebase.messaging(); - -// 3. 백그라운드 메시지 수신 처리 -messaging.onBackgroundMessage((payload) => { - console.log('[firebase-messaging-sw.js] 백그라운드 메시지 수신:', payload); - - // 알림 페이로드 구성 - const notificationTitle = payload.notification.title; - const notificationOptions = { - body: payload.notification.body, - icon: '/firebase-logo.png' // 알림 아이콘 (프로젝트에 맞게 경로를 수정하세요) - }; - - // 알림 표시 - self.registration.showNotification(notificationTitle, notificationOptions); -}); - -// 4. FCM 토큰 발급 -messaging.getToken({ - vapidKey: 'BF5YpNLRHPs9tJiv-Se3mIj4ORE7PdZ_q761BsWXCivfkYmMYFGsR1PDNTlKKZ1ho6r3s-79LWUaYF3Px2EQu6Q' -}).then((currentToken) => { - if (currentToken) { - console.log('FCM Registration Token:', currentToken); - } else { - console.log('No registration token available. Request permission to generate one.'); - } -}).catch((err) => { - console.log('An error occurred while retrieving token. ', err); -}); diff --git a/src/main/resources/index.html b/src/main/resources/index.html deleted file mode 100644 index 188445b..0000000 --- a/src/main/resources/index.html +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - FCM 테스트 페이지 - - - - - - - -
-
- -
-

FCM 푸시 알림 테스트

-

- 아래 버튼을 누르면 푸시 알림 권한을 요청하고, 토큰을 발급받아 서버에 전송합니다. -

- - - -
- 상태: 대기 중... -
-
- - - - - - - - \ No newline at end of file diff --git a/src/main/resources/static/fcm-test.html b/src/main/resources/static/fcm-test.html deleted file mode 100644 index d80cf30..0000000 --- a/src/main/resources/static/fcm-test.html +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - FCM 테스트 페이지 - - - - - - - -
-
- -
-

FCM 푸시 알림 테스트

-

- 아래 버튼을 누르면 푸시 알림 권한을 요청하고, 토큰을 발급받아 서버에 전송합니다. -

- - - -
- 상태: 대기 중... -
-
- - - - - - - - diff --git a/src/main/resources/static/main.js b/src/main/resources/static/main.js deleted file mode 100644 index fa4fa5c..0000000 --- a/src/main/resources/static/main.js +++ /dev/null @@ -1,56 +0,0 @@ -const firebaseConfig = { - apiKey: "AIzaSyBJ9T7mt7CtwZm1E89qLK-1XeRitcwV-Es", - authDomain: "fcmtest-bd402.firebaseapp.com", - projectId: "fcmtest-bd402", - storageBucket: "fcmtest-bd402.firebasestorage.app", - messagingSenderId: "603915203012", - appId: "1:603915203012:web:fb00e2ef0dab3fb51ef491" -}; - -// ✅ compat 방식으로 초기화 -firebase.initializeApp(firebaseConfig); - -const messaging = firebase.messaging(); - -// 알림 권한 요청 및 토큰 발급 -function requestPushPermission() { - navigator.serviceWorker.register("/firebase-messaging-sw.js") - .then((registration) => { - Notification.requestPermission().then((permission) => { - if (permission === "granted") { - messaging.getToken({ - vapidKey: "BFD_AQGpmHFhetKPS9Y3SKeF9j5iLdxF6v1gvMvYSCvDRRlRV3MkgpZKoPuKAd-LvkvY2cyXSuxcLkdDwoz6RdE", - serviceWorkerRegistration: registration - }).then((token) => { - console.log("✅ FCM Token:", token); - alert("📱 모바일 FCM 토큰:\n" + token); - - //fetch("https://47279e5b2b9a.ngrok-free.app/api/fcm-token", { - fetch("http://localhost:100000/api/users/fcm-token", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ token }) - }); - - document.body.insertAdjacentHTML("beforeend", `

FCM Token:
${token}

`); - }).catch((err) => { - console.error("❌ 토큰 가져오기 실패:", err); - }); - } else { - console.warn("🔒 알림 권한 거부됨"); - } - }); - }).catch((err) => { - console.error("❌ Service Worker 등록 실패:", err); - }); -} - -// 포그라운드 메시지 수신 처리 -messaging.onMessage((payload) => { - console.log("🔔 Foreground Message:", payload); - new Notification(payload.notification.title, { - body: payload.notification.body - }); -}); \ No newline at end of file