diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 446c193..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 5cd9a10..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml deleted file mode 100644 index 2b63946..0000000 --- a/.idea/uiDesigner.xml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/build.gradle b/build.gradle index 6c082ec..4cdf2d3 100644 --- a/build.gradle +++ b/build.gradle @@ -26,37 +26,64 @@ repositories { dependencies { // WebClient (비동기 HTTP 통신) implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Web (동기 HTTP 서버 및 Controller 등) implementation 'org.springframework.boot:spring-boot-starter-web' + + // JSON 파싱 및 생성 라이브러리 추가 + implementation 'org.json:json:20231013' + // JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // Security implementation 'org.springframework.boot:spring-boot-starter-security' + + // OAuth2 Client (소셜 로그인용) + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.2' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.2' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.2' + + // Thymeleaf + Spring Security 연동 + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // DevTools developmentOnly 'org.springframework.boot:spring-boot-devtools' + // DB Driver runtimeOnly 'org.postgresql:postgresql' + // Swagger (API 문서 자동화) implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' + // PostGIS (공간 처리 라이브러리) implementation 'org.locationtech.jts:jts-core:1.18.2' - implementation 'org.hibernate:hibernate-spatial' + implementation 'org.hibernate:hibernate-spatial:6.6.18.Final' + // AI - Gemini API 연동 implementation 'com.squareup.okhttp3:okhttp:4.9.3' implementation 'com.google.code.gson:gson:2.8.9' + + // dotenv (환경변수 ..env 자동 로드) + implementation 'io.github.cdimascio:dotenv-java:3.0.0' + // 테스트 testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' - testImplementation 'org.mockito:mockito-inline:5.2.0' // static, final mocking + testImplementation 'org.mockito:mockito-inline:5.2.0' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { useJUnitPlatform() jvmArgs += [ - "-javaagent:${classpath.find { it.name.contains('byte-buddy-agent') || it.name.contains('mockito-inline') }.absolutePath}" + "-javaagent:${classpath.find { it.name.contains('byte-buddy-agent') || it.name.contains('mockito-inline') }.absolutePath}" ] -} +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/SignupApplication.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/User/SignupApplication.java deleted file mode 100644 index 239a2a3..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/SignupApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.signup; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class SignupApplication { - - public static void main(String[] args) { - SpringApplication.run(SignupApplication.class, args); - } - -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/controller/UserController.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/controller/UserController.java deleted file mode 100644 index f43bde3..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/controller/UserController.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.example.signup.controller; - -import com.example.signup.dto.*; -import com.example.signup.entity.User; -import com.example.signup.service.UserService; -import com.example.signup.util.JwtUtil; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; - -@RestController -@RequestMapping("/api/users") -public class UserController { - @Autowired - private UserService userService; - - @PostMapping("/register") - public CommonResponse register(@RequestBody UserRegisterRequest request) { - try { - User user = userService.register(request); - return new CommonResponse<>(false, user); - } catch (IllegalStateException e) { - return new CommonResponse<>(true, e.getMessage()); - } catch (Exception e) { - return new CommonResponse<>(true, "회원가입 처리 중 오류가 발생했습니다: " + e.getMessage()); - } - } - - @PostMapping("/login") - public CommonResponse login(@RequestBody LoginRequest request) { - try { - User user = userService.login(request.getEmail(), request.getPassword()); - - if (user == null) { - return new CommonResponse<>(true, "이메일 또는 비밀번호가 일치하지 않습니다."); - } - - String token = JwtUtil.generateToken(user.getUsername()); - LoginResponse response = new LoginResponse(user, token); - - return new CommonResponse<>(false, response); - - } catch (Exception e) { - return new CommonResponse<>(true, "로그인 처리 중 오류가 발생했습니다: " + e.getMessage()); - } - } - @PutMapping("/update") - public CommonResponse updateUserInfo( - @RequestHeader("Authorization") String authHeader, - @RequestBody Map updates) { - try { - String token = authHeader.startsWith("Bearer ") ? authHeader.substring(7) : authHeader; - String username = JwtUtil.extractUsername(token); - - User updatedUser = userService.updateUserInfoByMap(username, updates); - - return new CommonResponse<>(false, updatedUser); - } catch (IllegalStateException e) { - return new CommonResponse<>(true, e.getMessage()); - } catch (Exception e) { - return new CommonResponse<>(true, "유저 정보 수정 중 오류가 발생했습니다: " + e.getMessage()); - } - } -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/CommonResponse.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/CommonResponse.java deleted file mode 100644 index dccc4b0..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/CommonResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.signup.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class CommonResponse { - private boolean error; - private T data; -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/LoginRequest.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/LoginRequest.java deleted file mode 100644 index 4b3c43b..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/LoginRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.signup.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class LoginRequest { - private String email; - private String password; -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/LoginResponse.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/LoginResponse.java deleted file mode 100644 index d5e5dad..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/LoginResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.signup.dto; - -import com.example.signup.entity.User; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class LoginResponse { - private User user; - private String jwt; -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/UserRegisterRequest.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/UserRegisterRequest.java deleted file mode 100644 index 1fcf8d0..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/dto/UserRegisterRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.signup.dto; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor // ✅ 모든 필드 생성자 자동 생성 -public class UserRegisterRequest { - private String username; - private String email; - private String password; - private String intro; -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/entity/User.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/entity/User.java deleted file mode 100644 index 031cda6..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/entity/User.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.signup.entity; - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.CreationTimestamp; -import com.fasterxml.jackson.annotation.JsonFormat; - -import java.time.LocalDate; - -@Entity -@Table(name = "users") -@Data -@AllArgsConstructor -@NoArgsConstructor -public class User { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long uid; - - @Column(unique = true) - private String username; - - private String email; - private String password; - - @Column(length = 500) - private String intro; - - @CreationTimestamp - @Column(updatable = false) - @JsonFormat(pattern = "yyyy-MM-dd", timezone = "Asia/Seoul") // JSON 응답 형식 지정 - private LocalDate createdAt; - - public User(String username, String email, String password, String intro) { - this.username = username; - this.email = email; - this.password = password; - this.intro = intro; - } -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/repository/UserRepository.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/repository/UserRepository.java deleted file mode 100644 index 0efb334..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/repository/UserRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.signup.repository; - -import com.example.signup.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface UserRepository extends JpaRepository { - Optional findByEmailAndPassword(String email, String password); - Optional findByUsername(String username); - boolean existsByUsername(String username); - boolean existsByEmailAndPassword(String email, String password); -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/service/UserService.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/service/UserService.java deleted file mode 100644 index 21b4b9d..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/domain/service/UserService.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.example.signup.service; - -import com.example.signup.dto.UserRegisterRequest; -import com.example.signup.entity.User; -import com.example.signup.repository.UserRepository; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Map; - -@Service -public class UserService { - private final UserRepository repository; - - public UserService(UserRepository repository) { - this.repository = repository; - } - - @Transactional - public User register(UserRegisterRequest request) { - if (repository.existsByUsername(request.getUsername())) { - throw new IllegalStateException("이미 존재하는 사용자명입니다."); - } - - if (repository.existsByEmailAndPassword(request.getEmail(), request.getPassword())) { - throw new IllegalStateException("이미 존재하는 이메일과 비밀번호 조합입니다."); - } - - // 생성일자(createdAt)는 자동 생성되므로 포함하지 않음 - User user = new User( - request.getUsername(), - request.getEmail(), - request.getPassword(), - request.getIntro() - ); - - return repository.save(user); - } - - @Transactional(readOnly = true) - public User login(String email, String password) { - return repository.findByEmailAndPassword(email, password) - .orElse(null); - } - @Transactional - public User updateUserInfoByMap(String username, Map updates) { - User user = repository.findByUsername(username) - .orElseThrow(() -> new IllegalStateException("사용자를 찾을 수 없습니다.")); - - for (Map.Entry entry : updates.entrySet()) { - String key = entry.getKey(); - Object value = entry.getValue(); - - switch (key) { - case "username": - throw new IllegalArgumentException("username은 수정할 수 없습니다."); - case "email": - user.setEmail((String) value); - break; - case "password": - user.setPassword((String) value); - break; - case "intro": - user.setIntro((String) value); - break; - default: - throw new IllegalArgumentException("수정할 수 없는 필드: " + key); - } - } - - return repository.save(user); - } -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/global/config/WebConfig.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/User/global/config/WebConfig.java deleted file mode 100644 index 18709d6..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/global/config/WebConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.example.signup.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebConfig implements WebMvcConfigurer { - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/api/**") // API 경로에만 CORS 설정 - .allowedOrigins("http://localhost:5173") // React 개발 서버 - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 허용 메서드 - .allowedHeaders("*") // 모든 요청 헤더 허용 - .allowCredentials(true) // 인증 정보 허용 (쿠키, 세션 등) - .maxAge(3600); // 1시간 동안 preflight 요청 결과 캐시 - } -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/global/util/JwtUtil.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/User/global/util/JwtUtil.java deleted file mode 100644 index 7e85db4..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/User/global/util/JwtUtil.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.example.signup.util; - -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.security.Keys; - -import java.security.Key; -import java.util.Date; - -public class JwtUtil { - private static final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256); - private static final long EXPIRATION_TIME = 86400000L; // 1일 - - public static String generateToken(String username) { - return Jwts.builder() - .setSubject(username) - .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) - .signWith(key) - .compact(); - } - - public static String extractUsername(String token) { - return Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody() - .getSubject(); - } -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/DisasterAlert/controller/DisasterAlertController.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/DisasterAlert/controller/DisasterAlertController.java new file mode 100644 index 0000000..f86f2d3 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/DisasterAlert/controller/DisasterAlertController.java @@ -0,0 +1,32 @@ +package HeyDoctor.HeyDoctor_Backend.domain.DisasterAlert.controller; + +import HeyDoctor.HeyDoctor_Backend.domain.DisasterAlert.dto.DisasterAlertResponseDto; +import HeyDoctor.HeyDoctor_Backend.domain.DisasterAlert.service.DisasterAlertService; +import HeyDoctor.HeyDoctor_Backend.domain.location.dto.LocationRequestDto; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/disaster-alert") +public class DisasterAlertController { + + private final DisasterAlertService disasterAlertService; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public Mono> getAlerts( + @RequestParam("lat") double latitude, + @RequestParam("lon") double longitude) { + + LocationRequestDto request = new LocationRequestDto(latitude, longitude); + return disasterAlertService.getAlertsByCoords(request); + } +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/DisasterAlert/dto/DisasterAlertResponseDto.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/DisasterAlert/dto/DisasterAlertResponseDto.java new file mode 100644 index 0000000..a138127 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/DisasterAlert/dto/DisasterAlertResponseDto.java @@ -0,0 +1,24 @@ +package HeyDoctor.HeyDoctor_Backend.domain.DisasterAlert.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class DisasterAlertResponseDto { + private String title; // 재난문자 제목 + private String message; // 재난문자 내용 + private String location; // 재난문자 발생 지역 + private String sendTime; // 발송 시간 + + public static DisasterAlertResponseDto fromApiData( + String title, + String message, + String location, + String sendTime + ) { + return new DisasterAlertResponseDto(title, message, location, sendTime); + } +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/DisasterAlert/service/DisasterAlertService.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/DisasterAlert/service/DisasterAlertService.java new file mode 100644 index 0000000..05ca6cc --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/DisasterAlert/service/DisasterAlertService.java @@ -0,0 +1,121 @@ +package HeyDoctor.HeyDoctor_Backend.domain.DisasterAlert.service; + +import HeyDoctor.HeyDoctor_Backend.domain.DisasterAlert.dto.DisasterAlertResponseDto; +import HeyDoctor.HeyDoctor_Backend.domain.location.dto.LocationRequestDto; +import HeyDoctor.HeyDoctor_Backend.domain.location.dto.LocationResponseDto; +import HeyDoctor.HeyDoctor_Backend.domain.location.service.LocationService; +import HeyDoctor.HeyDoctor_Backend.domain.location.util.RegionMatcher; +import HeyDoctor.HeyDoctor_Backend.global.exception.ErrorCode; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +@Service +public class DisasterAlertService { + + private static final Logger logger = LoggerFactory.getLogger(DisasterAlertService.class); + + private final LocationService locationService; + private final WebClient webClient; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${api.alert.key}") + private String alertApiKey; + + public DisasterAlertService(LocationService locationService, WebClient.Builder webClientBuilder) { + this.locationService = locationService; + this.webClient = webClientBuilder.baseUrl("https://www.safetydata.go.kr/V2/api").build(); + } + + public Mono> getAlertsByCoords(LocationRequestDto request) { + return locationService.getAdministrativeRegion(request) + .flatMap(userLocation -> { + if (userLocation == null) { + return Mono.error(new RuntimeException(ErrorCode.BAD_REQUEST.getMessage())); + } + return fetchDisasterAlertsFromApi(userLocation) + .map(alerts -> { + // 1. 필터링된 재난문자 리스트 생성 + List matchedAlerts = new ArrayList<>(); + for (DisasterAlertResponseDto alert : alerts) { + if (RegionMatcher.matchRegion(alert.getLocation(), userLocation)) { + matchedAlerts.add(alert); + } + } + + // 2. 최종 결과를 담을 새로운 리스트 생성 + List finalResponse = new ArrayList<>(); + + // 3. 사용자 위치 정보로 가상의 DTO를 생성 + String userLocationString = (userLocation.getSido() + " " + userLocation.getSigungu() + " " + userLocation.getEupmyeondong()).trim(); + DisasterAlertResponseDto locationInfo = new DisasterAlertResponseDto( + "현재 위치", + "요청하신 좌표의 행정구역 정보입니다.", + userLocationString, + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss")) + ); + + // 4. 최종 리스트의 맨 앞에 위치 정보를 추가 + finalResponse.add(locationInfo); + + // 5. 그 뒤에 필터링된 실제 재난문자들을 추가 + finalResponse.addAll(matchedAlerts); + + return finalResponse; + }); + }) + .switchIfEmpty(Mono.error(new RuntimeException(ErrorCode.BAD_REQUEST.getMessage()))); + } + + private Mono> fetchDisasterAlertsFromApi(LocationResponseDto location) { + String rgnNm = URLEncoder.encode(location.getSido() + " " + location.getSigungu(), StandardCharsets.UTF_8); + String startDate = LocalDate.now().minusDays(7).format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/DSSP-IF-00247") + .queryParam("serviceKey", alertApiKey) + .queryParam("pageNo", "1") + .queryParam("numOfRows", "100") + .queryParam("returnType", "json") + .queryParam("crtDt", startDate) + .queryParam("rgnNm", rgnNm) + .build()) + .retrieve() + .bodyToMono(String.class) + .handle((rawJson, sink) -> { + List alerts = new ArrayList<>(); + try { + JsonNode root = objectMapper.readTree(rawJson); + JsonNode items = root.path("body"); + + if (items.isArray()) { + for (JsonNode item : items) { + String title = item.path("EMRG_STEP_NM").asText(); + String message = item.path("MSG_CN").asText(); + String loc = item.path("RCPTN_RGN_NM").asText(); + String sendTime = item.path("CRT_DT").asText(); + alerts.add(DisasterAlertResponseDto.fromApiData(title, message, loc, sendTime)); + } + } + sink.next(alerts); + } catch (Exception e) { + logger.error("Failed to parse disaster alert JSON", e); + sink.error(new RuntimeException(ErrorCode.INTERNAL_SERVER_ERROR.getMessage())); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/Controller/SocialLoginController.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/Controller/SocialLoginController.java new file mode 100644 index 0000000..591b7b6 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/Controller/SocialLoginController.java @@ -0,0 +1,26 @@ +package HeyDoctor.HeyDoctor_Backend.domain.User.Controller; + +import HeyDoctor.HeyDoctor_Backend.domain.User.Dto.SocialLoginRequest; +import HeyDoctor.HeyDoctor_Backend.domain.User.Service.SocialLoginService; +import HeyDoctor.HeyDoctor_Backend.global.exception.responce.ApiResponse; +import HeyDoctor.HeyDoctor_Backend.global.exception.SuccessCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class SocialLoginController { + + private final SocialLoginService socialLoginService; + + @PostMapping("/social-login") + public ResponseEntity>> socialLogin(@RequestBody SocialLoginRequest request) { + Map result = socialLoginService.processSocialLogin(request.getProvider(), request.getToken()); + return ApiResponse.onSuccess(SuccessCode.LOGIN_SUCCESS, result); + + } +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/Dto/SocialLoginRequest.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/Dto/SocialLoginRequest.java new file mode 100644 index 0000000..7d0fd9f --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/Dto/SocialLoginRequest.java @@ -0,0 +1,12 @@ +package HeyDoctor.HeyDoctor_Backend.domain.User.Dto; + +import lombok.Getter; +import lombok.Setter; + +// DTO 클래스 +@Getter +@Setter +public class SocialLoginRequest { + private String provider; // "google", "kakao", "naver" + private String token; // SDK에서 받은 토큰 (idToken 또는 accessToken) +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/Service/SocialLoginService.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/Service/SocialLoginService.java new file mode 100644 index 0000000..70ce501 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/Service/SocialLoginService.java @@ -0,0 +1,113 @@ +package HeyDoctor.HeyDoctor_Backend.domain.User.Service; + +import HeyDoctor.HeyDoctor_Backend.domain.User.oauth.UserInfo.*; +import HeyDoctor.HeyDoctor_Backend.domain.User.entity.User; +import HeyDoctor.HeyDoctor_Backend.domain.User.repository.UserRepository; +import HeyDoctor.HeyDoctor_Backend.global.exception.CustomException; +import HeyDoctor.HeyDoctor_Backend.global.exception.ErrorCode; +import HeyDoctor.HeyDoctor_Backend.global.security.jwt.RefreshToken.entity.RefreshToken; +import HeyDoctor.HeyDoctor_Backend.global.security.jwt.RefreshToken.repository.RefreshTokenRepository; +import HeyDoctor.HeyDoctor_Backend.global.security.jwt.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class SocialLoginService { + + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + + @Value("${jwt.access-token.expiration-time}") + private long ACCESS_TOKEN_EXPIRATION_TIME; + + @Value("${jwt.refresh-token.expiration-time}") + private long REFRESH_TOKEN_EXPIRATION_TIME; + + public Map processSocialLogin(String provider, String token) { + OAuth2UserInfo userInfo = verifyTokenAndGetUserInfo(provider, token); + + String providerId = userInfo.getProviderId(); + String name = userInfo.getName(); + + User existUser = userRepository.findByProviderId(providerId); + User user; + if (existUser == null) { + user = User.builder() + .userId(UUID.randomUUID()) + .name(name) + .provider(provider) + .providerId(providerId) + .build(); + userRepository.save(user); + } else { + refreshTokenRepository.deleteByUserId(existUser.getUserId()); + user = existUser; + } + + String refreshToken = jwtUtil.generateRefreshToken(user.getUserId(), REFRESH_TOKEN_EXPIRATION_TIME); + RefreshToken newRefreshToken = RefreshToken.builder() + .userId(user.getUserId()) + .token(refreshToken) + .build(); + refreshTokenRepository.save(newRefreshToken); + + String accessToken = jwtUtil.generateAccessToken(user.getUserId(), ACCESS_TOKEN_EXPIRATION_TIME); + + return Map.of( + "name", name, + "accessToken", accessToken, + "refreshToken", refreshToken + ); + } + + private OAuth2UserInfo verifyTokenAndGetUserInfo(String provider, String token) { + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); // Authorization: Bearer {token} + HttpEntity entity = new HttpEntity<>(headers); + + try { + switch (provider.toLowerCase()) { + case "google": + String googleUrl = "https://www.googleapis.com/oauth2/v3/userinfo"; + ResponseEntity googleResponse = restTemplate.exchange(googleUrl, HttpMethod.GET, entity, Map.class); + Map googleAttributes = googleResponse.getBody(); + return new GoogleUserInfo(googleAttributes); + + case "kakao": + String kakaoUrl = "https://kapi.kakao.com/v2/user/me"; + ResponseEntity kakaoResponse = restTemplate.exchange(kakaoUrl, HttpMethod.GET, entity, Map.class); + Map kakaoAttributes = kakaoResponse.getBody(); + return new KakaoUserInfo(kakaoAttributes); + + case "naver": + String naverUrl = "https://openapi.naver.com/v1/nid/me"; + ResponseEntity naverResponse = restTemplate.exchange(naverUrl, HttpMethod.GET, entity, Map.class); + Map naverBody = naverResponse.getBody(); + if (naverBody != null && "success".equals(naverBody.get("message"))) { + Map naverAttributes = (Map) naverBody.get("response"); + return new NaverUserInfo(naverAttributes); + } else { + throw new CustomException(ErrorCode.INVALID_SOCIAL_TOKEN); + } + + default: + throw new CustomException(ErrorCode.INVALID_SOCIAL_TOKEN); + } + } catch (Exception e) { + throw new CustomException(ErrorCode.INVALID_SOCIAL_TOKEN); + } + } + +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/entity/User.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/entity/User.java new file mode 100644 index 0000000..389f59c --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/entity/User.java @@ -0,0 +1,42 @@ +package HeyDoctor.HeyDoctor_Backend.domain.User.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@Getter +@Table(name = "users") +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "users_id") + private Long id; + + @Column(name = "users_uuid", columnDefinition = "uuid", unique = true) + private UUID userId; + + @Column(name = "name", nullable = false, length = 5) + private String name; + + @Column(name = "provider", nullable = false, length = 10) + private String provider; + + @Column(name = "provider_id", nullable = false, length = 50) + private String providerId; + + @CreationTimestamp + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/GoogleUserInfo.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/GoogleUserInfo.java new file mode 100644 index 0000000..d555598 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/GoogleUserInfo.java @@ -0,0 +1,26 @@ +package HeyDoctor.HeyDoctor_Backend.domain.User.oauth.UserInfo; + +import java.util.Map; + +public class GoogleUserInfo implements OAuth2UserInfo { + private final Map attributes; + + public GoogleUserInfo(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProviderId() { + return (String) attributes.get("sub"); + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } + + @Override + public String getName() { + return (String) attributes.get("name"); + } +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/KakaoUserInfo.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/KakaoUserInfo.java new file mode 100644 index 0000000..fce959d --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/KakaoUserInfo.java @@ -0,0 +1,29 @@ +package HeyDoctor.HeyDoctor_Backend.domain.User.oauth.UserInfo; + +import java.util.Map; + +public class KakaoUserInfo implements OAuth2UserInfo { + private final Map attributes; + + public KakaoUserInfo(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProviderId() { + return String.valueOf(attributes.get("id")); + } + + @Override + public String getEmail() { + Map kakaoAccount = (Map) attributes.get("kakao_account"); + return (String) kakaoAccount.get("email"); + } + + @Override + public String getName() { + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + return (String) profile.get("nickname"); + } +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/NaverUserInfo.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/NaverUserInfo.java new file mode 100644 index 0000000..bc1357a --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/NaverUserInfo.java @@ -0,0 +1,27 @@ +package HeyDoctor.HeyDoctor_Backend.domain.User.oauth.UserInfo; + +import java.util.Map; + +public class NaverUserInfo implements OAuth2UserInfo { + private final Map attributes; + + public NaverUserInfo(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProviderId() { + return (String) attributes.get("id"); + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } + + @Override + public String getName() { + return (String) attributes.get("nickname"); + } +} + diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/OAuth2UserInfo.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/OAuth2UserInfo.java new file mode 100644 index 0000000..9930db7 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/oauth/UserInfo/OAuth2UserInfo.java @@ -0,0 +1,8 @@ +package HeyDoctor.HeyDoctor_Backend.domain.User.oauth.UserInfo; + +public interface OAuth2UserInfo { + String getProviderId(); + String getEmail(); + String getName(); + +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/repository/UserRepository.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/repository/UserRepository.java new file mode 100644 index 0000000..0197df4 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/User/repository/UserRepository.java @@ -0,0 +1,16 @@ +package HeyDoctor.HeyDoctor_Backend.domain.User.repository; +import HeyDoctor.HeyDoctor_Backend.domain.User.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserRepository extends JpaRepository { + @Query("SELECT u FROM User u WHERE u.userId = :userId") + Optional findByUserId(UUID userId); + + User findByProviderId(String providerId); +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/gemini/controller/GeminiController.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/gemini/controller/GeminiController.java new file mode 100644 index 0000000..54548de --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/gemini/controller/GeminiController.java @@ -0,0 +1,30 @@ +package HeyDoctor.HeyDoctor_Backend.domain.gemini.controller; + +import HeyDoctor.HeyDoctor_Backend.domain.gemini.service.GeminiService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/gemini") +public class GeminiController { + + private final GeminiService geminiService; + + @PostMapping("/simple") + public Mono> callGemini(@RequestBody String input) { + return geminiService.generateSimpleResponse(input) + .map(ResponseEntity::ok) + .defaultIfEmpty(ResponseEntity.status(HttpStatus.NO_CONTENT).body("No response from Gemini")) + .onErrorResume(e -> { + System.err.println("Error calling Gemini API: " + e.getMessage()); + return Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error processing request: " + e.getMessage())); + }); + } +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/gemini/service/GeminiService.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/gemini/service/GeminiService.java new file mode 100644 index 0000000..b477da6 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/gemini/service/GeminiService.java @@ -0,0 +1,63 @@ +package HeyDoctor.HeyDoctor_Backend.domain.gemini.service; + +import HeyDoctor.HeyDoctor_Backend.global.exception.ErrorCode; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import java.util.List; +import java.util.Map; + +@Service +public class GeminiService { + + private static final Logger log = LoggerFactory.getLogger(GeminiService.class); + + private final WebClient webClient; + private final String apiKey; + private final ObjectMapper objectMapper; + + public GeminiService(@Value("${gemini.api-key}") String apiKey) { + this.apiKey = apiKey; + this.webClient = WebClient.create("https://generativelanguage.googleapis.com"); + this.objectMapper = new ObjectMapper(); + } + + public Mono generateSimpleResponse(String input) { + Map body = Map.of( + "contents", List.of(Map.of( + "parts", List.of(Map.of("text", "간단히 대답해줘:\n\n" + input)) + )) + ); + + return webClient.post() + .uri(uriBuilder -> uriBuilder + .path("/v1beta/models/gemini-1.5-flash-latest:generateContent") + .queryParam("key", apiKey) + .build()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .bodyToMono(String.class) + .flatMap(rawJson -> { + log.info("Gemini raw response: {}", rawJson); + + try { + JsonNode root = objectMapper.readTree(rawJson); + JsonNode textNode = root + .path("candidates").get(0) + .path("content").path("parts").get(0) + .path("text"); + return Mono.just(textNode.asText("")); + } catch (Exception e) { + log.error("Failed to parse Gemini response", e); + return Mono.error(new RuntimeException(ErrorCode.INTERNAL_SERVER_ERROR.getMessage())); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/dto/LocationRequestDto.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/dto/LocationRequestDto.java new file mode 100644 index 0000000..3322af2 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/dto/LocationRequestDto.java @@ -0,0 +1,20 @@ +package HeyDoctor.HeyDoctor_Backend.domain.location.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LocationRequestDto { + private double latitude; + private double longitude; + + // 좌표를 바로 받을 수 있는 생성자 추가 + public LocationRequestDto(double latitude, double longitude) { + this.latitude = latitude; + this.longitude = longitude; + } + + // 기본 생성자 (롬복 또는 수동) + public LocationRequestDto() {} +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/dto/LocationResponseDto.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/dto/LocationResponseDto.java new file mode 100644 index 0000000..cdcc344 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/dto/LocationResponseDto.java @@ -0,0 +1,23 @@ +package HeyDoctor.HeyDoctor_Backend.domain.location.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LocationResponseDto { + private String sido; + private String sigungu; + private String eupmyeondong; + + public LocationResponseDto() {} + + public LocationResponseDto(String sido, String sigungu, String eupmyeondong) { + this.sido = sido; + this.sigungu = sigungu; + this.eupmyeondong = eupmyeondong; + } + + +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/service/LocationService.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/service/LocationService.java new file mode 100644 index 0000000..12c4530 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/service/LocationService.java @@ -0,0 +1,66 @@ +package HeyDoctor.HeyDoctor_Backend.domain.location.service; + +import HeyDoctor.HeyDoctor_Backend.domain.location.dto.LocationRequestDto; +import HeyDoctor.HeyDoctor_Backend.domain.location.dto.LocationResponseDto; +import HeyDoctor.HeyDoctor_Backend.global.exception.ErrorCode; +import org.slf4j.Logger; // 로거 import +import org.slf4j.LoggerFactory; // 로거 import +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.List; + +@Service +public class LocationService { + + // 1. 로거 필드 추가 + private static final Logger logger = LoggerFactory.getLogger(LocationService.class); + + private final WebClient webClient; + + @Value("${api.kakao.rest-key}") + private String KAKAO_API_KEY; + + private final String KAKAO_LOCAL_URL = "https://dapi.kakao.com/v2/local/geo/coord2regioncode.json"; + + public LocationService(WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder.baseUrl(KAKAO_LOCAL_URL).build(); + } + + public Mono getAdministrativeRegion(LocationRequestDto request) { + String query = String.format("x=%s&y=%s", + URLEncoder.encode(String.valueOf(request.getLongitude()), StandardCharsets.UTF_8), + URLEncoder.encode(String.valueOf(request.getLatitude()), StandardCharsets.UTF_8) + ); + + return webClient.get() + .uri("?" + query) + .header("Authorization", "KakaoAK " + KAKAO_API_KEY) + .retrieve() + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> Mono.error(new RuntimeException(ErrorCode.INTERNAL_SERVER_ERROR.getMessage()))) + .bodyToMono(Map.class) + // 2. bodyToMono 다음에 doOnNext를 사용하여 응답 데이터를 로그로 출력 + .doOnNext(responseMap -> logger.info("Response from Kakao API: {}", responseMap)) + .mapNotNull(response -> { + @SuppressWarnings("unchecked") + List> documents = (List>) response.get("documents"); + + if (documents == null || documents.isEmpty()) { + return null; + } + + Map regionInfo = documents.getFirst(); + String sido = (String) regionInfo.get("region_1depth_name"); + String sigungu = (String) regionInfo.get("region_2depth_name"); + String eupmyeondong = (String) regionInfo.get("region_3depth_name"); + + return new LocationResponseDto(sido, sigungu, eupmyeondong); + }); + } +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/util/RegionMatcher.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/util/RegionMatcher.java new file mode 100644 index 0000000..a3b7697 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/location/util/RegionMatcher.java @@ -0,0 +1,31 @@ +package HeyDoctor.HeyDoctor_Backend.domain.location.util; + +import HeyDoctor.HeyDoctor_Backend.domain.location.dto.LocationResponseDto; + +public class RegionMatcher { + + /** + * 재난문자 API의 지역 문자열이 사용자의 행정구역을 포함하는지 확인합니다. + * 공백, 쉼표 등 불규칙한 형식을 처리하여 안정성을 높였습니다. + * + * @param alertRegionString API에서 받은 지역 문자열 (예: "서울특별시 강서구 , 서울특별시 양천구 ") + * @param userLocation 사용자 행정구역 정보 (예: sido="서울특별시", sigungu="강서구") + * @return true: 해당 지역 포함, false: 미포함 + */ + public static boolean matchRegion(String alertRegionString, LocationResponseDto userLocation) { + if (alertRegionString == null || userLocation == null || userLocation.getSido() == null || userLocation.getSigungu() == null) { + return false; + } + + // 1. 사용자 위치를 "시도시군구" 형태의 공백 없는 문자열로 만듭니다. (예: "서울특별시강서구") + String userFullLocation = userLocation.getSido() + userLocation.getSigungu(); + + // 2. 재난문자 수신 지역(alertRegionString)에서 모든 공백을 제거합니다. + // 예: " 서울특별시 강서구 , 서울특별시 양천구 " -> "서울특별시강서구,서울특별시양천구" + String cleanedAlertRegion = alertRegionString.replaceAll("\\s+", ""); + + // 3. 공백이 제거된 재난문자 수신 지역에, 공백 없는 사용자 위치가 포함되어 있는지 확인합니다. + // 예: "서울특별시강서구,서울특별시양천구".contains("서울특별시강서구") -> true + return cleanedAlertRegion.contains(userFullLocation); + } +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/controller/WeatherController.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/controller/WeatherController.java new file mode 100644 index 0000000..97a71c0 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/controller/WeatherController.java @@ -0,0 +1,31 @@ +package HeyDoctor.HeyDoctor_Backend.domain.weather.controller; + +import HeyDoctor.HeyDoctor_Backend.domain.location.dto.LocationRequestDto; +import HeyDoctor.HeyDoctor_Backend.domain.weather.dto.WeatherResponseDto; +import HeyDoctor.HeyDoctor_Backend.domain.weather.service.WeatherService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/weather") +public class WeatherController { + + private final WeatherService weatherService; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public Mono getWeatherByCoords( + @RequestParam("lat") double latitude, + @RequestParam("lon") double longitude) { + + LocationRequestDto request = new LocationRequestDto(latitude, longitude); + return weatherService.getWeatherByCoords(request); + } +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/dto/OpenWeatherApiResponseDto.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/dto/OpenWeatherApiResponseDto.java new file mode 100644 index 0000000..43806cd --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/dto/OpenWeatherApiResponseDto.java @@ -0,0 +1,55 @@ +package HeyDoctor.HeyDoctor_Backend.domain.weather.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +// OpenWeather API 응답의 최상위 객체 +@Getter +@Setter +@NoArgsConstructor +public class OpenWeatherApiResponseDto { + private Main main; + private Wind wind; + private List weather; + private Rain rain; + private Snow snow; + + @Getter + @Setter + public static class Main { + private double temp; + private double feels_like; + private int humidity; + } + + @Getter + @Setter + public static class Wind { + private double speed; + } + + @Getter + @Setter + public static class Weather { + private String main; + private String description; + } + + @Getter + @Setter + public static class Rain { + @JsonProperty("1h") + private double oneHour; + } + + @Getter + @Setter + public static class Snow { + @JsonProperty("1h") + private double oneHour; + } +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/dto/WeatherResponseDto.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/dto/WeatherResponseDto.java new file mode 100644 index 0000000..d65b753 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/dto/WeatherResponseDto.java @@ -0,0 +1,53 @@ +package HeyDoctor.HeyDoctor_Backend.domain.weather.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class WeatherResponseDto { + private String sido; + private String sigungu; + private String eupmyeondong; + private double temperature; // 실제 온도 + private double feelsLikeTemperature; // 체감 온도 + private int humidity; // % + private String precipitation; // "없음", "강수", "강설" + private double windSpeed; // m/s + private String weatherDescription; // 예: "맑음", "흐림", "비" + + /** + * OpenWeatherMap API JSON 데이터를 기반으로 DTO 생성 + */ + public static WeatherResponseDto fromOpenWeatherData( + String sido, + String sigungu, + String eupmyeondong, + double temp, + double feelsLike, + int humidity, + double windSpeed, + String weatherMain, + String weatherDescription, + double rainVolume, + double snowVolume + ) { + String precipitationType = "없음"; + if (rainVolume > 0) precipitationType = "강수"; + else if (snowVolume > 0) precipitationType = "강설"; + + return new WeatherResponseDto( + sido, + sigungu, + eupmyeondong, + temp, + feelsLike, + humidity, + precipitationType, + windSpeed, + weatherDescription + ); + } +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/service/WeatherService.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/service/WeatherService.java new file mode 100644 index 0000000..cdb737c --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/domain/weather/service/WeatherService.java @@ -0,0 +1,89 @@ +package HeyDoctor.HeyDoctor_Backend.domain.weather.service; + +import HeyDoctor.HeyDoctor_Backend.domain.location.dto.LocationRequestDto; +import HeyDoctor.HeyDoctor_Backend.domain.location.dto.LocationResponseDto; +import HeyDoctor.HeyDoctor_Backend.domain.location.service.LocationService; +import HeyDoctor.HeyDoctor_Backend.domain.weather.dto.OpenWeatherApiResponseDto; +import HeyDoctor.HeyDoctor_Backend.domain.weather.dto.WeatherResponseDto; +import HeyDoctor.HeyDoctor_Backend.global.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Service +public class WeatherService { + + private final LocationService locationService; + private final WebClient webClient; + + @Value("${api.weather.key}") + private String openWeatherApiKey; + + private static final String OPENWEATHER_URL = "https://api.openweathermap.org/data/2.5/weather"; + + public WeatherService(LocationService locationService, WebClient.Builder webClientBuilder) { + this.locationService = locationService; + this.webClient = webClientBuilder.baseUrl(OPENWEATHER_URL).build(); + } + + /** + * 좌표를 받아서 날씨 정보 반환 (전체 로직을 비동기적으로 처리) + */ + public Mono getWeatherByCoords(LocationRequestDto request) { + // locationService 호출을 리액티브 체인의 시작점으로 변경 + return locationService.getAdministrativeRegion(request) + .flatMap(location -> { + // location이 null일 경우 BAD_REQUEST 에러 반환 + if (location == null) { + return Mono.error(new RuntimeException(ErrorCode.BAD_REQUEST.getMessage())); + } + + // WebClient를 사용하여 OpenWeatherMap API 호출 + return webClient.get() + .uri(uriBuilder -> uriBuilder + .queryParam("lat", request.getLatitude()) + .queryParam("lon", request.getLongitude()) + .queryParam("appid", openWeatherApiKey) + .queryParam("units", "metric") + .build()) + .retrieve() + .bodyToMono(OpenWeatherApiResponseDto.class) + .map(openWeatherResponse -> { + // DTO에서 필요한 데이터 추출 및 가공 + double temp = openWeatherResponse.getMain().getTemp(); + double feelsLike = openWeatherResponse.getMain().getFeels_like(); + int humidity = openWeatherResponse.getMain().getHumidity(); + double windSpeed = openWeatherResponse.getWind().getSpeed(); + String weatherMain = openWeatherResponse.getWeather().getFirst().getMain(); + String weatherDescription = openWeatherResponse.getWeather().getFirst().getDescription(); + + double rainVolume = 0; + if (openWeatherResponse.getRain() != null) { + rainVolume = openWeatherResponse.getRain().getOneHour(); + } + double snowVolume = 0; + if (openWeatherResponse.getSnow() != null) { + snowVolume = openWeatherResponse.getSnow().getOneHour(); + } + + // 최종 WeatherResponseDto로 변환하여 반환 + return WeatherResponseDto.fromOpenWeatherData( + location.getSido(), + location.getSigungu(), + location.getEupmyeondong(), + temp, + feelsLike, + humidity, + windSpeed, + weatherMain, + weatherDescription, + rainVolume, + snowVolume + ); + }); + }) + // locationService에서 빈 Mono가 반환될 경우 에러 처리 + .switchIfEmpty(Mono.error(new RuntimeException(ErrorCode.BAD_REQUEST.getMessage()))); + } +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/CustomException.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/CustomException.java index 63cfc5d..1e49bb7 100644 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/CustomException.java +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/CustomException.java @@ -1,13 +1,21 @@ -package HeyDoctor.HeyDoctor_Backend.global.exception; - -import lombok.Getter; - -@Getter -public class CustomException extends RuntimeException { - private final ErrorCode errorCode; - - public CustomException(ErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; - } -} +package HeyDoctor.HeyDoctor_Backend.global.exception; + +import HeyDoctor.HeyDoctor_Backend.global.exception.dto.ResultDto; +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + private final ErrorCode errorCode; + + // 기본 ErrorCode 메시지를 사용하는 생성자 + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + // 커스텀 메시지를 사용하는 생성자 + public CustomException(ErrorCode errorCode, String customMessage) { + super(customMessage); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/ErrorCode.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/ErrorCode.java index cd79e18..5f5bc80 100644 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/ErrorCode.java +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/ErrorCode.java @@ -1,12 +1,51 @@ package HeyDoctor.HeyDoctor_Backend.global.exception; +import HeyDoctor.HeyDoctor_Backend.global.exception.dto.BaseCode; +import HeyDoctor.HeyDoctor_Backend.global.exception.dto.ResultDto; +import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; @Getter -@RequiredArgsConstructor -public enum ErrorCode { - MEMBER_NOT_FOUND("Member not found"); +@AllArgsConstructor +public enum ErrorCode implements BaseCode { + // 전역 에러 + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "500", "서버에서 요청을 처리 하는 동안 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "400", "입력 값이 잘못된 요청 입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "401", "인증이 필요 합니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "403", "금지된 요청 입니다."), + // 토큰 에러 + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "401", "유효하지 않은 토큰입니다."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "401", "유효하지 않은 액세스 토큰입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "401", "유효하지 않은 리프레시 토큰입니다."), + + // 소셜 로그인 토큰 관련 에러 추가 + INVALID_SOCIAL_TOKEN(HttpStatus.UNAUTHORIZED, "401", "유효하지 않은 소셜 로그인 토큰입니다."), + + // 유저 에러 + NOT_FOUND_USER(HttpStatus.NOT_FOUND, "404", "존재하지 않는 유저입니다."); + + private final HttpStatus httpStatus; + private final String code; private final String message; + + @Override + public ResultDto getReason() { + return ResultDto.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ResultDto getReasonHttpStatus() { + return ResultDto.builder() + .isSuccess(false) + .httpStatus(httpStatus) + .code(code) + .message(message) + .build(); + } } diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/GlobalExceptionHandler.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/GlobalExceptionHandler.java index 546e313..84168c7 100644 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/GlobalExceptionHandler.java @@ -1,13 +1,32 @@ package HeyDoctor.HeyDoctor_Backend.global.exception; +import HeyDoctor.HeyDoctor_Backend.global.exception.ErrorCode; +import HeyDoctor.HeyDoctor_Backend.global.exception.CustomException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.util.Map; + @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(RuntimeException.class) - public String handleRuntimeException(RuntimeException e) { - return e.getMessage(); + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + // 사용자 정의 예외 처리 + @ExceptionHandler(CustomException.class) + protected ResponseEntity handleCustomException(CustomException e) { + ErrorCode code = e.getErrorCode(); + HttpStatus status = code.getHttpStatus(); + log.warn("CustomException occurred: {}", code.getMessage(), e); + return ResponseEntity + .status(status) + .body(Map.of( + "success", false, + "error", code.getMessage() + )); } } diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/SuccessCode.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/SuccessCode.java new file mode 100644 index 0000000..25ac923 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/SuccessCode.java @@ -0,0 +1,42 @@ +package HeyDoctor.HeyDoctor_Backend.global.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import HeyDoctor.HeyDoctor_Backend.global.exception.dto.*; + +@Getter +@AllArgsConstructor +public enum SuccessCode implements BaseCode { + // 전역 응답 코드 + _OK(HttpStatus.OK, "S200", "요청이 정상 처리되었습니다."), + _CREATED(HttpStatus.CREATED, "S201", "리소스가 성공적으로 생성되었습니다."), + _CREATED_ACCESS_TOKEN(HttpStatus.CREATED, "S202", "액세스 토큰이 재발행되었습니다."), + LOGIN_SUCCESS(HttpStatus.OK, "S210", "로그인에 성공했습니다."), + REGISTER_SUCCESS(HttpStatus.CREATED, "S211", "회원가입에 성공했습니다."), + JWT_REISSUE_SUCCESS(HttpStatus.OK, "S212", "JWT 토큰 발급에 성공했습니다."); + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ResultDto getReason() { + return ResultDto.builder() + .isSuccess(true) + .code(code) + .message(message) + .build(); + } + + @Override + public ResultDto getReasonHttpStatus() { + return ResultDto.builder() + .isSuccess(true) + .httpStatus(httpStatus) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/dto/BaseCode.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/dto/BaseCode.java new file mode 100644 index 0000000..5ae3028 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/dto/BaseCode.java @@ -0,0 +1,6 @@ +package HeyDoctor.HeyDoctor_Backend.global.exception.dto; + +public interface BaseCode { + ResultDto getReason(); + ResultDto getReasonHttpStatus(); +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/dto/ResultDto.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/dto/ResultDto.java new file mode 100644 index 0000000..10b8bdf --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/dto/ResultDto.java @@ -0,0 +1,14 @@ +package HeyDoctor.HeyDoctor_Backend.global.exception.dto; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public class ResultDto { + private HttpStatus httpStatus; + private final boolean isSuccess; + private final String code; + private final String message; +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/responce/ApiResponse.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/responce/ApiResponse.java new file mode 100644 index 0000000..97aa38e --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/exception/responce/ApiResponse.java @@ -0,0 +1,45 @@ +package HeyDoctor.HeyDoctor_Backend.global.exception.responce; + +import HeyDoctor.HeyDoctor_Backend.global.exception.dto.BaseCode; +import HeyDoctor.HeyDoctor_Backend.global.exception.dto.ResultDto; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; + +@Getter +@RequiredArgsConstructor +public class ApiResponse { + + @JsonProperty("isSuccess") + private final Boolean isSuccess; + + private final String code; + private final String message; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private final T payload; + + public static ResponseEntity> onSuccess(BaseCode baseCode, T data) { + ResultDto result = baseCode.getReasonHttpStatus(); + ApiResponse response = new ApiResponse<>( + true, + result.getCode(), + result.getMessage(), + data + ); + return ResponseEntity.status(result.getHttpStatus()).body(response); + } + + public static ResponseEntity> onFailure(BaseCode baseCode) { + ResultDto result = baseCode.getReasonHttpStatus(); + ApiResponse response = new ApiResponse<>( + false, + result.getCode(), + result.getMessage(), + null + ); + return ResponseEntity.status(result.getHttpStatus()).body(response); + } +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/DummySecurityConfig.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/DummySecurityConfig.java deleted file mode 100644 index 9ff75bb..0000000 --- a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/DummySecurityConfig.java +++ /dev/null @@ -1,5 +0,0 @@ -package HeyDoctor.HeyDoctor_Backend.global.security; - -public class DummySecurityConfig { - // Security 설정 클래스 -} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/config/SecurityConfig.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/config/SecurityConfig.java new file mode 100644 index 0000000..a8d098b --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/config/SecurityConfig.java @@ -0,0 +1,40 @@ +package HeyDoctor.HeyDoctor_Backend.global.security.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +import java.util.Collections; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedHeaders(Collections.singletonList("*")); + config.setAllowedMethods(Collections.singletonList("*")); + config.setAllowedOriginPatterns(Collections.singletonList("*")); + config.setAllowCredentials(true); + return request -> config; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + httpSecurity + .httpBasic(HttpBasicConfigurer::disable) // HTTP Basic 인증 비활성화 + .cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정 + .csrf(AbstractHttpConfigurer::disable) // CSRF 비활성화 + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/**").permitAll() // 모든 요청 허용, 필요 시 제한 설정 가능 + ); + + return httpSecurity.build(); + } +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/RefreshToken/entity/RefreshToken.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/RefreshToken/entity/RefreshToken.java new file mode 100644 index 0000000..fe56f9c --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/RefreshToken/entity/RefreshToken.java @@ -0,0 +1,25 @@ +package HeyDoctor.HeyDoctor_Backend.global.security.jwt.RefreshToken.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.UUID; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@Getter +@Table(name = "refresh_tokens") +public class RefreshToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "refresh_tokens_id") + private Long id; + + @Column(name = "users_uuid", unique = true, columnDefinition = "uuid") + private UUID userId; + + @Column(name = "token", nullable = false) + private String token; +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/RefreshToken/repository/RefreshTokenRepository.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/RefreshToken/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..2282588 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/RefreshToken/repository/RefreshTokenRepository.java @@ -0,0 +1,21 @@ +package HeyDoctor.HeyDoctor_Backend.global.security.jwt.RefreshToken.repository; + +import jakarta.transaction.Transactional; +import HeyDoctor.HeyDoctor_Backend.global.security.jwt.RefreshToken.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + @Query("SELECT u FROM RefreshToken u WHERE u.userId = :userId") + RefreshToken findByUserId(UUID userId); + + @Transactional + @Modifying + @Query("DELETE FROM RefreshToken u WHERE u.userId = :userId") + void deleteByUserId(UUID userId); +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/Token/TokenResponse.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/Token/TokenResponse.java new file mode 100644 index 0000000..f5b0a5b --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/Token/TokenResponse.java @@ -0,0 +1,12 @@ +package HeyDoctor.HeyDoctor_Backend.global.security.jwt.Token; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class TokenResponse { + @JsonProperty("access_token") + private String accessToken; +} \ No newline at end of file diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/Token/TokenService.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/Token/TokenService.java new file mode 100644 index 0000000..ee1f3e1 --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/Token/TokenService.java @@ -0,0 +1,43 @@ +package HeyDoctor.HeyDoctor_Backend.global.security.jwt.Token; + +import HeyDoctor.HeyDoctor_Backend.global.security.jwt.RefreshToken.entity.RefreshToken; +import HeyDoctor.HeyDoctor_Backend.global.security.jwt.RefreshToken.repository.RefreshTokenRepository; +import HeyDoctor.HeyDoctor_Backend.global.exception.ErrorCode; +import HeyDoctor.HeyDoctor_Backend.global.exception.CustomException; +import HeyDoctor.HeyDoctor_Backend.global.security.jwt.util.JwtUtil; +import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class TokenService { + @Value("${jwt.access-token.expiration-time}") + private long ACCESS_TOKEN_EXPIRATION_TIME; + + private final RefreshTokenRepository refreshTokenRepository; + private final JwtUtil jwtUtil; + + public TokenResponse reissueAccessToken(String authorizationHeader) { + String refreshToken = jwtUtil.getTokenFromHeader(authorizationHeader); + String userId = jwtUtil.getUserIdFromToken(refreshToken); + + RefreshToken existRefreshToken = refreshTokenRepository.findByUserId(UUID.fromString(userId)); + if (existRefreshToken == null) { + throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + if (!existRefreshToken.getToken().equals(refreshToken) || jwtUtil.isTokenExpired(refreshToken)) { + throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + String accessToken = jwtUtil.generateAccessToken(UUID.fromString(userId), ACCESS_TOKEN_EXPIRATION_TIME); + + return TokenResponse.builder() + .accessToken(accessToken) + .build(); + } +} diff --git a/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/util/JwtUtil.java b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/util/JwtUtil.java new file mode 100644 index 0000000..7e1ff2a --- /dev/null +++ b/src/main/java/HeyDoctor/HeyDoctor_Backend/global/security/jwt/util/JwtUtil.java @@ -0,0 +1,93 @@ +package HeyDoctor.HeyDoctor_Backend.global.security.jwt.util; + +import HeyDoctor.HeyDoctor_Backend.global.exception.ErrorCode; +import HeyDoctor.HeyDoctor_Backend.global.exception.CustomException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.UUID; + +@Slf4j +@Component +public class JwtUtil { + @Value("${jwt.secret}") + private String SECRET_KEY; + + private SecretKey getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode(this.SECRET_KEY); + return Keys.hmacShaKeyFor(keyBytes); + } + + // 액세스 토큰을 발급하는 메서드 + public String generateAccessToken(UUID userId, long expirationMillis) { + log.info("액세스 토큰이 발행되었습니다."); + + return Jwts.builder() + .claim("userId", userId.toString()) // 클레임에 userId 추가 + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expirationMillis)) + .signWith(this.getSigningKey()) + .compact(); + } + + // 리프레쉬 토큰을 발급하는 메서드 + public String generateRefreshToken(UUID userId, long expirationMillis) { + log.info("리프레쉬 토큰이 발행되었습니다."); + + return Jwts.builder() + .claim("userId", userId.toString()) // 클레임에 userId 추가 + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expirationMillis)) + .signWith(this.getSigningKey()) + .compact(); + } + + // 응답 헤더에서 액세스 토큰을 반환하는 메서드 + public String getTokenFromHeader(String authorizationHeader) { + return authorizationHeader.substring(7); + } + + // 토큰에서 유저 id를 반환하는 메서드 + public String getUserIdFromToken(String token) { + try { + String userId = Jwts.parser() + .verifyWith(this.getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .get("userId", String.class); + log.info("유저 id를 반환합니다."); + return userId; + } catch (JwtException | IllegalArgumentException e) { + // 토큰이 유효하지 않은 경우 + log.warn("유효하지 않은 토큰입니다."); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + } + + // Jwt 토큰의 유효기간을 확인하는 메서드 + public boolean isTokenExpired(String token) { + try { + Date expirationDate = Jwts.parser() + .verifyWith(this.getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .getExpiration(); + log.info("토큰의 유효기간을 확인합니다."); + return expirationDate.before(new Date()); + } catch (JwtException | IllegalArgumentException e) { + // 토큰이 유효하지 않은 경우 + log.warn("유효하지 않은 토큰입니다."); + throw new CustomException(ErrorCode.INVALID_TOKEN); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 58c0210..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,29 +0,0 @@ -# Server -server.port=8080 - -# Database (PostgreSQL + PostGIS) -spring.datasource.url=jdbc:postgresql://aws-0-ap-northeast-2.pooler.supabase.com:5432/postgres?sslmode=require -spring.datasource.username=postgres.hoxnroonysjvuokheyzo -spring.datasource.password=zCxhzvfMCWdVgdu1 -spring.datasource.driver-class-name=org.postgresql.Driver - -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect - - -# Swagger -# springdoc.swagger-ui.path=/swagger-ui.html - -# Spring Security (JWT) -spring.security.user.name=admin -spring.security.user.password=admin123 - -# Logging -logging.level.org.hibernate.SQL=DEBUG -logging.level.org.hibernate.type.descriptor.sql=TRACE - -# Gemini AI API (OpenAI) - -# ai.api.key=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxx -# ai.api.url=https://api.openai.com/v1/chat/completions diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..649051a --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,51 @@ +server: + port: 8080 + +spring: + config: + import: optional:file:.env[.properties] + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + +logging: + level: + org: + hibernate: + SQL: DEBUG + type: + descriptor: + sql: TRACE + +jwt: + secret: ${JWT_SECRET} + access-token: + expiration-time: ${ACCESS_TOKEN_EXPIRATION_TIME} + refresh-token: + expiration-time: ${REFRESH_TOKEN_EXPIRATION_TIME} + +springdoc: + swagger-ui: + path: /swagger-ui.html + +api: + kakao: + rest-key: ${KAKAO_API_KEY} + weather: + key: ${WEATHER_API_KEY} + alert: + key: ${ALERT_API_KEY} + + +gemini: + api-key: ${GEMINI_API_KEY} diff --git a/src/test/java/HeyDoctor/HeyDoctor_Backend/domain/users/model/signup/EnvFileLoadTest.java b/src/test/java/HeyDoctor/HeyDoctor_Backend/domain/users/model/signup/EnvFileLoadTest.java new file mode 100644 index 0000000..17f62ec --- /dev/null +++ b/src/test/java/HeyDoctor/HeyDoctor_Backend/domain/users/model/signup/EnvFileLoadTest.java @@ -0,0 +1,108 @@ +package HeyDoctor.HeyDoctor_Backend.domain.users.model.signup; + +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.env.Environment; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestMethodOrder(OrderAnnotation.class) +public class EnvFileLoadTest { + + private static final Logger logger = LoggerFactory.getLogger(EnvFileLoadTest.class); + + @Autowired + private Environment env; + + private void checkVariables(List keys) { + List missingKeys = new ArrayList<>(); + + for (String key : keys) { + String value = env.getProperty(key); + if (value == null || value.isBlank()) { + missingKeys.add(key); + logger.error("Environment variable '{}' 불러오기 실패", key); + } else { + logger.info("Environment variable '{}' 불러오기 성공: {}", key, value); + } + } + + // AssertJ 이용해서 누락된 키가 없음을 검증 + assertThat(missingKeys) + .withFailMessage("다음 환경변수들이 누락되었습니다: %s", missingKeys) + .isEmpty(); + } + + @Test + @Order(1) + void testDatasourceVariables() { + logger.info("=== 테스트 1: Datasource 환경변수 체크 시작 ==="); + checkVariables(List.of( + "SPRING_DATASOURCE_URL", + "SPRING_DATASOURCE_USERNAME", + "SPRING_DATASOURCE_PASSWORD" + )); + logger.info("=== 테스트 1 완료 ==="); + } + + @Test + @Order(2) + void testOauthGoogleVariables() { + logger.info("=== 테스트 2: OAuth Google 환경변수 체크 시작 ==="); + checkVariables(List.of( + "OAUTH_GOOGLE_CLIENT_ID", + "OAUTH_GOOGLE_CLIENT_SECRET", + "OAUTH_GOOGLE_REDIRECT_URI" + )); + logger.info("=== 테스트 2 완료 ==="); + } + + @Test + @Order(3) + void testOauthKakaoVariables() { + logger.info("=== 테스트 3: OAuth Kakao 환경변수 체크 시작 ==="); + checkVariables(List.of( + "OAUTH_KAKAO_CLIENT_ID", + "OAUTH_KAKAO_CLIENT_SECRET", + "OAUTH_KAKAO_REDIRECT_URI" + )); + logger.info("=== 테스트 3 완료 ==="); + } + + @Test + @Order(4) + void testOauthNaverVariables() { + logger.info("=== 테스트 4: OAuth Naver 환경변수 체크 시작 ==="); + checkVariables(List.of( + "OAUTH_NAVER_CLIENT_ID", + "OAUTH_NAVER_CLIENT_SECRET", + "OAUTH_NAVER_REDIRECT_URI" + )); + logger.info("=== 테스트 4 완료 ==="); + } + + @Test + @Order(5) + void testJwtVariables() { + logger.info("=== 테스트 5: JWT 환경변수 체크 시작 ==="); + checkVariables(List.of( + "JWT_SECRET", + "JWT_REDIRECT_URI", + "ACCESS_TOKEN_EXPIRATION_TIME", + "REFRESH_TOKEN_EXPIRATION_TIME" + )); + logger.info("=== 테스트 5 완료 ==="); + } +} diff --git a/src/test/java/HeyDoctor/HeyDoctor_Backend/domain/users/model/signup/SignupApplicationTests.java b/src/test/java/HeyDoctor/HeyDoctor_Backend/domain/users/model/signup/SignupApplicationTests.java index cef2df2..cf42fd9 100644 --- a/src/test/java/HeyDoctor/HeyDoctor_Backend/domain/users/model/signup/SignupApplicationTests.java +++ b/src/test/java/HeyDoctor/HeyDoctor_Backend/domain/users/model/signup/SignupApplicationTests.java @@ -1,4 +1,4 @@ -package com.example.signup; +package HeyDoctor.HeyDoctor_Backend.domain.users.model.signup; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/src/test/java/HeyDoctor/HeyDoctor_Backend/domain/users/model/signup/service/UserServiceTest.java b/src/test/java/HeyDoctor/HeyDoctor_Backend/domain/users/model/signup/service/UserServiceTest.java deleted file mode 100644 index a8cf2be..0000000 --- a/src/test/java/HeyDoctor/HeyDoctor_Backend/domain/users/model/signup/service/UserServiceTest.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.example.signup.service; - -import com.example.signup.dto.UserRegisterRequest; -import com.example.signup.entity.User; -import com.example.signup.repository.UserRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.util.Map; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; - -class UserServiceTest { - - private UserRepository repository; - private UserService userService; - - @BeforeEach - void setUp() { - repository = mock(UserRepository.class); - userService = new UserService(repository); - } - - @Nested - @DisplayName("회원가입 테스트") - class RegisterTest { - - @Test - @DisplayName("성공") - void register_success() { - UserRegisterRequest request = new UserRegisterRequest("user1", "user@example.com", "pass", "hello"); - - when(repository.existsByUsername("user1")).thenReturn(false); - when(repository.existsByEmailAndPassword("user@example.com", "pass")).thenReturn(false); - when(repository.save(any(User.class))).thenAnswer(i -> i.getArguments()[0]); - - User user = userService.register(request); - - System.out.println("✅ 회원가입 성공 - username: " + user.getUsername()); - - assertThat(user.getUsername()).isEqualTo("user1"); - } - - @Test - @DisplayName("이미 존재하는 사용자명") - void register_duplicateUsername() { - UserRegisterRequest request = new UserRegisterRequest("user1", "user@example.com", "pass", "hello"); - when(repository.existsByUsername("user1")).thenReturn(true); - - System.out.println("❌ 사용자명 중복 발생 테스트 시작"); - - assertThatThrownBy(() -> userService.register(request)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("이미 존재하는 사용자명입니다.") - .satisfies(e -> { - System.out.println("▶ 예외 메시지: " + e.getMessage()); - System.out.println("✅ 예외가 정상적으로 처리되었습니다."); - }); - } - - @Test - @DisplayName("이미 존재하는 이메일+비밀번호") - void register_duplicateEmailAndPassword() { - UserRegisterRequest request = new UserRegisterRequest("user1", "user@example.com", "pass", "hello"); - - when(repository.existsByUsername("user1")).thenReturn(false); - when(repository.existsByEmailAndPassword("user@example.com", "pass")).thenReturn(true); - - System.out.println("❌ 이메일+비밀번호 중복 발생 테스트 시작"); - - assertThatThrownBy(() -> userService.register(request)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("이미 존재하는 이메일과 비밀번호 조합입니다.") - .satisfies(e -> { - System.out.println("▶ 예외 메시지: " + e.getMessage()); - System.out.println("✅ 예외가 정상적으로 처리되었습니다."); - }); - } - } - - @Nested - @DisplayName("로그인 테스트") - class LoginTest { - - @Test - @DisplayName("성공") - void login_success() { - User user = new User("user1", "user@example.com", "pass", "hi"); - - when(repository.findByEmailAndPassword("user@example.com", "pass")) - .thenReturn(Optional.of(user)); - - User result = userService.login("user@example.com", "pass"); - - System.out.println("✅ 로그인 성공 - username: " + result.getUsername()); - - assertThat(result).isNotNull(); - assertThat(result.getUsername()).isEqualTo("user1"); - } - - @Test - @DisplayName("이메일/비밀번호 불일치") - void login_invalid() { - when(repository.findByEmailAndPassword("user@example.com", "wrongpass")) - .thenReturn(Optional.empty()); - - User result = userService.login("user@example.com", "wrongpass"); - - System.out.println("❌ 로그인 실패 - 유저 없음 (결과가 null)"); - - assertThat(result).isNull(); - } - } - - @Nested - @DisplayName("유저 정보 수정 테스트") - class UpdateUserInfoTest { - - @Test - @DisplayName("성공") - void update_success() { - User user = new User("user1", "user@example.com", "pass", "hi"); - - when(repository.findByUsername("user1")).thenReturn(Optional.of(user)); - when(repository.save(any(User.class))).thenAnswer(i -> i.getArguments()[0]); - - Map updates = Map.of("email", "new@example.com", "intro", "updated intro"); - - User result = userService.updateUserInfoByMap("user1", updates); - - System.out.println("✅ 유저 정보 수정 성공 - email: " + result.getEmail() + ", intro: " + result.getIntro()); - - assertThat(result.getEmail()).isEqualTo("new@example.com"); - assertThat(result.getIntro()).isEqualTo("updated intro"); - } - - @Test - @DisplayName("username 수정 시도") - void update_usernameChange() { - User user = new User("user1", "user@example.com", "pass", "hi"); - when(repository.findByUsername("user1")).thenReturn(Optional.of(user)); - - Map updates = Map.of("username", "newUser"); - - System.out.println("❌ username 수정 시도 테스트 시작"); - - assertThatThrownBy(() -> userService.updateUserInfoByMap("user1", updates)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("username은 수정할 수 없습니다.") - .satisfies(e -> { - System.out.println("▶ 예외 메시지: " + e.getMessage()); - System.out.println("✅ 예외가 정상적으로 처리되었습니다."); - }); - } - - @Test - @DisplayName("허용되지 않은 필드 수정 시도") - void update_invalidField() { - User user = new User("user1", "user@example.com", "pass", "hi"); - when(repository.findByUsername("user1")).thenReturn(Optional.of(user)); - - Map updates = Map.of("role", "admin"); - - System.out.println("❌ 허용되지 않은 필드 수정 테스트 시작"); - - assertThatThrownBy(() -> userService.updateUserInfoByMap("user1", updates)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("수정할 수 없는 필드: role") - .satisfies(e -> { - System.out.println("▶ 예외 메시지: " + e.getMessage()); - System.out.println("✅ 예외가 정상적으로 처리되었습니다."); - }); - } - - @Test - @DisplayName("사용자 없음") - void update_userNotFound() { - when(repository.findByUsername("unknown")).thenReturn(Optional.empty()); - - Map updates = Map.of("intro", "intro"); - - System.out.println("❌ 사용자 없음 테스트 시작"); - - assertThatThrownBy(() -> userService.updateUserInfoByMap("unknown", updates)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("사용자를 찾을 수 없습니다.") - .satisfies(e -> { - System.out.println("▶ 예외 메시지: " + e.getMessage()); - System.out.println("✅ 예외가 정상적으로 처리되었습니다."); - }); - } - } -}