From 9d61108068960c0aec0b11ef845d985a045d5fdb Mon Sep 17 00:00:00 2001 From: Staty Date: Wed, 19 Nov 2025 17:51:19 +0900 Subject: [PATCH 001/198] =?UTF-8?q?ON-79=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=9D=B8=EC=A6=9D=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80=20-=20Google/Kakao/Nave?= =?UTF-8?q?r=20OAuth=20=EC=B2=98=EB=A6=AC=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 5 + .idea/vcs.xml | 6 + .../server/controller/HomeController.java | 51 ------- .../controller/MobileAuthController.java | 140 ++++++++++++++++++ .../server/controller/ProfileController.java | 17 --- .../CustomAuthorizationRequestResolver.java | 61 -------- .../server/security/CustomOAuth2User.java | 43 ------ .../security/CustomOAuth2UserService.java | 57 ------- .../server/security/GoogleVerifier.java | 4 + .../server/security/KakaoVerifier.java | 4 + .../server/security/NaverVerifier.java | 4 + .../server/security/OAuth2FailureHandler.java | 26 ---- .../server/security/OAuth2SuccessHandler.java | 48 ------ .../server/security/OAuthAttributes.java | 56 +------ .../server/security/UserPrincipal.java | 75 ---------- .../src/main/resources/config-env.properties | 0 .../src/main/resources/static/css/login.css | 121 --------------- .../main/resources/static/img/225098696.png | Bin 194529 -> 0 bytes .../src/main/resources/static/img/google.png | Bin 9107 -> 0 bytes .../src/main/resources/static/img/kakao.png | Bin 28977 -> 0 bytes .../src/main/resources/static/img/naver.png | Bin 1926 -> 0 bytes .../src/main/resources/templates/login.html | 75 ---------- 22 files changed, 164 insertions(+), 629 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/vcs.xml delete mode 100644 server/src/main/java/oba/backend/server/controller/HomeController.java create mode 100644 server/src/main/java/oba/backend/server/controller/MobileAuthController.java delete mode 100644 server/src/main/java/oba/backend/server/controller/ProfileController.java delete mode 100644 server/src/main/java/oba/backend/server/security/CustomAuthorizationRequestResolver.java delete mode 100644 server/src/main/java/oba/backend/server/security/CustomOAuth2User.java delete mode 100644 server/src/main/java/oba/backend/server/security/CustomOAuth2UserService.java create mode 100644 server/src/main/java/oba/backend/server/security/GoogleVerifier.java create mode 100644 server/src/main/java/oba/backend/server/security/KakaoVerifier.java create mode 100644 server/src/main/java/oba/backend/server/security/NaverVerifier.java delete mode 100644 server/src/main/java/oba/backend/server/security/OAuth2FailureHandler.java delete mode 100644 server/src/main/java/oba/backend/server/security/OAuth2SuccessHandler.java delete mode 100644 server/src/main/java/oba/backend/server/security/UserPrincipal.java create mode 100644 server/src/main/resources/config-env.properties delete mode 100644 server/src/main/resources/static/css/login.css delete mode 100644 server/src/main/resources/static/img/225098696.png delete mode 100644 server/src/main/resources/static/img/google.png delete mode 100644 server/src/main/resources/static/img/kakao.png delete mode 100644 server/src/main/resources/static/img/naver.png delete mode 100644 server/src/main/resources/templates/login.html diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/controller/HomeController.java b/server/src/main/java/oba/backend/server/controller/HomeController.java deleted file mode 100644 index 430e2f8..0000000 --- a/server/src/main/java/oba/backend/server/controller/HomeController.java +++ /dev/null @@ -1,51 +0,0 @@ -package oba.backend.server.controller; - -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; - -import java.util.Map; - -@Controller -public class HomeController { - - @GetMapping("/") - public String root() { - return "redirect:/login"; - } - - @GetMapping("/login") - public String login(@AuthenticationPrincipal OAuth2User user, - HttpServletResponse resp, - Model model) { - // 뒤로가기 캐시 방지(선택) - resp.setHeader("Cache-Control", "no-store, must-revalidate"); - resp.setHeader("Pragma", "no-cache"); - resp.setDateHeader("Expires", 0); - - boolean loggedIn = (user != null); - model.addAttribute("loggedIn", loggedIn); - - if (loggedIn) { - Map attrs = user.getAttributes(); - String name = null; - Object n = attrs.get("name"); // google - if (n == null) { - Object kakaoAcc = attrs.get("kakao_account"); // kakao - if (kakaoAcc instanceof Map kakao) { - Object profile = kakao.get("profile"); - if (profile instanceof Map p) n = p.get("nickname"); - } - } - if (n == null) { - Object naverResp = attrs.get("response"); // naver - if (naverResp instanceof Map respMap) n = respMap.get("name"); - } - model.addAttribute("userName", n != null ? n.toString() : user.getName()); - } - return "login"; - } -} diff --git a/server/src/main/java/oba/backend/server/controller/MobileAuthController.java b/server/src/main/java/oba/backend/server/controller/MobileAuthController.java new file mode 100644 index 0000000..46497b4 --- /dev/null +++ b/server/src/main/java/oba/backend/server/controller/MobileAuthController.java @@ -0,0 +1,140 @@ +package oba.backend.server.controller; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.common.jwt.JwtProvider; +import oba.backend.server.domain.user.ProviderInfo; +import oba.backend.server.domain.user.Role; +import oba.backend.server.domain.user.User; +import oba.backend.server.domain.user.UserRepository; +import oba.backend.server.dto.TokenResponse; +import oba.backend.server.security.GoogleVerifier; +import oba.backend.server.security.KakaoVerifier; +import oba.backend.server.security.NaverVerifier; +import oba.backend.server.security.OAuthAttributes; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth/mobile") +public class MobileAuthController { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + + private final GoogleVerifier googleVerifier; + private final KakaoVerifier kakaoVerifier; + private final NaverVerifier naverVerifier; + + /** + * 🔹 Google 모바일 로그인 + * RN → idToken 전달 + */ + @PostMapping("/google") + public ResponseEntity googleLogin(@RequestBody Map body) { + + String idToken = body.get("idToken"); + var payload = googleVerifier.verify(idToken); + + return ResponseEntity.ok( + processLogin( + "google", + payload.getSubject(), + payload.getEmail(), + (String) payload.get("name"), + (String) payload.get("picture") + ) + ); + } + + /** + * 🔹 Kakao 모바일 로그인 + * RN → accessToken 전달 + */ + @PostMapping("/kakao") + public ResponseEntity kakaoLogin(@RequestBody Map body) { + + String accessToken = body.get("accessToken"); + OAuthAttributes kakao = kakaoVerifier.verify(accessToken); + + return ResponseEntity.ok( + processLogin( + "kakao", + kakao.id(), + kakao.email(), + kakao.name(), + kakao.picture() + ) + ); + } + + /** + * 🔹 Naver 모바일 로그인 + * RN → accessToken 전달 + */ + @PostMapping("/naver") + public ResponseEntity naverLogin(@RequestBody Map body) { + + String accessToken = body.get("accessToken"); + OAuthAttributes naver = naverVerifier.verify(accessToken); + + return ResponseEntity.ok( + processLogin( + "naver", + naver.id(), + naver.email(), + naver.name(), + naver.picture() + ) + ); + } + + /** + * 🔥 공통 로그인 처리 메서드 + * - DB 조회 및 생성 + * - JWT 발급 + */ + private TokenResponse processLogin( + String provider, + String providerId, + String email, + String name, + String picture + ) { + + String identifier = provider + ":" + providerId; + + // DB 조회 또는 생성 + User user = userRepository.findByIdentifier(identifier) + .map(u -> { + u.updateInfo(email, name, picture); + return u; + }) + .orElseGet(() -> userRepository.save( + User.builder() + .identifier(identifier) + .email(email) + .name(name) + .picture(picture) + .provider(ProviderInfo.from(provider)) + .role(Role.USER) + .build() + )); + + // Spring Security Authentication 생성 + Authentication auth = new UsernamePasswordAuthenticationToken( + user.getIdentifier(), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + // JWT 발급(JSON 반환) + return jwtProvider.generateToken(auth); + } +} diff --git a/server/src/main/java/oba/backend/server/controller/ProfileController.java b/server/src/main/java/oba/backend/server/controller/ProfileController.java deleted file mode 100644 index 08466d1..0000000 --- a/server/src/main/java/oba/backend/server/controller/ProfileController.java +++ /dev/null @@ -1,17 +0,0 @@ -package oba.backend.server.controller; - -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class ProfileController { - - @GetMapping("/profile") - public Object profile(@AuthenticationPrincipal OAuth2User user) { - // GitHub에서 가져온 전체 프로필 JSON 반환 - // 예: { id: 123, login: "...", email: "...", ... } - return user.getAttributes(); - } -} diff --git a/server/src/main/java/oba/backend/server/security/CustomAuthorizationRequestResolver.java b/server/src/main/java/oba/backend/server/security/CustomAuthorizationRequestResolver.java deleted file mode 100644 index 3073cd9..0000000 --- a/server/src/main/java/oba/backend/server/security/CustomAuthorizationRequestResolver.java +++ /dev/null @@ -1,61 +0,0 @@ -package oba.backend.server.security; - -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.UserRepository; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; -import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; -import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; -import org.springframework.stereotype.Component; - -import java.util.LinkedHashMap; -import java.util.Map; - -@Component -@RequiredArgsConstructor -public class CustomAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { - - private final ClientRegistrationRepository clientRegistrationRepository; - private final UserRepository userRepository; - - private final String authorizationRequestBaseUri = "/oauth2/authorization"; - - @Override - public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { - DefaultOAuth2AuthorizationRequestResolver baseResolver = - new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, authorizationRequestBaseUri); - OAuth2AuthorizationRequest req = baseResolver.resolve(request); - if (req == null) return null; - return customize(request, req); - } - - @Override - public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { - DefaultOAuth2AuthorizationRequestResolver baseResolver = - new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, authorizationRequestBaseUri); - OAuth2AuthorizationRequest req = baseResolver.resolve(request, clientRegistrationId); - if (req == null) return null; - return customize(request, req); - } - - private OAuth2AuthorizationRequest customize(HttpServletRequest request, - OAuth2AuthorizationRequest req) { - String uri = request.getRequestURI(); - String registrationId = uri.substring(uri.lastIndexOf('/') + 1); // google|kakao|naver - - // 기본 파라미터 복사 - Map params = new LinkedHashMap<>(req.getAdditionalParameters()); - - // ✅ 항상 동의창 유도 (DB 확인 가능하게 확장 가능) - switch (registrationId) { - case "google" -> params.put("prompt", "consent"); - case "kakao" -> params.put("prompt", "login"); // or consent - case "naver" -> params.put("auth_type", "reprompt"); - } - - return OAuth2AuthorizationRequest.from(req) - .additionalParameters(params) - .build(); - } -} diff --git a/server/src/main/java/oba/backend/server/security/CustomOAuth2User.java b/server/src/main/java/oba/backend/server/security/CustomOAuth2User.java deleted file mode 100644 index b78842b..0000000 --- a/server/src/main/java/oba/backend/server/security/CustomOAuth2User.java +++ /dev/null @@ -1,43 +0,0 @@ -package oba.backend.server.security; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.User; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.user.OAuth2User; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -@RequiredArgsConstructor -public class CustomOAuth2User implements OAuth2User { - - private final User user; - private final Map attributes; - - @Override - public Map getAttributes() { - return attributes; - } - - // 🔥 권한 반환 (ROLE_USER, ROLE_ADMIN 방식) - @Override - public Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())); - } - - @Override - public String getName() { - return user.getName(); - } - - // 🔥 JWT 발급 시 식별자 반환 - public String getIdentifier() { - return user.getIdentifier(); - } - - public User getUser() { - return user; - } -} diff --git a/server/src/main/java/oba/backend/server/security/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/security/CustomOAuth2UserService.java deleted file mode 100644 index 3f7a1db..0000000 --- a/server/src/main/java/oba/backend/server/security/CustomOAuth2UserService.java +++ /dev/null @@ -1,57 +0,0 @@ -package oba.backend.server.security; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.ProviderInfo; -import oba.backend.server.domain.user.Role; -import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.UserRepository; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class CustomOAuth2UserService extends DefaultOAuth2UserService { - - private final UserRepository userRepository; - - @Override - public OAuth2User loadUser(OAuth2UserRequest request) { - - OAuth2User oAuth2User = super.loadUser(request); - - String provider = request.getClientRegistration().getRegistrationId(); // google, kakao, naver - - OAuthAttributes attributes = OAuthAttributes.of(provider, oAuth2User.getAttributes()); - - // identifier = "google:고유ID" - String identifier = provider + ":" + attributes.id(); - - // DB 조회 - User user = userRepository.findByIdentifier(identifier) - .map(existing -> { - existing.updateInfo( - attributes.email(), - attributes.name(), - attributes.picture() - ); - return existing; - }) - .orElseGet(() -> User.builder() - .identifier(identifier) - .email(attributes.email()) - .name(attributes.name()) - .picture(attributes.picture()) - .provider(ProviderInfo.from(provider)) - .role(Role.USER) - .build() - ); - - // 저장 - userRepository.save(user); - - // OAuth2User 반환 - return new CustomOAuth2User(user, attributes.attributes()); - } -} diff --git a/server/src/main/java/oba/backend/server/security/GoogleVerifier.java b/server/src/main/java/oba/backend/server/security/GoogleVerifier.java new file mode 100644 index 0000000..621ab28 --- /dev/null +++ b/server/src/main/java/oba/backend/server/security/GoogleVerifier.java @@ -0,0 +1,4 @@ +package oba.backend.server.security; + +public class GoogleVerifier { +} diff --git a/server/src/main/java/oba/backend/server/security/KakaoVerifier.java b/server/src/main/java/oba/backend/server/security/KakaoVerifier.java new file mode 100644 index 0000000..e407c83 --- /dev/null +++ b/server/src/main/java/oba/backend/server/security/KakaoVerifier.java @@ -0,0 +1,4 @@ +package oba.backend.server.security; + +public class KakaoVerifier { +} diff --git a/server/src/main/java/oba/backend/server/security/NaverVerifier.java b/server/src/main/java/oba/backend/server/security/NaverVerifier.java new file mode 100644 index 0000000..03bfc98 --- /dev/null +++ b/server/src/main/java/oba/backend/server/security/NaverVerifier.java @@ -0,0 +1,4 @@ +package oba.backend.server.security; + +public class NaverVerifier { +} diff --git a/server/src/main/java/oba/backend/server/security/OAuth2FailureHandler.java b/server/src/main/java/oba/backend/server/security/OAuth2FailureHandler.java deleted file mode 100644 index a9821f2..0000000 --- a/server/src/main/java/oba/backend/server/security/OAuth2FailureHandler.java +++ /dev/null @@ -1,26 +0,0 @@ -package oba.backend.server.security; - -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.authentication.AuthenticationFailureHandler; - -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; - -public class OAuth2FailureHandler implements AuthenticationFailureHandler { - - @Override - public void onAuthenticationFailure( - jakarta.servlet.http.HttpServletRequest request, - HttpServletResponse response, - AuthenticationException exception) throws IOException { - - // 민감한 exception.getMessage() 대신 고정 코드 전달 - String msg = URLEncoder.encode("oauth2_login_failed", StandardCharsets.UTF_8); - response.sendRedirect("/login?error=" + msg); - - // 실제 상세 에러는 서버 로그에만 기록 - // log.warn("OAuth2 login failed", exception); - } -} diff --git a/server/src/main/java/oba/backend/server/security/OAuth2SuccessHandler.java b/server/src/main/java/oba/backend/server/security/OAuth2SuccessHandler.java deleted file mode 100644 index dd61400..0000000 --- a/server/src/main/java/oba/backend/server/security/OAuth2SuccessHandler.java +++ /dev/null @@ -1,48 +0,0 @@ -package oba.backend.server.security; - -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.dto.TokenResponse; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -@RequiredArgsConstructor -public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { - - private final JwtProvider jwtProvider; - - @Override - public void onAuthenticationSuccess( - jakarta.servlet.http.HttpServletRequest request, - HttpServletResponse response, - Authentication authentication) throws IOException { - - TokenResponse tokens = jwtProvider.generateToken(authentication); - - // ✅ Refresh Token (7일, HttpOnly + Secure + SameSite) - Cookie refreshCookie = new Cookie("refresh_token", tokens.refreshToken()); - refreshCookie.setHttpOnly(true); - refreshCookie.setSecure(true); - refreshCookie.setPath("/"); - refreshCookie.setMaxAge(7 * 24 * 60 * 60); - refreshCookie.setAttribute("SameSite", "None"); - response.addCookie(refreshCookie); - - // ✅ Access Token (30분, HttpOnly + Secure + SameSite) - Cookie accessCookie = new Cookie("access_token", tokens.accessToken()); - accessCookie.setHttpOnly(true); - accessCookie.setSecure(true); - accessCookie.setPath("/"); - accessCookie.setMaxAge(30 * 60); - accessCookie.setAttribute("SameSite", "None"); - response.addCookie(accessCookie); - - // ✅ 리디렉트 시 토큰 전달 금지 → 상태만 표시 - response.sendRedirect("/login?success=true"); - } -} diff --git a/server/src/main/java/oba/backend/server/security/OAuthAttributes.java b/server/src/main/java/oba/backend/server/security/OAuthAttributes.java index 497aadb..070dab9 100644 --- a/server/src/main/java/oba/backend/server/security/OAuthAttributes.java +++ b/server/src/main/java/oba/backend/server/security/OAuthAttributes.java @@ -1,58 +1,4 @@ package oba.backend.server.security; -import java.util.Map; - -public record OAuthAttributes( - String id, - String email, - String name, - String picture, - Map attributes -) { - - public static OAuthAttributes of(String provider, Map attributes) { - return switch (provider) { - case "google" -> ofGoogle(attributes); - case "kakao" -> ofKakao(attributes); - case "naver" -> ofNaver(attributes); - default -> throw new IllegalArgumentException("Unknown provider: " + provider); - }; - } - - private static OAuthAttributes ofGoogle(Map attr) { - return new OAuthAttributes( - (String) attr.get("sub"), - (String) attr.get("email"), - (String) attr.get("name"), - (String) attr.get("picture"), - attr - ); - } - - @SuppressWarnings("unchecked") - private static OAuthAttributes ofKakao(Map attr) { - Map account = (Map) attr.get("kakao_account"); - Map profile = (Map) account.get("profile"); - - return new OAuthAttributes( - String.valueOf(attr.get("id")), - (String) account.get("email"), - (String) profile.get("nickname"), - (String) profile.get("profile_image_url"), - attr - ); - } - - @SuppressWarnings("unchecked") - private static OAuthAttributes ofNaver(Map attr) { - Map response = (Map) attr.get("response"); - - return new OAuthAttributes( - (String) response.get("id"), - (String) response.get("email"), - (String) response.get("name"), - (String) response.get("profile_image"), - attr - ); - } +public class OAuthAttributes { } diff --git a/server/src/main/java/oba/backend/server/security/UserPrincipal.java b/server/src/main/java/oba/backend/server/security/UserPrincipal.java deleted file mode 100644 index e5b3afa..0000000 --- a/server/src/main/java/oba/backend/server/security/UserPrincipal.java +++ /dev/null @@ -1,75 +0,0 @@ -package oba.backend.server.security; - -import lombok.Getter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -@Getter -public class UserPrincipal implements OAuth2User, UserDetails { - - private final String id; - private final String email; - private final Map attributes; - private final Collection authorities; - - public UserPrincipal(String id, - String email, - Map attributes, - Collection authorities) { - this.id = id; - this.email = email; - // ✅ null 방지 처리 (빈 컬렉션/맵으로 초기화) - this.attributes = (attributes == null) ? Map.of() : Map.copyOf(attributes); - this.authorities = (authorities == null) ? List.of() : List.copyOf(authorities); - } - - @Override - public Map getAttributes() { - return attributes; - } - - @Override - public String getName() { - return id; - } - - @Override - public Collection getAuthorities() { - return authorities; - } - - @Override - public String getPassword() { - return null; // 소셜 로그인 사용 시 패스워드 불필요 - } - - @Override - public String getUsername() { - return email; - } - - @Override - public boolean isAccountNonExpired() { - return true; - } - - @Override - public boolean isAccountNonLocked() { - return true; - } - - @Override - public boolean isCredentialsNonExpired() { - return true; - } - - @Override - public boolean isEnabled() { - return true; - } -} diff --git a/server/src/main/resources/config-env.properties b/server/src/main/resources/config-env.properties new file mode 100644 index 0000000..e69de29 diff --git a/server/src/main/resources/static/css/login.css b/server/src/main/resources/static/css/login.css deleted file mode 100644 index a512994..0000000 --- a/server/src/main/resources/static/css/login.css +++ /dev/null @@ -1,121 +0,0 @@ -:root{ - --ring:#e2e8f0; --muted:#64748b; --card:#fff; - --g:#4285F4; --k:#FEE500; --n:#03C75A; -} - -*{box-sizing:border-box} -html,body{height:100%} -body{ - margin:0; background:#f8fafc; color:#0f172a; - font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Inter,Apple SD Gothic Neo,Malgun Gothic,맑은고딕,sans-serif; - display:grid; place-items:center; padding:24px; - -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; -} - -.card{ - width:min(560px, 92vw); - background:var(--card); - border:1px solid #e5e7eb; - border-radius:18px; - box-shadow:0 8px 30px rgba(2,6,23,.06); - padding:28px; -} - -.row{display:grid; gap:12px; margin-top:18px} - -/* ===== Hero(로고/타이틀) ===== */ -.hero{ text-align:center; margin-bottom:18px; } -.hero-logo{ - width:min(240px, 60vw); - height:auto; - border-radius:20px; - box-shadow:0 12px 32px rgba(2,6,23,.08); -} -.hero-title{ - margin:16px 0 6px; - font-size:22px; - font-weight:600; - letter-spacing:-0.2px; -} -.hero-desc{ - margin:0; - color:#6b7280; - font-size:14px; - font-weight:400; -} - -/* ===== Buttons ===== */ -.btn{ - display:flex; align-items:center; gap:12px; - background:#fff; border:1px solid var(--ring); - border-radius:12px; padding:14px 16px; - text-decoration:none; color:#111827; font-weight:600; - transition:transform .12s ease, box-shadow .12s ease, opacity .12s ease; -} -.btn:hover{transform:translateY(-1px); box-shadow:0 8px 16px rgba(0,0,0,.06)} -.btn:active{opacity:.9} -.btn:focus-visible{ outline:3px solid #60a5fa; outline-offset:2px; } - -.btn .icon{width:22px; height:22px; border-radius:4px; object-fit:cover} - -.btn.google{ color:#111 } -.btn.kakao{ background:var(--k); border-color:#f5e14d; color:#222 } -.btn.naver{ background:var(--n); color:#fff; border-color:#059669 } - -/* ===== 상태/기타 ===== */ -.hr{height:1px; background:#e5e7eb; margin:18px 0; border:0} -.status{margin-top:12px; color:#64748b; font-size:13px} - -.user{ - display:flex; align-items:center; gap:10px; - background:#faffff; padding:10px 12px; - border:1px dashed var(--ring); border-radius:12px; -} -.pill{display:inline-block; padding:2px 8px; border-radius:999px; background:#eef; color:#3b82f6; font-weight:700; font-size:12px} - -.logout{ - display:inline-flex; align-items:center; gap:8px; - background:#111; color:#fff; padding:10px 14px; border-radius:10px; - text-decoration:none; border:0; cursor:pointer; -} - -.footer{margin-top:18px; color:#94a3b8; font-size:12px} -.footer a{color:#3b82f6; text-decoration:none} - -/* /resources/static/css/login.css 의 하단에 추가/수정 */ - -.hero-logo{ - width:min(260px, 70vw); - height:min(260px, 70vw); - border-radius:24px; - display:block; - margin:8px auto 14px; - object-fit:cover; - box-shadow:0 12px 36px rgba(0,0,0,.12); -} - -.app-title{ - text-align:center; - font-weight:700; - font-size:22px; - margin-top:2px; - color:#0f172a; -} - -.hint{ - width:100%; - padding:14px 16px; - border:1px dashed var(--ring); - border-radius:12px; - background:#fafcff; - color:#334155; - font-size:14px; -} - -.switch{ - margin-left:8px; - text-decoration:none; - font-size:14px; - color:#2563eb; - -} diff --git a/server/src/main/resources/static/img/225098696.png b/server/src/main/resources/static/img/225098696.png deleted file mode 100644 index 36279cc6d8a0e0d521c0e7786f76a07b91d4c613..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 194529 zcmV)6K*+y|P)5wOXxfVRj5%Y=&v&*%JJ_x|qv!`DmTxzByx%Q@%sK5x%)^Ua1W z`&57)^Z+VAwJIgMoE2uBX-6@KS&s<2+V0|X<}+WNh=dnwQOq_Jvz@&t(1O%z^Q4F72(S}a>6v`G;#tIcE z9dWE@EXS0N64kL3dzqkad!~DrDs7)#jcDeu$~?$q&ed|1(#s9ZM}_i~r7T2q%;X~# zJ?KQWN>qYM6sZTjTw#XUWj;68D8eqZ+nJ0!Rce$yCYCK6Lz^PqJ@#(+5Ty|PY+YyBv6(CVtQ7JE4P=`iUqZ6&DMhRk& z!Em$DIYFhWwF~GsW6m=QwM*@G=AaJ6%CdL67qZDbXx1Sftw=>b3X!1{B_bMi2va`_ zP=`2q)u%jD<{WkoqL^c5C%np1IvQ1^ZjB-e#pu*7EoBu8k%AQTa<0}Y(VoJYsAIhn znZW^6AVWRqR}swGXFqh^$s5dk=O@qKQHv;Ku!|L@9x*KAFuWXMruytG`v8lzRMBW*6)Kgb zQbn^3KIW@Vk+uV34F?d8VrB>LB8E-I7^5BTkCRs+3VzmUEvl7;UZrc4G3IQu2tx>G z4w{jx9@H_JZKy*FV$i5uZNg|Uw&^xkeP*5WjdKt~0HTn>Iz(g0)~c3%XOZ(OTWb%w z7qXPoIak$6R0R6rM<>fj<}1&R*sB4Qa*TE6m^l?0Xh4P{l&)FkLB<9$lQ{rVk;i5` z&wYOKQ8Vm3;`ukccD8*HdSXd}?79;^r0#57bejhoT0 zSqeoyylP+#$Jk{TsKG8~DUvv>6uaIYGRE_;=MM^1v$_LM1C6LuqKc3mh#{iTi4LSN z$}B+*s?~#Z)gc#c=us=H%rfT$MYvzPQ$6wq{u2o(L?p`5qatRQ8dIn&jVckB+iv$E z6l0W|Fu)GR1uiBbJ{YiQH7lBaB$tc4d#Iq8MP_0z$#vrQDi5`TipHbXw+PTwnopT*J zm1w)skC_-ksqMG9YGbiEjCjpbH)-Z0RSn8TIBVF%VYT4^D$s*Qq@u)D*$Vnt%;oaQ zi)`kj3soppJgNf;ty)%@1hYnsYDA}!Bt;{V`N)9}ewAue)!Ky;v~q2rl#z^7CaF}F zs#YAz5sj~rj($|45@F~^4`Pst7L+0$Nl4LVf-NQ-m8w-Ysu6`40Fh`!w#hV+szM>_ znTI;Wsu6)e530oW(Th?RAcoCoF)^mj+L7Py=HZzxn%-{<7 z6lPbllm%RbR1{#b3WF`IK?OD&=~4DJ+8@G3Zwk zI+@J|RtKQTwxWoBlZ<{8r~}dPGDA|La?K)V$P)&zo;lor3|6QXp=v<2&9V!bXKr$4 zv4cr0QzP=VT#0s?JB?+g&GWDt6>7_oi)eHs(59NW%%mBkc!XhC;R;nB`<+D|pW^Id zo5l*Xa5ILuLD3vwDsQrVcDY@Nbgk4&>~tP?4x&UOYCspVIfQa}RmWlH9j6^JC`7Tm zs8uAg6^#xyIWrwcOKrEc%tj*z)G4pxnav({BL>AR3w~}bx|zzIY-buXkc1>vqgs*L zg=S_jMu~_~1j3a8NcJrER@R|{7Gs)@67AA@`=a)^yWO`@W!9KXbf68xsI%!di=`%t zajH{_M&L&+MlesUiVDVHhdR~CYY@XCHlhbUG$RZf&`vLQV4qDzUltX@sGT30R9w8c{w5RjUG2q84q6$4Z6jR_n5h z`OL!x^N|@wxB4|2XnrhIik54(cBsMLifBX`KTFYKmIdlUMQk$%%v=rGP&$jDA1s)IcAJ=Sfh5KyB4)bK?LFu#w1iDjLAxr>~6F&1`7>qm~VzPPqUDs zYP7+pI=jH0r4&2Q9#Wl((1ASGk=kyjxhu6^CCWt@dXU9-HZcKJY~##8A7_i%V5V~& zk~oHJ6lkH{uLAibcT5}ob9~MSTn~Q3pSe?La(Y~S8LQRwF`o6rA70Ur(7nO!)6RUN>{bKim+P|r!a{#O}$x#9u%n~ z7~qSMz?n>78wR*e>B>TBu<<6L7&+|Hh+XRL*8+3~+h!cRXhjvo0kg);K_r^YM)(np zepHz0<{A_;oC&Bz3H;1PJTn5k&;5{Po-d|sLA-O&Gp=2BtNXQGXye?)NJ2hK;g#&= z=ux?%1K%0N#&|rQzoVb^OkfP#I9G#OuO0Bqj{;P2Cyh$%<@V_S^eK{gsA0QV=e*_ib#(69COY4;a(+f(dn_z;gs zjRZOYQrW~*`px6c#$ayGMUQsa<*K$%YXNdlk0z(|-}krXoWUwSg+d--m3bSlpcc`* z6BWqOptjou=tm5m;0klKd4a!}>1G8Ia0Z@)pDj3vkMb%rfOB+-y&csWR2<$!HooO$ zOyiBXlJD^d@;HU(C?z0O_!jHgjyv!c`?_7qH1{#Kf#q4V6VA~|Hl20u0*S@ z#}lg6E8qRf_sWs%JKrk2>#@CW|KIk{KV1EB#>YQ*eX#Aa*EhbGb@-H{?$~RWZ~5Kt z&QIYF>bHN!1*V0WEJcXo5Qg_O!86T+<|7rU0Dk-nKr0GStvnpTC8%aQr*fE8OlB;; zM3H8UzcA6{9((%n=f2*0V#zsWmsKU#eNxXi-g~8V%gp94!|t5%%E@JO-k<$bbUd<= zig;X)f9X2p;CuLViT#)THUG#1oMavMnOm`s3HTbHCm|HlEJM4cHiaX61=kR`<0bq+c%h`iVoY^5aqnl?i7On1~i6~sa zm_R9;v(34D0s#ofQ@7HQr+!^)o-%vQPEDv!PoN1^9LAGsu#1q5=3ru-!&^|Kk2Rti z6eAuz{K2HXc+J*tK6vTz51*LWu=$O(Z?)~Z?eI^duN<#D&q=uDS67%1%`Lcr6?#Ia z^GoMtXOHgCBkE^0C;0>7@h)yg6h77+e9ig0a~}Su*X=C$`95QLC%;4ne2T++0De{k zCe&*0#RLL^n1{z}876v(3zeXmzg%4D7yysKQ+J2#rutl&wEL5HGo zGuD|8jEiuzDh__uGXvp>#UBxa`}r!OaD70#@o#||;j7$&I-H>=n91kNVT5oCpX3E* zr+FLqsYIg;Hy3gapVjZMOa1cbxQXRmW(lrCKl7ZjKi>cTqD=PlMf1Fkb5C{K`3p`0 zcqgvJos2P$Ise6kW+>9!$ya$MZe}5uaF~~wVVsUe-i#FZ;p3;gfyt`Y7j_S~pom@w zrI;a)-DPLvV zM`cb~dHZAYYYy~Xw|`ZC5{{sl6UgArIO5DVjybw_Z*Ta%%Vv~CejL>qU-_HIW(~@# zCp4%ltYhbyPt9MQQ}GtM5yu+-h5PUvn(+g&?IZdpqReA_o=5N;&e0R<(@pf6%gwPz zvYvRfzUBRdcSGAc@dGpTXJ_0z!*lg_y`kTII`HXN|J{GCO~{EGiH<*G;*^VVTwy^$ z6^3k#>bO$O)1IF~N^}WowHEhdh!@}-=5rZ(k-;~WsDIdT-iJ0!@GkR$c>#XZDFk`A z9q%I1`Onn)_FcO#{`$2oSH1ahTVdm!Z&tLO{POlKzcdeRdb$4O9d9&X+v4}C+UmO| zzpeXj{Kv_mtD=&l5+bfWQPa;0{WE96hZ3agx2nYv)bUYX=KSP5#sdD0&ml!c!8Cr0 z$qXR#B=<3g(-DqHR=)UPxp?WEVSF3FZ1i#q&*ek7o;}=W_L*PMk1O#I&Os{*kffjFgBS1VI6`nU-&2V^ z_BekrE7*%~;lud=3QZ0_H8-*X1xUsbR`DdW23uIgCidVGG~!9TkL&FT?)=Gprp#RS5^jw@PE2ag7};VoTZZ^vKE_1uC^ z+=)k7iYM8H82+1g;!QoP-y_3h@+!nD8m|W8z&km>3s7j^wU19OdiDJ`{_xRf8{G{L zZz*=CxyJq66Bm-}T>bOx!~1`Z-Z|&J>dzj#dTaUH}Olv;2b{33pmD4a0~87HcRoP#`T^3A1f4) zuS56&6S$M_^9U|Qq;5tj&cUxSq3h7htIQndf2u^casdeU@o&iC5p~&tThbC{F zyo0OEHRg2`AdmHSBzV+%z#MSqqX0u1MK}X^p3VXKm7xMPm@+1q&B|4%vXIX)Her<6 zs6n0W)+p=EI&&Z(h){q?_80a9ECulntB4I3`(|8n|!FCTpIdG9XmI)2@;o4CTPVvbUjiy=m{7_A7$5W?iM9ZEqovVy{M zfJCM3&{h~H*b4q%4Eod^DCt&QAu{WS~br(W*#)_Y^YtS^xysN zvKQwTKJn8d<9A!8q6c|M0Wg40w5nE>>egb!qn#UhfGw=i0{fEtBm7*#WlUzH$yABj zRD@JUA|FCHI-0|Gt^eZJ)nPT<^5bxqFLkI%Yqd#<&S}oLNt=9b@-DlB$4mt~k*F+H zGl65QKn<%kFTcuPO8wG~9B>xv#~9M`bEkYwbnwD_TBQafrzQ*?^)T zqb#H!$w*VTN|5V5?Hr!6B|ZJRlkWZE_!dt@Uqh!^Sm*2&(ym@E@2n?wcI)AoaS6HZBOW? zX&22~aPi{_V<-G7taIvHs70eH0d+8eRjlC1Tj zgF`64<|#`>c8@MnpXcw6$4tNX;ro8QWal65z4r4<-a|3hn9XPoip`23P;~}{V=cZgyickk)SjBKwnX{e$DqcRVN4nxv ziaZvhp929PQmL&~7Bf5#hs;>Au`qV^wEnF9Tl-Vh6Oh98Zg;oJIlv}la~(6;w)F0T zvu?Q{e_775EB=WRb=zI)M=G-!gAAl9QPC=5uSsBnBHhp24}x_)7s&``uLkXY_fC#E z_c+&~PI20W6!tO=$;wp(MuQz>02xd|HtU%NuXfvMY&Iu2KdD)vb}}Mpa6>5jVU5yKesc_5@JCz)(^suATV zL>9wY&MJ7(B-s)7LZ+Dwo?oF~^W@bivk7>WYs=Yco81wOXuG|^jG1+wukc_X9Eepd z223BKk*Q@!M&p6~{axQpW1FYa^R4P8_OV2jcD;6B2UAhZIcC1+K@Ga6Ouo$+mcoyj z*k*`2P3T zy$|f0H0!3^9U5+nj+gigNDQ-A#afLx6lpCIQH%)tHR{-CqS4HmT!=RGvkBekQ6+~= zh1no*4#%I}xABqJuXy#f@Tz>2et4cKER-O*fuK;`0S-UA0K?iJLDaC z-aYc>&nZ8@hblb44QN0oUej7dXeHNioUijTK7;vO%jxXZUR9$DKJ@Dh#cLPx)T>Zc zvzI@z##~}LZf!*Yj!i1^ALkVwkr{NR%lkF)|yh*m^#(j9d=r9(zl*; z@||lHr5@J*_~Xyx$MT=P{OP!j52DL%vJsfiXRu$hm9oF}<1;^M*qwfeKg}FXT$+08 zva1dvRwY;jA2*W7MjN^ity!|j!zenCs#XjMsE`jGEJe}LUw-k9ocQez|69KQubynD z$n@K@>>*piHT1KXb@a0nKr=eOKmYqrefu<`>+E6z@j<;%tvppK7ac5M3hJ!So?=6p z&1|-+KzT}GtXab}R+|EIF)B5x!2k_0X)5DR8Z={vO+c}VQG;ltC=N+TMl?!M#cK3u zSZmOzK@`AmR;bO?n0ZdNEw+j7c9Y>`cz&w_?Y4dBR{^Y1wf)*%8N`Tw);s_7Ji<1{ zG80*iH>Sa3MKf%RrS>yQ^5A&y2csB{Ty?V!C{>$S^9jFcbQ^7D5g_j`Ued(FefFwI%S111ec zs6-n&(1}iEDNA{5ayEyAC{ej~Ioq&~W0Gds?X2M{?nDji+0H7CnQPQ)&RB*~V}!&;xciqYc5}>Z z;PB*=?gjQ%yF-IWcR#g@?P6v!7F~9$ou&r6TBAx-sC?S`%z4k>_3{-B>2IIgzJy(Y zYoiZb4=)~CjZws?AAaPqN!2P)99lJs0u-{C_13zVyJsH%oubX>D@#i9pN{zD zf+JgwT*7r+#+e*rFB^l{n`GviVJ=gjB2=PiRReaVb}P$%ri*N!b|@5qs~4#pjR9Ci z2YS$N_uH#AifneW#?H3o3Z0zder8XZ_+sMU?$n8p$xq!2hk1D8k>`H?_~_eXbG1~t zs6;Y{Ni3IFwTOpTiN46;v|k=Ihs`zIW{7)!{zmPPM0)Ip%;VbYGgh zVd5*a!iQ1~1)4IlY_1lwii^w|r6Vf9@c^{43xym*hjweV>a@!iDO7P9MJLmksax z!kt{DTx;!(D$#8BER>*F|9@uKfLujr>;JcFeE;b8KOcI4ovcHlU99yAV*+P#0AY+l zifzy?^|`0m{dV`nITP<4e*z_Tmffyagds0DmI_de0`#gSko5sh705PojWf=spkXDO z`!%S-()``7!AjPE=4-NZI~5#q36DyKd+m^p*fQT(sJytMfB`;L!{KR(ue?4!w( zFl7G8m$3_VC=B4ec1>>l&86qu6`PP#a(zfj9HQuVHz0}iY-1`KHPc?C&oDIkvwOAr zrsg??{D+OxJllZkz*{A%R(~+9GA7TM_;ma|Ji!Vxj3k7h6QycI2BH{;MuyQ(XwVkf zrAS4b-Jv3db14g%9Q4PGnN{oKUOM#lLvMcnR@euZd(L(anE9^H_Shn`h^ZV?pM*$~ zeaJG#beWITqzcLYRU@{W*@!|1yBNzF3u_f?X!6m^ZS9LlJrq z9UOuC)v6sD#V+^|=L;K7{rM8r=`)S8h0{^5J{=88h$<#A3U%nfOk{BI+fTmn>_2;= zYW&q>51IpJc7PGGoSm%TGBn`;I^YWw5DVGHdhE7q?OC?f%rUtpjTuN~C#q4dT7)76 zjc8{*vXO#R)F?@LS{w9eg~N|V#IO>TOyOa(0W+1XB*kbNN@Rch$B*lVicji3yX>?p z1odf7k-8>Yep2y0Ca>1p@;$JyvyVcubT$ls@a6*?O? zvX>*w2+FApu3)bURG_VBK%72ABzst`a)vP;UZfcxMo|@DVuZO3ElRh`18}Dj_M({U zzP#XT_uDU+j5_?mU7){e7w7083fMxgi9#hBl;wHEyld99ul&zl|Gh0MBjcOj6)W0S z*cOx_9oZ(sIqZ2{2Na_g^ap4IGBBOnSd2w*Ca$)N)e$nq<8e;ZuR=IEKGBhd$KK%g=ZmwI$F~)N>Dp+IIp&W6jMLjliuj1@d_W{q5 zlKk)~QRx^$F4EPE=~%*S4XGYg!2wsGf9ztFv)7Z!V$R2R+@`f0v^|r*nVf}EBx3|A zCfPKzg)yvCpv$DNLU}gN`s}UxirMB=C(;8y>QJjXRU;KXrYWCgs0G!=Bo<0`rCrNH z=9w-;p%z0LwNJY)gkqi*NOhE{8-+Fxp@`=o;;M~N84vVR~(*aKZnd3Go4o`O*aGPb>~^< zK`jkvZ{0gx#3P;v8bKGqhZvkW`I>u{yN6X+ z$}*E_5;$f<>I{2kp0N9w72 z&scog^=d#W-scsZ%OU4t^SJ57HUPbL0R|vs1g(shW3{F+l_m7Kan@^BI@JcI9Tix? z9Cx36$ldNVng-KiyWv%w+00I++6W~o%lX{gW;XKx3z6;0{i99Sdeop0fkYG0K?dZDugq@nkw7}Y`E5s z-#+p8iC^@R`mALkUpHQjYLm7G$Mb%qBAeBM-DNwN!F49UF2+dpXjD;1#Zon(mi4R- zMAY;uLQ?#9Pk&c_XcaPeKJ(ej7F3}H8?nT$wj;K~bU9_FFlc+oVoQJ*;c4|F3_g~^ ziyF3KRF$exe$apv&usLg7|ASV8zPYkOur^{>6H5?5+|OYeB1LU&nr%c`@G$1XK{!f ztV5EmRi!<^5u_a3J${;d3&PN5QrXKaem%LLohHT1Qk*SO zBf{CjaP*NZU;$Pj0bS-DGmK)TsvkW_LOeRvEum9&c8?-ih}Rg;9y6cOW~Ety3PhqG z*{DJXz!!bwO{LpJyB(2ALCUd3Tid-vGCfBnJxEg2h@6*m{h-+5%}UDF<$dKt^O&Lk*OamaPj*vnLVqg|^Jl%NMV ze#gWalT+Tk?SmIO`p}F9Ek(R4kxrUprc3bwaprj>5(T2KNXefipV zfB!G@xT$BGouz9x({h)AGsdTx^bUmF6K=mA2e&S0r;dhX*{DIFFlqSb;El< zp%TqVv9s)12=L(*XT5#Kw^14Hxpuc2l!9C}dVY4UHS>cWE!c83XhexfSFt~OwC?yD zY+Cn1@6( z!Z*HX{G0LHSqdl-Ud}`tI)jHc>F$VK2rt@L$X+vS_96*6j75M~b!fL;;C`rPb!a6L z(1;S`X^%Qksac9-8*9uWMme8*Zbul0nTcZfS*U6>2lr889Vbu&7(kR+;hfDq&{N#O6^LPlU9S)AVvd<+#xkEXnJ1t6)y#IR z;Y@eLo@G}D)G}t9Ys@iI=BB$lY@9h@HZWW()v9b9P(9LuYYcwdXurntsku{%oa%#* zeD~W!vra8L`Mh7XsZl9PG3Rg{Q-Vg?V%|>jJLgpAVY6Xk>BRcU#cF^Tab^WN!10HV zCrqA%50HlxZL;^dhXT}=ftIyH3t7)OjKvtE%rfnA<$k6pb=az7pSu^D1Mffb?(H8r zAvb%{O@Wp;7u#C31jopJw4xQL?0E`*x^_YcDIeQxgTHu*^OT= zTsWSw=;E1(QUE@D?qYWqa*>PHiNfQvkAD&<6$sZM1v?dCPu31~U^~*Cn@l4!gKIib zAuFe@oqE(Hn2VjakitLVKgzMKc0CHviB6;{8ZD}H!zZ6YE-!O?ABtPvviO_RBTjof zvg(ulAOEh)j6C_vWh3iIR-j?zmSdN@yKz6FF=)@yo0@g}lkwY+EpxAO-?mR5zixcP zvCZZtmYJESJsNewiDwQh|Ne{N`@--(26AV$fqt2a|)Buuk-bgdBcEq!e0p4XyJ!24y z05=NPfEv}QWGpZbdtRMfIr$s+ET_QZby`gt(owH?^(hV!h(Nyyp7%}uIQfY-_~LU#x6c_s7BDziiZ(C$YgI_-9(vck+^3&482 z$<3X7lygm&A&O9fBC~|sjIrw#triY22RqS>4fLx->(t6&KFDPVX9fxxg8~L>t=ksZ za@*%VfJlhDSZ+t$TM^4BGhoZzJ?<$iWg!!lqCOqv5OIfiuQsS|C;#whB8 zj@L+3Dqdcdpd0aw0uUEKnc(8iUig%SA{LurCoO;!+QxM}Y+hHK{C0WJ!}=2XxkCf& z=T1|h0+cIPdG5KBYq`ekbPk;L*y%A5<)r*k3@Jjh(d^dZawVC$3}>%xv;``0hirz;Glg8vHHbkc zyc}j5D{P4xkmDRS;jC~RvlC-eyi+Sf)91`dSo!Nbbf8#ej0X@o<++eEoSpUzg`yQ# zG7qpa zZ?s=)2TIX_Xx?KNsvGGjM0&?}pB?|=_xwJjVOp{L$GgYU)WJrEGnw@~IPvY|J@!U- zs(YzT!KFBeb!NI5HnHYq&POkE*u_%isS*S3>F!(hPE}xo;t^&0RgOsXupRLj)jYOv z4i_m8)!eC4_p!+{O^aEmUv7Kw5BU8hcg(ou@vG-+7guo;3e9xSI5Ie5PG&vp@C&X` zGqz$u@Ou$x?{k1{I8(()XFQr&g+g|r6G`7~IJEchDW=e@H~H#Q zD8f;PI%PYN<~%M5u8YMW1w*V08=BVV`B)w9V)s(|Ouc!7Ew~#+ss#{@9MowAdQ^@+ z3^4=o$VR=2O)M7~M>PmVx#DcGN|?j}lYm~&uS3qq-Dts;&L`%5{!?w*39ka2PM-fc z=NU6m?LOqjF^{2au@QEwtyMIh)rr_+>udoU6{!funjB|52=MjFbJx2MAdkhy1ovD^ zCZ3)6?ZjbbqDPI0;S79(BzQ4Ia*Vy^9!^Jvd#jsb<4*i;`m^oz{LRjClP2Fr2AbHexy-=5raxQXt$yiW2|SdIcBaw6^oINeni5D zR5YVsB}!BrM&Z>wg(^~6sDu|4w%i_aZ$t)CS&TNWGkZ~rM%8)lL#f^LxKq(zTz%TX z)5lNSIPvY|qweKMSEM4%3dW+sWYVC<95zkN^jzz_!@Xt=GdX|`>`*1ib`(0d8Do|N z*J6{MQ_UQ)~Xs!XOy3(q{^+7ms@<_c6W6&c8ISKC3m$-T&K*Cyw7^Nl&cnM@@)7tMCIMx8~T z6G#0Me=+O6KmYLAqjmiyKfE=3jUCYgT8;q?Ap<=~9niIH8 zQt$*uCF*v+cCS_h$JoYV8)p+$o35+Ry!1J=u!XTGLJW)5jB=GL*G{v0RILVu27bo& z;9S;w{u}2#{_8hc7(Di0#soH*G={S+Xqya^MWoXG`D4A~^N;`M_!kpbq8tP8G8M5% zR)Yo=iee5i*<9nKnK9=-&O2rqDo`BUpd5k^k-jye^f zKu;qKF)UNJtP+u@I5n7U&g;&3NJTMHf?1HMP4+Ci8-@0A_eL}yzkX~C)yE$hi&e?- zFOPk0%a5Hpl7+~LE64w92gmUcBySpsFGAP^{XjILf$;-$Vqpc@Ly#p9qGuH&(>l<4cehyl6H8m z@SGU(rCDW8;3B)n&eAUTB|A+!+{N~5?XpMh-HHru95!l?-KqerrSKXRD^$BtjLP6z zP9TQmcAEV#xGdeqOfE8p?WCD2X|@)lRt2^JL&!rnXPPQzj+Xetj(_L5&htO#9z>xB zK6T5dxG57M6V4wde{i3-yMvOr1gWS%;p7jK`zEKL2Bk;}h%eP4!z^Qt2Ca2pu(^{r zOn!=N(kwx#T9p-Cb&IiyHo~rT-f_-0nZXsVwW>s;I_!RXR-iuZ0`;?vZEW#8;`xs= z9-JU{u@e~>MIDk*tUS$gpHjcAwHKKU&TY=U?gNw0O-!&7N${}{-T)5^PVj|Vc(vR8 zqx-4i?B(t*WCvZG4Z$7MFx99COOXl$7pE%_gFF^8$C+=&gKOK%(2rK_32=B+vJ&lP zMG(dsd4Rp>^uULF^q?DI7*QPxxe-g1s1!74nqIJ*>>ii!qljMiVn7k9w)H4sscANa zinndH7F&uBmrR`KUT8<` zfyuAz-^@SoKhJ3%&uG=LRg=d~J8j0DQ+s}SYV?|8H;sKU{=bRyr+hi(xvAHOR)##G zH$6k16P<&z;?I2h)MH_hVbN3ndUjg0_ly}Q{bl-rsb?z7UEp4%5){~U+^yAm+VfY> z=^;z(Lc3P7Z`qHv34Q`){~wX8)jaLc9<7voxjqcf$?#vbI~NB?)!Q3(NnZTn{-1$+Irmwv^F9%m{S053 zb;|gZeshI+t3Enwu`Sv7jEi^(aWW*35UY`kkX|V@QV@zhJB!s)r1zR{(MPIk1{uw4 zq)IMAa9e3>AMDaBmB>1oR9#B81}MHtuTDzmIm!b zrrs=nktn%YeA&ey>y4oCysFowx=ptmB`Ss!X2`r*kLbtc0ZBB@s4vt3 zDEKfw-Zs(o?MY0@!plrfGE41|9Ldy=O1}O?YZ2*{F(k@HeIGJqx1^{-<1yoydQ-h+ zJfM4YKw@o$wwKu?iQ>i}JglG#9dH0##X_bT4@gLsidQC4h#c8#ZiWY)NI`;lkgpD@ z0d=ty>9u-9ZDSl;b(fy2$LK`^RFSImeP$v2K7e0FZ`TJHdfj4q+Rcq$b(#<3v95X6afyXBn9cy z$HPysLLIQZO_$*`?!%;&or#i09fc%BTgf^n^7O{==I|WWt8!H-m9kZeS*yxbE((wY zg+4W*@?l3Jmas)qWejyvgCuDT2gC1+4}JI#d)cP8AONTMBbb3vn!>i?v7uj zhriZ=dH2tIF}fitD!M&p=*nYPe3@|Y`k6FW(nH^SB%?gd%RBXI^Bp{lUIe&C?NOts zlUVtINNUvoO!)v&N9)AC@DoM9I6U##$y%R5v{f{o7J*OD!Iq@=GPyL&7a((c5tt{ zUNU4#>eYa1S8Md6=0@|l?uH*Bl}yn4oqs3Xdf5T}^4T?~&oIOhUV{(^Sd9|PL}p2@ zw#zF8DHT0kbSs+^YD34-54$2gA*`um4Vw_RSFod@-1|ulVh(^_^?2}7Ry{T8}GrAf1 z@W3x=prwAy0Bm0vcc>+L%92~8OC5^FS58cxxB(5UXPP)rk7~xN40Qsvau|0Z9e%wI zY4D*=w6y7cGNzX@c{=~>N76d$W?QN;z!Pc@vm{1l#H%ZGfjG@u^%FYX*sA<0FKX%B zS++vt;2s%g_Uu`6=Spe##MvcC(Q$eNY22j#iwgQ>E34>H*~TKJL?lWdH4h^dZf0>3 zaS{L07_bdUoz64&qMoD3fGc8lK;CWSAXDe-R}l%yhmi{xi_tE1G9pp3SmwjUUT#xO z>IU0A09qqao+{#`G)j`rvRMwl8@-mKZ5k;QJ=gps9ON=CWDD&^y^&2Y&M1dVACy>e zVieu*b0w#wOI+fH5AASCta;Ep$VRnI6{1e+(1`-Jz+p8I%%GdQs8A(ix<E_06cD zCldEFTdjf@6=*>h+EF8!;uHU;n}&P8Uj3JQTlr>Bzo$3j{qNbf8?PCcgnyzF^$Mjs(TqwJIQC_@>`7|mjO8P8~$*3H_KE}zr4^W&|h-9>Fb>_8tv z7`nVQdCR5Wl|gBjMfCG9(~UYKfN}L8XV@VVl8ESVlsR8EzkJVM3p?+;;qL3RAE`pF zF49_t^+Izuqiwg@EQslyCM=3`3^EO6Sle3v~y&jY(q{;zi^j)=HQB295X`9>bKhV@eM) z6Y)rq9NCH-_<-3znH4q+Mq3ns5j9{OgI%1M5tpvhKDb$mdN{--Q7Bg(s)uD5!HiT& zNIb0NBm)98qFpHUGKft2RU4Lxn6IJ);EdJY;b5|Hv04S{F&RO$v`WS4C(kwLGvC@y zZ2#sJo88uCG{TNGjMp#epx%#e*0X?B(ujZ_mM#g*t2*y7`vFE{HEL0eZuD6-xEP5O z2qaT-r16aV%;?#C+q1^q9K{-RVMNc>D7f$BmnCZ(JOhGF}RtGGl};8J6i?Z@~l7h{V#tP`|Dd~(36 z(%-+?|DQt-y#H(8MBvslAD%gLF7xuqEC0M;O`h$CYnJ5Q`0Zl7@Tyr=)+%+O$_ng) z5ag;oY7^X8!bMg97$*+%c=%QsMVjJ6-i(0yEXtX#yU2xIGB;{7o96+Dcz`+r|jV#8i zLaftUbr5}O11n^i`E~f`JYvMy-bSOu!GkWT)-}?sGSoJdp%z|gN2LUr!Bxh^5z8uz z5P(OgvruJQ#r70_CcU~^mccF_^9hNBq)C)I!n;ZB)nj5;qpFAP2qBvGV|BxgBQ>?+ z$$8=W*Kfb}ij?Kgp7sD{K$*YPt#?Kh%=(p@K@f)+Cu6$Vd=Ykrm?>lWB{TKx+vn~L z_p^|Sf9C(OpD%NP8e+0g56Q4zC*6|3dNiX8Nr*xYPjiNSh{XLZk|+t1E_1O~rqv=< z$WUxfZ1DwGY6<@*{8#ShRUBEqCv)NAD{Fgsp9<*1vd`>AdnBAOcj|@GfFuk<7{?5> z`s85FzuGr1eRgVR>4UeF-G9$Bu_^N&x8DMh&~JFh%1DLYM`3%`H%PE}$&Z#;@*tbt3qbhEkE zd`Z&v9Ak%ZjXI)(x>%w(LoZxhggQ(}E`pe_1cF|BYE0~TPofF|Mjil23gc9d%0{h@ z<1)z=pKP^2Z7#G)vS{(a2^XU2Mmwr7%`^n1N{ZOT0JcIO82WkWTv%i@AF?_;VU1Jhd}1CA0v_X-Mq{6u?p1;umIC6(~Hf@@K>@`2CaU;C^%GwIzq4ciT2An z<2AK`2lP5wFEaa|a}(w+M4WWNlrcSR?na_I%)|65pK+Tc>U_N$B1iRJW-4W@LL54f zfl}$y4JeW+8HA5OrFc1{{JoF#(f4Hd4f7&>+>+$X)eR!jCHv)7NoOq`dkeYo$aArD&}G>yyV{{OT3+n4ZftX5;Q-jiV!9jP%Fv zn>>)ZX#CnY(*Cynwcmc<9eMmRbulKe3{Lu0jByBFY(Y?3MD$jP#l1L%OsUlSkcL+E zee_DE?37GdDjBH5G&J|=Sp z9Y_uk zpx5-7qGMUcMJ!^71(9Vy9XtLt_0fAnGj2X{_qHVs1@-C|=cC(9I`;_LN!5cOySW3! z+NoF1xxhGp7Sv)j3OLHgaU&X~9c~oBjv^VB%@Ra0(vibVZll+1)2Ad;?P3(t(Fi}1 zj7gRwKj1`h5>2Y@J_w$?X^27M~Ws0Im8qz3!um56>PR zRNX3#zhex%%r*DwUddFaRSDhV#31}!qR1g>(LSkLxcR#}A;3Q47Te|G)9dwIw6hE? zEZ~e94UJ6)&(^4as4MwLl%j{pdYx{NE)H-TC_81ptW0$WQYjlTrsCBJ z7E7i0VE_Jivf|P|rxPy8K`NS*l18kQ<1A!1I#|mIb-;MgcF(M0q_F_+SwhJ&eV3k= zE_O2x15zfP;$Q*lnHAL)HD~S=-eMCbIlx}khkA4%fPfU}-5j^1svc}bFS0F)%u*2s*@|K$A_t}PvV`5JmlR2b3lj(+ z`qQt5YCk)F?8V8ePF^2Bec`U?0?8DoByxq6pcqw5XR_MGTxRPwU8;Y_A$7HJG5(Hm z%typ*_0q*EdjFr6F!C)P-HB4kQRU2LKb+_lKS#I_Q|zag^~Np60W}IQR-ydkQy*^l zWKP%8ck=z2w|9R(D}4qn$l)m3H1sk_6}x^qvd4^={FTrsvzpWDaUOp!V^|?mlA^~Y zg{f+iJ@Cr}y=c~l%|?`1?SLh!M8%_uZnm;mr<%>OPAc`Z@^X!ek%(uUYC{!0R_~J+ zBQj{-YF?$HZ78VRWO>jXA)KowdQsZs=4jWo8( zPVAQ`-J+w=i7dQ}pk600VMwhqTK|U}Ln=BXK(Arg9*{UQXa@9gRc<`9#rSRKj$sy} z33c4VK~;qy{L*ghQp;7pxmh381Qsgc8?+h+YqJ*g^^ zx~_PA!FdZ_UXVTC?kIA+anT2P<`2HixGb^s;^*dlDIQ&E#v;UVlLhK9Q(e0`|F1$>|c4l zZX9ou)%q(DHO)9KLkMXolTu4P7L6*@Sxv(E>|=m_@kvYRI)lf_W4BVHz9;9E>#PXkO9pM9M-uKCrQe$lGGk)kYTBuwSI19 zRCHvQ*@7TT(Id9Vc>Qm-ikt<2+^vsdU$b)x@3Z|I+YkZ&Xt_i>ydbO?}x zN|mjY>X2j|W1+9RB(UW9oBo|%6q7Oc3EOWZs27{Jq8Rn0n&A?aNVnLLB|+BnHtjIy zhHv3#7=l}zGLBL;u3G3uKejMg59)n-g>1!Eaa{QG81I}{@gffJ7t#!`)zX$3F^rN0 zC#qz<97c%U%#;+#kV9B!H6#Pmve4potbtvkq_Z=nd#L|u7NSCKHceS6Ut_1#Ned^K z&OY;TeWhM)Y*%O0pvqIHRlj&7PKsu4nB8Qn@4evNSsz|e7`p9&?+v0(8l*v-m}VnN z5rAD!%VKjM2b3~Sqe3s%2k|((asd~zfn%tbdZ|Y~OIgQkM59ApuAV2&edZ@RR%NOf zRmeDwBU5cxx2bx)T2F|Bo0yG0)up@q*3RhSe&vFPWD?pZ%QqZL|r07 z9N{DbsDl$fLqK};UKDT@pGN>4GR1(g!H7Y-ERus5!MIE#HS%UK4T4r_WIbw`%T`s6 z61XwLM0HxNdbi}DFW~ua+GPEtoh2NKM6Qy9cHbWw=au%ar?coxYcm5;iAByh(+a-T{wqab}JN@#^4Az*RpM93!Fma+nSNBeRW{dfGW@2wQLmWwH`^D+F4-!R zQY~GGmiBo!p8xN8M%~Yzt>3VI))uu&g5rp*4F1Q31WFJsMd8EeR-frP^XZwL`YOF! z8Zg9exZx8YMxW;MSArz?d1le#dr)w zdcSrcfD}1}D&#N)aj2&s#Y`aKMLi1;K!Nlj3rPr~Ky|PRU6Gwo-Nt>!ACbvRH~>fF zc(+x){@?xnv;RDE`@R5x;7(L>hzRO zw?fm%1L!at;71v^Nms;!fK#v3b)k_^*-VEdOAUUf^L2wH(xr-+&u-+%GV^{tCY?sK zF{9$o{tSt8l99#YvA^Vw6k1_UdSEF_Mkf(Jak!HHdytCiF_P8I`DED#j(j z91GuSESP=lFUy(-|29uAltMkMoXlcj_yav9H6(_mj1KN#H1^PrIEj^9J!tOM2{MM$ z@Ts`|CGRF4`su2aJ8sDQwKU44c`G~kAs-a4w(Gqtqt|u`k}xE_NM$|J*^Q5QCH{&c zIcmPD7fXVXrE=M!VpJYGumym_c+$vG2>&H~zn-hMs1#1?1SynpHX(8bqT$DcUaH4r z{k$(PI5h8C`H}vc9AG+DNhsJL_pz2uhvw z!oxJ~vH0U2X%{<+n5)(p8{j}DyY+rOS09bIw#b!q6iFhxEx~@f)wbf2DOoRV`nY)t zY3Q)h=Wdk2C7I$t72KSW90_Wl9JHYQt;~g2Qgn?a>A&B6;mGs<{`axVRh}AUGO|$5 zCKSSnkolZGrFU8~(cnEhe)5AKoh)$O_Q$1TwkJ6$Q3#m#npdHOUQXzZXKklXEdC@V zXa4bu$qhaCpM(bitdo2cOQmj;!H5}_ts;^mrKp=3I(LCYL0Et;)?x&uRwKj!lNe&? zKi7@)Og3?k)uJ4^v|czZ61pD~NRpk>DdUnTPRT#{&r=E0UFb89p1ThHaF{!#9aZql zp!sO{Cur0O`V`t_9r96XDZxB4DC20ilxmIA2v52rttuhsx7R%L>V-7&s%fH7q_my@nz&95V=5l ztL#TUlGqB5?3B&;A5;e-G7P75%5JOiDAn@pBH?Q)LXsev;#S+$0#?8!E^*KP=dACb zOk9#8f*>bYhDN z_yxV!yxx35C&*R_NDf0BfLAJI9CdIY390O(7nvwR9mZ{+*?wWWW96-P{5g9GLuw0a z(XWme2HY%AdF}H89GauEDA{E8fD#Wu8sZ3LcP;1F{OT{Pq zt%z-|6(vM$O9>omf$@dyH{y~?5iT;0*|sBu02*yp58#Xy==Di6mS77raab;rpnlFA zmpaLmDD)w~b`BVexIwQEA2v6l(0GkkBcvwPE~KIx#SHRE+mm|Oye=HgZf@gxj;Ma) zKHX`$%u}plF_SsNB=pFNuq*sN?C|3Qwy=!tXRirw(DSS#xJ~cZ!#XzdnoFHLtv8Fq z*lx6`noF;{`1XtD=q09Uoibl*ZK+VR@|c81aZ8&3l1fR0=jv4{FD7N3 zx#H~J@X0x&v&(Ers6a9M%@@p-dRUdH6LcdR3otQn?**5h{~N5wk8uu3=wgZZqzkzK zTr8Dlq{vDu%^$}uZc`6NOeug2u2I?av6nvCgM1OXWSw-O1=HLHFM44Y(LOz<*4RF> z9e~3)WP7&qxvD3gk3F;e^b?_OnUOJZP$LTIh{ibfGmFU#=|UNn9JvcSW!Xk9iCl=c`5PBD{qGF>x=F5T%!z zyLBZqIKv{ixD~0=izG~({@0o9=Jo0kW45te22InvNK*9*Jq0_PEppVbWLwaf26|bp zyja63bV!j-*B5^E<%vV5w=@Tvd-ohs9cnjg)PGfg6X6WAOeZi8$w=l-9_9!t#e+t@ zPX;BHb6AA+db8x}1{uf0mu3vur@aDq1NIKaqaZ=A3RbjN+2E;Gz zl1e8gq!v#62@m6M{4pF@uS2lwxza2LQEU~*I#8jKC0}YVU@S0psct=`)1;NDXp^AC zNf)MPeL4Hw>>vO3`_GuauqC?Us-s_oUGLP>DB-A@kb|;9Z$_dVmvpu>TV_NQ?Jnbq)Rtj1i%D+KMIh`e#WtnltjHwR#^N{zYOXX zdNrCwqyb$hLjiysw5lEIU9|vNs>irn{?~}yt&&-RRJ2GU>_}ym^s<0is3KJ!a#^89 zqeD>#XJ4sb)x&zJ)Sv|u)~oDf42M{6ntHd4fB5dl-~a4OjvA|s11ejMs!6GlHgrjj zY}EVUkxEJBHsfmB_K$B4)P4TB@eAWdV}Tw-3fdX57Lwm;@E%RvxJY)s)^-HwuU>n(80*&S4t%+C01{gl<1-8);Tw$ zUYhlK4jH!@ccV`H7-ue9*aRmB*up-Q#sS1(h#f3pCX10Sjgl_`MEoHSYuSokPIBUk zFB5jhb+>x|^!=LEoBAt@%6aX)>)u>cZ3#6azGAP`=)LB8z0bTp{6lkX_&?$6%(dsP zJ@>fzNzAI4f#~QvXW#kk4=(-7wSRiKHrJE;&^DV4oJ6%v>{Q( zB#0oZ*kh^p6ObhNGR^?Aq=v-|F>m|j-tKmvPu_oIAPz(Dp`DW)V7;`-w3ITMGiZ%? z(dd#vv7_{YUtMtL1-~kA+;RJ|WgIm&8J8dxWgO8v%@@OeK#Mf!_10K}DV7_T+wNci zJJ_MO%1UeWN*k7;ngwuJPk2B_$x1yAyG%%*isupxo%`46W79r~($ATNr0WE|Qx2Msn(xS<+BNI9zxuLv%Z_8_6XyKzRpz7m1F1B3Yb_0` ziDeAQm?Xf(3b^UEx+9Z0srGOc`_Ru8mCbA>t3#~jq>PCjnScA$>#z50_~50(bB}GJ zn_0|e6>~WvnR=yeqZ1*E;xH}g(r6)qGNsHS?!v+wccY%wT*6VLA_Eyx3>WGy|K^H& zk~1Ej_3#BHeUGkOAN$j{HXncb19#<{*R^*)eG*1Qofop9lhh) zOT?iI%+0Lj5pGC*bK%_Nqq<1vNtIfqnmB>w#=B}rualkPQ9~GD$J;k{%l=sVo%a3q z_Mf9TKaa6HE;@L@rAWm-DL-@dnWl5U-Ph6E|Is6jo`%1*Zj%Z88_}$gDk;KF^x`10 z5GQ^~MJDQJhmBa#2>n^k{CcOZ(`~YwS*n21;*b`}Ljc{DV4)Na@kzRVl=W(r z5s!AbrP~UOOU19d#4b^Ytkw{UtqN-Axj*FOF5i~EA?M`xZ(4HS=}*IzxO(&#BW1^a z$qMzn@zlIKF8KC>|2yxU^B%Ha7xj-h&)e>hSUIK->tCHIn5kFKs2>{BGHgB){vL-| z!fYf(yiW!>%mL};5Vx=i9hNRGpv&N7YGf}ZC;@3ScZN$g|9w+e**`W{JfoVHy!YjS zOZ}J8%}#Di9$GLr@xdqWt30*tk~urn8cDPeYnlb{(}6y@N*op^b}lE>j7{lA-5ef- zM=9G=2+FkN$O_5TgVwA(J14P#A??(w^)TyM&j90Dt`6!+Mz2|g^E$x=!!g*dQ1;XzA8{zj5aScPw5q|;YWT`^sm{orQqBa@yQ0; zyU1WM>Y2th2GK5w(xxU9jba&*{rb2}>krJk!WWo1`VzH^KWDvpm)@tlWG`~Wu9m1# z6{lnMRuOdRwK8(%-E${(f|YIgMxu1AUPwFhR3qA1p!!rE>&1@{>@1K(ec0TIc2+C( zr&nINwCB>nB_IFb@L!c;90!z(x6!W;>&3cLk}!xQgb<<~xy(Y6_UT^f<)m7}0C%V= z)UcfWEHp3Db=t)d4%3ekw6XTrzxnmU+rQZL%}e)hdHllYX!k5PCoDcny*1gV5$S4+ zIu7V%vH{H)VHu}Us#oe1lwueOaEc3&aPUpNR2HVVQU))7{+=7}xM}HcEZ##ia&=HI zLmY=BO)7Q1HREzxImM}W>MEqMgZ1K*R2fIJg-V`eJ>wA6F1=7v(1xQJ!mwmXB}y^K z5oR+VQ3#<^w@Heu;S6d}D$8`1&XqcPF~Jxr)q!H{K>>=T)0%jcVS%S77_^~O&o%Ew zfofMxEM^5qq*2qE+_&LQ^H#252K(N4^|i~p*SxOx#B}a8|6=ZtCF%jb z0);+iakcG$akctbhjf~}Yb8E=%{O&}995}G)Px#k9*4}$dcQRM^udxPKXOW&9)M?|4dVqO)L~4+uvN(fOY(pOUB!gXS(W2A!R)@sL#6PMl z^#?i+op@Dt@(`aiev3E^SQ5%HGXX9*u$8GQUd17fBOrp3gmEcC3G11vs!8}IQ6iq} zGKsRPvtEgnPIDiYnX^pKoKoY6an*N!e#P$-6K8&Vy60>t$~pU=#&IbMhfkl--qZK7 znL+i6dRy&cKg$u2Bw2ub6roCLrA(HgOBPBEJD5fUE3uDgAzGds$&08P5j}voGL?t(W^ zBaC>ef01|OAM$5et&h@+Shlf;O>EO+=#nmW(5oW%y}&{zuFx;B4t<=$Iyowpm_QPy z(T5E5A(OePm02jmgoyZbhs7eDVw3Th?I8@B_2F0H2APQo)}sm|0&yT(V2x38@d%>{ zcI##t)cX*Jb~tILQ)Sptrnaa(7S(czxUJk#qwds?GM=Ssjil&i{l64@nZ;X3lVVBK ze_=ES)DC)CfRJv}NAUJqg{lNzrinwGdQ5UG zg}EPf$U)9I|G9Y^=DTx$dQ16R51o5@_{__v`)8`cnP!QeP+qm05z`ipA|Bx$6ys*y zjeu>QF`;7k06VY*9=yyAdQwkI4yP@~Pb8F!(G_|tVo-%#sWVe_B6hBvDr~xa`_17c zX=%{{^Q!P0QY^LD!8kT!o8;?Cz0mf&?RzRLN97F!Fl8ZcY7jt@Y}E&msbUN-y~b;* zSe1rtdZ!fgd_yNCmT`6cI81FyR3P7aVSKRB*+vBBo^6vN*|Y3 z=~M&iHuR|ts)zl0g|0vgmheQ!jxOJOzi#i^6Z7})pc%hs-a=Q})mOgo&^2p23tqbL zq&-c23a8a!--S}CqFwn_x&$Ov>(z{zNJLWMA7&3jDb2*<6I|M|#= z4{x6PUid3uyk)d|WFVM%EDPyU1GmLib@GpJqs;tV z-zuj#q_)v}?3IzU(X!g-pF7-A@^sb4$9{T`Dwi8XNu4A~hWWv{7s7AIDqM@b>IA!) z#wNC7LXS(TxK*=irdl`c-SvRk|>ERVLsB? z$2H1@3S>yFWFlf_YNSAhF~CvuAXS!08}cKu36q#izj)D&PQ6hln7i4{IF&2Ydal+; z;b!b-fRii(kk3ju&>{6YQHzd5W@UKMV#Rpiltf*n*up$@F@n+z2U-xYAao&=$YI@R z9!ILm;DAI)zNDg-6O8=qVl^h+HJ1+QnWBLnkJoVu0lXF(jU1pUxOD!B- z4!x{JwbV(D{U_%B^5$oMe&&znRmhT3eO!NlENj+ox2ol?Na)KEWLfFdpiD`QUa$9=AETZX zs$5*+lqi*EylY&;fHG9S)X11Lz%B{W(3R9%`O&Q3{rq=x{`ln4t3Ll-uzk4s=*D4R z_rx17y>;U8?`_<_)qyhOjO{b2v#0<8DUupYSTu$Ll(B$*_Mret%wRRzSdB9Apcp>( zpa8iTmZE)3k(u+v`j zTEjm3*`q2LJ@@F`VAS5tOP_kJyc}ib>hQ;ssH605ahof{zho)hXn_YM96~MYtvEoW z67{M=S^OtB5&5j-Q2UwR=ogvHNqY5uaY&sS;1AT^Os-#_dC^jn~v3`&Ut6{e{8SIdc7a{I!ac`;QumEY-I~; z(JU<%YP?fq-cuL+dER$RN+I8{r#R*`-3fU?G=5gIyQV|_-nnweAE1^?K?KZ*kOrs6ZN=6$uen2 z7rN-Bhb0zx64oGCuLNXDPcfvH+in1)3K5UrgCH7ZT&JrgDw@?&DdUmA(Bgxni$lgF zF>#RoY}FqFK!u78>`d4eUlnQl~04cags{-L=~PhIiR6}KleZt2-l`-|<5zxT-d_ndD$ zfD+j#HBu)vEJh0{l^8)A;-pAi@M9ct=F@ti6sZM~BVsuv3h0Xxrsavk!vq5i^VF&8RKQ+`OS}Re#Dg@7n=7` z)Qg-kZcxiPz&Pe12$$NTeL6)S7l#GAc1ohGgBv9rLA!M7jZz680t~Q(Ys|&wBjH;u z0ZWPPZR27Uqn=SQ%(mnjcCL!}2x=QwaVEOno?~y5HuGLRA0^f}3BOfph(i{t%!}A< zd{dqA>nC13c=o+B$Ie`0-m8zxu3ggB$hEg0@##7$-ws>G)c!jEF9 zK{d-%xpn2oQ6+-{iTGIg5rY@8tVlwTqiPGwWUuU$E+onS2%|IbOA*|%lU@o>T8f?_ zxS7p?3+*wS(_XW$3Ty8QZGHUC`+NWK?Y^fD{^6s&A20m0cqDZsfbHeb$Qu!|42)_d<#~XZRakPVsl^kVT)th z!4b8IoWt_P1h4b%ZWe%#O2p&evNgQ05 zpi}CZi!6&5RSSoe+v!CJ2N6B{KeqpmrFRdj>N>ZF$CwLLF6JT^(6oYX$4H$M9?OwKzfOQNgD;zHW4tVjezNC1T?3W4e3QUn6?qHHv!W& z0w!$)G#A+$FRVGfXRm(AKkDP-X3w?eJI9#sJI45~bKE|qNTlkgh_%UPGgp~IY(O8z zwa)I=#Pk;~He5XFK8>Gn+_^pMr8!??QrNe{zX<62>Ov>Jk63S>W{~lWHkHboedWrp zk%CMF)#iL6>|RXjyZV*Z*%hUQa zYRIG1tUl z5<_b8#v9X^%3Qvsaw{8SO3d@lr!=DoedGzQDsUR>v6USR@qiE|8CC+aQ6K(YJ8}7a zyv#iqM~hlQN4MEYgG6pc<;EcpSUgOut=eyOe2Wyv!P65Rn*DEP&nU z=Lk2m4`t~?8O=-YT>s7c=C7E-nAhrO%%V=B~;+S$Mn7KEWjd7ha@4p)InM7=68rEY++ zjASS8b_Q&~{rc=(>~;<~K9nlQZt?U^wJ4>Zt1yIo%_tACh-HS^W7eCqoX=mYQ>k_@ z{K|vWs#5td7<%w1X9luZz&2*6+=~OsF}cn{EdCP}8dWyHc(aqg)-rtp!}t+C(%&?2^|M!g z!#MLE_n;Mv*v~<8(!?uGGj=gc*~%h?R9oW1o6vrA`xWzT5{ zi&Usr?Ua&PZ{+!6)u9cmRfBb(TbKFwWsNwFZzD!|IHm*+nvLeT=YawEu#+cIuL#wm zmXi!^olK7yr$9>3gk5Cfo&O5^vnK46yBt2{Bj4_~7u`?tb+&N)^1iEdZwq_HS!e?6 z=T;I`s)EmUh3KT#Xq`P#Rrl0CfAo#Zw_myQ$`kGu_bvNqWLor8^!lj2$gH_<2R<9b zrRbOq*FSyjH_eyB-Uy#N=lbyN&XZ_BsXaG7GFtM^`uX#({l9C!616mNJj6)Vi98{_Uuc z*>mn*6v5@AsMKRRX9xJ0E;7pWc_uwOjSqPWDvv&v!FTmXS3kV+8fJM7_c0$uD6>I3 zuKi{Y50UnuwbmaN7WOS?8&8Hk;#`kgaReP0V+Xqtk1Vu>kfXWq@6SxOT=+GQpbU{p z)|6^ZwCUkE5|xc^1tdkV!sIfE8QO(`*;DTITKt{PZ{P4x=+N^mnZrXbd!7%A?* zFOLU)qQ~vx`3~FMrI(|T!EBbfdu^ivs6`rvo#SS`Nwm>wR;-<8OEl@l7$1VGCftBH zlVnbkNW@yss9hUz)^luckyRJEk*ERf))uVffEh8%(XKQlYX~s_z#NWntJ&>*$IJTM zh|^9K$GtJ62-Z1&a{2)EcrhVhh1b2!46Tgg2x_+ps@>F^$HNarhzdPiW|H_%DD4$vo zWTbrNjqockzWCEyfBD6nf-f!o=94Sh?|I~bFMs8u2M$*?9Dd~W3;pRBa4v`cLruum zN)6eIb_J?bY0t2Ssmvlxk2$YW9YdjKCs>96YCxofKsbs}gbcgI?sC^L)!gCCXOU?( zX4#J0-?@2yLED#~%I#CY)~m~0GCkZ#@-&;Y!>-VlwQqdkANS5KJicI4RGfFOHHBhq z=AneDJn&mSK^9U#wZhg zX=k7*SmEAgUsMf(n8Fx)*})3CTm4E7{p)s%Im>1y&Aw@ux<|OrtTpk@D`BrVpSp7G zrFSlEd;dG{U-0=9fiA= z*UdhO>`)O!6QdaANsp7Y*xhb7LEg+&5c#YQJ$q)c%w(D4{Z%KOkskAubJ)bQ&J=J& zC2B&zU149eE4AA*(Mv%Fa#+Arj+h?qF&XY{?rrwyh84we1$B?y`L)k}@iS=BB74LW z3)iU@S?orOlC@g{k{v}BtIZzHnB&GER3Mr;zyj8xR+E}WqZbJgLe+VaEwpafHDMow zg|PxKB z#FJ)uh}a_AS!nv0!6c4&K@_pbu}AGq>T-A42#pr*+E|d^eZyNZ>!SCdU!&>|ZFc3M z!E1Cyd6_ji+Th;*-S1X)m%;gy^OiH8WhR5&tY$T;m8^X2b}l&2o3mlhIv338Yu>u% zn&^GEKK_Yk6Si}S=`#zJ{ht{~q$0)SG76pM0-HI7NCYw9tTo2$ab7V?Sf*r6pww=0 z-=3Yz3Jf4$vIpH0>}9oi$}>;ORRzZ7Lk^2s!A_(gPO@k0c8sgTvm{864^t`^P@zV9 z3PF>>0_I?xGl)bJ2bJkQ?Y@Y5Z}KmlG^bI)&89&Cwb(B2Qx37gBrypglHP<#lz3U1 z32HIkMP|8|syd8zL~+Cx&i=ov52;-x_E#A4uFM+P7h=q8B{7V8C1^#|j5kkdxANRG0Q@RM4yridMJxtTsCK&sX`0pq%b0~B@5LU%ZpEq} zAik2*@_W#vN~*`b33YeoYm~)2&dpf6!MXeRHQQ% zr8e~QmD?c&u73W?m906OlM0%iz5Ih8J^9zq|K#59-}T>jeeKR~efrkz=brfRYrlQ+ zbLAU0%nOl8vsHP@)TE~w=)_i9La=v6O<%|^zt)7fU0n*&;jQgpBiTUl&o zOlRm5xYfjk{XOi8^Ne%Y+-LT1Ij2ye5|pD#rxc@7=N4z5Iqf`b=9^r1zqQ(rJQb-_ zh1x4HDpX=;f9Kw17obA}7*}oR2rTlK23eq}nhI8Sk-bBA->7#1*zgPh?wyAebb z5;a~|bKW!!JOfFBOm91=Zj+@Oq z^#5%Ze}V*(vz2Wf9ASJf4S-F>z+oW7deroBBcW;V=wAet`bDC8G~wfZ?iWg zCf=~^nosTSuWzhsowp`pdfpZ#Yg)&C^=MQ(Q%QEaOWmXHZRYN<2j~3M?sdOCdmTI3Y;uur zwwiclYOl5NVk1hxg2RUVfd{ZxGOk_HYm5*rOb!s{jEE157oSoE}7>Se@uqrroVdM45FM z;Mve4W(HGv$+LK|5>6`{Wf(-dVnQ&LQ=EYh6^y(5 zjP*H(JAV7>#b3?EvyA6j#vu+Zsz({JuS{Moa0g7W+060whhBZAOAj>s_pWSL*;n%~ z!`ljxyqQH@jzM$Yw5iEX*)`~LkJ&^Na)f#8M=1)?fdPazST7fO-OK?ta|C7bag5Ds zbSK=%tf-99+g{E5aoW*Oyva7_RM>-x)kpRVfc@+2y$WcPMoqD~!`Y`Ijku=}iAHv) zM7veO98=3UWm>=5*-c=%(ox~{RMWlfGHtJCfk|~7)!HuiBA_+;kXiJxV$O4OejmOC zKHQ7}^MCvX7us_kK}$!Td`^@(ZlX;y?>0G}Pg4RCkdJb^L+ui$bzIYSsXb!1bD9TP zZXdJr>|V3YRGAj*x3W9z7Q0`gCY4hNt?VQ!bV8BxAqJZ;i8Rz8;AVx^&2$(a{qCE# zQe7P3N>nRCu}F!`njd}5(;P=9??V%Zk&bk(a^_JeG)L?>jJkF=ofqYFP z()x51UD%*Xq}#)4Nvlepx@oLo-Vd*P{OoZ0Kce3G*6b5kUzpt&J{5jz*h|ZATKeqb z)`YPe-ns5;@~cS`2$jo$tG{<|bY0Y8H_DtD=YYxAxYwK8p)H!=5F(gu+Dtt5Mcg^> z$lR}>ScUc=<4}rvq@Y=bRUkT5&vAA!&PLia1d)vhrXWF?wqL{UY9?UB98xbDRqb_S zEeD-FrbpSfpA8tmWpTf=t^C@6 z9-cR;fF@OsF*a~g5q5)JqYk`?Os3k^ZZ*b`g56AH7UR%q4w*r_#@5gN4W+2#B+FQd zUNka~u}I=_(}rwQr*t)_N@K`Wwk<)KN|4M77BCWZ$WtmBP=zY^OoJ)r2ybR}AS-xb zCTaFQ_kPuce=oe1S4_4kLOI{yJ!Y*vZGCpXJtC_uw>)suXJW_ZPDDL6|HAzF3*L-5 zIp@)^O{Q*o^`Xr86952!07*naRP=k(%da5VJM+A@vX!5h_uSmK=NwdJ*lTkh#4_aa zy=y-m(HmAg!^=CbJo`ala^K{i&v#yY^YSh4|LMX*A6#(1YJa6(wQ2^#+`~F=)G!Ki zZg#e@N2BiFW{rAvLN#bqjLNl4F954RRKF{R%UNyKam+dHR3kzA-3_Q>Hz(1HSlk`s zi(VD}y`AsXy#3f`w{CnQY1fWmPGB@n}(}I*(InCyOknCpadJO}Z;uKREFP|nY?s;v+>Q6hFr&hSjW``?IA3yV5cd2~> zl_`P6JCcsg+isfO1v-O|d0K}NHDZU&vytd#3YV)^quP$$h)^TOk>-uV^qLSk?XtcQ zVkDPDee#nx*4#dDTk)c=;E$TL8}KVc@_!U#o83bg#!hTRo|4s~pbF)?{PD|=UmeqB zdqpSEV}`v+uu+Vg&^Js~HddkxjfiFGoH=v;82Tx4smEkE$fi3@eny@jz%@9JoJZQ3`2@VuX0(A6h?87 zRj5(BY8fP1#hdxOa*!*b7(>WrlC$1vwu@%VubyHvn@o?=xrgttm~qa-$k74$0I&Y0 zAb>1JIqRMIEKsEyRLL?HAv08b_QFr-I$#KXB$KM|pY%e!g$w8HJbhW3=iKRB?o} zd=**9Rt!7so9{q~r>*Zz(Lc-jo&JQib2CAwY@&i-)rGwy2V7H8Dt zbBZ0IwNw@SYgXJhy!v-Vt?RG(+-pq1ld|Yip&nHc7NV4&H6J*=3g|wG4>A|;*c;qL z_c{KaOIVCJTw*Mv%s!`)*CNlJ`s?g4&g}pC^dq0HnJ`;f&2n>ybtH3nfZZrXXzRa@ zeLQse*(-Nm`3uf*10B{Qms6+>l@k;x({8|kjD|wzuVOHudTmfP+sp#bCn9Ib@F%W( zEaBBBTgvM?`nLYzi+{Um_MO@HW^cG_&Rzd+>0@!h>o!F9$v69(D^JL$R)pTLtq5wu zCfIpwV!w%0iuwgls~C>}yA+8kR3I0{Tx;&YI{0)OpfS~}$g>$9V43H!m8V)I+C~jC z)%eYrO3@0b(Y`wSNi)np@rv!T^VFyq8?6lFGYRSTgq>EV)9pmF)*Nt-hfOQoUE%%? zITGs8&w6jvPMB5k%j?8G7&jiuUN%j zdFk?n%li~sj~RxJUm_Np%~O~}8M~3fB90?nwYH3ftYelpmQmn=jtX@sM=9`Y6kUj8 z9Ja#eg^-nKyN%%xH#&((vyt{i6q_jLvY82scmAz-JK=_QK_dsu64PlKl&2`0#Q}5D z%vYZK-s~SVgfx^g5%ute!LNYbEk6!=!JQjTf>#d{J-6mowQ5wAXha$cIL>0`NbZW+ zqZ~0iomWtbb`{FHyWM2emJIxaA zVW#`vZm+xl(xDkUQ}*FK7qZ{K(`MNdHeA_um)kpUY2-H}<1ha3;*#manZaP)rK#A7 z*oD_Gy5ZQ3J3oG1X6=fr{e$RLfB4*`BO2ZO$udwH@vz6fwj2kc}el zx9wVI*RV_lID#hnOgxHIuS(>zgUzVYfDJMJVpC`0*`e}}Uby)7bQvDffW{D^k8r0` z<20Hglgelw4&gr}$`8MMVc}*yd)4p0F#Au`a);TCe6C_AhLIM+nd;Ym)T>`!FifXz0=?zdaC^2&wFpTF|+us59lGA}qEm@xAr+oR3Ov`^c` zw$dpxW#+7^kcWH@DAs*fUsAvQ6APGO_Fzm28buSga;u<1w?Y$oT8(Dd9C993oKh5r z7H?3X4CLT7&qg)PJ+k)XJqMTFk8d)XYmkn2_#~Qa*6dRlV3V^1>CD!oHYi2M5F&%i zRis#j-mnxvk($vAfI{@L8y zes!wRR(coV>8Q7Ul_|(o=<&+bI2+7Pb4Ug1vX3cXWhYFpb2jW-YE_g186~x=XExK$*X_7`r+)g`i-*nZ$=`HA#|FhJlm^vc8u~YUE)nVm#Y=k{E$E3 zd?X5L6}*C0?2U}qX%fSVMI~ynSDEV4N}lIdrmG&6UYM#6k=*P&5cURq-ZWsPd(-Sc zO@_%ZH)~i@zK32TS*=5AwmG6to{QxUVwOMG1^c7p=wSCJ-AN3aexn$UK;*cQ1zV>~lV8O9Z-9Qe`V zNm{1Wf$xM3NXM@cA~*nstZ0es6rhG{g>%H?A+~aLx$$7^1#{y7Y>8@xQu;v zg|;eB6{)a)6yj}_atc7c{gQ(4yDRK2+wAN#rx=H7UgG20Wp9+w%KMRFtzD@u z=7ctd-L19A=4q@#{`D5q<5>FZ~`x?!9&? zmSZcTm}@GWL{zH~Q?|sq?snEOmfe{0#^PF0AV1rjZO$tivIiCCokSh1HyPf@M4QVos^V4}Ra4DbX zY9*i)W6WSJ4`~P;YQlhm^kWn?icu+g(1AD;XST8%iI`NPA~nfDq_9+FEM+x(Y&8cs z!dx^X4smQ|Rp?WctWuO?*rwTaNO$Jb$4JB~#=D_OK#lS=h&Jz6X(435F(Y#Ug{nbG z=n!yMLLB5M{3v0Rs%)40RTN<_&LES!xx;KTH-vq#V8yk6ng0$y#$Tb!yzW$)HY6(# zu!U;Wgd&uQdS)1MN%(KV{}KL61lh+YILHBvBi=+h`G`l4=Xlm=V^xk`98;!UX1Aje z!l15N+Py5telBAlH=6@0 zQloZb0Oc%VtT|xnm2G{tgw>2>q)N@8If)!Jv6?wNg!3xZxC%ML17_cxzsz|$JRG&^ zz!o##*~g^NPPVl+#^!0S6YHF&K@9RRBZF)3CHs(V(J0eRuJamvT4(cAfpLr?2W6~R zvMTKn7PHr6Az$@4$yU-Np<7*c1L9R=qyN+CHx15Nhls+$&>E;qB|L9#b`~;`J9(B4 z{BY2RNcGs=cAeeD48(edIJKI@8kE4V0aa=;v=bRZ=~Y@3=B3(Hn3H_QG%G_b>R|<| zkg8#Jn^>f|FVFst`@-%FzXsFxByYel&ZC<}s6&}2U5-Hon^}ZPv|>hUG-Q{dm~Ey2 z74V}MiReNQ0Tg14&FthL#yP@f4Xa#5^6QNICK6ET^;8O2$7-|3Tw3tAYaXBXf_sDe zF}uucF{S2tb}%0by_ASWJv%XOBkh#!a2|1%n`oV}Ycz>*7BG%mLd#=M*hpK>W;P-U zaUsk%yTd-JN*u*7#+*vC#|(Jsi1nDjfbu<+{Fv8JNYR^;oixum5+1kCTWD`w>c2f_@Z*N-d+!cHRV^ zta=$`UU6<$hf3_2CTDA|egy-JH1{*goHa|3fo@D`w_P#&yI@)1@4;Mc(H>1mdP`@S zf)qyDb2d#~rqQ%wT%+2HN`FM(*}=r4uliH^Yi9q;{jqi2z3zL2N}22p?NifmU_efGx4}*lOa;a<;K9bSP>} zew4w_4h6JBDN%8eh4cDR!3s=ir!v$Q7Igm0x!XQ(M!n`ws%q^Coz(a>G1m_-_#_5! zGag2*GIT(ds#gg+QR~H_mZO6e-bF?gi#_ddKwAJ!co8Jc=4K?S0=5#6-Im)eY{EPxXq}SvHph)|)?!F;_5`|_WHu{PQFfh|{n)B8l*`9r zW-~hsW1*Kv{ULlKVFJnUyJzf+C}cdlxIS!Q*uPM&cGY+>ncW&j9)fhA_n}^=lM~@aqU_Npq3|rq3ydU+K0BMHtsk)UyIYwwg^1bC}3t6fo1v z`_EF!HBHfH=WSA@?(tfnRjA?s2U+2M-5#@0aZ#mc>lfxY=e( zO))|wH4(80Vt{?9L!xOj)h3r|SZG5)J{4s=ZC)qORlnCYmHV-d>y9n*BXlOzBaykR zR{6&&GavsGj0^-|;Nu^qL@W(%^|%bU@QXjGyY0py^N$IbkiU(GaJYF4Ex z@JIXr-@>=Bk(nswQ`m|+d(b8##)&pPJm6k*ci5ZlIxW^gd&+)AZ=2;NmleuFCbLYE z*~e73#a2uD1!6Rc1<3Lyg6i3TY^=eMYVj7%+x>R2ZD$>~(2o|3B1F(~kfei1Q@$42 zD)>z{gNV8M@7dejn{L>*@brRRj6)f}j99PDU&qZ%l5CnyhL4pTLJs;Fi3FywSRLBx zSxx7oii75mIb^$LS6;oyd{ls{ltB`Na$Alf_VK)pa3`*QlR-{7`#iVF1k^JQ6-rj2 zJ;geXn5R^)EZdG@bpgz0I@e(s?W#p8Pnu^u!{e0m)9f=MZTU zImIH+QNI-v_; z_2|`v4N$IbOshIuXDj?m?MJ!b+6eO{!;t z%4`)TZ~997=dM{`e(F3J76xJ~)?&u)MYN~pY>?EUezviJRi2QZsI=#73p|%MW!p)+ zomDI}wVna`BziF!+Vd#)OsvZ=qC6EMgo5{Q4_lFCDln$C+Qvi!=gksi;(4qMQ-ZsF_Fkr%B~CK)*~=N5fML|I7u6a;l2&4&PT3vq8JuS- zmocA_rdQe8WRJ1H+~Jg=&`!9g(Cw)WJ2~YwGPk>bWgE!>v}#20>}4m5LHbRz5;?;q z>;|sMiz%v*?FFH*zGnlG=BjLlIx8&Jk#8?ar zeD@ny{;n3?h-8+TYI8xu8VjwSInTS1i}}dHFp8CjNtJ30^#~$WP4cUT<*a9-TIs_e zi!nl~NHrQ}J{!>B-AR^*o|{Qk3SQKC<*7+$vB-Ypwxa^OFu;CJ85cfIG0w|v96}jW zSc(P~c`>z~Z?L*VRAGzyImK*q>Jxvt`Rt7!h8NFCoBQ15Etk(-?l%{)0#iDuMOuMK z)WR43qwv?l|L5+tSsCdkF~%G=pjV4*KN8S^MI7W-o<|N6SdL-Es#)dsn37q~8D^si zBOE~ryLkbLsK>B3(|>}Qrh(g(Z1da=NMt+Lv7Vc`4dY5yq>?qy?m{hFOsN7k)?KMa z?;fmGvF@uOFvECuViJvBsf39z3b|e@~Pa8qBMl4Fs6QWamJiC4|M(AoZnlK zjw)1n^U(RqMz%M9n}Kd~$h zEHPWzVK3U1wq0EsRk|m&?ROSBdrTXmJcrC-FDNzvL5-6<%WjUajEOc)C*b?qPab+L zf9jeqN2G;+S0(nCyGa4}sQVE@Kz^l46>D4A#4<*E;@OjGwC%Rgdx;a6f5JAN#B60j)P!yZ6ERbtiH!_Qpz#p|BD z=2Kc}D;43%^0MbGi&z=4_|YdHTm9gl=D#)P4@^R<{I=arxXau(Lukh%y}gKJ8>&Lj z5tYj3xG83w4r-0op@kiu;H(kt-j0bxh`l@|*b;jJjY`&Tj44|o4gu79dYF17A_aa8 zxff^eVvtd$lMQHN5`#SLJkKN(?cA*hm1&Y0W{KH|Mij7(moNR}@%G0;AEtT$`HVxO zUErR;7*kCHauiTMhS`ZWv?`z&&suLlrJgFP0U^3p%v8>(1PLl)Co@bR>Xoe+)Ugl6 znzrZc3AS;HQJxn>if8xHfDDdv=YK+ic+#vj4Q(7ovJ74?E_doGvN72av#(4%J)tG``c}(XjvssNA(hwTeW!IpN z&1M_3NRFsb$83rBa!;z#2GnI%ndOX9q0%&oCX9KvTlpA612>zK%wakQkcbABd6LK~})&k7zl8@0&3=~~olzg`UCS?4esS*3bJDnarl2ZuRg;>~)M z+C_F0`Q|BemRa&4Ry9h{5Ti}JiPp4^DV9+@%U&gh@B*96plN0|$4MpIN?W6Bd&<6y zST^vy=X_G6Y^A9S6`W!LDnnEnQ?|>lLwbl#p^SBGLgIfsR8*pD$?kRMdH2Baq2D8! z=gm&;V+AtV_oi+b`i3Wj|Mbx+^j;HJ>uS_ZVaPU%iI(0^*rf3qhr@+uQB&yK{g=|$vUPEjF>x+t{VFyYV9Vw1{F-=)(~ao2pY{Qc4Nw>=@|T| z@`9+w*^G7#X(fgMZ{Y2aG1@)*EBBn|pOuO@4zg8w-W>EW@-PKoh<186RM}h}+MX&! z3mRF$5k@J&u5`~}fbnJ(YT;KS%8=r{h(Sy;-t?Kf(P)S4UX^RN)_ArbL1dv3k*Ek! zJPde3qAg0+C>p{aob%u4P!$@v*;((rtL?MTUA+~FI$;+8s74tFO^@k8zL|0E2_ZP| zbz3(35xY@@5X7EB7OFg%_85{;%6z-bU5ZrpnEBqumufs58>ziE!dd9t6LzOP7D5on zbCFzjXB)N#to zwneldz43rd#xcq>4F69=yAKuUX0bB0#vVZ_rqIg_mYMUm#C2!?%4&1cJmUq6 zb|cR7)%5Fxy1c82Alpov=|L0e6`Txq+b-?3miZx$f!fgbRi^FII@BXiMHrK0F{UPk zzD26p%4m*rO4BIx%o+x!75~;ND-Pu2S=E}8FRO@*2cIC+@&U! zL7rE=ChcMjv4Kq}Knl-ugw^arjSB4)#3VD&gmhK14#n)0)NXfp0>2E5pZ?ABg8&)pP;Sa0%U#N1()K(@|)n~k9pABieeBbS&a6GXXEMdjH> zI}bTX58)%T(X3a$4Z52UL@WI0hF|4qQ7sy=9NU=4W)65WBw5PWILf(7_3F|ZjKF74 zpbpiDK!Q!Q%e_t|QNd~^YQH<}-iT?$@q_|wU@v^^VwN&(nhl^_^Fja^ek`^}uH;^x zuNUz-O)?V=T)-l|bM>lxTB9{+!cDebC3czpKTeug9hZ^pXO8l55l4`$plwkVrc61K z-Dx!;g>f8ZJ6e#4LYv|)l~3ET1;flhfZ2AdE@{6a><08>Hw_{X#cbrTog?g}F?H7O zu5^dYdNwm(6R1ZmpagqRF*@UU=I=rRGErvhLb#8up%gR7GsWsd{U3%+irK^lOkxRg(1A1* zX`W(CFQd(rt#DVnSu8RIW&q2bR%f9{8>2#7odu{s4U&|nF}ntpXk!*y6=Aoi7TIPk z%a}{QHy4zm8ZT6?f=wJ@w>id52zaiZ3F@-*6wqor!2mnC4qyuFG-=P-#T>^0vk}8A zXIhASXn?T{GE?P9Q0R!rL?g5RKc@aYuF5j~9>>?ZpX~wAGC7!y9IPhU*ioRv#D_U`pjir( zDQrCO+}H2*-1z%`-u&eY_Q_{I_jRvz-RoM5^=6ZBZuvw>)6Mj<$pqtj(1=3mG%>&l zItFePp$3s6QOII7w{seb*~(K`!ZiK~UL*ldxni~)w0DlDu}R%;{akkHNZaOHY*8%| z>ntt0UC&Iu^3;~IqxR1}efhH&2F^ctus`?cmSe|?xm2*=<>_ zv`M8Gd^&w&Se)y+Ne^1HVxNmGyJ-0w`@&f>7sk5hJQ$PB&a=0i{?eo-)VqeBwwM@;GTG_0#9s160G@5zULM9+X*4nl`F6FonX{y4CalInx60I}g zHN`#O#37eBn9p3@c9vMZu3zcfx147N7_9GOD}nt#%oE)jG9IM6cE(>L%;LNpHCjLca{k za_!f5O1YkA?_{;=x8}+q`t?pOkRo=Wi+Re&L^-NQq)fteADZM%)FU8$GRj6Z-FjCd z^^feQn55QN55aH70ve>v*-KF-`G}JZ`lzw{67fo=977tq;eg{<{UlmcqIv~xnaOm- zVXM?SJ1SaKxcFt8#LEJm#!M!uW2oSED_+|4HTDM%LD1<4lSJ7eU998|Dbl@iFLP0i zbPhAbUSz5kwFGsNr^C(S2_ZxRI`BbwmG7$9EBLLpmJ0&;bfeHlD# zS9=hJUSzO@)oK~XxP?uU%PO8?7E)0{a--Sr4yq+Gf<$RCK@7*3rM9SDlBIo8r>E+* zXMdxXSYB5;-0VaG3s?sw$-34Akr$hQc9`=pw=wYu8sVweTB8@+iQ^lj7MVCoFAG&W zTak_^og-;_H@jHjEFn8>{8jy${9Q+K8XvK4Wv42q4*?m}l?d1K^o{VVesxMj@3lRw z;%a8XhfcPzj2#RI$37lKH08u(%$4e)Ar3ikhK`oOxjWsAl z3dW3d#6ceAOk*Ls5tIPiRSr5OU*;o@aflPqeX;>TWWsBFHW#4MsBC=fmof<%C)PM= z#J?~kg{Tm}wCNGnF@~jRXA3K&Lr=9=!m9>VhY=gc0ElA@sZQ}jM5Elaox>1^R>rG5 zG#Izi45spR3Fs2L4nYZ*V}L!PW6-Wv&}U@PE9jAU>5~=-$Q+bm6g^BtzSOcuJ!jkRt(LyukL@wmvDNa_Yj97ydY=Fm=&&byvTbUVOtv zSH72a?1oRTy4PxycgM%sD#v70Ht7-jWhv3|b_7!tR43-C{pv1*^M>h@UTHxI%B2;4 zX<)yMVzXX}64amw(J=X-vPTvmfFkywMjz8_(T}4>_Z};&S);s4FWo?GEYo|H-pvp9%eG|M&} z2Zhe>L`x)sfmBWY4(GQzwnU8OJ`xtxY% zq>Ep2bgVvVOk2V+fI)O}NNrJjqzXMqWRdYDEt7fD#<}V(b-64Z|9bpV69txHUF%vu z=?cACFSie(*wBPSt}iFOz-TtnCy{!ey%bH1P|5_FHb_8EwfD(p`v>+R-uR-nd=^ww&J{)8}u1~XxwC>~j^r?L*t7oVxR ztXU>nYLU*juQ4=Xjq5H3RVi1XT>NY_k?@58OP!lbua#GbwI73ex1M7^ zVn2NL4Ykd>!sUWn7DzXol6uMr4in6fa2l$RWlnAD#BaQ}=Svzoq!?~VMkRt0rqht6 z_FD(&m0}~-ERZO;nQKy_n>aw{{z-S}p)4|XhIOVrT#Q_ns{JUCW_?te<(P!C+KdM| zn?|^CbtpEi=mCjyXo3;$2AH8Ttk-1(0p}`+TSnm}(Zv?E%eqO5bgaD>IdV)IxL-YL zy(|63C1AOXNC&q t~U+@ObiiB!wDg6m||A>E8;g`u%2&bzV>UMZK|GK%HWh*7Sa z{Opv4|N7$Jf9y=(^5drc+rH`P=nZ~x=cMmkKe5&`f&=DsJ08)@qL)5r13`xw3oEn^ ztIN4U|3&YWrRtP=iy2Z2LA*M}W$d)4=!4QhA4`y>!}KEK#9M`cHQjoK@1jTgq%XWU zB0VB|Q6PK!Ov7zba( ziE}Oe=8C2u9n}&hYvo7qOAn%%VZCBqi$O_49fqV&YqpTg)}s<9J5`2tmvyo9*$P&Ua*giE5{gc^i1i&RRj zK`S#DgD4EL5uM0G4~k^5PUC=@W*v}#Bb=2OW&SsMP75 z_D=hKW~*t|dRDQO6QN9D4u3{32b`rP;r3Sh1aj%;F5_C%j4CE@#W_cwa${qY#5L+B z<1JN*fQ0Ev)TvTc?|dGo9HU=qF^G*!K)*!FG1GNPlNQuUi|IFoMD$eqGA3Dy8%?wq67Wm<2IlbH_`6!YwP{O4T9t<%C1G)tnyA!LwC3c8qzK{TKe@klYAo*opSLGq=H?Tl9yGOEk8mJa8eBvltz zz$0N2hE`IoU{F=4Y0|7~bTfQtMiF|9jG!2mQj12ZGlNKNQo;oFjkQigVi4hr~*paeWFmmO`zpR=Z5|AV~-GYqqW2+@_i&6J=5^ zV@vbBxRW_wk3n`r`d(tUuq;*m&Uf{i&Zn zWvx+nsd}dnil|j;m7321<|1U<_7weQMCqiLtm*Ek8UGBcj30<7pZxup!b$JDE>WxW z2eLd=EctrTXp8hSc=kK}GZ`H7>1!ly(0BHR@t=M7+fec8YX{mx+s9{czba+M_yzVO z`aupchKJ0_=%~I|Zn1{``*kATr?wca|DrWjGKm}NW$TsXuYGK@oeN6 zW29Iw)~n$&=s1g=jAj)a1lfonJlLQ&>wqLmvg`yTR*rFv>pa&P)JU4~9~LP?r#hwX zH$ygI$U>(PjfUuarU{KezBKDM^*(!!0}t4wTCLZuQ;3j!NkllOsl#Xx(Klk3YErXM zC1KLd2sPbWW5#keAf%|AZ+5wUlS68*x?kK9hh!F@0LhZ57wCMHz>nqdn5A31gBPwmy_9l#9pQObO=5It<;+ zW41&|nm!62qnXDP459@ET&HH48?+n&ql>LDWG!F%bTeAf&R)sayX~K`P@S@Fl~LI# zqj1BAAiL<`q-3sB`weY9Nk4{2_No=kmQlS@R$>=pxW;*Z6eC`z=^N3aoZs|FjmhN+ zM>uLFQ#YeRWmvNyXh9=On8HFSkY*{9OdYA$a)_;{k|HTXj~VLpBb-5#@0P#})JmB2 zVE|Q9E6b%8wQRL!S+BFzx%DN0X4IiWY9)-rYK2PG`Sy+W33?<^cf)I%-$RmVga_@6 zfm>=N68%^pjp#=`b1`J1J6o8*YV^V_8yp-7h~GhHtYf{}!x)vMnw*8J`_L?L62K7B zjW2zkI)!o>)y-%|rgWoAEwL^)S%VFF-uS|^w{eR#-E}vLjjv<2wL)!?mE%Y4dojv% z3?Tu7@JPOPFlLrHTpqI4TeFN~Q5mOkqgp3vI#xf3038gGO&{Bh?P3YS5uy*7GNP|R zyE^0)Ec-tDXG~J{YCGN1i9rc69P#LpMwA=Vj zL(+#qeT_qESjR>bOO+U}RTQ%kkbZd8Mm0>2O{|jzQX~cJWffcci;r$NIq#_(H;y%yzBg&t{tvEZwc5xKiX=|r z(9RIYq(fKgO&2{eEoJfzzx-jsp6HUxADVt@$`2w+^j2A}?zd0c_o)=F;0mNO%izQR z??-m)O?nf*v2Ic~q0&K|Of`=L%bEGzf6ybjAKv`krDH4J|JjLmPR;+|-odJo)n`7H z4G#U58mVOr+v&5`SX->at8RMeq5Ga*ymV1o>Rc4)2kjfA60J;S0_zxI(k^&i(ZVquB-vv3QlF%Xh^h%^~iBjgg(U=rs zh^=gAgfyeyM9@}A1HDK`fw*OZ-h^&=SO_2}%i(1UQ_(L$l)310cs~!RjjCSiq)-Gw ziIOn2$8~(tH`1lE_4869vG&!H$SjOv06|%SL8*}(qX$9R)f} zFNQe83!O45y@;c;wEB^c3XGy1@hC(XYh_fL^&;k}Y%A3Pw*cF%=dDvH)?Ic1^3cQ- zmTeU+!$RRQ9aM}c)H#9T?1Vmrn5iA9V=INUObMzv0@L#<@# zM3j>bDdT>on{3_&2QNt!mM{r0#1^##S*l)Lf<&$KgCN$)D4dNc z5kn5vHNBga65`7UN1rNpZF}ul+ou0*-iVsM7k+9PEbDG64T zQ;O7j^(~^Y9or>R7uz+ASJ7%dda(<2h{T(6Bl;zT21H4XbaQ}1>J=2rkmMi_0f$&h zu^vH)ZWgkD!;F>|sYHR{1b&3LLOG9dqh8`of2s&fdUMgkQr00P zjZ(&5y49G@(U04YGaRiTTdn!7S<-`WRI`K`9FsnstkWb@p4M$rjwqC&Ut(nk+&qSP z^qKV=O)uhIs8-X}{n8_K`abEfe_-z*REzbEgz2fehCz-oi50GYPkI7F@((H28}yrY zFA5m0lBK|2ZMUICwX11#D*O^eBTkwD_M14MNFBxuYmMs}^hmk{S*LnbgdVhC8~+R4 zN~v2}fKj$cr^Fk_&UgoS+B?S+;Z=o(Y4?a@mu~wJJCnVLHG>K*j7OZYPHyEc^}1xjO%Lms!etVGSJD|& z9xD-bGOU{=PKVh6dYH(5Mc1v99+Paz*4t#IjOrev&2jedOlHY_a-)t^hphWu^^&MN z^f45&2T{7#e$!s7%B^qtZ>dAKY(OqjSdNe(dnszZwMzVwr8h~ReV_dWGt~W75r?b< zYrV-rS;_{kQw~KH`!aLuM@usu@)Irr=cW+RFH1neFu(#cywN7t$W(Z-h`SL~@2X*T zx~_AbCmr^i_DQy@Y=)%Ge$bx6PSvCGB~$0fL8jA#M&s2YKdQQc#6wMe#czBjIi}m;XIiWSull9WfqY+{D(D(`4r9$c^ zb+Mjv>gv;1+Z{3>dsGVh*r&d+R#+=A-zidhgDjTCvKxKqV+8@s)zWvMKEr@hJE#0vF@dc@onSuAG+d!|?E4Lbi9H_S|kNchFyqc=_;^#5siY;^PU zzd0B&6n^?Vbt_B#VSdk!qIah3nDf6y8z1uhy8MEx@4c*fb}KsdefnX8z#C*kRX8m2^wKcqCtTs#fbE zmxHH#vK?-;=`x3;BgA1As&>gjjr7yQ8sjmyk-bPkmIiQ$oUgu7E6@)Iy2sOlYQ{+s0w$|28p-ftP?Duh1`#m3ksoD-&TUck$kCB{ z1ceN87=^4eXe^h6ALRlR!pkC~)owAWq(+1@NmZj$y7eYRp^kOvL>Ynzmm<9zjezqR zjQ@gZ<1)pJF=ere=@KiWdaZFBBFmA*8i^E-Zk870qm0o=M;>yGBEJyMo>;>Or;M3S zVl;gyL`ZVcfn3%h3q5+1Y;p=Z8qEya9)wWBpgFjRGo&eeV4>kOovCg@@Q`v`$g1pr5ITLInZ{XCw1aBLTfg-1ZLrpmSwJxY0Un z-EI1nJxnoP>j9G-Tw;85MKU}Wmi68Et-^sB?aR!n+!@>5gxuIpMC{H^N;S6z8Fb~#Hq#*j3l zoUIs;%@Tzkqh)lni+M;ixVsZUjG|Gt>Jc*?;YSKO&DPb)dU}xspH#}Av`Mjp!dZ8i1Q#RI${UYHu=eHp$Yg#4Q2oH-dv8bJaFAjk{Q7&TSge z@9c9JFn*n0l%dG zF@4N_Q17!}8~+ZC27rd?9rk@j&R=U9!=o%T!L9>lzZpU%79bskC`O!~sSnC-ov&Y0 zd1{IEI(g7d493aeSCZPwpDoX;=C;M299(2e~{UbBX zk|uQ+lyc)mo{Rx0k|OCAzjO-_j}D1LJhNEMCT6HD5~gEyq70d?#GsyM?~q6%E)4;w za*&!zG2%`y`dP|#NH(z^nR=c+XuMTJlBk#3dl51@&=JPF0tiSW%9(|7)UcK5h++zx zkjXI)!3!^2(P2*G;$)|80Fa4Hx)Fg8hSVYk%{d;=bKmAJIs29to3GcT%Zi&m8 z`SSW#5@Rp=`;6<^g%nguu@tNMmZkPM)LQx&a!Qg+6p;;jv0lsyrvRB@t=@@JTRcX=ACGVd%Gt$s$u)`b%jrcE1{g#Y{H$h-^Gp+XJc>fJa*MhjCDMYBIZyU+ zjC)k7nOZDj3QOr=ZZRj_RnjaQq{BXlBH5%D%b?EDBa$f1QfaNJcyRdk*U^hn{1SKI zD=EhiZ&44ULtfF-Www4n=dhI#YNO27-`X>ArP{?e)YEFdwHRN@dpdw=NpXY zS0tX5)6$I3(%pWA+E#|xJ|<41v$fq`5R`_YdvUf!THiGKf&9m;*)UW4Sa`e5D)MPMsPdM%I$hF z{)tI!VvI^qm!T7F@@syN8`WR%o^;4x@v)o{7oKE-dKk%QWD}yz#Myu3zp~r>Z}zj5 zzd!_P%*kU9hER*EIL7z!D*lXi>DFmFmPxAK+KYI6E>B_xf5CLNtLLp$9G4e#C2r%h zyocZ8eEx<5{HZ#GKT3}L77s9mU*S3o%54ndQg%v_Ln-0qvv^MZl=BraiejnZ5S~=O zU>$oA!k{$hx9|Y&VJRaR_Sd(YOK{hwW^rl|qWng8b)sH#S^kfuF$LX6}(eb zs0zF%zm`d?;~RVfFR>Agm?XtG&S$J8YKenXaj6`Wf6Kr5IfpoeTW~%6{G1Qq0bGhd z8~u}m#eU^HMm@)cT!;eak!r8ykk)wVMiDPkHkI0==BPRHC%r@8#Eb9-Q}CV)$|QYK zKd+ycD`iAR@JHM&cTY*hll*?#ucD4jpYzGvC#Q`Ke>3as-}PyJ1>&>+=D%04G&CCvdYjZIkJG|d`TUW$0S^0k;^8wvr`SLr3}j7a48Bo%v9s^`2)VHs1#<) z&GIP+P^h-BNjlNYx#rus1V2H%+#y9Kq4Hk-0cYf&(t`IS zjDJQevrH@G*OM6ey2Xh7e*o>J%1aBLrdye&aEb zf{>J>M;g#6WpGGh0|c33I!OHe;Eh&Dzo>LFj{T5 z4#);HO0H4y#7Lx0mX+*B3YHtKQ#1-BMsH9->r3lA9nfCwGpRgXNXHn$r9p4jN0G}Y z=D`i;Jvv3C1vOG7LN~o?NIKb~mPnXX*_;^=K}&-kv~4z7S*|W*Fk5YrV&g0oE4Fm7l(i-%Di>AEQiE2m?$c{+ z%>nMWYIO7Xo8vE|6GM{5UR7`1#4coEqeEJy1D)za*OzFWG(Nfe_^*y*WFkNLixI-3W6WW=_c+Av4 z(#0kR!Ov1-q0^x^=^Yb9QX5i%1_1^$pz%jy&*1W2pG-d*4QV6Z-bz z-wc(;JazAj>baL6c`&ST%B!v~rAjIU*dUSUuR`2nW*r1rm9_P7Rp3qt%)a3mrm5m z0!B+R+NDg2jR0|hl%bRZtY;ApFMmARCw^9$jX?2|qGX!~vz$yQF&8`V>{NRPI9o zs-2C7h3rK@YQwy80c8$yt8{@Q|gU|GMD) z3%)+@Pbn$aK6A~!Yv-ocUvbxYPfXhq=2=s}=Kc+l)^*l&b*+iPDpqf)3i8aEGcS(c z>AKi;h4rYKWqsrN`S=BA&z|``OISg#h%7K6Aw%e&eeslqu1|{t-+wl`dj6iNX_IH8 zQbxs%?am#4u7{P*AZzqq+qSPk6$VW}XtVC)n7Jz^h@rt&Om|411mHK$Za(@ZXyE@P ziDKxa+NMsaQ~!@t!jB2cIU$vp_?dVe^GVKi$5PZGwtjb3c1uG^#@^LOetGohSl;+8 zY-BxrKc98S6+eBVbjynBpKYaAY7sJ`+@r{JC>bJ9{lQT`;Bn?7(Ja#f0|7qK_nQxK0D zS%Bq8<{?x&tIMqRXuaIN(Y}D$YP(96Zu?%_x$B0|&wA@E>n;=-V;MI}Ov}Teny0h( z_r7`?QaDV)!*X^aXa;)wB}$xS*@#wzbD7FRv`KdBmuu|5pr2G55sxVPq+2K2Hmcae zU93hH5>O@mvRDGB;Q+IY7OKRMf%GCV+FHC z^o{x&CMipuk~AY&?UYf8LOO@o2p}9aD&2ZreR%p;QZ)IF4`Mr-_r4Qoe)p|C-|lSN zaqxei9Dm~5N5A_sQ#cvC(GI;AhuPs>uSVm5nat%K)e2;s)Z$%8@3l`82+4hm4E z=jqkb%{-=Zh`nq?h2E{Nk*#W)dY&x9k!f*n{Jnb;J8htaD`f-_t}rwkMu4(`xE>9hrayy zh9fUXl~hVDQ&a-e*VnJh`0b}Re|X)8^9~}*m;t4u2T|z3D3jnup>&%b@lIx_Q`S1? zJ2xgVMI2)Zvm}U+)SwdKNI|&-&>-CiF~Qg(B}&o6YQm7j>1McDM;{=5xDlRAYbztYVW?Nr4oxg&~z>Bo)begI;7m z&eQYv@A~^0G%Bxp3-Mg%EEQ*wNVinVS~EM|jcAl}nsE@Vk$g#%KBlNy)-q$gR*emM zr~L=SGo&mjke&7ih=!LwHZn#vNsYuwxUmQcO1nS;SftnL9Y|p-BbcO?SO-u*FMDN! zedqW+a5ImkNI@r8uz>9xf|mn~z>wt1psYljlyM_U&FNf&lshGm1qh&1GNsluN7FIJ z7}Ti*4jFHWH{mxb=_czs>mRH(QBm*&EZ`AAYC=w4U zB@sOsK)fu#pwWC+t7X>f*q{&U9Y|2CxQ%|N_?r}%Rt%t%J?LkXv>;b6k|>6hS9y)6 z(9|#gLFtY3R%aXXnX1S zQyf#%REBYnNJlaqtQ>(TX_iX(Scnu9Neip3>67m6?fhcq*HfC4U;pttC;SC%JKx!T z=E~E5J#({_t!A-F7fUxX)qJ&!39?u=$RNki3%~Va>wuc8H%XiHTb6a&x_{=$sOHE; z(Iqi!W}TY4ck0Qhi!ICbZ|-9Kspn}g%c-+4)xn~4hKz?Bva00s&h~3 zNK@b@P6>m~bJcvcUadzuvyi3l9RJ$>dZN*O?llbxlZi(fYB}-3=guD|Fjtisc$&a* z|9|WjM9mAAetpr2cUlj9`P<{aLjmjAn9w_Se_a3HPXG1V-(N?sZqs|Eif*K^j!CN2 ztc4Q2K}XsTvXw(@hlktM3WN|PeheDrdb~OJ4@WCX5Mqe|Aa0fzte9d((lQOF=|QI6 zryu8-ic!x?4T4NTJmMt{$tW}W!fbS!*@O`MdZpfJ4&THiaSx%6L+muU(k<*&+1!RG z387y{+Ru$Y%xHC!+RxKV4y<_fZ8IrXYJ8b`kt?SB`l z27<;Kum}!?8Z~~2MY2gRWjR{eg=%%k+JYLLV?T{3=BjK|uoH!-kxp~GA1h@HIv7T8 z(#xG!W{{~ULM~g>HtSZkUI%oVev`ROu?|b7-Z%aThv;CFPBY3-?UYPscaSK=8JVS< z^(;goqg4zVoKh>@dZqpV{W7ZCP|pmtj&Azc1BXO5aLl^Ub(a*&TFFHgyi$dbEI>ZO zIm`(k?;vtzlb$LYWK{ZCL8?OtHzO0(>Rq)8{jyuy=oIBtBdN%hGKrHxG#Ft(ixgu4 zE11MRNJfu2bP(Cf0d+{t7mu#gmFB!9h$>lYj)WZ~mu$#GJPH_NoQk5P0m&?&+kpIC zs)E~%Hop@N+N$F)qu`M~iIQ;1(TnxdDplR3o|gq`{-ocwl(fx!@1r*lw)F?MeX{VQ z_x8Q4HmWro#sECXrI#5d4z2)h>5)!!su+&3-MZY`qhgrKL-0zNWTH_WQq>Gy_H|;> zJblq~v3Jh7?3#O15|RTm#%FwY-ha-Uf4zY;2%+(#bTX#<^Z`XCMOI&AM zvnP*FIeF&W)Ayg+zIWlr|J%1C)D(&j4G-@hEE+1=`^Cp^@0~KdW(dQrQ}3L7r|Zfy zzdd{X_#c=BH{9xe>sqx{hW6$x%)Ev!t*ZuyE`=F$%MS5r13ZOV&qQw&7{Yy z5BI+n+}1vH;O4H}_wKNks6&iMkB{n)h*(uW_pRszF4JrEN$JqBlH(9P@lVjVbE1b* zHD8^g2b};689aCPx5z0&D)s;A;OXFHuT&YbY08#E4`uNL@|q}H=Wv<%nIMek1Qr`) zv0i+xRQ(^kHc>QB%|a|zK5X~yIhz03+ar;uPaD+3s-2aaOL`VXdU~$79U;WQjZDcw z8DL`A{E}mQ+Z`lSgbE$UjjUxaiH|oui(_oc!24QF!A2;$dzP?LkWAC0zW(^RH;nwMkCWzs{yM; z$YLwUP%90x2_@WabUhvDN2LtPCJdm~P$|(P+^AArf0C6t%$|d40CDIs^Mb|Vmnt(S zRE;D~V+;IfMy~n54d^$13Qk6=;V~?NWP?qgTr!8P(bzG4qLIk|{OFWXM5;4w^$9qEX6r)XG*_DTxxN z)6A5A80)Q5uH#69Q+_**UaSDoM#(k6HSnSinZ{P82=V6q>)48PNwjz9t%yLCIb3gL z7l#zpDug53fM*AFrL%-dk}o-u$SzLfE;O2wwcL4s9#V(RWZ#>%jZqoV8_cxeA;g)Z zg#ip8i34hlbpUb5mmLnOMI#IOJl9E>eZqdqxEw`rOtq@H(q|FlA%ll9L<-ScFTlV)i9Gp6kf;!eB+k|SApbB+n(6{&9l#+ zv+$yOEqT1YeCfOR!b{Iyd~s^b4c}dN-sRJ;ntbJZ>pZ1>Iz52c5?|<_3Uyj^z+V zyJlO~_zC+D`Vs3c>%c^}NMzQxvmcv%kj# zq$V(wo~dutQ&7QZWXo>t6t(|bVvNS>{|@Wuzi5Xf4y&82o6e!OF_pjsgz5jUcun-Y z&fzSB_0E;EiNA(BWv-{>^t)#tSatZLE5CeHVx?IcV&-3T{_IKr{P*8p_~X0Qddab; zN&$L|QDmdZKrcYFQC#LroUGI@GlLaMv6>0YkY-d#In&vxQcQSE7$%0+7nsnQaQG2K zmrAv+rPt{2iclm)COH76iybgp!w@v<6g$WD%-K>VmPrk_>D$}&ek zt#1_3Ir>eus3dimT?lf(TC;!UjlXyjzm(e~j!NBUqLwO9;4n-kVG@!tgHQ4SK8%nw zIu{2q?F$9Mx@<-?pJ0l8VkYCFS(#9v{&vHFV*n`hl$Itna%$9w6Cc=@2 zA$)}(2JjgYF^W6Th1-mU$PD?lL;>n0>l^ES{0%vBA!_Y!?Hj4AgRT+Dkux$#pW1Id z!a}^td(g^hY8Q^1{O+`49XF{L9z>lGFz|0Kh;9@I7X#Ru0Scv@e!V742SWAxFihs zSO;Az_pUn-_T`5q8D$Ip^uXWG|J&{VxNv0WiG-26Bua9mMw0O!9>Ya=5`UO}*Ys)A zhHt3x{4?#_JC5g{y#35AZ!c=Q#dqPOvqvv{<@8&jh|r74FI@LbN?p>z%jYKbEP5(? z>_AAa*maQ8FsJ@G*--{xbu z?V_jVO#R-j$+Cr$*84Bs^xaNjcXk!gpb`X<#_vHBr-1i|*V<}Lwbttio!4_}As0@FSM@*iVY%;|U?L8e zvJsa$Te*5p?NYnWy%!B_Gzza-4^Q4WDQy4RGpo-=AA9ihVSDaK&dF;=o|*GS{EM;2 zE{%)Z5H*Pv{Jv6{;glpkzzCM&NfgUY_97dF_$R=iZno|5uXzh@!3}tyz&*STALCLq zYnR@O?+m3^t3 zCmvus|AVu5TjJyjIgaD<6IrPrvM;yys+ZKO>K%E}MCJ4#Ucz{K{(&v8y>Cp;{KkbH z%(hlpA38UJ-hdtyVS)2EDK?&qL6g+nh+@4@??4?ls%wo%tQc;Il@1X-&)&>xROzomT--_i{t>4jECoL`qUQnjTzwd>gD!t z#-CJ$YX1L^t2cqFvWnWrpXa=Q3J#dyn4qZ%XqJLG0_Fsknt)RZjtQ0%XlV(SmS|ZC zmYQG=C74=5rYTsO0;VP4ESOk=vtW+kz`bYx*FE_AzHj~SyOztP7r6KGo_(Hu_A{sj z0E=136>5&vL8fN%rR+gIk{MvK2tA~Z*7^rNq@#&0Vy>EvG`ca< zkUd*6h=cf(jFqvvJ(C%v#z~bCKUC9n`hm%b(g;K;Q)Kz6FuklZrOdO>Z3MW^;B!6 z)PIM@<%H$c-8nC_!_R#g%!f>!nl|G?Oia_k&G)xBMH-BdFDb^*u>b`UC}EOvoU~E#d&at@!huuy8iR~$s+}!ce>vmd3O@#!`S)g zf#QK;ro#&vvI$vI2OfxzaPvxg=J`oOTC8$!p{Cl$=$22}@HJ#n)7aKvqNnFjU6lhL z4KyTD^Bgrt{iJ?!U@O(9W+M5tR6U~)*bhhz3JW%;MuGgFIiog>q|-X5?oQqR@0l7B z9L?kDXCAGnE}6URWP>X^7{GCCjV;>$ai2y3C1(#-y8|9Dv{LIV*3b+lq*k=ao`=lF|$y@~CGrBQZI_WjKJrea- z`WsfLm#iJeHK|hO<2Q+BLqp-)#%d-bL_MRL@F9}8m8-ayx8x)uv5FChMI{!g)v71j zq5+m*I`1-rlMu=MlEQf0G2Wa@7>`Px#1T|8kqPQq^@ccJQ6T4H8s;Lzpqn1>mlU;& zA6f6A6H{dr0XfJ6FxtD`u&q1 z>}F41rvBrLEFSpGt4l+xot<4yx?EKq)Gw+l65)zvxPzbNQ`JGuQJ1}Zyt}uwdN=Ab zCvf|{!h4M$Ot_kM>6yzvpd10rvmUE)IPp)f^GPMXg+CP}9UI?p!80uyo^JY1(~u_5 z=uglMBe~UqQ`9=^N9#u{mML-t;f@xW1qesDwscF~9@B6h=T&RfLba%Msn#)<;!bg$ z|LxSwk1xE|;rhK*cQ4%ASe|wB;>}0~FqrOgR`!Y4wW^Cx{ynC@u#K}XwGY!b^uG+| zHdd?(TpjVzv-6G>v6ZBB+t?HEN5@Eh@L!0 z8x!;_2eMWx)M<5^Jvo}A)oS)vd3u)as5^2$kMM}RExo1pQx^wAG!-)?L|=4A7F=)# zcUa;;SYp~k_GGad%}6*fR|4JXUco@<3wsr&0zhc#L#87TbOAP}X- zAIV=XGE_E6iloAy-taKlEU9u<_8^o~nNCk*BqqAHo~a_N%hp#CF7stE0uha5q#CV% zh;pMRsw5I2C@}9ho&`uotRASpMu75TW2ERk;ssy0$zsGQU-gr5{%VB+2WEjkmaqVk z(#Qy*Qjo-KwV9dbmP|sD39Rs!a!GRRDn>ZrMQyY)IREmdqAe&jtGQOx) zC*CPsdSb}7_crg?Iu|L@Qr6g?w-3`gusk@p*Yelu4LdMopPp%d)_xz!tfIGd#X4>s zw^mq}t;-T37v-YlOSzQmI{Lo8PdDCT@r~2hU%GK~XTJ>B9k(wo{eE`iVQ_VxGe67D&&e%L10S#HU0>BwX*b4BE=K8p%?(1YO!FgD(Askh)}EQY`! z1j#}DoqPvxxWUa>Tjv_eN%Yx9r`s15WK?XsmQi)T*1e5)+gT&6<4W02+rKuFbq|!g zv~zB5y}hc<%GW+<^1_|=-+68SWyWt#N7pPF^vUtmbiLdDm#v|k6%WLEoOQmV4t?_d z3oks|zv&<9KSVth_n2bD0Ch=a>PC8i{r#HLE6JBNdJPRhq2{Q*h5(_D8lq`U*{We( znTg@kma~S2S~E{He^;~l6MuSYZc`%E4 znjlbm>w)xBbF6m^iOEZUVY^p7i5;w8tsMx_!}LJOXAE=cDH%^gXfjM{=4O+F6(YOoFEJ88{>OJ~v{;FDAgXO5S*B@C6?#*aEVvKax6LcMAx877Qp$+n7 zFc!)%IgA3fz!H=pjng@Zt#}Kc$~19>Ct4s1%`t>7aN~YQ$&yGJi3kkgDaLc6tV9oF zG63hrUk;P3KqQWc7e;a=O3)UE<%yg}E3`!?Cb2OuAX)vXj$x-sp9#^I^%>eQgQsvp z{?L6;f_G7kAa3JFxC(d1$a>w;{xvf&7tKw)MIRQbrD`++aRSjOLno}KjSbasiI!1F zK{cj}4`Sts6!DaL-LlNce+K5`Io*^GF$pnrWAj?w&+8yQ8%?)0uqL4lM?IESj9>L2(slygBd zYw5(3%8vCCDcu-kZMHtgblHl2e5m@X{&*4b{QOBEQq-{pKP)NS`0UQ+KYf**%|zUh z43y#siZGSqEUWLB0pt4qG4aln+7m*&+qLfO9Ry#rL3h;GMi zxi;?Gy6eV&+gd-dAJiYq9qA&?y&JT$d5wMJr*Va2Ml+S4F%O>H%6%&HDYEFvo-$GT zN?$`R$yPt8uhrMcGEL%0d0XDCcc}j5dS3=4bl>vwd(RAS_`Q4jKZh>7`0pn+7uzIT zoNR(G{43JS*O$-z>*XV(3qqRQX!5-0wI&OiUTN|iu3?!GuO}i=Pmr;CEWj*wml1MY zj>}w>%0xZG{<{ul9#8V5)G<`LDC$-WzWueGW4t!lUJ*{`3!)T&j}M*U_CIMDm{!8M0oJ+be>mIq%y z_^$GMn@#sW)c)2l4`%fp)jZ05Qk@*Opbb}%k9_$`f2`LT+JjAx)Q|Kd2f{+A5%)}g zN+n%}37Ejf?9cw(?`QyNV=~<(Ne`3bCQu{oY1uN89j0;@c3}hBGL1bP2u{k;TY4i3 z(^*j~PyM3$?JT%{>E2IN?7>#$4Q@`jb$#sjZzK%<87_LDEdht+yzVB4WhFMSOtq!E ztOQWPt&)Tf5rloRn8|F+d(7hoHBUa2EXiR%sg(1w4_?w8&S-$E+=UI2hD=|SVmaHZS0S^aDy{) z;fo*+Rhz9ZrBZ*XAE7T6q8}1vf?VWMHBZe1v5a@6Qpd{2bmcCdL=J6pvc z-EoyeP>LFrS_Im{*$8p=01_rAuoeCih*WtXn>gD#ZuLPNXK(=C#oO`%0a_JOPD-F$!4_$xe@3d>%teURxAh}lR}C$@k)h9HD+Z}Sh?7{o z=i#^~BW>B!-QO9zJnYZlBPR=TSpZ+evnM0epvJ1{?k25crccj}-qUeir#l`0p*LS= zPp2(Te>nZ2{xsa{JMy#qj3IadPLE?BpMTtD)rI7!)z4SwJ}H0lGu#0za|v+iS!>?g zV`jB}>wcZSu92=2)k{Vh*H=18C#I`Rm8nOV0snfe#7ej!9qBRw-Ox=>(6{w%ye%bC zV$_;59*uZZ_;9vIl4qR9^1#@>fAo3w!Qb}}R&BZ(f4Rw(n{dWTw6#yN#Z`|!cB3Ho zc>C%Xs~c2Tx3=1pKKDJVS&J=He|1aEu>WN%tIk$k)mut4LS0gm`3CZk2Y0x@MeTL$ z((|r+YQ4YeoEd!O)uF-X^4~pKQBqRA^G2`opsEP{jqY|bXjjjA!q(Il$Z)w9+&@`9tBHDv7t&i?!2{<~3m_Wrpi%WR)K_~XIN zsx1TF8Q3N;tU+_P9R~Aq{ERqJq_TR!e50KsF#r|18}Qjx8v~GOR*B z-cv8B(_Bq=y1*ON+=w6yMRTd62ijj{6wA~u#;Lt(GmgjxJi%OvLgVHbz0*QKuvmOV_xL^Oaid{F^+=Fu0S9Lk2)g0h*XM*PZ5o>G3; zhe`m`IhwifMlPHghau>J5PGu}+__fu$5t64qv*~SY{gX)B0lioLI&V7bz1epetAKk z!6+891KOYnX&547C4~j*7wZ?}u_?UXwlAN4jA!qq%`L4(BCVuOHbhB%(RmAPta(n9jLe#p_(EdeVi}Y=I|;#96My zHB^)A!3*?b6eD)y>R?%I3jdm<%lng{U zM#(5yr)x6+Jy?twER$vkK#9zfXbHqB?39s6l7$?C3V2I>xoYffuQ3Uq;R-6`1qs9? zBkc}gE`s393f{ttxWK7s059}YyVN6PgQJ!9DBpQE?z-V{f+DY*gw(ZwWwR{&wFFO7(Zjo_bnDZ``xp3;f5aQVLxSGYhP>q zYu&JJAOdA5v(2;Zwe59!;#A4I`J!`QpU<~%v)QVvz=c?di6~*D6i8EP`m~%G z424{DM|VBqe>w4pH-)T;3RZ6TRN6?OYUseWHFEB&p0=&J^ZDENcRaeq6{?Ukn!MC( zdy~g6?ee?Vsu(?FnQVt2?l1uRWF?mI9{qSqreF&OBSDSk0+h%dX~Z~QGnPtA5J5Md z;^%ykj(a?b{jd#pxQh+BkyXsrrTS%E52d&xD>;eo>N<+CRlFQqH*(cxwM})Eop3>g zildE=ZeTtJGY$plfn-ca147LfU+%XSteqJ=a+cZEJE7e8hMt6{$zX1d zK$I|^&5coi1l$n?U)d?g^)T)t)gpMI6Zhj9m&qM$L0eSF5E&?ct9j~k^$kkIUmC#~ z(^)3Vq+HJOp^9Q%oD>g3tGi1_gW-iEEVE{+&yg&Nl8kHY$5EWiY)+yZtJp(c(H-qg zP>r^zW}dpI1{pw{haT9d#<5tvq~>6siN21+Rjfx976Q1&H0DYMB(ItX9hyeOX`T@9JBA%eq`aTbBQ>h^*}=x)it_Q`O$K z1lt?3SVm&|fWna8eMfZ}*|k={PG&lmll_=|g?)w738x265A4VFZ}#61CegTR9kni7 zmz~6EnUkYXFP6pfvrLgIvJYFa^;+W9HrLyI^VjNmYci@|v{l$__Al%|+TZN*X4iRL z4%a(f|9Jf$+%Gij;J&MST<;w{c0KIz@XII9U2a*np=>Gw5yOcOKEBuRUh5NX|oyb9sn(4T(9{aFQG8})h1wO-Pyv2rW zDD9Yn6 zkI=vBGc~0ON3aE35X4+0TbI=z*5&;@ZvJ-f4esZk%&YdWO_g^>%#}uHqq;5-7{G#=pD+iV?*CnJ++yI;NVaCJZ;E3NL1>2;un;+R-l~mdJirSZlnl|5SPAN3x6#@-O+CXzNx0;Lh6rI-GKbSy32QkT?Q#t(hI zN#!**XDp_hx}^@G3PFq@*_dS}5G3t-yZ^p_kTtdAtUXKjR*rq}yw8c3XAUcT+eOY`k1)(Fg??5x1E+T`eXT+F$hPvv^PU{KXW}q8Ov_Jb9=;{6L|>- zzCBo0J;e5>?FR{wSY))@*1mSzQ~jz#lLmfmeQEt_{mNVxve0R#^EKydk|i0E;WWXT z;50!g(^_7XA~BgTa#5eLpV4OwRnb%VaW>O;{Qbl88IVL7i!RJYG6r=&(d*wHa~p+u z`gvyQ#d?q4WB<1+D2Dzw^UdEx0=j)|E-RmK1~)hIi}a>g92nAN0O3qlhZfWIyU@l{G~ir zu@G@}TDqKe`s1U1AG|v45B-Jx3q63hNEYq5edYL_4Ht9lL&fVUanw*rpSohz&=+bZ zeZ>Fop~L=_h1JyQ6{ef!EQ_J25to3ur1Ent zZdDJnunAcbjYwmfmV)gFr6&W#L(Uk>uOc~XXkPxN{KYbxWojzsi;uL#CWays1@OZr zbGRLgTqdxtUTm*bJ%paztO`wvata#BSv`ztj-n%h#=FuRz$WC1mw0h1XRAKqrMJr( zxH(!eCL8;_Y*kNvfItQ?3f@d*0ZIYPXB?8T*95AU8yQ40bMcTtk}v*}gDMmlv*d>= zg1!hug?uNo>$@fKZYvM}H>YCntg%|I;aj(N6O7cFjl%` z75rk$O0a(?+mWIi1@x!dQ)>U^G9zrv@Fv6lwLfF8Wq(%R&`0e@*@2gkt!LWbu)iU_ zj2YY%N2?`W9GCjiI%FNPZ?u18|H!heCRP*2QV{M#sx#7HLZanjk zRZp!`k4*CR!`#e+19O9S4Bmcm$8j~>`rC4{AJDt)yJd|0C_ggWQIaIm@fwg~bOm1M zf-Y7^$E7x_1!}=lTfXn~ck*5B&b4mT>KWrV{bY1tgAd(XG;~1>W-~_5(tp`&z4!0@ zvq@>?Rd;(lIwpJc7y64AC%13Z+-1x^QB}i-vxupTv3_&AZPybLtRH)NzzxFQ1q z2w+{+MExoc^fUUVqhui(@u+lE1p`DNA>u0@+VR+9-=ljmS50LmAF5XBIKvT*0)(Ry zAt;vuNp!4K!^cWRGz-*TRSY+#BMb5Bk^0go5t9(kD7rCQol|p^qi{%wq#{jvOQn8a zkCEjPBJ<%XIarJeWMHxQ8Ao=KcZ zgfG(IA@h+ayAj89ksOs+>2*F|$*sj^$u)p5(>%iRJKX(7H*Xiz0jHD)0 zeWN~5AK?Ru>YfLo2`$WW9Y-_2EhzhQ#nraTG4e>aLz@rZ z+5F#kUADZf>MEsfae)K9+IKsOmmXj^<%NcHoPl(tBaU&5lX4Le`@j0WzW?;^|Jd)_ z@7KOxyI1Y6-yHL{HRYyLUuP%hWzN&AE!Ld7+wQEob1h-%M_;5Qp^9C34vy~;IP?CL zJwxX;eD;|e?vrG(o+6u0?fP#=W%;qkm5ZPJ{FKB{$qECyCAvDw!tfx9eLnvi7 zicxHT!+zauK))Y8z|lsZWcS(JI=%ebT1-?(;M1b*pnwch)U+n7YIT z%sBI2QN};-Ayw_Q4#{^S`XH)Itp8brh_KAW21iQ1lpB8s$HUi%`-_n*A_w)98i6xH zk;yW6qDaQlk1H6ex>{4M|0Dz9SOUkY+Ai6WFD>bb2oxiLq#ujaaO)34S0XAT7um>? zL|KjuG?ILY)h+EakuPETsGem%ZF5m@Gpe5=2}2pvn8&V<7aZn__Ia;;_UBB@^Hw zUQ*5&B*Rzc>-MsptvHB#naW58Aqh*6jU*E^(3O5_wlTMT1{rcwzCf}$w1q??)r69! zGgB2?;}K3b1~Z19^hUhIN+cr12XTyI1kNG}(Gr5~5(yu~A)bYZmIso9a2C@KKKA!* z{SbsQ3DNCs?VjvoI0M)i84h?)H`RfDj6y6TP$n&r3vX389-B$8q8>YxF?-v-W>%zesQCB6V!1 zs$Z;j*)cdH30W z{;{3y=0vrCQ4C>-T6 z?`mG^i7S|b6lr8lkUI8xlbXNTYqX1fraqvjAPA`l-SWzn!?(8}2mX+!_A)gH%d~hk zI>e`r4u&QF^ccQ|V^!m^WlCEO)9U~EY8f_s&G#|h4>g*^r)bMCvjQ5|ZCvZR>Z4kz zJfjAUC@ehw)w%!ZX*4K>>L+!MOHhGSJTwt}9#RP}7SoLZ5~2^v`^XU=G?F-pM7f0M zf!Z#0^clSy^JSp@3#p@L$#w~pJ(4f;B~r$sNG|H$l8=12$pbx0e=PYJD{C0SRAx(- zEY`#1WBZ%>S;VUVQ~W}x5QI49A_Zy4f}?9`%tA(}bSoiwM%9R(+`uv}L4nkf6Ug9f z&Oj)7AP382ogQH@LIkE8&C?LZ@fI?WFTLT4iP*qgb6meqcFI`UAQL5Ce6SVeNYfw7 z8f@SsEHVDy-s(?IW(Pe@e{VmB6r9j0GE%>5{}0hfgg@ixuHp=rJe)05Evr6Ur2zd< zjS?JT8FykLgZYv=Cv{}EKEnk5&SL$*-od^NTagN9m8ZV7Ud1W~a0&M4uk;7l%3Nl0 zlC+g_X&^cFqby-4`y(7hILt&;vLVvdW`2Yec^A!joi5ynIF=xggZPU&#$s!!n!!S& zP@HY;R(-e(o-9HxDwv5O#_j4w2N*U{7R9`WDh$Ci`BchLBH>sMclg3zMxn3d(}nxF z7IP7f!x)PqdP)!diT(ioa#p&qmHNl}Ub@K|`>QPGY&Dx{>T`7;j(y8lb|MwcS;%Vj z3v%RVz1Y?hnO6IlW7E7pt}i!rQ+^-y}<~a0&pJZ*_J)n z1108wd5Oy7Ilaz4Q_sXzv}J)BpvG9YxroKA_~Ylip??fK^W4dZGff$XvnXkhRd0Z6 z)|e3!CX9X8jc(iO_P1WP-nQOWi-ds#n!t));G;HB5#eP)tAt z`r!!X!h1_b+R3d2WuIMqd~udsk+!(&S?w9;nK9wDnCMCORA=i~YXQ^Y2R{fpp%ZU$ z8@K5NdX9aLG>~^On8ED9A!?(#rEcj@Y)kFGm}MNtQdaT8i`zQad-2|lw{IS=KzZcN z<2UQ5AJiN*@1FxD>n{wECwhu})n-b&Wo>`6{b-wKo2SZDp(^BLb3NpBUYDkL1+Va= z$#2P|3zx`O4*bGD(VbaRU!KSlj}Xrmjlv%FdiD!v$$@Mm|L5OTPY8-&U*HLKR9K z*(du*mCVr?MNdW`ORnfEm_(uMcHGPe!313wQw~I4NG7 zt!(NaIMc@8rJJ6mZx{<&SGX|^QOL$Mtdv-tqCdc0qdRP?@|2P%dYyiaQ`Ir68;DNy zmBG?iQV_~QUScvA@)p`68gXj1_1Nhhl*<Bjo~keaDJ-D+hkbLrY+JZ^v8P4(UwOZ{?+;V zrE7O@{B7N^zE_V%FN*RWo&MZQ?b6zMiij81qZEg6m=O-7182CvgWXUTC;ob@{fyQptB%({wd>b9S;fD;qaLY-yvL6i%#))&9ou{4`6dUN ztZUL0(@da79l0ZSR4?_idYMbrDzyr;c@MMc%VIShzJ|3l+E}$zurY&~04J=!|J?lp z_dcrQ(axT2*F7K9 zCeZ$seYbv1{i%LezpFnTMNpz8M{@X&Az}JUCvldW`Zc+TWaGoT-^k4O@(541zv&a% ze)6%83;#Sm<>A7rtf~xKge};%>P}TvY_(_5@^0-r^yeUPY|^nV$*!({b^L4f@kZ=~ zL9DXHw1@;HRVYTV+*!C#bOQB za5em?XM8NNI@#eTik#mi4`YVDObgZ8=0yG@fv4ykof2k`en6ReQWJTj#TQV z?5|?fdsxNE>Kw|&S6X5fQqY_g1OwoP2nHbv3z3OV%wRCm)yvibOqB0M5XaG+fegbX za8r-eDTHDOXQLV`iA9kV|)3Hja4yvx23J;QNrMLX3&&Vd^NT8!lK@d~fO0AWf z`l7BaXY?@p$4C`d`B>)5cY2_GfNfRvVN@Usu99S;ug~iC`U@7rACV?^CtQ5=9{YXi zqG!o>$d^jlq_^t_wtM=fyeaP+4<;`&6Y)lv?9l^FLsiLC<5e0f*+>;1y;whsSfnx& zt_)Z6nXo;6D5-5UbJ^{P9NZ^70=(-+X-ADeF0FshY}% ztYBvM4|}fa_Enpm?fz*y*WiRu$&ge@bre_lBTd2_p989-$_iG)t>IQz)mpVS*3}Ok zm`AegAKNGEuh;smy0FG)<=oHaB@O)Ni}TY;j_5VIrM@2~BiDr`cCmiE@R=Z%SXKySw4C?3Yo_;)kNj7>+beDH~coOV(o)Ewsz_#HOJ~R{Ls*TLD{zJ zw!dVPIl@lN3oZ}2|4U6?wEjOaVowcZY7XFQ3}kBB#+o#p8iSg^{}u7nOckH{WSKLp z8kx@1lP{C^qfhJi^m}?NN+qXruLgZxz0}Ljbkg$VJq2$ zD#RjJ4Of3-lR21A6<0(fR==-5Mmndea|lI-IXCq{DFWezAPLhy+9xwgb+vYwCOXp` zDiv7?*)s->JPLPgH4LT%y2A}qB+0R3A{uuX!2XOgXZ01bMX$q25gp42+SKPNSFN$e zOPX}jy^(`%vJVjwBlWQp<>1WC#f6OuWG2Wt549}B-GvI8|n=;ly|umQD}=| zR;yKPE^*>2&D0<@)9Q$;a#Q;tRXQPyi>x(HR}d~vasqQ%hR^V+?xs63LfOCxQie( zz+I-ngTYK!D_B4m+K|W@d=U^=?4vs-G7E|DmJ`TSzgP$0jUyP1m2w^*@*Y>SInvc5 zs|ovIf;iKUEjY+1+5DxG9xH#-nfqCWNl3*>DVKNjuPRe*<~+pVB;#-u-f-s<7Sa!; zXdvt1jA`cNqOmIEM)u$?G*>^V*XK|8=*MsVD|@l@vr9jah@@}7y92xQAJ^k*uiv{} zM!Bq)txkiT$2yJ0R&+v!Zm*xy(f$(~4NJmd8U`#JkLJzjU#ovqib@z!`rk>#>n zRw4r#_5WxPSbtf7d-qSf&hgG@^--&9I4fPSk1N!-{8oQ#zpUSsk&dNntc;8GYcfKQ z;640~U>PRkWxU#~epSEfj=G+%XPvRWQX^LGTmIn7zpF#5GoD1#4HKE|cdFypKB*BG zM!z)j46ec%?ud6ZkG#q%&gE)GF@VKtm%68ZQLj7Nb6(>$*(CF2zI~g0qkW@#S+!P= ze*EwI0bBk4{j#L{g#^sS1ulH{FYm~fIZgj==Fw~mu3;{!J8kIv!Hc>79>381LeKl( z-s^GiPf0=!uALcvqVm+S=N>=zn)kf=ZQMU|zl*l!H|7q3a4>R^WBqNNvCg2{u_mks zmbNrfum)>*l6lONI}$5*>i^u}xBBC&tE!vZW?sB`VdKRQIGsZz^6bvyYZtA-BL=k! z{LOkz)>WUn=6>hmqc=;k|L*cnp{)p45!Zip(?;%t-Q3v_LnX#A;A?pBQD!4CsXRC= zTVzWOmDJ#-cWQ{EPfJ@dZL~SsM){GR#&Bk^Q5PDdCNIc4`W^8VCoER8u?zj2=c?hT z-t)nYH}8E7S2TlHNkPe^vZzrXk6ip}J*R`pPtK#0Y{5S9!H2TNNVR83z8uz_F%1P+ zh6GOHWb~6bnTBNUVidPw5iel_Qj7;y3&cw!ags2mvYncT2)JMf0@P|gL_DT*Au_O1 zddoyPp&v7olhqI0igw(F9@rl>>c z@|lWwN8gD!87mh}XhIdD*;PfU#*&2?#32PCCfOwko-9;dt#6RRWX2)egj!{*sj4pG zO_)FoqELh|IV#JM#wGMO33`z-MLZlkBVw=wX-JVeEJmIK)gT9PlBmDawIv^(5@PoG zb0*I(R08Fo%t9FaWr`WtRwCKxn#v^{-ufB)U-tJ9X+kT!rIC23bn8p&Z}_TUlH-(8 z-$Ck?cCjEsPpszZw!hbFj~JaYh*ji%;9<_!s$srk{Oq}mIamtoakE^e(cg8^>dOA zeR6D0zH8yq)1M!I;pDROZx`47dqhRljafJTtsHazjr+YSBP(xK)-kQ0kMly8(Y3ZY zl{j^B>I+Y)M5%SzdSpGKH|w%40vV2Q=fO^Go!eS(Sxc>@jN?+rJ))FSE^S?Uxb$#( z!O3#^qt@2<(rN_E}VU0q;4dYNObAvvfp`E z$J?Doy>Q(6&w9>!PTf*~uOier2C0YE4i#XHcY1-z^kXJUd5&wD$pE#PgVa)W8Bqq+hR6V28~J*c z3`CNMgu$D)j9TkI6-!WRj8%zJ@saQBf7$k%5LU94is4j~_fpPqq%#8*jK>m0!rMeY zX46xm^;rEcfJj5_3=22?s}RYl z9At`ep=2NnH3R+S04m|j1h(R4@zRa-F9>8J*P;Sdk^>->snSI*N)9g>bDLPTS=BS9 zF8N0M>?U5iz5c-rXd4?x>N3R2MZK8mNM@Nh_Q*758X{ylt9T2k0^{@P#t3PopZWjD zdKah~^Y@MWy1&mZ+B-xCr&N?tF*%fREXJuaPLNOHG$NF27Pg zu8pqst4n!!q13i4*7-&E1I{CqKXq-)^5j<2MQIdEvE)zv$r8boOgR*dXhh5TaQy{F4k_HHU_ z`my<>@14=1`Hb)XFc{v^wVKZd|4;gKb9*TED;<;$|DT-d|HCY8E?CV5)cyZdhSf|u zu(({joC{9`QiyFq`(d_@M_mqDeOt{IYP+T;N~1DVX)H4%r?LabXa;XTN~JYg+Il}Y zAcXA5!2M)9bpzaxCYHgKH^|k^OgE|$&&3Cbktg^K{3uwN&(paUvxC38#PVS=&rN8nu0^vv-bqJR=Y@}d{5Mjt-A5P#Bc~!>1 zjTnJ)Q z0a)>lgAmI@`3OZRq(mT>OOeU094I|a5|ZM$fNbC>oaniz=S8<(r<9 zdR1&xE+`j}#>`GAq&4C>1yXxDiE_>Qn8dCeMS)ldFPV&==$r;En?-u6BTFtY6;Ta& zwhL*+fe%K*~?WKOSUszj6 zi>DUW-3Pha_DG_WR7+`W!^ioJ_(PQ&eBb2BHNBuiw;|3x0d7p`y>8K%-BV`NJXr{@@sck`)t>a1CF#iAbd&1r_FGAEog zh)r`W{ul2GUj_;Re}heatGh>ZrQ4sMX!smS)Z=O56ZL7vnfm|c+@3Zf>w_up^}!Kj zP&f>j2~dQoIYR+9afm#kwopfrt%wz-T9Arla-$rP!V9#m#zIO(6a~ufcpqmrxvHa( zKr>KEvDhTW!icSM0g~xE%0wC5`2!?#9L2$tLfDfkm2@!%+3=>f z0r2OU2*xjnfFG5K5OI@8+!24H5jN0aG4c?FmApX?M+W^Xeu6ja$%pfB6xCv~QmlN> z0kWI=4;r;A5&o2gb?8LN?99vMpLC2s$FP##6DFMzMx(G6^H4}>qD+kC*7B5k2c;aR z9i|Rt3w$fOQxW;G11gb2y~&9Zg;2Va53>0l^jOFFNTOquLBj~*IXco_d<%0EM{NXPd-E*?J>QhFr&d32tSx!(=_;LjL--K%fB!JR;b1xj2C-pCECzN zN`!%Zc>r?6Sf$$rhi{C(L>LR6_Nt7cM#`d<;t(ZbZ`B{qyHaPx3W>h|G#YWGB^ zp1J{MQ4kbVbgS=Q$Nu$KGZP+7EFV7geRuvyE$}K*e zGvdmpITNr05qRv9`QC%><^wtm?ml3v`iVM39m3NP&f#JwwH4cSyL88OS8*R^FjI~z zzbU`9JK64GyC0S3x_I4=2Z{HF-Zy>55|CQ5uV~N%OZT?Dy15rv{?Yoo)+eN^w2&6f z*cQ61wPvUIFaC?n$d2sjW9{FM(FB^nk2ssNTRXHVZN2%{-#1#{JXY7X_CQTA`O{bm z)17XS(b9g9xtGD!S=5_tHD7lo@N~k(bhz*el(rbI%U6E=U`U$?ivu!1w&bt1xs@xe z6nn*9(#{H-7oq74F5wdYB_GO%9ExmYi<@E#ZK2NOrD4j4^3Zq48q*{F*jGDM>`ce# z7~R+AVqrWE+4?r39d)jCQVuD%3L0OHt?$F1;T{jnpOJ5NKjPybrv~`8;kojZ{G2Li zl_+Fij#TfGE!9#H=9&5ldLrsYFU@prjgew69l`-QfR`c@BRL11Xq2|#)r3Ik*^@_z za*->RX_*eC7>^q0sY$F+T4A0@6qgYNPhN~DMANqjp_^od&GL%+8XKuUIU${|az}c* z|6bQa|FSO|H9UTZ=n8!^Q;4t15_l7dFc_F+43dzedDwjrh(LsCe*-ybRDxUz(}EOZ zPzoQ4;5a^vAWrf5j>HIKxX-cx|eG>TSk(T7^eJ8fyjZ6rf~u} zg0p2y3K7GUkF@!e8(g&sSTbzb24x5{HF_MZB>)0`h$kCLfDM-deeZ|ec@qm6VD znLJd>Ep%WT8092&BKbAkdpjA)VmpmRG6E@rgUvRZucr*9gYL_xEvx92{q1&lFP8sY zp7`|j%Q+91SL7LO%2O)LjmZURca?I#y7P5a^&jY3>rRQ;@aAA_<#5Dth8Ql&MToYw zmoCT1eH_@(y75xeyzWKsb#|%fTkMtAZ^QdPc&zKW(0ob9#emWAG6MXeqt|j@CBh^h&KE6HeoA-5nzJ0$n z`Dn^IP}^tdPs3;!m+)aetfj-Gi|t~Tm}P2$5C|WNqHRAbJGaCamU)%NmpO0}pOZ$% zB>Sr!r+o6&l6jvE6wzXsa!t;Z0WyFzSfKiaI#Hb{Qr;~InFMNxcD}hwJGe;YWKO=e zDc2`&U|Qkt7Y|NQ&$26DZ9Tm8+Scn%M5fio1yC3{3CoWLO!;la`_E)W?}`Z>Zdk;a zoA34LbF<4&GC=)F?fd_4_nIxN?_yS)Bf^@qb~Gz#^O@kgrmwjfY<9qFyNAv5FQjWH zjLi;vZMmnhQFluDZLU?0W2ye-y~aIqpID@f6?bNfIRmE5UUYI+SHD;k$Sdj)4(CW- zAciSZl*wGm@o*zA3Zp6w;>3s$*F+UBRo`p+0?Ar*gJQOhqMLMU#fg`wtBbec-B{S)D$U!X}MTqFBJm(}N z@c$@+%aFyX6et4V%SX7Mwz8T}MoJM`aKs4?(;|9|$fP>jh7iQCE2qO9{j_A~neY)o zB8X?QFXG{d9EydWg5l4h9LrnDRz!#bilcMFRYVEB_*322biCA8JpAAaZ;j6$&9NNH;nGM6B0_P8 zkEWbzPzxu@Afh9vGwBx&kPCRJR#X;H0xhFxPG&|t()lb0U?Xz1!MX$ai6GHmQP>ta z>Qj`$4fUucKXOAQ44kgaX=C(24wm+ORZdXtDU@c3Yn(%|ltX@SV+)jO zn^T|&@sP*xBzcaQ1Tly6IT->yYT=3`3Z!U6h%ZHJtMP3{w0dqf&3u~fVv~8(UUig! z=tr44ozhkK>pcvOhULm$(UtDiOsm;k>q{B%VdDqG`wVhf+|kEC$`VApeNkHrJ?aod zl_F34feiVd?1ErC!Z0p{LmoP_I+A{EA@J|lU9TkU~ zrq@lkn{Lym^d)`C^D&x7RN#Y!mNNtj#mjLV0e*b@wkjO^;5yk^Zoi43uwV z+Md|m;|^4`vuyjY_44V}6Q&H2C|A3xUK|f279y2vIEI;9$|NF@AlkF5%#iVvrsV2Q zA)Nd1_f#h8DHIVxh!hUygun^Bj&5Unn1m z?x$ougS?b3tC6TKH|%@Two5_mw}%H*#0E zx7~hlau1#M`K`sir@b>4o_57fwveAH&y;;i&qc4M+l@Haes$aJtv(>43VHx=4j$7+ zYQjjIkgNGB9^*01rMBW-`EF(uZ?&1eKy$5Dx>Y6R-e?I zs1ItG`rPvQ#Fx|PZ}OtWoQgpRqgNuD20eT9^zWyyFE{n#(rQX8Yd^>FnPayp9dw&@>8uU<#c}14 z@(AXbi@EAe^`LrC>7~3-UTBTTJkekD5qNU-y)42l0^WT2>ijFaC-W=H zp0t%MxeI$}Fu6zX>-zTXJz0D!dWu8TQ`1xTQK}hd^N}y+i^*cLT+PdQxtLCaXb@#+ zddE_Es1%pqDXV+9&zSnGi!oC7NVi&PW#!WLpLXAWFueCL=hGdYb?9bO@!P2b_tQ7? zI+!7}`o8*w+&ZG9-B5!&pt;$b)sO~?p}7b~aI@V3n>EN~CsU&*t>thzyqQfZkMJ8_ zPJ6W8-~j7bhhXjT8#QFjWaYNF^DeHUP(+JplZF>*9ZEzm`nb&s!-^I^Upi=Xd;Oj= zMcF5=U1)WoIH$70r%Kc$4Z1WargxccDbl!6?o$6~Y!s`MV#ONUsJB>xA2^Z&X_RQI zY+@H?c?w3(=1;XS-|lo8QLx1{4qzLY)Lsvi!UeT>jL(n+13jSgJdU5qUI?NP@jr6Z zPG1*N8Vy4j8aWo}C?Y#0I&AToDTnC{I$5ra--1A-M&$Q_O_ zrwB?VbNFBys?kZqY!#4`s1ondNZL!e_+2iQePN&sc)$uq1fvc1AdKvV5T9ZN(zzpr zV*)Ma1@f@^gxtjwTEzqT2W*8Sc~XB0qQPP^I#VN+QkeK#yr#!=jA~JhNDk1TC!~Sw ze&KLAL*AeadPUvQgte%F5q>-uc{~+=ArC7MfohCH8G56)X0CY5u_$GhhxsUsyn=Gb zjxJMg@s%P;IpH`+jQr({^cLt8*FwZ?P#D4&v($XwhK z!??y&Kq!mcDHjD=$&SS`w9$AL`B+SQ$q9K_tgXN#AVp4KRl^Cu2jgsL*71%=O zsRBpgO=nO|BjE@ij*+g2g%3@mN+hszX>~>C7XddZ??&Nfb-DT*@2JbFt*jj+eo}_$ zBFpWH4;S_1&G4bhUPHQ9blf?6Qb5Cp%f&nmPG@TQeMN}qF80c;a+CT2b*2g84@wjZ zX%y|iVv6NtzRM-#F8)VjF_3JrUL-1sq6K!y4SX0oMGJA03Mh+SXUoN2nmS@Ey<7d})?)vS{{ z$2J!n*|_(6_U!3066a-%yiTUq>8ixH1erW+k4AVfS7^*AcK@L?G#XREDH&DLB(J!!A_nAXuCxM2^o{07yu zMO2}dSHc%z)CPKG&4kbGrXEEC&4VLCQGjs7!2?Bbr#$2!gjDR*@==QL3%}r4d`lTL z7+DCV5JaH}KG;m(VlGB-06*dYj6fjWCv$p*T3UxtOrSFIhfWKgdaTjh6KNhL!jUc` zmCn#Xu?9w-DkotFXYxS2#139S6_`#rGzcEp!*i*KcG3j;N3`XE{1St(k|CFqzt}0x zvoG(KYspp!>W^AFBPKu(7u?5Q@jxU}G%8?&byP=X9FABXM>`Qm5maH4mpqWOIiDQq zN16o#)zThXi9G&=j?yvFomOf{+wFj@<1FE=f!#2jAJwsf!a=h7mO%E z2u1?%qhYiY@vP%`cI0mAmw1VhVj(rc9V>AV1@J%?E>Qv*L|a;hGdvCHT9VM8h+wW&5BMFrlJ00`rgK$VHbpdgHBEo^=&AZ_QbWIn zj*TyQAn#+>&>ud&F#4ABi4NB728%1o6s09)h)+Zn|02Fq&Xu>S?EU(dS!Ii8UAoye z-S4_b@Z`_rht!(}iwp`x6d5QPqbLg%2tz4CK`25LE9?uZ!Q87Y*7460L>EBj%KJOD}jMovPCI8VcnCHu;fR$rJeQJ%RvJ6^Oc7`~<7E|=Os zw~tN?UD~4F;9_to>-?zrnMZAI-N$dgM-Vz8f?c_UA3glFtg_7fVs_@S3q4+*d(pAl zr?b6%Rp*`NZsrTk`@;K;Cu1}|0OTN2JzHUJfZ*5|EPe>s2Y{X!+kMdnJ6ZT zuHu3it^O>3mOt}<9KZppP>a=KFO}dxF7eHgSHJfB zskg01H65cuDpT*MH`SZUY-PE!oNUR3T-bwG@Cq*B|M)-cFvX8-sSowBS#9HK{n0;N zGUlH4Q+^WlH0;fSH(%783ixnRz^IdkgqDYNHLnuiwv-+6Hec_TUjj$+p$DRmE>*PX zJ3&9t%#AWMzUW*Hh1|@^`bh5L4(}#hncCt`Giy|{j5eD{@fg`WuGwPxZi%O_W|eS7 z4)U~hpL8^j=e4CD-mINPCZhlmj@2#x7SHV3wi<4syYk)hLvN1rS}tMNSBqbIzWVoK z<3*cmS5DkJvm)nM(TjCAgA+RoURu^tgQe4@-pCu*Ym$mUB3ga=&k8LG)#J4x9t$`HplxP;O)%HS%oQ`BPz zTga;zNI{~IJ{EVBvp8xhd>q3Ur9xq}5nde0uDqK9X(e3H8^M%?`&3J>D3`X=F=X;w zImx6;C==Vb4ck&%afSZo5*f)g9E=nA1sCOq@;O`(!#BhpYDO@QTXV4nC`m&& z8|51wDDTQ1lp%&I---3&xfpHImTbg$(QOn8_u44!yA+q3p?e z#8RV3qmOA64HlQ_6*EU1mQf3=(|l52(K1?woj8D_+<{;43k0H!=BevdQc1ir zl{0mbI#?Zy-rByHn$3b4Q%8bp38G757RJBe57npHc%)PQIVKzYCK!6))!dBT-9A}%HH~RLH(C+ zf7a;~YoWUR;5x^7Xt&*tk2@@KIPF;3*(*6crRrFbP(@o&Reb36ow`*YUF=-fHqh)B zw&oGd8j4nly|fqKn3_TC)zTQVHFRLTntyvy3DmmGGFXvZj)*G)vd18X?2A!8{YP4I*E}q zjWgeT`0C2*yH~bc`QHuK-(DOjJJI@g<3aNindyQ5{Cr_X&n0di&P#3MZSGl>Vihf< z!IUhfi)BdWX&j6Q8b;>BNPwQq(FiN}paE_)o}J}ebp!X~zj!O$w9M}Vrq+?% zsem$+X#dZq20g$Ec0&vY@JwD#BZZy#5p#JOZ$_^0qIn#~4*Zx>$V=RzFy()uJzOxI zNJ}Zva}58&Q#EJ#W=f`;;vhoc0Uy302gu`8A?}Ee;KhFY2-VmFN8Z6x*;Q;8ZN(MX za2xp%VrdYCQ3w^$dTl*Lk4rR?j!`4s5f6AO>$nRMHHlZq*x32<95LhAs7`2RMxct}(sIyqOMC6y_q2lChqn zcqY%(w*FF)4L4p4UJDqL9gEs6`vN!9zRy31wH=5^mT@XUIiN7hA}Oj^P0a zKa#0yPeCH?b1*W=z?t0IX_m9Aqtn434?oRtq&%vnBJq_tE-s)G9O3EfJM3oPem*(9 zCp)i383yuc_)sl1aVZU>$w|heFaO(Z%zXa8S3&9;c}hMi%X(yOJbmi-^Y7)|PY*rc zKQFuOWt;o$>YbN5=-LZp$q(deY7{r=N2JTyd>jQhz@gMuIV;+t4W?5TbryfqCx{>` z>;Z5TrLu)ylK7q@10)@wiQmu z{_M(_++$8xoF+H~xAAD#zwJgm;t%@zQy3N_&|fR+LOQ2Pct8@3+_45P+)nXmXe6Bxg5%&q8MKm$ zP>PjQK?#~F9Yj`?C4PkmM_>e1QXZwiNSDc$+psmiRo1wF{`0gIuo9h3AAYhGA-IG< zUd*Nz??bUf^cOu%YKV?vAVtH}CYs8@QQm>|NTliTA#++LK7k8oaz2$|Jr&^+vU!I| zZ;&mPP#TTITIg_sKSMf)lAR#2AEi7E@w|?i8z{llVqiJ~;mFR~Zr^F7a0w<*C(5A3 z@IWWHQyhKEFS(x_PRGcc(oFSM+{83k!I3;TPJRe~Iz!!&hY?c8=eP@EXbVlCAf)3j zUWt5o&}BMFYeWka@CdAc4YGMAB1EpZ0zXWH89(Ot`3HJ}D7dgQH*gm+P!V;9IbTFP zGBhvycn~V!LOC=Y2RIPEaD*EQFp{e14BXfoM=_A+@-(Uze~aOWhCA-VmMX{|dd%Zc zK1#myK4oDfa>d8Wer143qe*KzNXN9~+a{B0rD{=yal8WA6phc&$TleDK$|0NZ(8=h zxGtyh&NZX!v#nKs(;3=$7suUPbo#`lQTaxD`_2hA zvG)D#{%zZYZM1~GrK7YCK0Kb8TT>@mNCq6hOAbdojv|Zpiaw%>Ka-LCI|flBB5;7G zVjfKp*F<}sDrfLmidMQR*C~j~$cHnucHK+lQV@bUpJ!qls?kY|r$q4;^%wTal|F-e zjqc_;inw+<1dvNFHR^zxhCFg7}U_v@WYjZmolzifBmHT z^h=kny_`LqeuW3x&`!}+ToJRViPnf{WwLmptkK9%g;a_lx=9nY;*vl+X{Y#Dl#6m& zLLn4lTiJe*oqyJ{f7fK?X(xaHZa|U0#`>x{%i8q=N(QeQ*eH+lQvR+v|3?0)DsqEP zr*qIb$RqrSAIX0F9e*b`sPpAR9>;+kX#D+I2V=v7EB7OcHqlPHgz>HCw5@7)Xk_V- z3qAiQ(!^|KQ779jZ#pmey(4Q(?_nJE1&|$B~zKBAzrqp7!t${0-HK zr}R08@;GE889R7~sR^ZZL}-H|XKwkaE_4EQKGrt3@rK!WMZJIK_0oo|oR2uH6#K;; zF~NC`^DmArE0V&u&b+D|7YDHuNxYal!iY|YhX)-+clMMYsO>3)I#UW3qZSkBm6%6C zs5J=!9w@KMuH=Kon2t=kL@pR?k_Q?~ZJiG3q}M{1s%SD_<-XE_a*;2>(GS_|!`tLD zxexIaf;8B&FJ`g@FOcWRPizxG@Md4OK$)-=i^zsH&{+Vn2u3PxR2CwPyfkQE4wu0Z z`!E!d?9C^zkf1T-!M^a+;9kM92R+peUMs05EfX8zP6qJ@4`mz97Y30b(%2K*u~395 z2Av}(@on}&sFZ47gu($cxsnRVhBDX}Apix71TwV`7f?X9h$LGI6?SaF8<9+fqJ#LJ zk06{Eg18?G#A6sTq32+>ph^JIFtUZ5A<~4CvW5dVi1qLj1`5$A^)@Jg8p^V?uaS%!GX(2O0c>2j#6a%>B)OJC~{pc^J9! zQp{&pIaHk~gl>RtKi9}o`INn-UixY4P(~DRFrttSUyk7#bp|h`U9=770rr$`97-v= z4sX9l2~jB?wyAt^?Z<+s;;1{r$_$l3PhBc!RJW^fc{BXYQl%YgxNqAIt=wA7@7=HHo8n0Bw{%-gdY zW^CxZugg;V8OjSKSLqfWA2vRJyr_T2bylbOs)k_E(0z#2_*|NEy_qkH+kBSK zQii6y^m3vc!xo4@iAMfhSeDwjPCcxu6a>9fM$4n-$EZ&AlYVRuSGbao=qj8zQ>Jq| zVgQ6w1}zkYFnyvNeLSQA3axMI8!E( z9+OZEAFY}7q?2Tct?a`QoWuFZ(IJFukquXJWIcJ3qxb^`wnZ(KifG!7Ktxj+DiO{} z0M%%0rb6n+S#qZQg#73aF_u|os_#)K!pIIj!ayfcz_wh1c)67s(KLqQV2db(Q6_nd zDhdNp3LyM(0!gC4ERpr<=S|b$O$OnQ2nvKHyb*>pI!QT5UN5TqlLXFyM+F+8ur z*CgmgIlNJTL@n4<&qrjS%ts9$;WrdVg%klFE+BuTV<|IjgDrwNMoyALsZRKaLJr`; zvIK4%D5lDWDE&IS0fs`iti%lrQHVtKB!7-e~$K`9d(pEkW8_Yy1 zrBOD8unicVl&L{ULKQNzdMEG=3N zak=Jb=y(JFHb%S+q6|?bwsXAnVA%h6hFpaJjrR2y3qYm~P(#?MKHm|?B zFD}0Sr2F$l>zu!zd}!#s!DXKtCy6YPL1}5mbi+l9&OsfG)-Psyk6!H=%--b23&@dd zg(KI>meLV^nrYRMqS2on_}MtPi*?mB}ZmoDZW-*7$--|4ZN!^qUO^#F+UyrzWS$j-umHpybb=g z6P|ojD%@$97^eQD4pF~iC-mq3)DOXQ8{xbF>B<^qw(_-|2j2wzFcg) z?@*kkgy{m5$_IfD29-v5IQPETYYuIsWJ*?+DPg+ZY``#%{~P=XTHYkz7u8pP5a zaus#LiM}okY&27U{q)84E)Um`9eql5t$u3pK=)g(Yi&dHv0aX|DAyS}?l9}DOjdrP zTl5hd5y0_}gX()Vtt#DBAK3W){oSvAuHRmj)Sxu=ROSmCab8h$o=TX=rO7neszuvk zs|lOGiS4%ZwZk*p-j?H_X9wxVk@A*2Mk&Hw_X8@mZQx*v(jep|RHh|9M{5w z!a!PyHYnpbB(n!qA)0IvPN^D<&J2Y-jT`tGCDIv;f(IPwpcd-Wh#VB~r+7(jWQ9r` zfFDlt8`g2DyvQpkTCAcFxS^VDI9uM5&uItDX+0v)32l(g9eF4QaxhP$6y#$BDrqm- zVKaOw7-=+${Eg6h^6t;(mMt0XT|u%|x2c z7GzFdv>s=W3N!4a>Er|>kB2u-(_U<(?i3<6aXdnKDtW+$Rv|*{r>{7Y8@LS(6Ni+a zF-{A+Pscahnp%iO%2QP1GYrCg#A;xZgGj{+ej$J1sR-re6o`@h89d=bIn+ee;?VsY zrKwLQABjKWn(+yJMR^o8K=Jyd*GFUa^}qSvd062kPt{W7jB>f!RUQ(ZbtjZw6pb5L zj5ZvBOhnUO{7d6F9tWtNtmv|pqv2-D`muk0XwlEPsg^&(&VqvbjgN-wUMfSy(~j%x zMq9ZL&h<#@HVM&GMOCQAN?L&g_Cz}Wf(DeDo(CD|TZF(1onlj1V{C?+cHXr$@!R3XB|Nq#A3 zt3Oj3Eump3#c34K1o9^XFUDi`rZlmPE+YuOh(Je};|!&dmG~K@JX4N=7nM?P9rRR7 zX&Rs+8u4soU!+hW6(F4Rxd!ocf-2b$#HKP+2TKH^hD?&tz_IXTSB|AvA_~*?iQEuD zHWWh16e<#sts!bMHIDF7iYztSxfA&WDo&H2!7MBHG}EB5y1_da~|pEvqWbnc8Y z-h%`UWLSfZR7SSsrEC|IxmLB8UocB4t;r|~MIF*LE_y8v!<_@9_xNp^3~&Vk~}0fnXPQ` zoAtSNx2!H9OMTL0M+RA@N;Z}LKK zKYb8IKC}(F_lxcY7x^Ub*nMhmeNe#1qo+pc9Ss2n0|kp{5sh@t;T#ztEu@7A5-EZ> zf+N_%*|*2>?thzIHQQ=-Kpmj2SFb&hkLNsch7Fa$TvU;p2$5s>8efwW*he$U>w@5ZA~}*{1vlcYcVO$d(Dx0*)dK zVYn(k=Ms2wJWAO@4v{TkfgBX0R0N?fJh2U7Tq0xS25M+^@1Mh05JiR5U&G)Xh9@Fm zL?CLB!AG<_7(diPPi1sooP-;oZO_Fa2T59FgTE%u2qZfsK(CSD9e51D=?K&!eTE^2cgdL=sK!9)B1m|# zqueDUH8P=}hWG6U2lnI|xRVXs5R58vLMeq|BNBKhTsfOVIhM{-Ap%eUJw>zRF|wHI zM7?Ow+47A#nhI%|vWaY!3rcGkxI~`g7!F4$+{lu=P=+Ij#}Rg8UrM7KILfW+$Lf4| zA{GG@M6-k?{E)=ih(rk4nM6Eyp&?{5zY@6qkU4!f<)kjeoZOa`T=sDdpV;l-?8mF-|dvG5^3-8W_(*%!$ahSJ8y zx8J}AZsez}1i6W2%74mknJurVXUSCH6SQc4EtY}P$rg^}#$EWZj4{1(F`OgW0j}hO zZHV9)*swKxs7hSZ?us!KrXhM<5sxIUF*|BLfeI;+%8=Hyx3L0x*;ihrD9S(%yUL}! zl_DsM&U2a87{+SDh-7v}KT~UtM2_W11W+LS5QQ|ciGtuD$8Z2eD7}>D9LQtjYTBj& ze9s}B(k^=Bmz1yh>h`IA5Exu?mxW(S-O|xbN!~Q*oZ2KXUK?VvD ze~9PeIWm!rY-P&-!_t*UMOFUq=kwkH8NihRcW_B%&`cIJQ!p(B+Z50&0sH0x`pp!u zECDMEurdY9QU#9?EqF?m>O^&>x?cT2{QwqC zND-v@7IQV5j+SPnPE)(6U0inSV_gH+^nU-ptG!!eZPjgl>O^%THx|y@yDTf-bB4Q@ zZWLN6MeIQs;yIZdbZ^Fk?`(1Qz4Pkc{xij^so>6q#zs_g0nE?7@0sqFxcbAv?uG*8 zhHT`#C(SxfT63rfHB|G27(PjHc#>AiTGG~?<*28&&8x0BH`Se5a(8KK1WJV$S)D&| z>aNrEzV3C(5STG3)0ATMiR|Cs5RN1{QT>qPI}f!#PbAWnm)IaD$rY$(y_}`(a~(lB zB~XPdSNEx1M2K=q=>`W?b>&I&usQ%H+9YaW(ad~dn)f%AS%$-ga8w~jdwzFnzQRI| zLKFMS0M&)O#7-elt{LHM9M6-eL39<@slBZ>@|Vp-!d)=flVh^>^e9C6M94K30K>`9{ zBv;CW4St-=IjrX@3PCcNwZUoQEDq%YlyH@-qja*6JE|#@A`lDyMQcAh{)z@MQaP;^rk22!%Q=;du+e_Ri5W=eJqEWN@*R8=oI>kx<{I#oX?Hf)v z*?&t(6J z!~f~GY(~(OZ^n)6J+A8!=Qk)utAj0v30Yi?61Hd(gk0o``7{~fay92+iMGNQa2@+1 zlO~fNaybL>@PPq=2qecnlEITW7zs3=JSl>bDU&VQVZ#MEPws{vc2W#Vu!56N&uJ*6 zB5{p2QJdH$_H&Xf;6^eDAvUo=eyUzYE=MDln_%Wb3Pc=5qDGX8{p5)zfa~D{Mm^Fv z_eq>2s?myy+kZW}`gq)$>tF57$*L>6(P7=w@k{3_wI?NrNKqRRF?aNwrNcKE4a4_3 zA98-P%N=#N9Ig&`dtIMyM^Mt-0N6Nbg}2hRcBI zLCyX@b^o^4b3F%eovh+2Q7S4$CC75Gw&Co-9$lVuDbr^qXTEoOwM+Yww&wOV_MYme zQXbS5re^zfzvQxoY{;f&l%k$|#3{sZGLn$WRdPj5N<+-ub+MUCu6;bZL-+Vb`!=ef zI5PV0=ziH{;|HsTyk#iSJ4RW?lN|T|&&+xfHKVDnoDRKOrxl)(Lm6(wfXI9LfW=qTUZ-r65Yw(&&~Ti%a-3l%NWp$YdML0J$Pr zi#|pCxA6*|q4ji})>DdTMkskA zQ7fr;!dcYwcsB8UK1ie;f^TNMgUUFjo~Dj%#XsUjA$TbFWnYm^xpWy30B+(-?#mhI zLHU$`BmNlF3drNT4h90|Jr5Ln$4{_-!~*m{`arq+mUONCYB?JZS=q zWJGs3VF4APAB>u0B99s0lAYEfi^KUFxZ+s`j?+y_rl-UmvC?rdQHWoO=p0p2BrU-U zq?^{91h~({B zYxO&@AeTJF2ecB$$w)znWrMtc_4Et{Aq~)UEkD2&Is7Hg;IjN(9Z3tsI-%k+f6Gzy zhVq%>f+PoW6{Vt!xXW|-66evk^e?TUYjlPD@U|90^9wRiO)ok-yNp`DaO2S*&f1+j zKd}EbWW``H@b@`mru`Tgh|B7G@*^UVO()4+X&3$%hu;XWIxqO=!!29OPR>8$eQ9d* zbN7579cW2y)wiv?KIP_9*6MTiTK|RxzkRr`;MmQk!iQsQ$Nf)@8Z-31lSO#(c8Z_| z`inCW&rak_on(g<4S3bDykR3c@eZt%FV2geh~)sZVlz8&UsT{v3?*mrvgnPzSd1JP zIUc`oFh_F%3ON~06iJ@sDSj2tEBmobJJ=pZSLh05!9=-OPru+tdLT-v3nKVGD4}y= zjBtkqhwv==Q3YPpmRLRBMiZ)eD&J?l2%=BLH8|lv{H2Cwx4Tt3o5bf+Z%zvL-*EEh zQ~RroEi>D!9R>0x|Bt^pd+gMPb8cR3ec$iGa<} zzL;J5L*VVN?>x5NYW8Y=_wK}p`43Va^|^88+H(z09d9@?{P?QwN!|6`#`ilm;Me|d zh@X{j#P{+y^*Onp+{BCGMP0npYtBIjJ}Q`3^r5^eAIo;N*g`i5d5%*!s86PuXu zom_^wRDmG$)3PJ}A}@Lf6D<+hw1AwclKXI99#2-9A@>V8rYD4EKXxa3~X9jX)Hpi6e$#j zeB@FLN3fpDkfu5Kt5A&yE|;o2thMI4akQKz_pzC$tHop$BgGt4b2Z1pMsZYxaE|8y zj^)M3;#S@&N05W;Mk4|h+$wj=5pua!@==0%4&>m*0ITuAW?P{xtbH&=QW$yq?jCZz z?^(p!&vkZEo7kzDz%wZs<;q98rJwqLGc-@%a-k)ut;Xq~?j7CBx?@gLl#5#3?Ih=^ zx}nPNoiEt0$X=cA+CQ|Agr!=Y-BgI`#bKnaN?^MG|gJ?3WU3$2*Ji4l`B~$Z} z_UmddS>p7i^E2>qTGr(e>Ny-4oWR9ghkA|Fq}qF^zru_{&frF#F8vXW1WI9FIf-X+ zIkJ$&vCOR3sAUny@*@bksu&;EY;&{xV3WR<<-BCW5nK(5$xWOsOe(CIT3droz=FYA24-tUQNnKBWHNI6d~R2M04 zDcwXH)zT*NLmL$#U0>!hPq|1zN+0DYH*z?FHC|(c83xXvW(SX;T(VJVV5qOYfB$Xk zvOGTh)sW$7^l0HloXS=Aj&z(+mv1b*+`TD2vG(G~J6~CX9xiNm?>Hmcm61=h?7Fvf z_jLD=#;q`tR!;H%MfRGA)VtpKwsQ32zQ(BD`<0_KgR?N*aaT4r-umv2TDj)zvWug{ zHr)Z;+tenOh@D!7u(wDN>%=MKpdLQ#D`#oFhbCC2f!h!$@(6LO5rz89AJU6qF-GtLZ<3fhFVrK6yty7m4hNx?tnfxI2T=?esEw^K!iqeYkcI;G zl^e8S7s0`Nibo=rVkj61h{RZq(gI!Tk*Broqyl2Tn5|srB;F)1A`wMmB?2iIL0qV& zss0qFj1l^UM7c1)hiWzN zVUX5t=#C6-;%cfP2gCOvKl0KtC-iVbk+wqZV4F6$1_)1Osc!u3{*9OJbyNNp?4VC# zKP~CH-L-F*;WR_cRw5n40*bbf#5T`mNe#o@ryYgg&d|e{=|@$moH1mgzL%yW|6P_T(Y8~^wK|nt}3>j z9&*C1{DTXkbx6Cr{ax~<&o#7N%pTsxew#guBK=-__W6ag^pk6!*157U$VrH0Zx|>K zgD9CTDCQ&)BuMOmH+fPJ*K-rwXsl=!f+Mt+<1{vK3$NzA_=)PsLXuPYSExi{sdCNT z)n&MI$%^K;X20C7y5l@g*!SPVv(AR;UeT>p=9Ml#yrx)x`|QnEZ?3+ab#cO_8}co6 zhnzr5M5!`({Nv{Wp8d|ZcBIW%ba#k#MRUxRkjuv|A3C@8^sIBYu>-TncyN8uxdXq9 zF7YoJ?Jiadp$Hfeicov?G6@B)6KLCPrISHUIDNlVoBQ@Bx?n1=*w3=$&_sZwW zkbggIP41YJ5qEWXbF0(W&mvoGX1?&+j_MKzLbxAWRi(iiEQz-ZjSDO?mzORx6UAvrt%O5mG0he} z%1ZWT0}n(r59D$LA|BPqr)bf}t%%X6X)+m*%HA?b)QZW<9%d0ok({F*mK$k?h!dqq z*7g_Txm5;mG56x>R4U?>&pC~&)g)@8NMT}cj$$LVQY6hlFs;KbxFHXDF!EN^AciW* zkD6&e&88sUMst|Cm+HeTQ>BYm47C%k)C_O3l2OD^1a@+$*6&=Qb!qR?lDSOWsP0y~ z@M12J!$hVCP=XPMO4M;6H*p;Wq7ezGrb-GDrshu7}hnxe1*F%32> zfimEHzkXi%2*N_ng)4{}w6oAQeZJFN<#Xp}oX#q3JRkG9VbZc^hdq7IYghLQw@~DC zi*oDdlhWIxXK7%;@YH_01CJRouz1b&4F&)E`0>Tg%iK_M;lzpyE_J6GBd0$Y9B63m z8UjzGA&|}3$Z05-CN87|C0SSzf|V465=`P)F`tT*HF6TI5nm@YZT|L;m;d?HGPS%$ zB+^b1ByNdlN^$wgS?Y4Ihz8IXyq2f3{x(^2?&oi5{;}upZ)`i5Q?>W4RWB^R_@*f= z&A-8?9qIHt5-FP7P{SFlXCn+Eg#sy86At8|i0d&BBhZQra-%`y#zyuAYeywx#ZqMr z>N$<|2tx*PkV_wnS5Bp$e*L1-)vUj$dwBmNwYk$xd0yNlYnA(j)iodBeQg&pgEFXQ z$&z=~xb(ix{h#mOpZ17I8a42o&pGcMV?u)vaHIK`=4JQlZ~k&U_J-l%xd*!*E;x3) zq@uLTxOEfXd2X1~aVKXdXOt?H%6}g}-}#R^PT8TmAog@Q)1}Sn8W*b@{t7rhYR!^l;KSrAvXwc=f`Rjp3pd1Zfy`4kC~Fdzx4r&0y^W{P2@|E$yL6kr zBD=QNREcQ0T~0zYy^3dG<@YfcS?I@lUd4U+4B5p@v7FL5ivOk#(gpGpvlgfoVmP03=tNP=3!b$gY zlaKqIO03y+@$r?E8_!;weErDn=dQkVZBAqP-2?YqAN{Lr(CL-w?qhl+^sv!bx{V3+ z7uHf7?vt6)5hH#V1I0T02`3bCFyE&d@sc=v^h|m9#eHRsWjD^Gku$Z?xtY_Zy*0jS z(AnPmx=-bea+16X1HXgqPUS+t5HLF6Cu4?dy}s2sVcw#!m~q$L9_U-TjKpOQ=3|sV zJ?Ib*mH*=wpWr@yJ+7+fkzpTpYF<@z!!+Gd<)XOl6WK4>@RafG!KJ-dXqBZYWTQ95 z0$Pg-)N_E$VjT|hVQnvI8IHq@a89b|SKEH=YF1wEu>w6+P%~9iPg;m?`#BlH-QS!# zbmH*Q39m%GkTLGm;C7!M`t)s_)YQ~Gvdyo3cjw!PpfKDozHsE{(^E$6@EbPtj{!S9 zUhdiz+2S@$h8brNOUvjxvZ9m2Sq~pdMh`7``YD{_w~@wuxDVf?HZhsrz#sfEGlDq- z9polnq>-I}iiu7Y-|g5Lbl{P?Q@)NKBQA_MIW)B8sO?b4blV|Yefz)M%6k4YXw!Jt zu|Z=N1ojP(LKQB;g~Ry|4!|v>!AbLScn;b>xa**bW6zepdF(Rvry81Y|Hi$7d%hE= zO#U=Dm?Fhm@!doF{b%pjm7Xs?P@1g_QKE$CAK5>%Pr#gB;eSur!K+l&UP;f;UsMDG zLg0+25Qk-2Ke?4MVI(ucP{Ns1O6zGA-XRM<(5Pd?(EoYA-Fw>WeIqT?+J~+fHgbr+ zOrrkEkej|YS=}$^oH}h zT~5<+_>d=VafiHzr%-~+9Lr!mnK2s4h$Jh0OGT6p69N&$Ps7AjJQTN>`7Lq| zO{=hq&<@W75kiBwh%4BDdW0bXnTUqt!v|A7Jh7X)uwKTiJtK!vD6GjBvJSpW{;FfK(w}$a>xiZ{bcR_=(q*Wlg4LasQJuAD=x~JE~S(zIFYVOXiDvuU@T=s9JpK zt&T4{UsHc`+OE4KR*LtPca+n7kr#1i=OuNod|eqqwPfbi2xlW#$wIaxncTz>U5CO% zN^da-MfM5mc|Oy*tn;{f&B;r5Mr`uQ9lXaUbS#a{8}(V(?9kBQ-@Ie{{owH{udpX~ zehF^`vj@PC^^Fs(hwmPcd*y4qm11cMe(!DXamDp{PT_$Fpi;3!S;CS7WCS;H6332q z^G@)H7(LCqWXj7FMdK)rz13^-IjuN>IGMxQNEy^dlVlc*nCg+*eX4uA=arssxW&sx z`(FD$UKT@h_cc^aZRDpFq5(zV%n-HPcIWN0_>9Y3Ge7DgZImvm9)vh@d z>KMF9wb)OY9L8HY6K?Qxa0f&nxKZD1xo3HNuk(}M@ANCzk z9C+~TKRvY?u8{ug2e|kDO?j+bS4`>_`Jwuu2qrz%PzV)Kg%~R?ictoG=N8Yjfp>gf z^-jP2=Z#A@mfmc?dAD(y?R8s*EmQC7+Nf`A{;Zkq8=ba0IVl;kQ3kT?{RsT;^F+C?6cxlB%j5pk5n zuJ(nUep6bW%`m!7Sv=k_q8>rcpD(Sf3AwoRs^QYBSMJ=XxY2s+P*e4T2@k$~^kK)% z&cx1_m7U_gvZw#Peg{1(13dgE`ki=siZRT`hetdfVq2wM(SZyKK_-P!jM&7Xl+R`g z7uo;QP4V!xO#26LDURLfAUwZkQmMYzEVR|Jts z`CNG#CUvKMEl0~F)r=hFi0)@Jz(yf(L#!+$3#}8I*qgziLUI=lz;EJs_)r5fkjKn* zVx+Q5`B`SEz3gv@NYN%5*jtvV9vngSB8yz921SowvH!~f|Coy}l(imNdaU`(|Bekm z_H*TY`K>JAelVkelkEM~ncRppZf1k(&pUYmcU3dw?v`)bXYkJ)Ez3F7$w`?`gIx4Z zpNrA&^uO=_8mT-d-V;j#KOAcvqpt8>YJC0cz8#)n-SXRJ+Wc)P)IyQ8 zoW{}tbn7DxiEcw+MG~qJ#jacrZw}{Kv_!ZoBehpXO_9KojkR zi9&SW=!UEtxqWnz;rHi@tjAXA#_Mh<4T+Tt?!CG=IArwZ!7stWW(O&zV1yu-TiKgE zcp%T>SnT6eYM>BmLls(4&4p}4eE*1^3-p=A3A?U*a+|lxaPcJ~fkH&pdsaF7+ex()kfDULAj9j@9krb5|eTYN@P0eW_;9gLhi;Y(YIA zx|#G|oXjPxMXbH_wyf~oarY*?VHmJxGBg(vE4c{RQ z-H->+QmPi;i3lNN9rxg)6o+h7^J)H>vrySl*%m{w7V^1}s+BrLPq~OAPs%4j1cs3(!Nk=v2SrFj3~I?ulv6oAlQ#Bc zJ;ErNVzG&{C=@Xq$Pt{1dTO9VwDLw?!F5k+uep$Cu^as^-qU29-Y|0j7a*71Gy_5i z7jiWlxJm1Cwjh{Wxk*!)1j7ciw)H($WOFKq!c6X>LiiyADcH%bs$OkG9#?ZYc502! z^)f&fiP<7Zxxuj<&*89%BIWMwwM|*~*Zyq&ZTEqn_DwI?QJlqFCY!S$cye^A^ZDWNo86}(xCwQU| z^&BqqxQg0DtvCg5O6Ni(%f*_X(+@M?M$rf(5@V?i)iQyjm^)r+52ZHhN#i(%f?+@| zijd1&)iLT!4RObE1i(HZ&9*)pNwRK5?SJOHy#CWKGxzW6-Szu!!@3>qalZTOUAwZM z{h7{sE|ZJpPpHE{SP@5ykV+Y}lhP3kPna|eXrm$&i6xxJ7WFG(QEEkuSuC`T5@-M^PhMBYG|%=YxqlPRqlz@f$G32Z#RmdX%wwntNTIs z!`+vy`OWR>S89gbf@xdFpTc&og_ zfrwjkVBO#yW#_%m9IUFtBZ`KbtdQVxUd+>Bws@V`d%-_AXWX~G36zO2L~;@D;}ujP z%p#Lb+{$T4;>B2mXsSUZ8@N?YqFS*=Tty(fD4kv9VwpppWD&FF0QDdH1WKn}l#FVQ zV1o|Hupk$)6i9AVNevW9GiWeEIEyQ}hOOEWdmvKzNyvUQ=csItLJ4wk1rbPs2Q`Ui zC4`foSb5OOji^8#T#-o)N(|~bT`r~u0tIjwija;5K#PNm(^kge@+&mSBXCEusG^N< zMF?5Agu~?`2Tl`r*<0#mtB4e}A{-SchaV+S82s2IThxijfCss7D;IN_CNoV&5_@wM zj2c@`6F)0UYiyUhTp#tru-r}imX^J7bkoUVQ&Q&#Y*4SN&&lWI1-ToyF^xuwb&8oM%J<~9mb%W^ zwlzFmJfjR&UY1+b0qVbEsWL&CjN?c{2A|~r@+h}BWu@qln*g-^G%Y{9xJn^iU)?3Pa6YF*E`3>XX`IOc?1e%O)sk~dP$%4V`*o38c}W49*ay|j(kidkdEdHyzpwo@^>^N(1)2mK zTs=2)k)g5|7mx)J@P?J5*qh=oh!VKPKHdHoxrwE=D-DyhF)HPOZBAZuHzQbg^95$tXk$ia3{jIR(`yf;Te7WZI7k4rgyp zLmr}t$d4SfF^3IsgX8IT<1h|KEQ+v5w#X6cKiW2bk$@}vav8z^j+N!Cmm4)89f~xp z;1W&z>9}|nY6d6*F)R_!;S@vjX&n+oj7Z@Mm^lYNV!l|yv0Biw5BZ527~!VbcnUcl z3FJmZG32JX+j41!tU@?54HhfKUAST~hoe;sF*b0W+$hq;Qso{TKl@TLCBwi5E@NMw zB=zbRHp*go7*Vo>H}X7nq5Uf)aj;yW)yQwsYz>3!^tZb_w5JAq{{8l+mh@hF1CQ@!{k7_BZ6!_V)JnjvImbgJ0@0@r!P6p7ZeC8M}0xN5HaYp8rUv3&|e7SNFZE zO>Anvt4{0utMjJxmlxy?S|av{QmUjjA{ihrsJju%p*)?L%ecgD?|i27vi2BTk)@5Q zSWWNiFVD+j`$7jf&|d4w%;mgM2B;V0Z*sBpkXO~c9p83*?-`=cYg^ju8P@`&wy*2> zL^3Kk19>m4oWAARp_Hf`(7n}?^WdKc6KFO?lACOi6J-@b#AG34xsDb zwKVDfLv=%7Lnib@w%oZb2sNDmO_Xt9kEYs@OJmI6Z*xywb@@{p2 zT%@VrL#aXRr))A&5Cw4_(l`r&MA)f`d(8lX;6q+YZ)F^%sC(^)sEzaFL>Q5yb#3P$ ziy}pDF`EmyLF+d%a44s;2YVxp>(~R;$lyH9kmSShT*8fxJDYQmsCg>QPB)!bU0GZ2 zd+UL?F0yG5G7yWb0mB9??|)_RkijFpL)Gup+wv+3VL`*u@ulmI9aJCM*V?}#6YZp( zogJOGI&ZNVMJT+o_wt`tdY<_A=z-&3B7}nADNgIY*XjHh2W5`=7r|Nzl@Gwx|IWO! zw$7LCiX$SX^YzZr>iNLfG5tqOrb0cr0OzcMy8IVp5a=MICFCd4_h~-o^$axe?R*@oR zi?5YAVzBbG?!JyDiVIv>1 zi9~Lc(`9kTD?EfEvyOhh?e~}SUf8{3-wtf1wRA{c;N2Li%ppJC^5Xb0YWK~rzVGkv zdcxxRR97y=-yE3<-MfW9=fjj+b`J9LDl?% zj@PF1Ryj*f;s7q=V%Y+tGGj-*#r@I0Jca}KJ@?6OR=vyk$iQU3eBLjOoqS=hXUF@*_MO^mTHpRXySvZf3+ru~(ITiV$iTpH+OehEQ2wn^aMi0~y+#57YwI_K}IzB`gyx9yBj_@u7!Ii*K?8;5> zp?b&oRJO>KnG!6j64TT7$1Ztu-&P5yCD3elXK9|TcIZ>vnHnj;x znuK{|(2Bfbu#GDB`&xXV^tp2Aa=*I&$)98yPwITN^NRXz zpNV~Sy?aZ4j^gZqjKJt;-aWVe?7{Q@KJ5R}WLCZV)o1G7aC%QSRk>(i ztma8W@4H?Fy(ac~+R)VPb^1cR*V#u#%4_lh0v$t}#)`qZ@$4a4TF{6fY=sXuAz5xv zf9yC%L1L_K3XCvdEW)Un+-L@t&?(N6Uh)E&;Xq#dg`ctq-aJF@VT*Q(nn_K9G<#eT z$1_Pc@Xo!@#XZ*+kZQ_;9zv=WFE>< z+@c$=e6JiZgnN$ls965NYUdBW|NFhYtM^tE`2O+7zKJ-5RN$XprT?4?p4JrX`}BO| z6VW47BLX&=rN+yNR3s{dm++&(T+frZ1hFzt2FN;5sVGjlY?8TBQX*ArC9Iwl$+3t- z2&`m9kl;o+PcBBLXji^R2?DiR@F=+z@m$AIusT*_3gQ*)p_R>tQ3wqZ?i!eHg9X`Q zGDq_~b)xL0{$#(x!H7i#^0h74T#jN7m=T8}#Ig+rt@blj>=Y}N-W@BaVT{y}ys;F5sD;KFk>=!H9n?t#gG7-qkv9gd?z(nh4?1kbh zJ#W1cvt+@KYcBqF=eG;{raxZV`K`T6=c}Dh*VGp z4k?s|I3!c%lc7TuWOIOXFknAvA7;OugCkvS5pgTGU$S~`cRI4oepbV1N4ShP%GF#Z zH_Fx8dpQVI5mmN&cV5;Fy}7wLvi&15UzsEJPi_l|4fIx)iZx<3NcWy|Pv@^({9P7{ z!D1aXJHM*DML|eMFucV}y1$)9vp27ni#dZQaR90{AzvVwkWLm#q!<|BjX;Xf#F|B% zgrvuVAK&PlOCdCaNVtnVKi;=;uJdjzc(&gVp*Dp41Y*!wS>b$Il92)zs>hIj=fXEj6#VIM(E$Z@V z#V_p&UD4w~xA)y^*_8u0>E`P+J^)q#$`Wa^- zm&^OS(%-rN!&$%0sf{R9x2iqu|8RixkREjf|Mss7kXu>L85BfTWRB}P;f)|aWU1#n zyTg-qir$*iv7WApAW_3x)p_b}xWUZ1Fp@>gU{|h_^Qcn!#_3N~bGU}vTUlyDCYF3O z=V=mq$YKPMg9&B9%uz6+3Du%VH$?X|rBefKqEh95PCej;OmPGa$S1SLW~QTpSuREf z7Q-T9$V&vljoN6_mj}Q3;+J3dAK7mzF^F2)L~TkRu}g?P@AY5M_x|VNo9kcw+Iv&K z2mPn{7eBlG+0p85StjRkD90k>@RmalkBoe@`0+To1!juTVxEFwA`*jz7e}x+vsSI_ zm=1xcrjf#3^rkrSq97vCAhscp8pLFc+7`+Hb-6lC3yhqnbKFc~vUZcj!f zSR;N{6i(n~IS5f`LmjW+C^^fqX3{oMBQ{8KNz%hsX*-D7|x)jlqoW)nhFtw7^KP7s*BvsF5Dt#YP{P=+ZRcrA~8cG;u_N6 z#!b>i{SY%KS5#?<2)=TkSm)H|*MoV{e;@y0&rd&^Guvjgecj>Tc|~sKfn$Cfl;G;~ zjyipHuZ07R-M4po%F$}a9-MtU@uyd01=PEoe%+yw& ze6943i>d3c>;E1V-Pm zhYM6rQKFCoWiL68>ZwpvAeHMln#?GqI3%Ernz5L-axvnx=EFpaVG{?-jl6<_L_VFO z2o9%I>1+2^vnWAXDc0}>StYBeifpJR6Xnt*SVS%D;w(9hLwN+d@(83;5?LGrnoE!@ z%cVCj<^ZlEFOeul!UPkVDFLO_a6ar(;`Ps+e{@RJO_DqKSx)P7y6@D!2W^SA8kuoqRWNqaH^ndb#E+eqDb4w+ovt{@k&+)2eoJFLe1;oJ#rj zmCrqVCv*<|ST~+W$WQFUxSFHkL9vvKiNYk(lqBBD9%`njpb}9?+0;yHC=SWU6wfQ? zbORE@cig>o{6Y3(9R;48Uq3*cS#@Qa6#Kc1H^|kF(MEpStJ=i(;<|2>hM1z%t!jU@ zpNJy|hRVV$_J6~9{3w@`V^0jJaDo4)-c!Wa_?(huoD7h9)j$>SpipE}5sIi%?4ijn z0m^xMLDazK*1P|-{mYVB4_B+R?f2~q8@D&bT5Cl<+3>i-!=5VB#X72{27Q3@HSsFq zG_!;&!s#>4;9!nJ7%UV{Zklhcg1qP&@_8L6A|KJzOnKxcCZh<+XzO#a=Wku_4lW(& z;%RN@ee-N{tqj9X*8kv|hmrvCHXTAHa`6Zi!bQxb8Pl!L4-TqLu6ygwv^OY(s?nrT z#UorHv-mTO;(1cC)=9XNdZP$AoJ^s}6@G|712=)V8a8f%jdQsgq3psARBuKF1S-jl z5ONR{=EcZExXj~XiV#aE9WhknK%vSM@jot9ljNHm%^9+Qa)pW9kV&h#(D?D)u_sBxKL^_vKEGM6M@yxeZM$3&{$AUL2_TovZE--6d_Ky_9 z*7ZL36)1y=GO3YCqcRzl$qYiF83ar^GMMftV3Lj$n6weJyAuJ^od{?K0n_QoV7e0l z?L+}H7(hFT4BCklXrc@nqfAC+&?GX5)Vt?jb;T71;5O@ ze$OW;Mj^SvUP|eMeyT+kf+>oE5iaXw9}Od!T#+UGNq{*4UT9a-Os#Mb%Qz9EY@*HL zghnt)e~v^jMPd|D6ps`P>xJ?hdJNHq1DvN<>q~{up3%;58lsVke71uxx)8_BRE7u< z0y8ym7!Fge=s9}+Ss!T7G*QLvs`;Jd;`e3#96LZ3@=#P!x8rw(R zmSJo*unT)~4t0xi zv65?~S$0t(rBgam(9DCpPx>f}P#3C!9~>wh5JuX~W_^gh5J|#InBYQDNJI@aPzMI! z4OgU#+1fi)OABy?{bZL~qY9Jf(a%K)MiJM`4B;(yYrDB$w(880+^ye&3sur2cp;JE z*vvKPfIX$7ocvIUdS)!v@&pz*6_|xwOsw~f17V1~yy6^Y7YuW11 zC374O-~F@g*748u;_;*77ja4T^S{I|b19o;p8Oq4?7>n30bQ^Xu}W+BkRTJ~QG?9Y zk6E5t^zS*IjseH-Y8|>&5jfYY-0&Z|B-TpCUXIkJE7kWAo)Ak5l$-TklqyC=uU4d8 z(e9GLx}6*rLTu39zxzLvFX_WUAs!wua;}_ju|u=S6eD1^M?ITR&%u<8QHs@zb+evB z#QTC+#PUlUd27@5OYXN9h{x%EIw9R<5_ht< zyhr{=t=MfvDH2uFk^>UukY%lWkSeHy29ZaNsxfE;{`c3PebwxJcJ$I9I;jx@yo7y`$ZfKeePP5WRNz=K*<^T@a%njca;V!nNZ1dt zWE7zy0{KYfcutg7+E)$cPJ-Abx{xO8xD63xLIHt7L?{Pwu&h(<^0hEh0gR|cfoh9) zMGZkP1;C#+!^nqpcYQL28~hC&7?AEVz%~_bk+~*r(kc>V>5R0_oo(}TO-%=eY}CMS&zW~=6zxQyG57q7v7x4S)65) zXwzUD$$%F(8~$QwHN3evA|y9BJFqyYVe#74GI4!WrX{TM?%;wgnv@P@LV7-Apx zfd?JIk#q9>U(7bcuSzLUB?Y_1(PSGyiQb9enxhUzRxfPkCcnosy}!4W+In%_?kxKw`na7iTZN z_VtU$=6+?yJ}$(e7B};{?{@sSyQiuzZ+6Ly8Fzn)5iUhDT11H0ESkj`!*?8yAX%Xj zQ2VGDS=1==_?BD+KfWx_u`i$Fd7@FvG&EB-nJ{dCy=o(_XA^hAgmx-`19GWR|50cTvoopv#e<*Ph4U zcP!V}j_2Juq$lf6@*tV1UNv)uis*@a2RCc`wRZi~_>1H2^d4QsVn{U5dX<@)E|zEq z#C~p)p`53-42aX>h8WcM==M5PuBBbPpv}~tRTOoZE-ia3;}nE*d@R6i<^$q|1()yH zZ2PvX*4_25@65#G^v`cvFAk1ALOVq`auCOpI26wylgg=ujPTobt@>_E&BsIyR47`0tfRyS#cY zT>saw>Gt;VzLDFvF5h}Dlon;q`*z`xxr^@k9lbBki$7ryJva&@Gn-I@Opb?@<~M-J z#EI6KoITONTX>KhwcR3sO|qEFkO5Clqco**5sPLvv%L(GO_U=d$rBx7Hdls-Jdov=f<$fbB@nal|Y;}7*hWTBP{6|$@1W)yQh^3hLToGqL6 ze7K+=uiQ91TsB@X_Q}{AWAAHo#Y(Z?#&dG}q}Y3!W_^3l#XE*O@#B7~r=m(G$V8c_ z&zI-)N!kr9KwC|X6e4!gAa$UF+W0)RBOU3;pm?P-Wkdubn30G?uI3RQ(OwnrXz#TD z>0J7`J>197@HbRUBk+2-b@`VM3^Od#?-aqFPEiD^5GTBmz+EyM>9ks$=TWZXRG48> zy5@dpMlE?O!-;sxL9=i&gm`SLAaXLagMD8rhF?PzI)A zr4E)NGL>R_n(VW@WZ6ibXov6re)rwam-l7o=WZ^3N}LdhC@}bGnWE5r`|R_xcmDO} zYuEoCU>Gz!XLFVUXa^7Up6SfI{QRt=-Ys)FXcXS?<~$iAV~lejeeK~LW&eIZ?*r}U z?{A#Akt()`l@$NU%_Hs&KczP&C$GATBYCZi<0ekzM6~cy`3|yWt9+h=xKGwmqc+2^ zL+lVWT7o>J_ggB2x40of$(6pt2KMAkcp6|rJ&NE*#gsyo!P}4R^Qf(=ZL9wT z4&+bUoF23DKvwqJKRoAr{T`m;dgqU9#{t?-1aTAh$wF@BLsW=*1Yv}{scf)&%x_ZD zq$9X#`R1<2C-2dpM+=RR&`e^E;Tf4Mo%JuMn1DtOW;?dyU_L1QEOK=J$aaw`4hu(; zSV7HPC}U*|N<z)Y*!-#1>SEe~9~0#D(0BD9S+`DkXH!VB_3^nK^-LXHRmiA{WuA z{T=q)Bztg+exW^qnoXRI+ya1C9+hR&Tyk+bN%`09Lf18 zl40z{4GVJ@{e0hf{cHUp>4;X!K`YV`hd3-n0un?LZ5Es1#ss1r;tW}@+gz@s5=y6H zy-qgCCbh6sMxLmoN~Mn0p=qK~G_nI9<{Pc^zkjVY2;1R=U$`G}Xnx@2k`wnG0BDdV zp@x}D$w9P;P}FiR<)WLCP{PfehD!KSh;USWC*25^OReKh6o^5zbFFe4-^amN!Jb@? zB;m!`s6nujdCB8s8W165j|$G@Oiq)@EX8WEo9a188K{6wa=z%$mKhAahW^-F z@mmm?UMYEafWgW9@ z;wF7P8!bne*^cX_u0O9Y)(SN{ElgU-(dNLmZIvNEe`p?6!!8^sw@x!Uy*=&i-@mZjZDA^Qi(+J=AFha| zNJNoQW^gR8)gQF6Om=S3+e$AjYR9FKYa6xxar7u%em6S zZh_rLlcvoIbld3ky!W8z-)7o)x6J%`>XapaTk!atf7&+lK1;W#KQ~e zRF7IprxDtvgiES8huzp4;hcaBc0nw!wN92|9Sw-1O3Y{&j_5`XirEG0*$XZlhhhp5 z!Purmx<^pMtsF@8l!zQ8ss@jVLRPb?#d79)RKO^rv6&Nj1+Q&>qV3(T6Czsto91&n zmvPdX|p*q+@UG+U|L4^X|>W~GZEy*Qg2{<`q*Uu`?*SaA2<)Beh4S;&QyDGEeEN5HxD=NIXZ zOQE;kc&R_Lf3h=X+;o3wLitN%ivY1 zIr=06x1*T~s7#!u|H2Pds33mz6z({wA zRO+U9d0ti7oJKa=qk_CeF?qAmIy<5W413-#ms%baZZ-!dokEUGm+9n2J(R`$d|c+E zn95a7#GiJFsNY&$?`xR%!dto#V1u z)}f4>_!6(+LXLr}wn6LF*6B_9T74Q9V;43#6dQi8FV#*6Z}J}hMjn$fw%=%P3VROX z1D3a>E}z$DVKLRxlUR=!3Q`3Mu>e|OeUFvYSZ_BFq7=GY0J7Io#53a6vwUr%IRE*w zmddePv6Z1mZMFFao!98eC_)TpqXl-<52(E)3h{*%E9s{U&uESG>0Md&+qK89-TU(& z#vh!y-0^$fA+6uL6q5GPD-=krI7pyKu}vlTQ?w1DSDVg3(oT2W5dZMs?DKb={A9^! z&#k`?dydX{|Jjdh{%a8DMI#-gRJ7nB-A10`$LK@yK+u8ZpB|{s`8}fMFX=_>Sm$V) zzFl-{dru^O|6^y)L8t2QZ|?d2>vOMPw0>6R*ke>hEp!r*_#IqT$*?Q0!eO4LRQ(?m zHM9kZ=98Sl@6t_jqftFvpD$0@{>|o~%}2qD0yp}$KJjeC;G(ypG9Ubt-wTu^SL?5! zU4|o)5=ECNp%U>|G0KhH$DUSv1%C?05=vxO)$nRBcgZ$I;yf`7FNDDvt}r8s9B`hK zIFcDAZsL9=c%6^2PP4m{g`7%4P6Blp_r%>ds#0tbkpg0^U9W~bPiD%r#>PwMGr>rUp8Co z(EG+NqL!kBC;Op*vzQT1MP!64c2ES3QX~ARj9TDHJ~V0#nlcd3ibM`NFiJ-3V;`QzaQinefFh4T#wk0xw#PaM40>+`_)xP2o15 zO}<$2&q3Z^U zQkRE44{;%l3KH9RnAb{44#HoYfS=5e+mI!^ln;y-@=+nBT!9qSQVv|;$3avqn#5_8 zaV7h57-!2>=zxdV25>2xxQsh_8nscgaAP<5yzT}+lv4rMTH&Wi)f}|P(avW1A!k^* z^_{z=q`{)Z_g*T!s(-?P{H5W~l*QLydozB>b?e=48qVDQNjAxUa4Ch*X2A%75h>Py zC4HpS2O+4LYN=ZWOMA)lo}BTV&9g7O{fLL-JNGW1e$`+>D7THfb2027Rfa2_S!T0~ zDApEG8I_1|kuQs7t*ljv`4A4`8(dDssHHO1CASSFsHN`G7aK=M%6?dJ^{ug+Q~vC5 zQG0j(zl;O$LNM1;yGWry(Sr~&!c7#>1)<4gxmj-0ms*@HvY=|sB>S|%s$oa!a6dJ5 zKNesK?Wdze6iU`G-z4+pVnkXt(jKg#1{LyN2g8iG<@fuPh#TXFpM1;Bt=Q>U?_lzrrV@%I~h_KkRUpY2`TzrL%c|H9Ca<lnhXe0(oSaSAAWPTsqoxGrwr!Ztvf!w6D$H6)9?Rd_7g^*7Pq4g z@$4!abWhIWJZ`a~X=>qc8Oxj4g|AB=)X+ZUlY>|%MpYX;;D92W=1AGiRq&OYxEYC* zjskeX6)x-|J!BwNi6Sy_I1jTg)xeV?DO0419WcpeZbJbgkodpL-!1=P<8c2-&uBGg zV1%OPubFr5-UQ2QAgH3FrbjqL%3F#mmZa#eDA3vA>u=ISv4`*qdj-lN$O<%=LQRc^5 z9@Oc!_(^kw5%vh?QkkLqvX5LP_i&sT()OYVvE)egT)@mL^n?02O4W{OBiyfV(l4P} zgh8SUc^rva3ZW8?=OQ>GO_nkth;ooA{6)6df?*y(7Z+1JS-;213&L>%;izQ~cE$+e zIN^uyI}@)>xw`&J{?(5}gjhq~v%c}zXWKc*jay6lwhv`*g&Uwn!j?%)m@5d~CWciL{i(Ruc%dw-pM{@T_ndwV~T z*|LsvT3T8HTX$m1bGc6haJcTSKTTFBlR_b)QCrQy+$F$j` z@;QwwxKJ(owHuH~(X^j~rIf2sCcn_bIT9`i2O*IQt)Ns>pOu2MI2()vv z>dhLkk~s3CZjR-A-iIO-s8paEg<^!#&`1T;Ksyj8{KaR?da>>fPnda_?2$)N=%RPD zXSs`9#c6JmZICZ47HSby zg1A`bbB3w}A=xCI98exLy@SHef)Mh(X~ZT10?&!T@}O;(Lnjj*)@<9^auf z6mk~lkkC@KVhR-y!ypdje5HEPfmDQ32~rS%De`$eP_KSW`jpa+hkl&9YsQPc9s{P^ zwuU&dmcI6Oa2vF(<@wJOKU58{CZvhPTT8&s{ zyU1o-Th43v7j2>Ke+^~S;`f}Z-Q<^+KRWfHyWA(QxBW)sPd-I&qtu~EoY0Q_x?p^K z{Ms*TZ@*{x_m$Gop79rZR*sI}nR(9c=AO~zH(Ewh$D5UF=?Hq}WxWpNV8Wk}=%YDb8O;uQ9^QbFD-J7J_zV#IPkJEKfQ zDU*vtwr4BCsu0oI6zzh{)9dsB8qi)9gfh-Tw47`S)HhNah7bok3{m*k`YHDglPDKE zXcIZo6k4+A>0ks6X*;z8+5s9MB9c;`ljk_cde?*GVtuhVN{ynCazz=HQ7s~=6p0wc zsO7B1VzIyvVF;s2s-i0Hvyw-)XFo>o^q#C$JHe`wzEfZOvc+!GM2 zzhW_Q21l|dB~hg)*I=j>nVikl9FK5ph69q&#eP()91@9{)2N-2D2FR$ja(r@v|jB6 zuHho?q7*WcBgdjh_Mwb35zPIt4sjzTQ3UD%_C-31;l;u1$$7{}8W&LkHHx147f!zU z{l_+U8y?mKr*bC;&+wUfar%&amFp1#APtx(S->z4b2Tj!rzx)Y&)u0l4IX>E`aJfD z)!M5zKk3<)G?}JkJiWP=)6ik5mLJHl2ObLh@cuiMIR~nHV&w|X=FahwI~OfaBAB|g zV^m8o%iZuoB{I+;E9C&iVh#x&@)~%i3MA^p41~?#- z!>NI?sf4PigK|-T-N;1-_i-57c|O;oUs+c-(g~4^8g$_qiWe)j1MJB|EKmzSZlfr1 zR7A3?9;t`J0VV9IG_bN!h6eIxUl}f)$XgrHdXXqEOCPR53K0tAaeWPWk+-NANwoY? zuJ|b9Kkt8D%MmhJZe93f=a zsprK((t1HJ(e`U6cnL?c3!9LKtt_#JUqUb&kp$*E)MKw0B!MEzL;;_Y=j1Hx;XKZx z7Hg=o20_Xaq1pj)QU=Qyu7a6&AxsuZXSw3xXM7(wtX%qEpU7EHPMS?4cYfB3EZLT` z%*c^VBA8OOcZg_|TxB!Y%bMTT1eb^a?S`u3cBN20Ky}zlwe%DbU7-8e%wQ<}_9E&yH95X3sbvJVlE*BYtLPv!#yBGVjig zWq%5o?l|}B^KH??+kUwB;=5gDGNFpv#ehh`2uE`Q7mKHGxqAGGGA6qcE`))VsEz3vW=c4?7MAmhcZ?&uu<^24&;g(PT)#Ly3Jy-7;P9Gm2H<3dQ?>ii5u8ys3{_2@;A1!R%{$cXI>9uzo z?TR=-Kc`=#24U3VIa0>T3`!G86pd~W+L5WshnqzT*&|ZcbDhd3rcorti2)AdJXuNu zVnq0%Odeuy^rIV0i4;H)tr4Y|jT5S>EgrSJneAk!UZ-!QD6v`0W=|QWH=!97JcMR$ zLNY~Bvo@Q9I93APNT)>9$VmM%N^uMJ$RuZ~MINPb|CvX>Px--sN>ss)%eW9l_q^|3 zKkG+I!5svtuvRc(f-f@ZAM~BgQo{>q=`vrw&^`LlA!Gj1TjK}E-?f|))3s%WPbZA> zNjV`R626F;Qg>I@T_?p#af}usTKZ%3zNZ(x>1RZZ#o^8*jF2C7!H8z$au~XhPxjP6 zuHwAt)n>{RIjS4ES{mgpiWc$W2D~te5i(OMZ5FVH3;7}#e%2_>**uIKB`qIMMC%Yo z+o(b5OnW1n>lDB&u7*T3IzKB8*{E$e+ zlqJ?F7s6)b&}vnOF)9Mkj~bM58Z+B{^x0w2;5z>O_#1Z~K&jlyo1UK*am=xUx5~#X z?{q^NbhwJ1Uk=S-9%D9fQ# z70!Bjy>ynhRI7uXdNovR(0VmZ2yKJ5jhj)A8kL4$tEFna+H7g&JXtvLU2>`ZlI~-e zDG!cB>-+R0mgiNPzYSLMSkBF8K&5IvNVo25cwD5?VO4sXr<$eBT!&yXs%Bv$jIv3t z)!kLI&U&(9F=5zXm}&E(I+9OdMg!NVSGTh@Y~`q97=~)Ywtkwd)(dra{hIY?rIUKq z9A$ss*reCw9?J)El72~FD<{K5ad6PD>jz~XS93kWoQtN6X&dH$FyjT=tK)Mmf0T#D z$Mtha`}oa^{~UBU^XV@?jWqBI_CNy4kcUdqB0{M|=E*RwwyctC*@@ebjyTyW*GMP- z%~M}<$a-bXeSerWYtw1}Yc3 zxlPvTb>yw}XsN0|v;m%QfghT=7^SM1rj1z!${;1e=ZAQzLW#1*$cGt4T%;PdhcJRR zbkTswq7o&P>~M7VH{WzPj^vNcu`EIv(%9rz6tKn=XbOYwHGt9FUMMG7n*$|7mO_kMGLK_ z-4kUB+{A@ksPEAi>x;W0FYoDW8@n)ea_lLOI`2f!Y;=p$+DeX8eu&`|g{a>w^40T; zf4Z>b$E*5d@|;?T>SSLpc$lW2r$(_`L?Ba~5Q8W}8C6jfc@rTQJ><{*yi~5CGUOnO znu#bvO+vs3Trr@G;L-&5NjrVLTHSL+I3*Er0w=PkDlID_Ke!^D+hhjkA&Tu}6Z)mI zY~^gZU7trrTGhaztw*kp|I_lQeC2+F&sN8d`7g~lCZ17^$GZ`vmR79;eyiG2 zphD07-@;0eY?ZC55tXI-Eztra1m_%9DR>k>&#Zg5KTUTuHBY3&;8-yXPu<> zsc57E%QVY-@E{Xl)SLN#* zUgmP~Zv)1$C)hf`N|lp|(tgl(Xw$_qtyg zh&!c+ysjjSij*u#kSZ=slr$${c2g>tp!HV+etXGAZ@AqfL0;yCVP zPmYBr3aEt&P)tOF@Us36ilcZcffu)n{>(c zqT#r(%0dAZOeE$fADVRB=I6HLvzJ;|=r8N@rMq0J?~;MD-kSCA z=>cd*CFLSfZk4grLFZ{eh1V`-UMr7dnBzDN4HE&1@@W)a$bk!Q*FV&q;71MAAt*vb zXj3Si){!^5P>eWLX;OwR*rS~*C+xL2gNHdEQRKjtNTFI9p)zviewZm8Efgw7(agi} zWoLO@e+5Ncigpw$SJ!avaUeaO=L5qK<9)1eH@^D_>u)U)E14quP2AD)wt9 z)HGSf`*sX3o(nH$GWTXg9`{eZD0smX8I1-*nn7BS!AuL3)2PbkrSFoKGd5IDk zD4RH$`{W_EpJ)Pi^w*|~+0XvNH^!~juFqhjoQf(@dSubj)&Kj;<(R&NTa#6LcBbl# zbEYctrVtdU>im4RV=MMgp_JbqM-a|kG$P7H3#CxA^_*El8d5|mIiQ9kS=A46M!R3{ z`-3~$ws&-NZWjS!P-KZgv7b`r{>5kg{Nut~om+1TrBF`e9L^EBiV_Y`2UXEwaax?_ zdN#9}YSDrgFB@32vN#S{@f|eY!;(J2+=36UAq4ECH>-a7r&A#WC9;TFg1wt z%WjA5TKqZpOCR|P;>8Z?Q0Bx&L{U09pdI#{DXV#f+=Uz^fK$d{Y(^=&%lYUa2U^D_ z>7!pqzx9eHBV1sj4v|V(swd9_{V>U`C`LY;FoJp&-0TsIM9yGeEO-0S1so zm7L7=oN(cVAFgy&+FrF;s6DP!A878vc}MP@BCpFPVVpo!zgFXUl{vg z`R)C;|1rL3-bP=$`EOCQ=oPcYptej~#%Vmn)u`k!=AU1@x$?$|pDy;EygE;b>kQnv z*J7*d^Y_iY$72Z1;=Gv6MQRj!@>V&dBp0m^q8PsAZx-_3#5V2*WVq5Jim?K*u%y*59HxiDFT!y@Mk4jloKYrU^!x zj}|hbflZvrwG$*4%uT372xVbJScBbp4&q4mLkrakH!>jv=~Bu}J$#0C*B%VdeDE$S z@z|?k zIZ&SB1ahNzjB=@3GY+x_FD*di;|B654Ar~^@u(EFw1moqoA!nno%t-Sp?e~tXFTqh zTa(t~@@uqsN^B7O%jnbO6T7nfo_le{Wkvd8s7vI~Y)Z2J*<8f^>`5UsKn`#bUKGux zoKOBZPotEIQcmDpWt--OH0jA<$fOV@CpZkRgKyW~(^NqY)&LJPS90c}e+RZMo`-*; z9e+S5GLfmTl^HSvHRwmbSRiJL*?fb0*gDIHb2wU*Vf|`tgSNqPOZU;Q8KSfunkLQM zCfnEt+gXZ#iAS_;)uvCM`1}K#6*lX&VEvlzC@-%%7i)~#VSm$bk;>VhGsGzwYnf(w z8r}4vIHB~U{H;L^IpoI!+>aI!LeVIdZP)}?3dI&qko)9Ah{I(#zyUkBMLC7$B8e-o z4JOrd2`Z!b-`brktq2ggRy4%vY%jkMMlqnJvWv{;K4hYTS*+Gx)plI?@Y0%}{z);E zMd_T!rLdp(S|RzH)vby((isg=QFLY*b?tB&cNv~G;Fy9$ z{nYq4c?y4V7!8QSI!=&5`g}?!qX?mRab8l^qzGK3}x^V-oPigS&m8vg>Gs%n2l^i5bV&eYOG>Z zILLyT8&D(e*M87uYK1fhKGGzeBb(fZ2!)h2vHeAr#Rl!e*p# z3DxP`m5T3vZkpyJC5e^LP#$Wj}|p9VN0S zlF?5Uh!dMdH$_{%woH=l+D=2ah(IEMI7Jc@IMQ-WCQhKUm{;7FIES8m_xjtz1;0Ez z_Nis#R~H&1&V*zQn0|Vqi*m;M$6nwd9%VlzHyB4PqD7ogO8m#E0VWZGIIfTtTp)dD z6efk?TwvxV#Gw=R6ompx`sKCpciCK%^3~_&7I@$~)+ll9{0CnSKl0!;xl6LN6`^8_ zc%M2@hB79wTDWOPJGqh{`O#i+Mx4>z^-209l*`CLK$JR6?yq9|pV)l5cepgcNkMOe~~GZDs;auF>&ktE`27+zd~Z1MxRhPx;S zBPf<#7)CgX*o;UxqnMld6C6MqykG}V3+2i>9^kgKPn>)2;&+_Kk18t{d$`Pc$o;tc zWVGXR>-46F=`3osKWiOqmW8tLPKG{bJoHL!?|oP9TWU03d7$R@%G(<(aj2&ds+lMm zRT}MkC`zPiv%5B38n~29cgO-0zl1&>@^$twn_8vXfS9=R1bY9#}sD1ZyP0rD4aVvCe=kBm`? z^A!_APCXCgWa%vX6b)RW?bYUJPfaA?)qA4VO5(K%$-RjTluAWQmCuStuv+#d3Z;1U z7(KD?1nE0~!eUDmC2X3|9i1SRv{wv@ z>DKVd8A50q#J1n^UqaiU?U$$I8h!Bup;MW%(+V+VD3be|KG#I9P3^l^9bwf(VqVeS zT7@g?R3iVx|4$6n`*$h|oUQiiRvEY16TeYm%rHf(t;1qec&kulRAI|otz(vIuiAV( z>4UDa>#vX0TITAfT9L57NG4HgVtA3Ik82) zqF(|-)=Ztw1?H7%7gM-$*jjLhiSnKh_1bcX?Y>+*=tY?8EVUK!sJ-bq< z@JAWj@i13%rz(H&!Ju^o(*OC-v*{}>G90-zrK7{&lx(SR_~ zX#1^w`i14~?H9&SiF%l+iZT)9e$dP0*-x9rVR0BLaXyiBpJ=#g&p-Qo>n7XJZNAjb z-21SPolnefsA}RiVTd0kdbHV;g#r}(IDX#od=H0lBJxEv5mnB9)%)nISCE5pag?Ia zg&b~T7deC?gmE_PkwmrdrxYqsS>Iu$f7VV`ikKyxEVITRLAr>bayB86LWQj7t(=T9 zm^hgCaUHMaO_pnxUpYuh-CYLilPuHNm$%BxaDhFV$%{=~OrENAEsgUyjjh3MMC{kj z7=A#p7}a(noU5gWbhdn8v1L2CR{tF7@Inn-D3gj&ggCCp2qzBuhfu?C=+T$F< z+1#gT+H~>ENaEsnTKSkdwO&%{h4Lk}rnX%LAnV+{Mm{!?qMj%wP)Y0Uzfs8b(pg@XMI0kzREBGk z3M=_4FBUa%Bq*U;YD6+ea+El2B^GpA1ZeBjQB|uDnet? zGdHPkuT~|jS&ER>t1!KFA`Gdd{!taCm#VPhs$>HFRhqw11)w0cZzfdpkdhl*sz_*` z`h;QqTgyDlHTA3Otl^b6seqfT&d*wP09;ddY$Sd9knkuyrC_bx5l+# z>Swe6?Bp@^YwdzIroHMH>zrY~iO!=6UfMB{OQ!mQrm&6<*)JcHRva@VeYg!H+|KDd z%$;aQBIh8Aj;g_tz@ErfX1e*DhDfRsJ1B+XsGAyp{=?X5{jozspWV~opbyDi>`6UH zf~zt9;qM|E^!fTK`HH9#LI}#S-Vtua1{1_bl!(n@v;L5D(w)XX8b30A1Zhf#pbMSo zlzE&d^Qe)EshE1iX>t18^)ugne@}03cSHB6?Bf`=lYw$FSHEaTseft;ds<$$d``7Q zutzvTP)~#6hPIMO{6p)7kw(R4>JU9tLPl{xyFyXaKoL|O8~bnW{N+)O;z%}G9@Qlm&bsB9@9FbfX@bg3QHV&T)QMo!9)EWJxwn;@D z5`VH8$`c)`$lyMOgOXGZt4oE0ZMa#Qc@l7$>2A5%ywUo6q~i%+yAh6@J6|0S?hnjG1jqexbUYISbZ11%ru z&$C(XlIxXP!`o`uzm62jMEV5vQ^(e)UVTM%9WEsm;3g;`2eZ8*mCkaqLS)rSz%N*y z(%0$_$;JAIme*D2OjGG|KUEl!CPKCS+6DECHmPd+5T%BisX|_bO4!$`vk@(X*dTWP zwssRrTWGhcStFRa9yJOxMvxVk4QktLKC}6&U8hsBo4D&A9(HpdaTu7f+Oz1JUwW4~ zx`{2?25Nx`MQERY$tBOu4oQ?waT5g%a+i8k&nH$1C%$pYe%qIA-<@+{s_k9(``LST zIt5OdJ?ZsH8{wn%>d(_Uv4j(DHOcuhNZX+8)j|=10#s2Yxly<97QMoa;n;sT>Ea*$ll85D(FD&S@maWM)g9W88cmisFG@_R<4k`)M*{1QmVF6Tc@p4n&?5wncSX=kxuD`#| zI0F)~58I`|=|aq6N7*c%D%yyLafNVD_<(wN1b2fTD1kQ0G_WYlW@}jhg+e~4Z@b?6 z?co{4@AB_utH=prC6kIZa~OMk{10RQ9O?$XU`&0|`jVm1+1Q4C_sev1bUOVLmv+}G zN5iSnO7onCli~+XQ7Ac) z38XmWm)43>aTSmXt&}+*lk1QFv6!<(_~py)1`2{TKF9*DvNL+~Uqpa`~0aFA^p@*hdv!xfS#;r$`M(hzHq zKqSS$dW5%L07`@b=7v~_G@a)l9o>tpl1&wsNtS|~p+2AlZGDn6%UkkxRY^eb`3sTg zf!+`N&ff^tWt1g3k=)`;tafr@`gZDg5Y6NO|4iiS11%8zu;ms zSv=b)aU!J@!E7W!-cQosS}d*CHv&xnzjv_IxJx9pdAhW(6)Qx9itf|u9)wGN>o821j&C>TAcp;s-q^A z_VXj8K^3mRfPX%H@KV>@D$`CyZ@d9guy05JN=&L(HVFA(Icbsk)e(O=$BmO)mY8Rl zEieto1N!^IG6;?0w#C^OZ8v$cQiJt=v&RPTa>&eeiJU(zzc~EuDGO~yQw2SF}`Q4m>hTkZy4`~qc2U>yirXoT7)pLHwsdyY?3n_j*S z+1Mm?7?UA?$<#OBeQg%M%DcHOrAbH86#B6rk{}Wyp&hN!TG%Dj3-y#Bx!f;MIh9i{ zXhBO(;75M4)v`^pO%!ARH6F`-x$oI^|C}v}?EL5H@Q%;fA-2Ns^st$1!TVL=O>cIx zXmnxGpv6S2gJkqUZ!nR3MhQHXe9=RSTI_`!N-#)7YyuMsq9ClrUhI?XsTaW>O<6Nm zq9-22a8}F?U^se1vJ}D84BisG$ifg-484>KC5Ws_SS98ACSe}%TFwxtw3Zdb`VvQ?>Poe`f%`r!8>BhkdyW$3Xoitq_DlVk zI?sE1luD(S(q`l&^Y<*2lm%_zJOprP4{dmq3b_q$s12_^RqG`mC;w9rAk82)M}0K% z-taTF4dd=wCKv!;I1Surg<(6ZV(n{7~V~gBQ@>|DTo_xZYVFIJV@htyqe0TN%AnM!qmo=2yamm1VitkYx&7% z9=(1eI3+G?^T{7R+b4oQdV`f{#JBL!Q>kTTkN&M{@7OlFf8=9T+mxlIUe*`Kizelk z+A*_@ei%M5s!R5k@n)0xWFggHkj*sESS(;2LvLf_=mw<0b>}X%Ow+wt|h;zlhsG3Qc?$XSlH@S(`yjqfY=U)h@ zA+NkazTIXs1;fwdX@IjKaN(S1@b{#;n7?G*SSpqF+XO+jOLj%Fsy-buvPuBjE&*tW z1f9nura4Q3Gq)i~m45#1iajO?rdEoNhHV`2vYG4XW43EXPPbQ0e%0J|;&Es1VcW+H zw>>4-nIxIIpeJ)>u22CvltcUZQgPIe-MN)FC)dJ#v3edN=-}#m zi#lAhD)0W&n$v$AkCJx8OGYh@OCrU4ZQa_n5$h)I9=B)2U9#idVg_EM3a|%HX;-$t;a`+;<|?QOJirp1@gmlN zC6-D>@;vS3Ch){k!)p%` zs05s)9IOpc%e-+JhJhtE89)h-!gh?qqo74+*l_>q{VmOv(qx|RdN%9k!v&)~y87q9|@PHPh;umDP+I0`keT=axS z07zufY^UrFv_Y1EbCH%>r3AFAY&$a(wo1Fk4Jmh zd$F~~PpC@hHpEx=7@wI_EyXJNJW8!x_)l4;Nl5AVZjWtImzWcXgr*+do zZDRArfuK$PyAECJ$_ifRKlkdS?V~@CFM?cNCbqzSC}nfN3*O_L{-suGg^5(eAM-={ zA9YpYTK$Q^ZQN1cPLX`Q?(9G$ZKgbN9^B+>=_<8~qW-l0w(g6;!+e?kqV6}yA~kQ9 zdQOc7js`^%sAWMFxxx~RX1iFuWRfUik-{N%iAQiXH8bFu{K9I@FAq}%x2eZU1 z>_7(D;QeA$VJPUv8!C7zltLCJV?F}`1S_eWpM`{K+|M((4L8S1*2nrNl1!+ZBFKV_ z=_CCY`bF}w#;(du?2z&u#b>hL6w{4A5)LUw85yC9EnwFa872$OwnH=fLD&gL3|E8l zNJ}g5O+0}0sx8-@m-Mx=Sbcf?i z=7w7)PMbDsx3U!gz-Mb48>n?0B>;(6>(dd zkNH9hTZ@rA0`ef6S)(iZ<1DNvJ8s3Dp%lC*jxLCc`Tp}4s&6!JzjC$0z3x5O3+;Fm zvw&0iBAIXgq5<58;qX+lege5uBIQCl#6cYGf-`gmov;jz`4;ZPohXd?Fdw)8u@Ea% zvlVQGsNu`S<-#RI;TS|g2>2hrlkZ-z1l4GTF1Qs{aCG9ri3(Lp;HZGom#2e|-gTfK zo4^I5cpimvcUaFk-;dcWSg?ach{8DZzys(*6=Vmc&_u0#Ddm6%hCnVYBOg>VAGVFc zcoBseP8URx6F8Fz2Ezq#gEX+hI#jWIG@Pbb3L`J_p#5Np5qOcD`Fh%qxhxt3pj22U z{0xmeRBWXT-pWUzCrcC@?vK}K9_2zgGy$*#3_!HWv^!0Ko4kw5{e=vx8={#A7WuK zmZG;*9c!c(qDYVeokWJqp$D z8?2?JG>*SVTCtel<;TU(^!I2g#PA+ANB^ccMZBs1ST_RvxG67&bas$^4e5M~C{irf z@Kgj;W2FRPDky>5Fp~^K+no)QeJB4{*eUE1uCuS?s}$cu8aTs`=!GFHT$rV(GE%Tv zvTd?RVTMtqiMwp0;s?dw;DLuA51vU;9~*coeL{KSIq@94fod?Lzfp~zP{!?fE_fIi z6C!gJTp!?gdmO(fGKWyIgjQ$- zHI3!V+4jB(x-GmgJ$Prwv9aigE6@dwLL8XR z9+=zp^0cv?b8$!w#v^=D=LzDr4-!571-X_5Kf`2Q*SV zUN`K=IQgLBU+jZI`dhp(cyI7IOJEty4AxRJL|`O%VV1a(KIZN$mpzch;|8q6X38WB z@Pc489BOY!N2FvblG?u|6bs&LBijh<vz8!8PQLKCFsaQwCoyp2QILvrrFK5DpW- zgFA5x@CPsSgA!N+_B@fzl0Un9x^C9p6EuvG0n~v>lU0LnLk`cO6moF5IJs+b1Dk1JMKA8`&^S&qkEINDn{7vRM{(<;$A|D& zhy)XHl(>?+Pyc3Gh*PRm&{0tZcjD_o$x^W$?9mr1ppk%V@hEs0WG>m03HG8rI{^9A z!@bB-Y~>s;%Ff7FQ4WRjBFttE!ZsOt3flx9a^YUQ0h=(G^^pmNfIYThD_cSa4V;Bm z*o1Wu2?mPW3*P7hc9JPLwB#lquG5H}J|CIIo^f7 zbtQL3G^L0$_)pyU<=6e}Mmx=U7INOqnv$tRUqpVC2jk7cEgJdqB_}?5Y;)_BX7_gw zf}hXtwHgRrll0x#^B3or%*eMJ3@V(z(@L1@@7gh3LFM`{2dW{Gf#3>}q$Mp>VlL*g zNTwFlk{!{FXK_Dq9RdSZ#WQ)8xDFBhSQS>Vc#H>kc8;BsH!HU(x5S`P=T(frV7dttD&57Acs7-n!E8XmY~>O_GMj4 z=bJg-oW9ajQ?|Y)rZr4hC2SKmKrpyK08b_jMH7FIkP$s3TM>oc_Sq1J@9T~Gsn(zNVU`nPQ17Hn( zmBe7Ka0ru&iywcZ4eZJpoXD@rW(w~!@HsZ`x%(+NPg*L2WJ-lxY(f*91Y(Lx2I#>t` z5ndO*7B-#tI`i5;<8T0f#tqDk?F7?>*A{*<=NsFV;}%(Y6|FnJzjE&VlbVv2P>o%) zsTNWeDmqcolel^BGvcZS{jzA32x*Zvv(qlE$C8e75bsQ?312OL}`d^mCvf(;1`U*7CxJQM87+(n)tnqi0dFg@4?H7(_&QfFi1rdT!n4mLg|=B$$X8N zN{y66IZ#d(dHNfb(cP(xa1giPEH zy;v#*YY_O*d~U4&7S$|P_?jQ#B0tAAvR%Sch$WD^T(Lx0gE_39yeUTvgGLGfPpH6) zRDiW;#mugp{;%ccYQBxulXi5o?We=6W}gYNG|d*>#JT!;;EwK?j;&CHxjdD}(0nJi zm)|z}z|v*>vWZ#8|IYAeHhLD=YB7*^(fiu%=B5(&s)bFtR#~IQn>hyL&OK=-bAL2x zqDjDXaCp_CRk>fD)YnH5VxTx4D_IzZVFkpq@E6BPf-tI}bz&|>(K%E@rc_aK7tiVa z&{N_jS>PvzLxdzYvkeSRa?kkj2ywjl5tczXhLZ(9%PlZQc%b_Sqp%pif=I}L9K-h! z&oNIShvo20(T1${R8_Dl1MG@^qMwxT?FUH|EPT*c$phu*Z~Sm?PS3j2E6Tl^tIiBw zHEDTQ|1md$2-;4U=pZ{N^fL{pFbXzUbdR}WJkKNW)h6@v6OOypC<2UIO&?-`;ddB( z1$@97>nNPU=|$^$*lSiGwoWgczudC>dSpqiwx_0`L+ER3GVP%rXWO5Q?km?}AG2l! z2xy9Rq~uR{AV}cV{}#H@k6A}6ZZ$Wf37h~c*;~R*Vaz+5{gj@`U`0hd6zl+`a9koS zs3DQ0pdv&_L=`g9@;sh^=~#zBciw6LZE*FE=QHy1X0SKeZTt$nAq*}BOn+1L+H1-d*^99<~4FAuu4*I)I9V)Z+6C;3HT7P*m< z>{vF7W;4Ma5@06Gf)A+oF5XGe*s&gFiqU&k?fas@!adMq;!9SswA$vK+(~=vYOD*u zi!!JYtSE=t!3%Q05h@5Ml8EN<{ouvE7NVJ2R);TJw*nz)v55;#_GrNR;Q!oXSx1*y=sgwJ_z`#@8pJ0zp zf)kcvI!mWga-yQ4QJY$mTgJ5^;(vT4Uu)}VA7tlB_d$g!D&a-E2-ix!t#+nin&!t1 zj`!B-x9i#mIaV`YMAecB1-5S(-I66y1vQGN;8p0sB(MN`>;rq=EsFYAF&kInDe#v_ zh6&V4Gp-4cNLge?*$@gxzz)@DhD{L3b2!H~mcr6yR6#+KGslgeM_>n~%WVopkVfw0 z1zL>4U^vQJ1r4=P4__(RvrB>tFQF~`JY+&IJi%br#6qx-oo4+MEC#?bNTwuSMqxaX z=dyNXj7d*pvSxkr3Sj{|hGU1Po6l$MkcCyS9=a(Uw=gTLLKTEbn^Ux?#dLAKxSku2 zqVWW4K3uW|RNt5M9zNT-r=s`l*%o)n`03w<&!+Wc=Y|yCsLk5nz}v}QG}Vvghwa+O zeme5X)DIk1S%T{_RjcvOURS1#8GU73r+}b~}LwgkhKz=8sr`R!seZ=o%gr zKg?okrsi3+j<16$erAW*b`MyE9Dya6tsdy z_MMSUZ+ZV@exug%!A-rz<9Av=>WOOJsa@GQu2gyb%Cp(`KY4JcgJBUK!(tZ1s@QbM zf;*H877&ZMh$cfCT9Fxev>&jsr ztfN5Y%3N8U0m7h2vS!v9+l=^uCgXOz%C?H1iU08i(bqUopM=BEnic$S^$L?1umVv7 z-u%=z&i{7SUvK+Ix0qW%DAYhg=-J?e1)GO&5N?T&rPZh9&_lIg2PSwFkHXMm)L2TR ze4feYiXtXrJGSv0*(Zu`Hrjpv#m+UlEqXI?GZoSWS~_l;&C;=#XD@oWY3ASH0VAPZ ztQJp+rBY`)*?L(av!n|YLUz2G+fW8O!%C=z z^|J#k02tMxJ+CH5t`?iP8X~a?8?o)$p{iB4l0Wc{h^yYT?#7q8l{T*@z8WyIoho>^ z$T163STuPN@iIukWoQOYyqh1O4d{lB;6fjx3x+{2r9mAcYl2#?6oVm@FB3OV3RW-| znomplQuN1cRsoji$1)qgY1-cMDo^D=t>}Ub6YSThI_$#eEh>^;GTTtaQ+c2mD2?wI z8NIBSEG%sI*7mf2ZIx`@WECQhSG=zHs_%L4p99k%S;}c>6O%+gC__)kxb0E5;_e~& zB>4&1J~2$R6L&cN?VLGrH#Z)9W8i0Wgsad32T+6Vw4X=vx!?`^VG02Dq9=GTd$tv7 z#WHaoMzPUs1ss5U$d&dux=^H8BTfM?3_=g+g9T781Fb>aVFk;>a7j!06A^$K0kgn^ zvS>a}!VORX{-8yBmM86*Q$a6Sq0t6xyz-Sv+)xaqLI{QlgV4xM+pyF1$kzc&(%ewTgo z`1`i*uE0mZ+TXkKPe+%9VBZg)9DH(1KJJ|5l;OWjUZr|C@}5)21pSB)C%-y+g3;nh zr)@Tm`pBZy+}O<3Bu=qcn)9mBJwlJZu+A_Z!;k1s0-+DnViYT8#X#tWZWL*dY!C@r z2xr;C1L46Cx58+)O#sZoU1$YW7!D68o2;mnd9ta(R5FEw;#EjTZ?*w~pbF3E7qbz*$gdC|m@}&Kw1#buie+w_ua=9E{@9W1GHLM|Sb;qGMxT`C5cSTsUZOx;n4t<4K3vTHA z>Q_Dio~l(dLn^&ZC_&$)i-I=LPy$$D7I@JIhU@Uton=Nd7@6#0{LRX0Ii97fO!9nmBK4%pMKE-`G=iDHIglpV{ zyKoojcXWdWxC$1+Y2m4GMV}*TL?!bCcT$R>Vj-OqHVSKnuTTG>_CNdmg%M{G|9PE< z(i++U36KO<*oV20C{~Laz8t!Nz?2oqT;(T*Z?yf*;R*g>@u^L{P^b7vnU3E9KpJ`v zKJH0>zUFpExmKJqkk#7+(bBv`DrM08JxHLX zV6p{oNP`Hn<2@pmmPZ7WC)G%P{Bk}Ya}lr`60sQTNF#QOb6K1a%mOF@(x{Ry@ROpx zdQZJy^KC>HgH6)jgfN?F_OtBlAQ6w@+dNrZC$58h_?rqL4gE2o&$##7?soY#*(+>? z?RtBU@q79Y4Frm|SRx#fIl$1%JsB>7KiTmM+_x$5-n~1-9l0;hhII79G>6No9vcO| zfuG|n+{zBJdgy~=$S9b6`4m(`6{x9%(xiT48vynR9zq9HP%e~GGFk8-tYrt83f00f zSqPYr8Bdk!v&&>s9#8`Gf+fU4Iiz4VIzuCb0q_Wlz&N&(&4OUqimr5l-_jq(4R8_C zKx>dGXv$Q=S}^7A;$q6DJf1;~obxRZLy;5&+p&+Wf?Dx+ol~$Xuz%5tp9{Bl@BNsM z;k)UW`jy?fCvKD9m~qz3ZsHBkMZ;$Fo$1-FAJ)~=W+Zw%D!5zJzx{b(cSdiDY`E}1 z*lFEvn_*hvp;>G!+;CgI=!V(P{1hL}hvP~3YGitw>4RlrLqo*P^S#awPv4o<`?Mjw zX5(`erHM5VYf^6fSn;64_4)cf)qmgKRP?o9nSXu4qg?zJv%pk$RPW7o;tugYF`UKY zUCc7yH%zanvk}L+Sa*;7b>y$Z-!gA9-mLp+LjAZV<9U{u!^bNBc^=UJ>ELeV5%~l8 z*ui1CkHn^dxdYK{t5^V*3iaYjjKY`Omv(-o{JLj)pO5~m>=mOon$OdR>uq(b;di*e zkLdpsU*(TsB=}L0)T*e%Vl2k=uFsyV#FXoY&Ry;Dxc%?76Avfq-|Tx2oJY&b7i;TA}xSpf0>^fbz|?JdrqiIqtP0Du$U~UhJu!chx9H@vbbO|)!YT_ zpdEtw25#UN&8!Nmpa(6`LSLi5pua#zD2*c7T45)<1WPCnW`e2V+{p%IkAz25Kr_L; z@m7P0ruGl3oFD&i=yvS-x>E-izyxBU8bYZ^%-}m{hiTF9;UiWl6%*&Utu=mQ+yRGc zWH;*6xEm&DoS|$R_*y?wY!V(IVIq6L3NRkuhgectjC1Q-P}NdfuKZ_9w^-eHDVT2Y z2wun*umh+{jZDkmGkVfFhcmVlre;lYA9Dr_vKpy}Vt6-1f(L5Rlts&qk&;{RRLOId z3ANIG>r4uw<5%6j2VPW22BRB-Z-XINi~)G3bJy<4){=wRYPl zjGhqlB2&~G{lOnSajKxgwU7?!byWBLosHB;wsd^_Mf)cEMc__Hcne-)p9?B(&d>An z!dkX~Enu@{{%BR}ck6Q94m^f!*aVi8Mb1-$ogG!Ta57V{B>)sgd1Omhxd&#UnwgS@ zYPc#H zE>J>=Py>ku3??s7Q#bli8NUdnSigDR-0x zFpIz$GJ!}*WqdhUP%|e`%elT$N`<~Iw z|J|P7cf0>dkLBP>VF{~-HX%i5mX*R48V>!>5BK-;=eOqF8UJt>t7GAhFTxXC4QnwD z;?S8a$c_?m9bUmYNCc^{jP_v3mO&{j;!&i8O+1Ds^EkE3ThICZDROp~P}zQNMLw{{b7s9;x2KDTQamM|7aLgtyTmSu zUE){zui&xRg!d%>R49+(-MkwCEzygg5;ybB!Z9IRxD1Jq1zCf~2Nn)4JhAC$^xskc z>Mzaw_di*U{8eE(#KODKNOly#Lr1>sFx=HkQRPrSy^!}=yG(lBXurHoc8B~ZozjJV zv`4i#gBD0}H0QxapGu8XDu0^?5{ao0h#UA+R={?e&7S`4!pWnK*e`K3Zr}P}$;oL0 z2cO<(z#F7_fk`^0T3gvNM zu>q{WATzU>;p8?ULIKSuC!WguF_#^s`4B+~M8ai7OZ~#z>+hfB^LZZG4Ib6)5hqPp z=CIc8eHeoc5>!RAp+TG?UIwucd*|`(3H1x%U-%KD96xr}IK2iTun+x6N$LNW;y}LO zL{2)z;PioTO=!a!&2gEj@HwlLpHQ4I`pQi+eex7v_>CfUN{B-hyNybS;Q0_mAv_P` z1s~xuc!C$sf)Z$ggW%0=#3qP@9`J`=sz7_l#yIdtOSVy1MkW-)kCG+WW33Eo(Mrnc z3Bz<~!+eT|a0-T0$irlef;Os!e6j?89>=oSZT7y@=h8@#$}W`3mE;TQP)Y|d3`?cW ztP%zj*4sF2=e5-D1n15iMm)W9jpKYEI`K`wnl(<~qbEBbFtx3Im zt>M)3{XNjvD9G3fcF>%gwkmFo**0RIVvg=V{X%Zs@=xc#gR7pM=$l18^~y@WrE^40z1srmFWK+9JH{tDMo7xwT)Vt4hsy>GOck@ zlyR!CjkyYMJx|hx;*_4W9*@4%o|dQAWM{e#Jw4k~)BE6=Gq`lCx)$j|2Y&C##m2$; zy&v(vx?b(-=pHke+5ZI%^s1ko5R>>xexD+!hxXEQ*n}@b3)n+1EXO=(!pMsceD~^=6G(V!y_? zo7&hR-5H30Q?!oxFeA2?1bUPH6hGzTP0kD3*tUU%;#BdZoIhEtnKCd@e^dV>er)uz zQ43~S_SkGzjO2b4q>mLY2?v=ODanZ>qX%WMcovTZ569FG=zc5e-SflaXE+LSAxJpJ z0x;7q)!fZ^jqw)6X5ov*u=e1AWqsa*a?u7tA(c#YIr?0_7^5%}_jg|DNf0%a{@tGs zl!GT8VynOdvcSY@tEHd$%J9gbRPW@szVxNqjQZ@E^sGsfQbLM~p(0-mWIl zBn>>DhB{!-0qI}{uKX7JnSHVT$ab^**i!Ysbn|tVhGw9K4d97(4&P0jq2wy@uB@2WrcR(#cGpDn*=>?Zq# zl;pylcp)@m1%^stnO@k$Ca|qgN4;c6k))(Bs0Ac%w88@BAni5m#WW1S{CoGBC%2v$ z>>Ru{_zQDo2QkO=b<@ozPfRxsA3tj8!0>@F`Xcg%bkZ@b9DfhBUP%w{Fyi%eM(WjERh&r&XC%)Pi@^V%upD>o@i3E34{bLq%Z5#Wi)6U4u|s8h=Fj|@2HnsN zGSCXGunxi@oWgXU>Me8`BhHPvW;CidR{%_e>*&UfZ|B2ow z!fdRgG~H2Mn(hSkQ!6B3^nUuicn;5D9kd#LyQgy>>CoZ$2N`CXj?R;eO@7??Od)$tnVTx>*u!${s z<*aknh;DtZHRy20t&??inr=*E&zYGZu(i0v{_P11 zRI1ROAAS%9;(i*=Gtizq&;u{9LRKhcZC1$6$cD>?Ln)YoC#K?hT+d_pO1_dcvJzH; zb+`dHa3y!=dA~kR_x}C&CoetvGnoKZS5a?K=J^P9w%V+e zdHeaiKj)>O{x|>jx#aFKRdCo1xq3br2)XDX)KL#bh%?X+x3d%|Kp(ap0G(k0r1C(j zp=RvE#JhF(tR9H6sj^>K0Q&%aaN%TkC(Y!5f$s+Y5WkXE%)=040&j?hXrsT4eh^OA zSpYQ>xzvx> zo1*b<`MY@cN>Tl)r_`JBJYWA`nOotAuB!_UzB_f)NnXvZ#X7-NaAg_;JVClt)CVYr zV(CKXK~mQnEuYFC6-Dk_-{kse?ETE1$-*@DGjn9i*w@T%@Q&`+$DcYS&1^Q)&ON!b z#lmrb(?e;r`8h^vHU<+^K-Q0p_KowL$G_^u2IZ30;=`5Uuqrd1U zYPpl(Dr{v1q$MpmwS_dPG5ybbKkT@7j_brz7{`iH11To4@;$=Ao>)Gd8lTU5woE_1 z)cEM8)}&JJqeokV6m#Vt$X>@tY)9|j$$f1Dx7l)GB+HeZ5w^0*nKLImHCtl(fkJ_^ zX$&OOT6PK7;#$j0Gi&)Zo=1f=aWFuas6U)Q(QR*<+GdZQZ841p>n(LH;H~Q%JO*mq ziasn}*e)ES@`18GGsY z$3Jv+#_ZT~X!o4nioV(XW=z8}(H^hiRIGF@cH7Cs!DBktupgk7itFa2L!Lw@=>6sWHzKZ>Iy@d0V5 zL-3m+~DQ?Mx7`P-w+=7Qh6^#Y@<1IJ;EGV<|^;V~I=*d17w0~$WcWmq~RI^bP3z~uqU{?ux0O~Po47 z;-poK-cmcYSoVv2A~}IA5yWy|aShZ;^K+44g~5{5Ar?ZQ2TI9~Kj*C&hh`W6y-+I6 zN6o|lHdS_Ab^}U*G{hYTJ3Tx}yt_-y;f zXTVN+A%P^RUd*yf=FSWW7X63*zpzc%#`@V30LAzJ>I;Pa%AfyOQ+@mBpPPStPop{f z?oSO3g=xvFcGam1wV7T|AKsf>wNbBN;ry0jy5P!O{|}g6Oe0pZN){~$!ewDWW8AGN zdVM~mn>{!veyslp=0iC)QO~;H-!~h(@RUt@#grLaRz@Y;c8?Y;e2uq=Q+Oa;QbGg( z0Du5VL_t(P#gFhLvKU;g`$j)mcSAo(-vUk;2VvGJ!v_?d-nov|BflJPH0q|}V__y+ z#j41Tl;9`6$H((qit+MyWxK$SEXYZCC|qN%)vb3YJ-Q1nx=#ikVjtfxnqxeBU)Y6p z7|!CMkh3c}DERiK5WIiJpLnf36kU7@Gm{gp+tcIGP0rka2y+M%74c3X*_ z%mrO|89&d@vze?8vj?XQyf!fV>W)iaU-={_ZP&Fu(+=C@S{+(d>t9!S@6Fco)_JWO zL{y8DQvb!xa!7Fdx<- z+a+8RR*5$JJh#wwihITPhVIwEW&J4eJ^f$&0;Q540C?*^=7FRXZFnG+15hmZDZZ6G z6&`>A?2^UjkclC~R?Jidn3|?dnBdBc4x4l9VqqBzfpi{Ah2%#yA90}ClgG9EI8Yz+-A&qy7NBBJbN&R#*!xUW0Q+Xi|txc?V)o9QP+d(a@ z$1}0HWnXIVJOouQqIKHY0$~C3#AuAJQ&*p=%~HIp_(oW5-=?y+>*1l42l+!`ewZj_ z+YgOKuQQs=Zdcph{I52JyeJ3~#PgyNH=nxA#lvAMDJh7QQcCJZP@@ksdyzArM<#qf zhCvp%fCrR7C?rA(UNle}?4<;<11E|EXYjxPwoHm4SiwevC)IFwjK-a8mJH)i#hRg# z^3t4+D{L31$8fe8=_2~bMO zMx%t&q@^$50fgZ)>_cmq$CDvdZ>RrF^rJLL0vq8FJ!GzKKiR))b^vogjK8#J`@kge zaVvH;>Ar7_tf}jEzJKIaYUe)jvRL@QlI$S?yo3QZ0egjPR=^7AIr!01*@wbqA<=fe zagt2-wu7lm8M?rX+sfv!f4ClY`nru6XY|FF|2^ZK=e_~RK? zmnngqcsb1@cgVnSY448}vVIN?tH}nN!%5lkyRePy2i0tY_Ls)0)Ts zYFG3Gu{P8)Gh2=MRmClHZfYZc-N;Jb%bJJBn0zSz+x)yqqi`25GJDj~JZ?d$2B;!G z7Q*70n$jVea%7pT9rDPD!XQEH=WF=2ZcT6Fz%aVNjd=lh(FHgU{@8{kQl}{wJh2XL zQy38yLJ`DJCImnnTOwskxPvKJfD|m{=8KK=3J>8-&z#<_!4xdPN-Sr+sKvfv zJEsj_kYbYUczRanz+bvf{bPBxOeOU3j{ykeBV^Bn4@4U=ke87S%^(YLnK+8?AXn1R zUP(~(y~qMHfR{O@8lG&?)c{Wemvx=GFO>f(p2W@?<&p^3nX)Zfqw;3 znQoTVoGF|6Rm;H1X|i}!VU5w+u3;_z>1Wj4xjb>;+LJg<9OP3GRN!(vg7JAZc}_)1 zgXbKOS5Fm$2O*gWK4llX0P~GS#}*N0&a{X2eO4AD1ln=v2S(o za9GAV*a8+p$=sj&V>>Ru1!6V-e{{VER8-gZ_r3SIcj&!Y=r)=iY!T2zrI;8gMvZ_P zmEtdn0!9->6ODkHD4dB<02-qLR*D*>sHijxRiq5e%)Mv7i}Clo>;He> z_1sx&)~w~sb+~ix_k8z0`|QtG{Hv!rPr9Bp^WQ0lg6zI>zU6d*d0`&LaLWN##UpvT z>_DDS4IxT{o`XGZ?bq6-c1+QkrTC5&30|U&;**i5hQG8=#0bs$KBP^2JNJVuD2FTL z%@0VsF_akyJF!GqE$*XKd5v@)lmt8k+gSDu-fit%4_HP!3sBKD0p^CQu?L zd5uWL5Jy(v!1d@hT7f87a$pO zp_NDS+47f|i|trI#9cW-EKQ&~%wq0r2f0HCgkS(P!tW4=@i>FIvqp@jBtDV%XvRt_ zco-DZ3P|U%d;+8emG)(RKl5uJlpYNsOVHT_$0QZSlxOnUG~h`HqNnhiz87*14D{%mH928Fc>AJzuXX!Ft!I$8)(Je;>ef zuD&NOozgMJXci=MA!!@n+XVS-V689HYi(d#AXcON^g_JrVRebt@ z!43 z3yqeJo#-1OSMoY~q<==SOo)>!=`{o>{DtL$Qax4LDjlJzbcK@04q~BH?~LwNom$zB z-{q@$C3oT}I@=Um#h)pKVxa??a3-`X7P1+v9k$U?@fV$L;Ttxrccpr~bW#`)xJlAV zewBh03&p*H2gai#_9-fbyR1VF;|J*(jmI?X6OJ1EGF%~?vNCg=ZPM36%|CU{y7}^) znL6V2j8-LMm;#1PUo@6H|6}EkG3?u)(w?QhztLsnExT0~wva(tRNg$H?R=N6Qs}Et zEfdwk2drPV<8?$MmbpxDN#vZnQlD0Efu~Rq1!p2+{Z;vT@8~k|J-JtQ<*{Rb8~45YH273Aq|b)Zsf>F= z68Qmv0Y*YN8sJ6NuigAyNZxX)TnYM+4)#zFxfDggybLx`CLN@F3`J|Srd%|H6e^Wx z%iAED`LGDI0)hjgb_dg8h^As%3jvU!J%K*;LNz5pB6w>1CVEi>WMc+C!b{XZp0E;> zZ~>yB8sedbon?kXF4@ynUQg*134TxwsSplzWKAJp1)17Lwq;ZYuDnMsg%EO~J&;Rv z)H*bJ@ZaX|2s4EVEE_Dq4dT11`aPr#hF|DxR=j~YOn`7I@%KE4zQW8g+lnh9G`4c+%(A2RLjRH<&XWUO)CHAVRgQ}h0kB^FX)X_|Dow` z{1(2a`*e&sGkw;?K4meuNMOtmlY$Qz|3|MZqFpgX-(D|9P2m1N&pfz@G_&M1A4ENKEyQ@;f}A-lxk{qnRa1va6k) z7n*09S`2|tVFjIlL>PvBxD(QJniNLjV57T+I=V;g zUO5IhjBIp|eVi>mR!3-R_7A(ky=w(=S%5k9hPoK`dMJnm$x#e33qY5YL& zr*Wb__y)|u7N){h0O4mg6VLW9P#=}v=y)h*Dmnllmi)w@SsHG}Lg5=R0f5rTU$%of zDCTzZ6>f<+!mmOfJIT&syxc@};AK$HN~9C4LM8O{^letg^-t@2sOnTLU|Te~eMV+h zHhTyE!p54cwfRwIR7Z)to5lgiXIP#XB;Pczw@oq$_S0SYrS#z=;bwMcz`rX>{vk^# zNfY(@#_Qb9uRs4f7Ius~R%HEkLCTa><9R$d!5^7Ja~z zToIWeT2mq!pbfN=D=!5l2HqB?)r6_8|dLcuMfn-AXjpy){@+*u6H>!tT zv}W;a2HT7MR1I@!FC=QoIql$qfe?aUV=;ttE??2M1`MZkI7U}p8iuvme#k3%KHrE* z7y;Fd(d}DQUl<%wJi7RoH3H>uee$OPAxptHNWd)NgwVu1@o{{Ky;{=Y|*o8A(` z_?gD~d3xWb+jk}R>}Z^cJGeInC=Rf>XaNS`JR}W%U}s0K)8C|X^uv_~!TKwm%B5G# zAAtYA=V`5iLVoP!I6hxKeg9YKvS#eXYtpak-Jme-=O0-Otxpa+CLjNAR&ANK*m8;(vd`uTAzkzg$u2_t>=GSzIqFgz71$MocxG zWw1$9()hA*#rBjv zw>7fYcS1+2!KrrVZL1&`H)5H_UiF*$QCaM*)%ij4D_P_R1xJ>T4rU)rK0miZdT!)t zos1DwN5^3*#nMQM2Pf!b9_%cOXYtx?+ylO;NcdS8FC2g-m|>NH!6Z? zbc9^;q=}<$xqURUShGRXCSO299LefXOA-X%fl<^os`HoEyIv_?YMR{`3MdYPgAm0= zLkKM52=RCp1OMBPGztzNSR;&ad-%kWtF&RJ@v<$c+enmFJ2;rWh0|PJvD=1MuAl<^v zxE9N`wYHhyz~Zr8ivp&%kgnz|L`9Z!CO5p&2s8%czW=cymcJdX>VSQNGI!W&t-YHBNs?Qp)9e#AX zyH_}@`9_}Y{58{MGaZV^p0P1gboVGux^Nb+Sg$aZ*HF0De}zL420+LtUkVn!-cx4` zrb9Vrd*NG6r|=Y0z;EEk)_ovp$XtC~u!Jqoo>8tW|Fc2RwAzUi4G%`!FWz7_bIfeF%jgBO;js)*PqVeX zGf=Ak|BLA#!lfIU_x`?K(%Bb}S?Gz`6Gl148t=0;HCwG?{q{X?1zr2HkQNL`6`xKP zv->IDdA`lV4=E>)+IsL{}KF?S7Y=jW-0VVgAm9p)LRk=PF3sle5 zyX1FRC_2Gi7y*vxMaQ9+J`;+CeQ3b4F&eA63vHrIR)T40%?7axY^nHK_?)%Ne{mbC z2OqE@>s}vaysD^Z+g167MaiM!wKtdXMKlo#sFSMb5=aDxYNFRTz#g;ZuFrU?Pe zS2sa7K_}A9bIcfz)DfS#op4=-j>0vu8Va<1@uR4W)=~qvmRFVRy=+(Xfn+P`N`r-H z=FfJUeeY5^u|W62Fk$>xc#~~qXZshZ-c^5w@yrqX5a2At3r*+*3EZFRz(*@bn-%fG za(2RCy~0#nF0J4}R0P1%Sc3SbdwWY}``!n7*T?ksHaI;zgoWxBr8|_vcw7M0P!Apa zck-io>~H3V6^aC%ZDJBAp%)G@Uu2LWZI!ji5-InO{iEI6L%ijuM`l<0krvQ%&(s2diu>t+f2L4Aq%kyE|3x&uni6DYaRR@A~BiW6aIl*DkFke0s>wN2cho1{T-vb zi8?8@QUZy!E9peYF7{A2rZ`1RbhyB>EuvL7HLe++UYpV`|A1s3)vI19# zGTy1bK+yNPzsJ@MO8Eoy;v1< zfi@_DC@l!54XuQ0LO;gCS&ATQUd1ixD!(G%L)Ep~889&9wi^3 zAdG`@%9?5HW?(K^RvH!x!~W;(ijCNZh4O8FfKQ+toPf9md;WgU9_`%dHgMx%gLXa51xhH2dd26$Lv~1UPu#_r zfdL{U8%9F|I_an2W{hLb0~b(S5B;j6k z!V(O@a;(NJ=#CY94eucowb?gH6 z8RY71SD0ZR*09F@o$5cNpA;X7yVxY2Lqtgyd(8hb`=_U+TeoMsW`uM~dMc0NM` zb?)2EcUWt8*?yH%phK#S(a^=VuY@gPr{V>bLpmgY0j1DHs@JYZhmj9?zZustx1;~} zU8hH#-G|6*@B#!=CV)drMDdn}H{Yrj%CfjnTqZo%xhZbd)~wg@Qh6o6LP0_ko5?!f zCean915)+Yb73K^9rzWxzHMHmjHoCtjCj4X;o6hSh%A}yWsU3*I$F>v*X(B;N^TR6_5j4#`l^LaV4Aa>*as zAQcj*hwRA`4?!OGX?F{zV-#D*c2JuZ^%eq2XajAKMEY7jM1TlZlnw>ZfZ0$EeYw!;9fCMbYM(UNHY0Rkwz2SZIf@Ub9 zRJux=AeRjILCj`Z><+ttx$HSa;|#1p4|W)*ym-|%tEaSZM}GR%i{dc$5L<_t51nPU zt=+n-q5t@+g7$*$B4L=gP?&qW>)HCACf(h_WXc`7)%Ly4)_k6(1VxCN?yo5V#o0g#0`ko=+i?#HrQ_MX*^%&^2-&S zAE-8W+AEW!SskOhuc(#egb7$9J5m+($+t8&d9La|Y&E)f4u-{Kz$5ua`jQy+k`)fa zT+Gtm(gt{mIWr&TDR>EPS~%KF?#&&zq4_&zKN@#Tnla`F-IlQpt`p6-PChkeDytaw z$Z0-kAe21&dIyZN`V+{ZW0*Pc`mLX$9LQ#WbIboFVL5ONf?jpCGu>$$zTNq5Lmb4Ui=hPBu$jWeUBWVY;JSwJ;fq#W? zWvzOroOfqN-M`&G)rYhd_gR4fIDi|vV*_RzR2#JE=g(X)Cw1oGuGw9h-YqJze~YqF zsAPY$3WEZpY@-761C}E!!!$psud6TL-&!`Ud~S=?t3%Dx)eEIu9)+Q(#A0C#GiDd~ z0c6zAHsEAzq*gG72t%f?uNY@~#A3RIiOEb;g=xO^=0VG?txO+TgqSZj`N8Z@ixU(G z<&+`pXFm(arP0?V z?x>Z{$3~2GUZ8VBcfQ_r`H?(b{sanv;1o5|Q!2w1h}fxEq&r1de*Q~+L&K^2v;SRv ze}3tn8$)kzRF(8^>0bwPsT{&IpQ(?jTpxdaanPw*!_K_-i5#AA zJLr&|h4S}TqZ%3a_%q5vXEsUr8n-|+6!2KNRr1GBD8Mvr%fKQLtf7d!!4nLy9K0x&G9d?& zAc$glB)3OL%wUJ8p2tdOAQs#(8Ec>dsv(1jtf`9Y$w!4o@dw3CiX-BaF#{RfL4}k_ zVOl6tzSew(K?FO*9MKa?&<$_ssb>w_$L1m}#eefw(-SOe>0g_J`nymiPW(--2oA*m)kVg|nh zJ*erW{^!y$Xk$ZJNq>jBSANj{rRGn5u78Urg}>i(PW3wRQmHJDtpZcxpE-t#$uZ&(>%6r?s4xNF~xi9Te$tGSR*EXcLctnSDGB1=hJ@INN-X5SEqNhdOaAEdc;>6m z`;J$)z8KmYtl7+GkPjAPI$jgL6V{^#Eg^51th%OhRvp(|()_6Dbg~(lJ^UQ_!w2{q z7?LA-q9HS6jQ=1Zzc~A6UtQa%k)>#WgKnP&pA3f1sSDOB5sA>9`vY?D2=^!6Pe&SH)L*wts zE9LS0$$*pM2Ut8hVV_(HfQ4uWX|!2N;XxE60s}m&`>imJEdsaK_n({9F1?s}!a#0N z#&zjJ4LizquytD3b%{MkuR+%a4YnRLZM*MepMVL`p5YU^!5#L)4f+#Op&Ozogx<&x z2E_*u1sa|Zo6^W9me-<@~y z_rER|ynl1;HRGE^i4;QNPy^e6H+*#U;@KwWpWk~Rr1sRCPIq3d2%G#%aE#gB`?m^h z-ssd_do)@zUHVB*g$ZOxR?dCHr#J_Q?ZNI5fsSZ|$H#s$ z{)oFdM$3Omrzi(fArlKR3j@KCqA8RYQH$&VNf1oq(Fw9y9)(eq*6h56pmFrY)p(E1 z#mPbztA=pQ!#+xvuSlz)nS!VV+*mSZKmdfGwXnr(&4rJ4LNCSA5AXuo;73frE_8xG z$OQ$|!W(ddsh}AMa3C8JK!ff$1ESFkD)2KjW42I@$xsL(Sb8j z!uosNJ08t_^`Liapx0i)q*RnQgp@!Y@gVD zs3x#>L(dm{I<27|_eHkO#s}={ES~Cg(R2EM(y zmOc-`cC=;R4$ZQ)8G6!mz1b17F5#IlO3vT1Tclk@V>o`i*|WtuO?JkeY7L4fqHC0XIn1)+Alhay*x@F9ds758uIe z7>{4#myoKZQYO-;{8MIxN7 zvzk5|!;x*H-{p@D4J|zQM6-Y)WrCkFqkW6UvrBqC1?Is`m}l;6nPN3&=5+t%ejl(3 zc9_}Wx9lmC2gVz2?Ks|A{c8RnEoZ*UYp|c}nCd`*Uj{v!-@q4=37mwJlt_tMAW1mu z!at$+&%oTGykA>?d3C?}T`b3J{CDo?K<0aSbfwEh`{9^|hWG~FP%fOL5VGVBFbh}U zWw3@{AsU825m`YSq`_izV~jOn8k@~*@jkY(-4qKWU=dz`2p9{GX$<(_Nc3Q4&;|vd zfzOcPW~Oaq+>RO8hKIFOz-TH5D=30$M2r9>)SwayD2!+z4#p4&fdoV-0z=3ICH7$+ zq(L4yKoO*&C$-WhZ6l9#%7rM(r;U(CnUITl!a8B0mMndSN6I5L3QJ2H8|y5_*bcO5 zjCs9~?_Pa4wXCA!KfCtre6XZ>dgmh5a_&N95N7wxq2Au*omxo_>nYUN;lu0W=o8fx9 z{-s5*_Pot)VJM47YvK5S7kgXtg-+lDxqOLi$E#QgYiIFXn<-QMs8&nTf5WRd+zjE` z98iz&5j%lfj1K8OU>;r_PRWMpV}G=rr#D@g366Y1he!A6{sp(*sVY-OZ*tsst)u^= z)t`=NOFgZZAKQIJW2$~aiI62+#6>f>tPMFV;*dV3@89EFE5Wk+aqVTCk4+u3rwUR#;LOr=^TcAu3_6f#@d8FW$MlyMJ?kO#fkR#}R8N!3xY%e6M&R{Ghnm zTi=qYb5ppBXWv#%@k;p$uaxb0FqKKCcj_ZE)>PU5QeZ=|7U&m33G)}WY zSjIdsPccn=EgYXYc+Q9K4Oz2h!<+a%53jK=##~qbsJ@{Y0u59T^^7rR=FDscp0NV= z;XWvaYRDyfa^XQ##1r`h?!pI-Ac~|&3L#e_*3a5mJ1?VjJ}~7BT#Zb~gq6 ztjK`$p@>=mY_bQB9@Gyxcb~^RfkZ9XD?Dpy4835WWyah`@5R zK|_{}71+j%1O`C4kONszk4fmnjD;N-fYDlNgatneKvtAOB(IfjK>%i89C1F8|3$G> z#jlbp@0NqeoBQz=@aG%3J_9ABQVSJ;BlLo!b^sN#L(CcDpb!sf7e%enh7Dr-C=1f5 z5aKA7^dS?9AOhO7;a)ZPYpcB@DFI@sh#bHXe5guG)m;j?T1-}lKJ%rI1!8U^r5m~iu?kUyiDC5-|xE@UeAK2uw#?{}&GHTOjH)5j4E}VF5f}d;f zXlHldSFc`v*-|O2W(?zX-Y76dze*XRaor~_wZD{NDdvjA6FO7e@C57iu3bn8j)`9Wb{7wP8 zh8?(WK%}rxF+}l$&>=U=NBCE7e@8Znt;Rc|TCq@=$+g>w6fcBjxWj&lg{AKP>3$>s zG`Z{HY?QI zDH*&C1B)|nj~9&3=$#+cwi(^A9pg-{8yF~l_1-vYjoEC~4b512Ri{D;@|?#XG~e#4 zxZU&g<8IwQPu{uM9+=g(Y?Rpde27HbE+H&mStU8mp2=2HYm_54buxe z?p}QI$KC0B;O4Ib7$t$#Lecq6y&2c(HI8Wr3yobv8eED-` z#Ee-ZX5b{Y7ekqi7O7f@3G#TE%hSOZdZ9qvDXPTRkObD4#&)pH=*!Ns1;Re8L3a!s z_$=1fFIvI_CA;ul3N)N3&rzq32S3GoY-431R=cmMP6 zcfUV6y*SV4)v4#7zFYzIkbyO9C!5R;vxn>ux@&tv26beBmS%~74EQh#Z6&3Tdl`mjCpYM#RpgHbE332s{;j7CQUTAHIqzCKC6u@`Rvu4 zcLI{HQ}`|SPQFnWXZUf?G-u%m7o%Dh)rS>WMc+o$d~0Wr4NPw2#gthvNbyF z6SaM*Ly`R)G{gqX*1S;`cD>WJyVUP|knT3&4zA;Kx}-KQa-~#g#XO7vN7p*{7?&Ol zWKDwe+Y~!ggBQj@_<)Q&dHHbA!xNe}(m%3`gPT*J(PwJ21VLYE)#rMVK3bo715icB4$X*&RZGcRk$(N7=ZK6!_q)fgC>YAcK$if*g3?g}?{3RqIW6t0Rebff^7+CFRjoE`6Zy;l<$>VJ&#`4a9&S#Sa_W=}Zs@$oGxS#wT$AvCeM zxWysG{^ihJk$XS;Jm#U|6CFwCS85|7I(#MF^N81@*VR!~ew2s$*w+}}Ue;H^JlI3F z6l~xP_|(1EenB<3et27G|JTzTMocpuGPJ=wL8p+H@&l9t4xkSphssB|4T}>J@D2-w zB+R5zb^rq)l@cM7>aYV>Ypa~Hgm`f|+X42J0REVRN^H@K6URW7OWfcEdf`qxE${0T zHSO!H)qK}j(=kE)NAFtIx6$d^SdY)Z0sV26{ETmu$Fooj#X@13FiE^YjC?4OTq%oONv8e0S==dn zBd+64cu)uiR{{c9jg#R53&qWJ`TmuP&38^bD7pL1gN^b{ew6C4=ICBC;wSb#FOs%GJ=($(tiW~bI6p=}mSBk07>@y%jSM^?8?q>s zrc)v&Kmd3`9Jb+Ju!0Dht|b!gC3|Y&95#VIRD%-g0WgaVTmd&Kr)t z%79*o#SFHE^+PlyffZ!Y8Qw#|5RBQ_hHcCdA~2b4Bz@ASc65YH@P|T7h6V_LdT5|b zew6RQay-s*DU}L&F62TmS%Ni$U@~|?Ce@=M>tmC^lavr#G57wH+Q>7BxsDfBLl|r# zA6So_ICGuz#$D@D%?DeavbrqKmUN^ph=UfmdabZ{`i+?_^{-~W{20UG0Cg+Qi$9A~ zmpzLfvC2SnR=6qjmNF1)O$aDROhd`EYFq`?)cSz*WFaEf~9?3UT8D+7(&_s+5P&t(KFHk?%oa!5JcKXx?L!tVlq9CjP89m^~XcHJ} zLxXb^OdrD#48i@c=5%B$9dQivMIBf}Nsy@)HRq(GL(Qyujix(<*-an(?zkVFYxUCH zZaPU8x4q7~ZZO-R{|@H$=0YRH$q}GWBY1}Tz0UL(|Ei3;zw8{Y8K<=BenrGPC=<)^ zL;UEj_^eW`#x^{RX~H$}8r~grd+4PhQL>$+FS&w?;4J8aCnzzQ@@XyKNKsTxYcWe3 z&HyGOa0~vI{1;AuXiUZnES+r<8U#VujDzS9+3-^CLq3YBiW}l}ewZJonL;YdWJTm4 z2gzS*KGA$3z5nWIlT%~k-MeMW@7i^K);YE7JUycp`bYd${E8h0AKC=t;az-}otP9c z$!o%XKfm{n&kYtID}Geu&GrtI0$Pt<_+@J5pl5Sx9iJF#ND7zdx3tvN)yfbCk86D) z4pjILR6{-efe)}mi*Q(in_)Gag$Q>q_d=JymhAcD?}#|8!M&`XU*&W79CeTSnd&bX z0SZvCJIq0Fpj3(@e>g;&;9ZjFAm1i8N)=Sjwg~;gFd;?=6++=1umB5kr)rwS?y^3i z0<16qllUt6f&2iQ@HM^`d_{lJpVE01P2nzlC9MR2W~rFIq*5ql$Jq{H2Nc3Sh#o)N zGh#yD&8lnqH_tS<*C#eM@+@8;Z)`es<6dEiG*S9UI!*ax0Kmhz1z+MGH`>PikaUFW zQ61)jHxTm>cCww+#OjldWz=dUn`2XWxQ;@Met zwleh|-2H(U@P%{+dcgpEr~Wkg{FpE3vbjaUqE|Bn4@~qCY^Jc2ddZImp%2B9H(0|)N`)$@CLgG#R44*Bs0S~KrbP0FUMPhW&?j#> zUnG0Tq!4Z0ggb@s6nPUkKp68vA8)&p1mnz!CwKfU%0J1ExE~8) z$I)r-()n2*yfDcylgy(joWf}}24gSw_J;LFDx?1z^!w+3PNqL8pX}KoHiOXLnolJXGI6}FS#V#m8eUE3IU-|Ezjs?<}@$-l3XD}b5b)_a)V8<0#@KM?7$jU$0lPt1wkU{QV;2qKQ$8r2XU+Lih1zU zyq9kxpkQ!>QDCSIzYk*tPQrGOxH%tBEwqM$sF(l6r&AdBrk@~Ij1=}En%_2d*1v77 zXT8kgh|_MH)#A^C$oh8+KbQUXOLTWlUgs7tsLD*E?=LOnyZUEzj=^+cr{E3gP zaC7+?FS4l`e8Rd%@j~ZM9a|PEzmgZqN984aEn9%rP>4ywF2!(uMsAi)Lj@kfL7cdy z+`}4$x#C<1l~biPn1MTmg%AgO!AD+0^J%=|6Y;gMODa_VsUGpPyEggBg|-dNuUcm` zfAI3&%ctGC-B)_P)dn3&kO~Fxk9b1d&X!UVB|e6n z$1KvJ8k)flR4^C@vneczeX?qPO#4ckzuzqk`TN{I?_A8iycl!Ai(L6Jp2Hp37PbJh z@dB+y(5ga1EnzFD}&f zmG5Oc+1KoAS^!}b#)6riphsbpL{Y4ry~o~@N68kl1;j%dr1AayLwO|Fzyin-jD&T=16%s32o3uUcv_+A(EBcy7+cP z;m0rcJl@f<=ZvD*Ll*oU`U}z_187K-?VF(*p-WgTPSNyAqGS%?EEEeU zQT~czA5~OE)p&_6!Vyfii5aqS=+`dn&k)6A0eWWBdV3{VUOP@w42c_j>a`2GEo z$|Gzk>%g<%12*74>hXvM$9{B1W7Y@;7!A<`bdV=vueJ`Wlyi9%gy2=wgR8PkI^YWB z7)6OZR|YJHO)zReH&{I7^H|;k22cP5ao`9MV2yDY&AB{BT8s%8!>U*S%VXgpASuC3 z`=7<47kemp@hWMhW%7yKafob3i!Q@S)&<3$IoeH3kGEoWPET*Nh^J!niHC0Jk=>celcqkv@JoNPt z%{w1@=e!fj|HH3xwX|4LN`K*i=F)}Y&F|pwsOYq_kQpN>04=)bRVN30wT}} zF4KNWgj6(#4l0BOJkBC8Q1X!n%yJ^7(00Xac34y?^ZE}-?o>d{ltNXs7OW_ra>x?B z*c#*%&08o9&tg0nvdQcZWSXPWE!l-{m&fy!P=PVbzu!Z$(4^>mB|O3kD@&7MIxj6_ zt->sIExpaUj7@BcP1m!Zh77elglH9M@xX!)`(fxT-Rpy9nkp2g7R@IA>aC@bw4F8q z*PQF>LQ8Pu$?y(-gEp`SW;3QX=y}|5uXk4W{=V57g|ei-QF2yIQ0|qJ)E%l57==yn z6=UqIum!Ba2QEP-l)@% zKqttdWV%{+_R$}YRK}gA3R7Rj`?^Y4|JDZgXgV(w5phvh^SS8%-oD`5`am z9Rm{==CL-$F`o3vitNP0f`<^QW2a$B*ujXJl_Gu}@$0#x^c-iZ)a6u(VY!SY|3e4vEyoI-Do6cCU$*i1}Qvu~rjuzHl z0FjUisg%jDk{wlnC$(V-&cH%g4Vz#S%!g4hN?0na7FN@2a;7KPj_nx8`}l2sTk(ye zUD2-Eqgtg}CEgP!iy88Ld7HdgsALy~)x1T%&2NJ@BtjMV${<&hH`$XtGh%~Spy?-O z)6ABR^Y>glA)>6{=GM~tR6wl|%c9w2=1WdgL+*}N&X*l})eV}Ld?MSy0$2d}YI9hV zAchL+8lHSnyH}x5oD^q{-ZyUl=;hLW`3c>I6e@rMN*Zuo>@166G3}e1uQ$gwU2amn zJg=x!+!Gf{m*jt`dBV+c8%93FA2A5$k|p0RyI`-DQE81;U=0zhfFTxRHX2bX&*Och zPmw$leV`DPP>to73`(p(0n@Mw8z_|us0<+s|Zd6BgB9u`t zt)(<*pwx4@TB2+&R6_w{P-!FaY;2!%rwNx3j7!|<1y+z8v@KEkmIx1_*>`Tv7q(d0Fk^^32n=v7v;QgciQIH46p&S~a z4cZ_H9H8#Zyx(q|sbt}J2frjAswV%1Q^M>Q4PlLJDD#kC$=z}{^Bsta+rDi5xc#`8 zAoR0myqR-u4M98;44p!T4RdBXr*&uPxYV9{X!rQJ@H1TDJA`F;2;#OLUpZ~u&35}P z3Eg2cjpz>))rvgb7PACHcg0JiKHV#ns#l`(LT(rL=zeJUzTO|YmOA$><`@fNhuJs! zUN{UKF-xUSiRu`Yf@=9=?gYJ%3Q>53tz+xpB7UIlS_F~=+sI$z1(Vb!{heeXAC?TI zseKQXFEwU;alPv`Q&l#qdz#?h!Z#100+mQ~kVnyCHc1Fz*Qk`2Q9g@e{uqNXc$QrQ zPxOIgNT(!-g$yi&Tsp`Lc?ol8ZY-oS>g5|O{HguYvmTe{cROyXhoKV7F$3@5S&VtT zrKA6~VTtpV?IoX58|6?MAw33vc9@OENxD^f;kvc_>i@&ibw^coE$^8*_tLEtjUvTD z6B|X11x=A+qEa+@5lf1K#w4Pq37DdwCQ(3B6f{LeeTj;u322HG6P046St)kFM(3VA z^IP}6AM+3EuCvZMXP>=izS(=e`P5n!D09VIcA4(#uc%!1Qa3+oX-Asg^qUQf(|{Q3Tk*5h=o*I2^ZlE*b3M?mFt%F^fli_Oxko z;H1~NfZybO@|N@!C-IfVQbpO+PVTBerRcHJD%0e&malbcD^!K*tFO}@XoZH4a4&Ab zCAMXDyKPG*l}vRDsYEZV#_Eb06{D*s!cc60L|MeEW%JPAhP^W6k3mnnXSvPMH|fLl zO!nbWPQVOI#X(vIg;TqfLY&1-WkaH*u{T%h6XX;p$waY%>)L!RL<>{vR9}_LCceXW zAON0&$Gn)Ab2c>-Q4JCGAW!T-L>FaaHp4H5977IPXHG7e_fMsd7&?$s1^g z?$~Z=+9N!X@3SLm)RTHjv$%kM}0DS*N7K5m5;I;`d}>XS7X#Eb?UU$zh_F;p$Gm0O}G`a zaPQcECaCf6IV|YW;4oEPSJvux!w^G;;k$nZ7iaz3zbWZn+P%HljQN=C`PtBq2aol- z=bhvAHXl}f^%Im&byUZF*@u0~kC%m=ZH8U&rbJO8)uCPQX^sU>b;}%Aj9Ye>Qt1x) z?tf}``99Bci_6l_=D~ULq?)lGc~9&e@?8Cz=_*&KQ~D+j)m`+9{FNH0ze+pEfrjf} zclPVZ=UlZ`KdBCMf8YHB-iE1M$WFSeJ_-O1z-&mtWX!NQqhU~nlffOAYBx!vAUuM_ zssuw+GzLKu#bX(^TPQC{kO6@0fPff`(>tc0Y1kWw4ft; zqc_$O5bzIbu9{0rEaeeXaWKBXf$XYWF%kpNMm4EMy~)ti@T&1Mf8)sS{9e|8cE4;a ztUB>@@vpas+^oLU6V0IFh{tC`x`zsm#y_zO32$PT6q+5D){|!A`m4`go^$mN?#s>+ z;JIjM>kvUDmHhtggwreMDz{?okHp}YQe zcM>?mD>xpUzz))765FWNxSp~x8umgNtbsyFg&gdlW4ITpMW7T{Ntl)n+1Lc{LKp<0 zA2>rJG(iqHLmN1Oi=~KV1-eT#&u15if+&c=4vfGMgXLUr8x*1~c0dkfN&@C!7-q{n z4pqBgK4n>YJZ7W2B*+aG$l(;8h?&@i8JLJgm;q+*pNgeakM0m6c9zbP^I#q#uE1ic zmuRknI&vVtf6o8YS@xY;san(-aRVO+4f-p%aLgtEaDs*8pe>|*cmO0{B1>6c~yO#fECjLA&Fv7wT8u+y{lY4G%*R#6e)F zZA3(9YxnZ5rRG!+_rM1RA$)&=U)Q6v{0{snj1Pb|#I;$q? zex|SCWjG9}@E96kpwy~G>N@<36JdeeRIchnPF17T6FN($sS#hIW*Q~`awMODFx-XZ zPzXhmPVF>Y+X^Ef8y?G536lW+TCLJANEuv)GJ~xl*|-8q&DZLqOh1`kMx-G8f08p~ zN{S>P(qL^R#p7Yw&RG)4i5LVcaRZl18mDQqwO@=cmU-MX(w`aqj&5sqcygd)46N5* z4S$FaDrRIvtitnmly)Ku(y*#-4w&^>) zxjVGmMxV&cmjLCO)JnAel1kJ!VKX9v)ysy3+A-`&{iw6Y+dcIj{ai;5vU2V3Y;=C& zxWnn0e%Jdw<8jAxjyv@2a*A?9{V&t|x|h1Ee%Ch{-ZT7Zylwj4jJXXRhGz}S#Mfd4 z=3oOQ7z;L7hVv;y+@xK~pbDH|vie;=pdT=-G>$in*97is@e(47WwCNL8#ZGN)?kCS zUTbP|YpZLIxLS9)-<6))5$$J+uci@_Mdb9pl#gwy;De}mPSjY%*W4udneTdW!p_h^Bd zMk6?pJye;hROY|e^`p8za^jeZQ5O%rb|Cf0KKV`-O0wyD{R@@m8$E*j{)A{0+$SI) zwNhxtho!f$xi`x(Nu{@N5TZ{MRn@!g@O$ND-7qIg^)neqepXmijW?5@3i z#dv*|r5dhM>!A(D^*(vST-*!g2$Vr1#zQ0INPtBwFb*qml1%1x(k2T2qkm6zw2!JF zR^lX8V!4oGv6+&|S8_Rp&!96pQwrqM6pVpIFag{o3d+R;+9coXPrwe^VLk?93}j#j z))+90_R=w#C+;#Cozb4=OPzqkK@)~h1Axqjvk)zlI1-DfiP|Mqx6@m}m)1}+dJt(l zdACYZfH7u(3Ne^P3&0IhrIvHqn=j#E45#T*#Ub3@^Rr&NdX1nOO2%ri^r7w>+4o?d zr=?ulWFUgHVH@9&1b(IUqm~)1pEh{cM%O!Ads(kFhPws0w-54h40BxWI16$i9Z`rS#3Z4%_iMUXl`igJ(b-6hfGkNR-&=x~bjN zL`<*H+q8q4shu;$mYL%52<1y0hR9?Nl3+dzWmpJ-+7jx}?Dd0^uRT>dtu|i$eAM+q zzs?@#zo~0%Z&EEhPlOWa5GM8fw#PXKocVQh*vw~rtNi1Jd&6Xy42xtZ=3tJ$`!NO@|nwu+T~Z~)wuL}&*5*|e`B0uE9bVm*!P?0RIgn(j5GdA`)H=N#u9!aY!2Is z?Rh?`4ye(xOb*Li>QnWT`WwH)1Q-oP(1ZrrV>Y!$S*um%00q8=1_K)3gB(Z)Fm@PU zH-5pZc@Vd1!P*Ax2p{6@yj?HRYxTEj3Qd7f>ZBfIo_X7O8pcpC+QCv;1BsMRQ|PdI z$84|{G9;dDIY45mk@6_-;^=dxOOuQ*8SS;Hk|TLg@0T#V**D#E!F0{^80sJli)664 z$$R=8{ZrFOcn<8~FNu~42-Exx3k@5=4x-^Whf0)s$1oJ1HH334M{yJnk|>Fy6&Q`- z>?=3q2G3$^HHhLVghI>L}#gkSUCh`l87M`ug%dfn!HRV(zJbsU7ZU%S~^-b8)D_(Im0BWcmQsc`IV8(3ucKntOirwdjR(MMyxIm_9u_;$24d3eXs{3$gf+%4wkT{8? zVA@Ml!5Q2z3#uR%5^yJOq%J9fR9Fe6;>Kq%nGS2kV#|ZnhtLQqkbyUGI%LQs@eyD2 zr5dV)W>9LT{HFb{okK2%{m1YwAzLnb&u zq8yTNhy!m4;3N)!XxxLLkc<)diKMe|K30<-mO~(?@meSaXAXu^NJYUaY{biuCnbCz ztI1#6s(+?G&10w9l+Lah9sSA=pXf*iAR#07{DOfixLsemG4E0MAs8)^E5=WcG4h@yv=_bYXB zdG*030o~o~CGX1sc^gklC1ipx6vATI?*C}yCnIi0&3@^bs1z$Nt52=pV>J)9EV|2{Y#{yvoc7O-^fjc@`iYhC)LPhfp?X0#{tC2Qw7H4e2A}r!a zi%8dOT1l()OZr;<73wsnOWH}ZaT8Z@l`NHwvJt|u2wgOdrfNa-03YCRmCKc?5;HL$ zeZUREp&G+61ZGQ=*=5|AajoH+7ay>Y!(AZ~?1%ivtA5zmG@LwWm8w*wdZ`?d#q|G zDGH>3w@azCQ3fSrj6Tcso9QuzV;F>MOSNANS1u=B-(NS(`nuIj!*krmwzAyI+V2Z* zn~p8**yWEk7)hRNDFoG)YQwdq*LPppam7IWX(Y`WoHXqH!LRW}<;sp&PX5|bh~y%+ zkubA^yWVo;<0}OnO&!PDr;r(^=5qp^0Oyh44c|KSw)`rw-G9e9Xu7;B48oNPt3chdgnTD6j*2von!c z*-fjorG^*z5a+9V=!@$qO9DAcA~6*ju@MR_RY+Oj!}lQ$BQOe_EcC@}S34jO2BV99LbdWPe}|F1{bMNH5`*2L9oprP zd0})t+x1>ouM1XZ9nO9U)?h6IoKl?9oqa~6j2tn1Gtc5z*bHr}pvv&j__DFzsi%vI zi$CSH>Mp17Vi_cxTwR?$CqH%8G@OEAti`F9NE#IJ4o+rU6m&#J#J+eBOE3+$V>~ru zrv-EQVHqAF54BCXs&{ZZMxrYscAy6@Ruy~{voK#qOPuVI-LM$KB#f47N3@f?mn0SNYGKN9xw8p;zAP-23=(*Ae=#%L+DYvJ zuEO!y)4VL$Mw_AqX+hdf&5R(@%%;7kZPm8whx7n_vrL3)%g#|D6l&+p3tL&Md+Kk> z(9-Fg<)~l@w{j&{@S*+;c1R}~Ik zpkp!SV?OSndDtvva7=daK_xuV>U-;f)-!J$y*BN-4M*}oaO3-2!fF2JM%?n7qHj~% zRe|i0<>15}T%wL>3$&fuNlRLGj^@yO`ci+Q-!T=?KDweAAjT3yYG4Z1pdS`fu{gsX za5ru--ZtI_Pw0eB;|Swx#@ED00wh3MC70760se+DyjZ`hY|x&9X#ssn=ja?qnb8@q zSL-+#mSZ?92X{;OjIzA%VKdt0SWXi=I;st)IohAvV8dXT0S%B|f8|DK-9eX_{ux1UZe<&MrIq<)zWQQ~jU#2WwujNsqL|y3Ju;xkii>12{?qsa1iS)y@fI* zOx(la|l0ae(H zwy*?pU?;@G4%mTF=mRX_GGC5i1jb+tgo7Kj!4Yr=cMQgfI8mA;MN+7NZqiNtl<95L z0P3ejXef(0QX*xEwg--5Uwj+irk%8(_W#cTu{RImL70!_SPpg|;K3`{m0kG`|HXf4 ze%eNDBYL6>PG#o%d>;>6t}K%R$TTc7t}>pNFb?6{Ek~d1dNP2H&>C7`t*lzCKC$t( z@95#x;omW(Ess0YXjKkzkd9drBcU9__r(pSp$~q99~qhqWybf!1MDS>U6m`lLMqII zKyj0PGL^USJN%BAgAvjO+D*IpzL;%0zrwFbt=!{nSd1QoSf`EE7K(5lFP2<$Y77Tu zp3H-Ktiw92gvsbc4y2JkcQ7)_WC@ZW!$!lGhA;JG-Q8p>)1^=fWukbq_l@J%3u<3R zZ(2$3@qB6GI*;!LzvvlcLFH%upA}drd5~v4!Mcxed_~iRH!9wSJSajZuT8#H-nrn5 z&tuvDID?j>nsDS4bE)vK^E{9@ydvhz37cFC9=Pc>qvI5j32R%L;K5Hwl%4 zlHxyN_(wzUo1D}$e8=i*<87_~<*u5~ue_w)*LrF_yKTBlx^`hZ0)b0;|LOhq-Hp2Q z%e|YUW{z4uqK6)B`pa|~?n4!v(7x0B41TRij|(3?YW%7(v@uXyN=s?qXt#g|qxbS9 zZs9=?1wP=Tn)G&kpwz)2SblDCsau&37jOyxrY`XeIX!w*vq07;lU`dRlXn|U{9J({8YVq0h3)7sFswQH?YbKt9sgA}P4*ApZ@qS}?~}di12sT4K^fho>!?YhyaSQA0-uEh*alm{8LcrL zr@$o0hZJUYgO~UDv(J#;%Y0w<|JJX}%f+X~%XP@NL+b}GG+J9fZI#B;)HYr#4&X1p zU@~=LC*M=%#p907mQ>9JT`c7t451JSk$Bjgq_7)Ts+3DhE*`4b$Lr*I@$MdPiqY2& z?=kY%5fdQ}k{|*bsgCMove{K?8?=4eK2^j`G7a(}2!bFMQX!R($~NAHj&d0K+~x1(YP0iu*mSQq1af=Q+X^ed(M)RSr$3 zt=e%u3K24wn(1BomQSc86)o=K!#+?6?pOeJ-~b{P3XeVDruO16TjO_8+W=B$;7)Uw@iQJX{9PcF(YA?c_-!yYh0(!3>PQ7kP(nFK>FL z(XPqfXtbJb_1U07?w@!h>(`WrrDI%zBtQs-Q@HA@u6MmyS$T0|Wr}u?%r0#W2I6|3 zLZ3ed4@YN8!+aVu#DEB2m4oa9z zqhp4BhDW#qI?&9$qF9C))+!Av;h*OQ5rdocPplRTA=wu z4EDplkSLDQh#}Za(HzR{Y>PSQj&{%n2cfEWv4fx0qoIq3Tz9vs3#&`NdG5jW=3~w8 z+^M|%`)#As6z34zB0TD^@wnIROgs`BQJ9@ujbpBfz^m~%$zcG_w3r={o0k(8qX_}4a*Hl6zvkx z&(}H8Dbabo({8nhi3vMlDooYRX=Ak)PF?!v-sv}K4xPob=9CiJ3+G+_xpQklXiZ+n zA^w{yqzzIeUDENsd9<|tS`S(zbEGGPnbw+u^a(Uw`;AV~`*aw;!C<_M7x||8fsOJI z;^4LcwJX~5s+M2jRt&-^T^=|W_rW~1NmcUi=z(@vjV`zXVx&z%Ae;!RFdCYnOhRmP z?B?6m4cUAQ2zFpH z7J(DnN*a!ZrT8lD#$Zg56getKF%Y9M8f-1hxF~RwHrNM;(a!j$)sNOcT==`J`Rqch z!87>1Tdcdd74~xI{dMo9yo5LM9@$`y-t?5#p>^;ASuV@T13f5>YbA;+WfIqkErvl0 z&QopbIek0Lqi?lL+=1Dc4N>9?hxE_%-%J~=c39o9eqFYhF`A^QG?gX=@|L^>Z-Et9 z(F3hjYgI?pYPH(xqLr1^U!1`ie2}-vHhzbK@EGkgcp4TM&qKC&NII+pN4T#_)bDB& zO{aylQ2fMMoLioM{M+MC=zUB^D`GxB_kPARbgoT~)tm zN3QYH5>J2|!;2tfDCv9q}N;Bf}#e%M;lT62wWiYjM;`j%tzG#RqW%egq#$ z6S$$B)@2T<`Y6)?eE{S`GnR7|7xEzSk`y?k+v)AP75Vj^XUht$d$jID_}d zI&R?)4KEoF8)rza*$Hxs<-I_&*VfUrKBcz4wFp>LzT=4t4*5NL)&l9UO84<=ANE}$4%BYQX( z`hpYJas}o=Jyu~5mZGx=xX2nf%=aac3&D+BAO|a{97539qSlneWl)GwSP21e0Ovxs z*ohm&Qx=Rvdr5~T17u+Y+C#piN)D7VD_2RxND789jDt!zOhwurI0VHoUmPJ(x5Yvk zE+ujZ@+5(ZwVf0q39u9FG0S`n%*PZ+hX4o>UkpckbcQNOfF$(A6?hnuxSu*a2Veg8 z#SLktU(DZLgjWpVTK2D3bJqWQdvT9tz2bgVMd|}Jv&Tz4AM{u?K5(MVxDn_J?)W04 z!g^TG39t^<8OjY2h6w6MwsiERKVx^lWO!uz@Z(1Zy{moM`vtb)F+5Ybs#>*+k|~Lj z?rgtfdw;*pT9(Wa^Nwh7B9)!oCtKLR-v>hvsP0Y5JwC3vX2%d^o7NNAqyW zggsJAhw(6NP%~9B`(hAI#zt(ixNm330C!k|e(1t0BvI^SnuJ0&o}#lj4f3E0cCas} zh?AB{Q!!ip#C@bizL8xLt9$ClRTM85gSgopvaPWm*!y&!>wW%1YsePye$>5$#(COV zZ9W|y`t;BrJU{O7N6&X1J`Qi6*?UG~uZ6vS?ftgtQ_}-eH!jD$xCSPG9oXr;)l7XZ zt~MhPZsi+%lxm=okRDJLIq19eMfxJl!3c~{;T+E4)Igb-sVcfJ=+}8N$MY82N#)cb zzA_0)cr>5Tb8kF*?bwY5HAKCke?|^89G47z&HHVyUc8L|qsBoR9>NZ>;d|n$vQ?(a zw6tx@R<3-24`{_&hIWi6fjfs|H3VW3v*}cj}FHlf~O&B97yiV5fZsn|;=`ii3y=sM8pcc>+ilG?Vf^KvK5+O_y#ZN*Y z1UHz|8HhC-0=8kBdZN$Nf77}Q>4yF)S$EO{wf)qIuHXQrP}SYs{Y;m$ydp12CIrFT z@DpjYhStDTGa|!paX!w6Y>Rq(kSRj|N2!7}|f8)41Le(2eaTlv_U4cLxu$KVn~8^h=vGSrM&>a;VKB@C5v}M z8I)79xUnlAMR&tE?HO8cus1G~9URH`&dZq7C9BDZCg4K{~`jI|k4}h(;G& ziS_70eli){#R(cENt~#G_R&UjEYmcY%d3?a_(>TgU?65d2IQa{Bw+_slSS24CQAl1 z;S_LVQIo+LtFZ~gAqEf6oH@-kxF%~x=D+*?dA#V+(Z`P0Zd~)ZexPjlnIFnJN8Jr9 z8&#&a=}OPhaI5BIR9K zt=>=*R38ea*|df`xRqNyJUo&He&ZJEes0jVmTgadXqoZM{%3P07s+j2&Ijn0maS!* zMw=d+CZ1{i_pOo=D8opI$2=*+Ei{&XMi0YU!!I;$(z+=RLN*%Suo`L{Y8u)7lW9%& zGo}~y(S|fb56#1Nvvr5ns7j}cxfNaw`x^G$I&VC0{M_*I4>ogcqYPDDiCEQfIzL_z=rPy_YT`bma)OfUfv!^9RsA&?KM zDph4zV%TEX!b>GYLT~}ZKnzE!xqK8iP_x;cVlY0$CAbj%AOM`e$?&q_XR8lt3aSrZ zPJ<2L2JMy(POaXNe$n1Nq+6083MWGmG{bHvgo)q_ArPWYs1MaF*aJJDn2WiYn;{f^ z(U(g(mkS^d_P`#?X{N1ar-25Xq-LpESc>&nZ_&GM#2hTfVkkwV5}YLA5~zJ^{9HRJ zi+GHTu{350l|vSrE(>G<|HhluMA!p|@DR*{1i2w^$OM_d+c||(FoNdOe5%$CY4zHh z+Fk9l9w&SG^_t!NU%fA1vCTDjYTvcTv^BP+7_tqo89wD?-ojhdAmykW(Hke?M73G1 zQ|mAm86yd`99^%Yi&k>Yxtlw97Py=Da;6LFCKXopT{@3z2w&%ke3 z*cL*t95Y}GuEq-5B7qXc%j-RA->5r)^;iu#gVwp*yJg4(u9aFT1s~Z?LDWf|lE#q~ zadl*MdDT7~i2LA)L`f>-dDr?4^eW>Z)1uCw!B@hd5uNE69pg-urwY-9j$s+tp#w&U z6EBu^m_=FS4tcOw^06G6wMub^Z*eTdqcg^!86hS~o2pV}m;tf87@H^r=Rq1X7s3>b zg*r%=D48JXm;gIqJ~Tn00qP_SCPSXAR>%sB zk|;jNd!z^&Ey5E!wf^j*YW2mK41o|Y(NGD#Fc}<0AP1JqEJ=}2Nwd&vb(=P479qkFFHFTek$yxe`}4J)&mYrEO@C=+|Kr+guWa)8>Y z7#~0rxS%t91Y2MW_yRx`)mUsCy-Mdfly9ibT0bpSbAWl4;?EGsuf48eWW(&+y)Y0%(AU4uDDi)O z(7&F?Jr`OUiHyM_{1Q=>NPrA#cka4ndJ4K=BmniHWW>AMZ|}dmO>O2cWO2{sJ>qQp z+Sl5jby(H?TKB{5f5;gf&_O$E^@H^j%SlWYreiF|O1#+(DOY8!thH*YrfP1pRNN#; zltgLgv<2D%bheZfq;M*yN-X$-uQuEKJ6Bqgpj9{+2g5vwhG>egsMHy)u30^IZcW+y z=lrt8`QZG*vI|3?2%_o1D543BZVG)bdo3wf&s&*E8hwDbW9lQ@Z^BWCQt@zNqK z?9D;kit(@)_d*k#fit)l%P@nBIFIu*4{fR;Scb!F$-#dRaSjhs6f5&+yRJ5R3S zDrquL0_C8jNfte#{^Z1)c{Oi_NGO34NU-c@*^84nG4KSQAkp%nd`O*CN~N+6mcw${ z%s%Xch*M~bDo|(iT@V6;uod>0F=hkQ&%3$q(VvfdwXPN~Xn{)2oti26=4TBNx3)Hn zsq?wnY+j$TUapCV+n}B95$?X;Ild)6rGr}C8{HN+#oXI@Zy7D759n!27yhaJhWDS< zf1G>+honxz*z8!GF$wZ*Hd%jT^{_7P#_P4uYm4Z6`q8J|Ct{dR-r9I=DlCo zUAChy9F+9S6*YAIcbOt?5(^2C0#T#V{HG876l>@#l|z(Fv7n_CvBgI1koJrSr>MzT zhxz0T*;pp!Fi%RE&9jBFp&o+3+0rq%Msnnkl!6mXhB(ZkXo|ujIEyEt5i%iL+$E3a zN*uI7J(OW8R#}t{ZE*!U;CzBW%!l<*hYdIdf}mY8!3jga*RqW&7zkw`U=O|!4y6zX zl@Nn_Asnl5HYH0GJMuknhsh8Pjo6MIxE`yq9z*aL9RWK$jDAS8hJwif{iqnqF#`QC z8~4)i1^F*MdTxf3k8_Y?l$ymAoY=9l^JdpSdn0~0ot|4ARH zUxPYuhj7^~t7WycNvV`#5jJ3hg&D5}cZ}-}%E962fm3h;#-P3X;lVvUoApuZ1GTy} zptYba_t=qNuO1DRN^ych_Ewklfo!8T^MPZXM_Z1M{kKYL`;tBbAaTVQP#%@XqPmz3%>Q=&!v=v-^*G>b3s=cKx@j ztLr!vTFO(ME$_aFLphWW@FHHs>&!SpZBk#TFQ|qZut9C(&1y4snw_|IS-Yv-loUy@ z^yfFbun>zAk2bplb%JlG8<>E}m<*X_>;p+=;~Qp7+Faf=zj?ujCpK6c&ued&Ojg&TC#NIF&!!g{P3`(vxSbK&X8cXlpyT6Syr4pRX zfd>P9CJlLipw6x4ug0zG zgF#X&DKbX(SOm}#AzK>d5ClRr?uAD5g=k5FIw=J^NWy#x;7RHXgh4yl!wM{idP^AI zt*x2xj^|665CSjaYvRT)$h$BDo`YYh4;|AsfjvHrQzT2`WU{!+m+Xi`@dNr@3|yk7 zo2QmMq8K_rb7UFc;oDwvz3>!_ zgpu$pjEC`5Y-yJEp1dlr(ucH-w&6)C;S4O-;3XV93ZK7Z=D{tI6?FzfJ(yPv$$V%@s< z&Z%23+#cWAr(<^4oIV|W_w^3$72WsuKAkm#E*-lx>+(C*PcHX>q3}7_4YC>dd_NlX z{n(?yKY5UBv<)E zN~FZ}g{imcPwl97L$g)=RD(K(193P`fk3l6kbiih9H#{|i@cx=s-TJc^Fuy~K{x|n zKexU7(+jEB?p}HG@<+H9|AQT4r%yQf^hl2~&o|tA(-~?ef?k%!jY;qrSI}`fLPy|x zi!5&wBw!YI@mbyn|H5DLBWCW4 zG+g^g`vZRjd$5-jcu(F_Z|lG7_V6@bKpnmYU-<3Zx$-{e2ei*`OKAVfc*U?x`)A;_ zK_>_9bDrVi>2gW#o3S1~k3r_KfEid0uj3aKL2pnDx$s;yR*j`{ZJl-yOz- z8*eSUlRxjXiD!DWzE$;1g@Ip6oA6=pKgZ4)QfsryIjL8-Ri4!cq*Q~|7OQq+T}N~K z5`9hg5H(R+wG-L_S_{8|vpEo8G)}2&x)yQ$Xnx?YrAO}_EZo=grv;Y>S8lKHdX)3f z`+?W3{dME(GKUs|JdV>M{&!-CvQLTvORAbV0R0P;IUkVy%4Ro{4{*e?fw5! zJ^c+AwGu5#^MCip(K(Ft+|qx=hxU_N@_OfZ2X_s1VG9Y%r? znk<5TW=xz97jXfE!wk#<6U50^V1hdN1(NX#{1i`NJ`4amIVxXbHElQi0ugu-?ctgT zr(rw>(*iJJIAj@cp5#HI!Av&L?jmkDj@{jX&;dYgKEJL zD3ui!rF?e@lX}SjXKY0m$u=VlY=R8%0bf8!G!LQXDfgAFejQ!3o`z^CC_J;i=oYocJ2h;9DH-B{i_b0_D4@Yb#mjGM_LVSp=$ehJF@OG@y7FiKW8hGBudiF z5el<#Hg#&L+HjgeWO`zn*8L4MVua>NJGGr!j|U4HCpJ~1BfN+OhAY}LH07xk1FfFA zCXqamUzKp^3w=Sb4ccHktb=t_LoO80bx?fb%AEEgSBt~8X*tT&wq8+|NMJWFCnm- z?{S!W?-t#n0#9eq8Ld;1eTsO}TsDpNBS7Vfy@-jYW+-Q6(+hv^`he+@O2dp8& z;p%PEC4H?S&-l6Vsu`DAPSo;bF~&eNMr(H3In9~lIf=bFlT&#fO(GkzF}bPl)%R3G zYiTVdS*+7i(Aa_fpdU0yCXj6BT*)PS+Cm=UCQZ^LyV;DzsxS+SAWjk?k)~_2DHZK8 z0>6_+iDet#I)9ttD+>$$4E@_l|68T1cP`(rI#j*GfB2{mMm~dUa0RY_{jf@=>mf3b z!!=F&So;jJEm%Gg;$$*hhRJYJL>i?~1_?_W)sh1RLkvuTPFMrY5Ga;{PrlD*)ESyW z3$z8QNad(9%(K`M2*d>s;~zYD=wiWKC5CRAD&I)9T#=Omrez&8B;N4|4jeqaPl>qU z#xWfZ9pVY6;gHD>2K0PbpVbtuTVK1@SO~k$^tt{`_mmQcD>*GCFdzIV%BUIV7%t0~ z+@IH7@;cZ5!hp)K^T8K?#Ud=i^7D~rk6g^HtW5aj-RT@JXGH7ybI+C@Htyj=QaoGT zTs@lH1Fgmzm1a(;nc9@n_aJPRM!uvr zi(D;13Xg_N|t9I3(8Xyi+ zF`o-9pb}$n2`-`Q;;y*M&F~3)Vgbd_O$E3ew{x}SC(}kcM2DPjy1e21IM;9)zb8l> zq=OwD5B}&lblke+k>j_q05f2|$*eshPWyRJf9DD30qM41+ht%A6oZ`#=6DrA?erZ9 z4u?!Qg?0EYg<%iWs0L2t6dcu4(w*0JfIvKr4xFSKIe+-whqn)(z&V`G<+Ooz(oX1s zEXd+a4(EKBh@)XNWx{r>QZX!9g0H13;VzY?(qyQNl#yrwe-ZMK+%0zl8jOE0_5mM^ z$DO!SRjN&D4&_iY-BQ77Bo9RgJc-K;xODSIRg+UwF|Xv6kY{aWEv-h3{(MZws3YCb z{ap8R+C{Bj>lZskx=80JUcd`59No|jj#!Q~_(2%Bi(cUfF`xw82D$jNx~xtpTg-0QoCMmdG!8|`ZZIPHi!x=AQM~rf7ci3m-wP8Wt)4$?~i`4>%rOcZ~gxI#UH9S zU2MfVbj2FwqgHXf`ApB<&W_Lhzq0wcZ$#d(jYFqbXIqhu#KQ5c1SA4XuE#vxp&f*}F|AO$0!6^p?U@}Uh$+ zfhDa#2zPQBH*go9z%D34W(OW8Gcg0t3wH=YZ$zlVnYbFuRWLUwS2a{dV<_3vPgn#e zAqk404v=lsZaIT<(H@a%DT?-D0p`%b;LaHx<9i3~_-O6M8F6j#J*iE&6A>a?quXNc zczxWnBkz;=$3B>H;<1otpA2_=u20)%I&A7fz*@{_2M*u>@}O*LhXN>q9%`q%^p#TT zvbrptq@#4y25C=dPbgG5Du)V0wWv1lHm@?zfD^C+-h+W)&-R!Iu^1~ucrXvfcd4H0 zIS?G#QTs=GUHl1c5Qx4|25X?_R`HGUe|$L+^1uyJFb=au?sc0z!pCj>=y%4PR(bp} zhtM*pfkHK39#-FSoqCkRU=LMLFot3kwtzdv(F z1Y07-IH(ciMPBAd%!AE?DV0{!YE_|19JxPz(<7pr5pdfDGRt|wiD1%HD zrQ|{MM#cO)J)}G6B*q&X1JNp2d8-BBY`10djA2YCOnt|{FI zdXvhpok%)qKj-c2xfn9J3@RZ8LtqehBnG`?@vv^U-7s{oohI!T;|Xb#~-x+886?*Hd($8oa1IsER|`s~(0 zUH?FKqp3B=?Dh9ot*x9^Q`z`Wr!-H&AXJ*2<}KR59R{#FxzI@rsJ&RT_pdK%|5ICg ztz7J+EqFpzaWEgSEwy=J!0VoyCY&toh;aDpZdhd2mkM|R)?kO-L=gWkB!P^YQn2(?5l8IX&`a1!#t z3v+Q9HerFKJyyXOq7JJRbS8iC6E34sq7NK7A0pV224f%sldv6^(*?1R!Z4eh0T3x$ zrOC_kd6lO&DfPr-kG<_4_1^gR==Dzq&l}u7XqdXKmav=toV=|X&JV1LxiBVW{af== z-GACrWc#xwcgwNzdDAbZ-%KB171m*$_*}cE-Qzf3%>l5@QqO2Jy+v;USnB`37owPt zshDbd+w?!vZ+OxYT%l^XM!%;2YW@^sa071OI5j{Gzz9gfq^`2A%I;R{FRfk`0#9K! z2FbDN9OtPWszL?ZGz@64{%Fe3$7fF+$ZinAsbsjnsLQHUDQLqc%vQnds%D_!CBv1F z$w`*isTNE|#DcEIj&l#z$+7B~+G84HHN@&!{WJYJeY)~duIx%-me(j(bxZ)dKt;dp zKyQQb9AfMb1pNPonl0N(Yk5f?vb-xyRe6>)E&9Z0F z^b3WNNs2)}Xg4iOL!@ru=9 zxs}T-Kf!SA-e5^->?p^|k#Zz?QzS(ibeJ~fpju>$eoOz<{1k-ZHZ0&xyq?!X5obas z`WY9-@rJ_ZG7N=Kh+>otshxV+SpPyqe+)9jZ7eV=5JJ@^HCBJq`MO;J2ddK1C=e%G_`26J6^Syts9`s*a2uC{9aKUXZo_hL=N2^s4)9iW2&0&}OV!Jd^t0fOO<2c)oW}dX@@-~f z-`+nH4u1rh(8i8XjOCOJW$epQ7Jvvv+^Lqz-Pn$~*aLy!20_MlQW>Q3Y6$1m#sgE! z>s2r!O%-dP5vweZovN234TnR(9x+Q?hfEIOLWsAdD`>P}Qs_4BKZ+m;V;}1Q$$5NPjeM|3e_(oIe#(4@*yjFE`fv8%k{i_$R)%*-u2RZ@qs4d;OAGvWJ@JVD zcE9wgB|bdjglnAJe4fD->?U`}8FDDui#4=Mp3qmy>11!Ihq#=Uqd&GmH@d5F?8>eX z$4Q(7ITjcwMvGx$n5tJ5ssgeg9zxH4c{1|!mO_W4))!aU1yHUHKBOn*Olhy-y87hnP=fER{f zh#D#@WCeIyddc6OJ^ezZ&#?hV?7v$BZ2;i(f3X$125ALM#bLm8ul?0QsZ*(eUk4VuF|@W`P&_fuA-^2qC1D*2?<-C&J5&1Y|7A@M=r^e<|9I z$(T$V4Dx4;B`wli;ZOcx3-0L720sgy87!(6Ri-j`tb1>HT0r}iR`=FMOvYH8IqsZS z__%jpnjPKnpI6L_%x3c$$hUM)=#7z>tX3Hibs28Q?TFZh<*Gs*Q^)8eHIu!nmpkMt z!@$Y|+|diXRE4Zp_2RCkX%l&mA*`Hgl$#KUf~fi_*WRgb$VvTjZEi;2u-;L<&El@; zqfzX}4(to*kc3g_j2?*W23{O)SZ@@Ifmp|x?gdWC*3&2WyVhGD8e8E)RPFCR`n1&~ zyJV|dY9lwO6LN|Qkn7|~?PYDa_PA1Nw_4AcoXUydhd~gv|B zSdHC=SkeaCNu+&aWxoG#PY*O-y>RQ({?XUIY0Xo4S3mye5A)!w6K?$8|L22`dS}W* z@^3Yt12Gx5<4o;+afjNq;o5In|M5>ss`kcnz6CZyEd;U;Wb#r@LZtD+AIcz$b2v+l zQ}yi0rMw=K5DoRc233eXWKW}^k#o=yf}oKr#pYkvu3a!43h*dC1y&FWP4GV+#9u=e zX5%fKhKt2McnaIF7T$nqaFYk~TU-Ngb1O&lV7|ahDNOUxeE6n%OWlSSVL1GSvG^QK zqM72hc7Q$AP_+V&!WUo*Poa*>AP2ngF)$(0QHaMO_+NYzbMQINhhoftE*`@+I1{Vs z>8`JO$C`a}=N#<&;SK9zt4d8X-Ph8@g8nDXq54K118+lw+%5gpcQ-1opT9AvqU+3$ zl}qc^*0|L@+V^qK9%;wRH=mz5Cb;ZOf+58o-`F<9cEw*Z{aR> zkjLa+eFNulJp6^_SdGnG1vlYl!@|G5xc2nn_1{F~wd&hsiCoM%Py|hM33uXjuR<@U zN8k1wJn5T>f5927qih;QQ!z|kXIHq0-IxQ5a3_9AxsZy*c!nF*MYV-S2p4U?x}f^` zhIkKeankuY6>-&zOs|^Kw73y(JnTNqb;5U(OCGc1AU5$mcnw~M_ss?U-}g7*2rS1o zaY&34U*TRlgongpF;wi5qos>Sg+{hzH}>T& zF5@yfL^=(DH&I6&yYUbF6-zJ`%FYpB-jHGYz8??hO=n#EJ-@yTc zcl{KY!Hm~nEv&^Hd;{OW5`&TV2TsT7I0UERH2g13!)cNX@=Sci6ZZns})pVdCH?b-HsM_+%$bJPk`rs=-v1J$B_ zQoEoGYN3@*(s4Sj9F&6^2WI#kegS|w&f+RggeN$KySPF9kKOoxyq|z%biD>vOamM_BrE@WWrAnAXr-aSpcdGhB#%vb$Y< z4YV&aU-_~BYZa?5Fn9HLXZ4<29kFUnAy z8;ZD4u9xY`NA%KoN|w9Tb_kX$GPA^ENRR*a%?R3V4L zZD|L-;00dThCSHBAqHcp*a8o^h-Pm|Br#qjQ7*ke@6-3vT?WcP+KJhi4Ygvg z$j3UYMYtb+Z}`2z@?CjMZG^Kt9=8u0J$#GHE{um%h&OmN19%){K{>i(0&YV)jKer( zqimHeWl%CDvzIDWrE;=bCr5%KOu}MFhhtEWHQ0igPy>Zf$jO||*%(BA=%-f6#cDBZ zhZIPGp-=>k9Kb_)C>9$86gLRs1#*WxAQRyl{Ffi%B^(ZO2#oE#wfe*IEZwF63cxPf zi91!W^5HJgBuYdHN2~R`UMmsjwexC;N>bx+1g^qWhO%xbJE}a$bD!8w#f zNoO89^}*>I%pAf?FTPyud|~#Tad+{tFM%b}`xzrKunnOMn|} zh6S8yK-4lw8)y;tr8=U~euKcWJ7K)t5GmVlWn=ZAWkK2L0*odHpVG{4Kik9u$AKw(>_+%GL}#V$fFuf+^v#9qy^t&x}0g zu*TFjRt`&`JC6)?S|(x!Z?JhEevmuNPxXfY^9J6I)#!&Yyjz7p2rCsLcVMQ$-HTxx z)y;clhyD_*=GF4i!A%1eV@l8NK4G@A>NRQF=L1p(?6;1zBqemh6nq8Sxt41n#&G2h zf^-PvU_-5JDC8LKNKUW~+BljE`LbanSPBuUM@54lv_T_QV;1CSxPf*W3B?`!pw*C$ z--fPO3PoTGd1}3yqkl&B;;Qxn1hS3V4~Rfy!J`z5iI~D~K@fL?7dL_v7eO3Z>u8?#IfrvA$72ww^hqJ{Tux``5o8u9`GeQ(N5bS1-u})Kizylui{&Ljf26Twf?K;&sTm{jn96( z;9BH}-hDltJ)O9c*Wo($;tqDhC~QIu+dl7f@Bzh+<*37-No0d zU$}6`A=YX5(9=U63=1CV0Jav)EHA4QO7RR%=TdAn>_tCE7xdt8mBh|`o9lU{+++Tv zf04eTcWciwd=?{NKE8ub!86(x(?zWV9zYdTnSM9jGu^`p_&&a`3Jgh>COU!%GD@E& zx9aEg9Q_bE3xDA+7Sbs?#SOe)ZNegIB0Ff}D2~EpjKU~!RVbl^5EH~gqt|m5NupJ> z%60O2ITlxAGzO4A*;DiV*n1Q2{Rwa2Ajp!wDwV4SB-n&oA5yMrs|vvb*k&lKdSM3G zL#h$UkgT<9o3+h|*p1!Fi9M7Dw5reWGulR*X|MTK|CauVTDj>3QzpgI2HF75-~k?n z*kKQc;k(>JMETOw z3ehKGag?E+-epN5##Mi-?2tp{P*Eq^M4Q^GmZ&BAV!2XZNgHqgZBR;Psvor%tQKn5 zzEaD~4eeo`Yhx047sNa9R_@6(##b*2tXCa^uM5+w7hX6jtf#55Ls%!N`IwjB%s}WXKCcSS#@2>tzwF_NTqpVOT-~a?dD^$sE zE$RE7*0W7U9iM!8eb+d#SL~%?4(CixB7bm$lwlW#NUM!5KRon=HgR&YYoZqI?J@jC zI`GJ%hhMPSC`#0<{*O6ECCPY*fh6=+6>_nR;z|xz0jEFsb7lVo^Z-vB(Eql%NV-qz zo$|qi0}!sC?H>b4_y!4R12^`AM0Vt8D2D>et9c~v0AKWiI_^|HG7Xa{NSg|k7zK7v zz(rgQRcyWp>iJ{pj`19 z1VIkO!+Mn|cl@j8jJZ@y9*||+Po?6c>O(lmCt)UJpdEN&0TFFyP$~LYD1w>T1a*`x z=5m>8Qm){R)i9qk*;1FbVKVw*7KULn#ECL3=Gq^Br#Jr$@%Re>x2PF>cvSW12_7rd zdbLVD4>^{{#ir7>+s181VnE9iUr&#p@#*7%<}>Dt`h&W8HN~~RH20m6-#j^?xdNNKnWaY%;sGA3SWUdL;S3WtGEhcpb;9? zKDA%%r||~8w(Z{=NVP}pL0`*zA%{x4pjt9Vr;XlG5G?Xz0HY<*X}MnBSXYK|PM zYOT|(W?Nl@>xSxP5XMq05;mcW^pT#@lbWfUy43(>qiitV(!a0+N3*ZyqFvNTHcFO^ zPt#6nRx?$aN)z+MLa`7Up$VE)sj(E5ju}v_s?`S7L?^`+aYgNyX)+BiLl5-eIP^dd z)Ay#|Ouu0)eMm>;C4HX$nz$-RkgT@^9DiMa055;UEGFE z*ktlHnM|>&NtUZ}JV>52*Zf2O6a6=k(Un5b9qVYts*Nu%SoK1A=!xKypZ_%M$2-R= zREO$jJ1mCv&~U!7GNlSGY^d_Co`^ldM|;L=u(zG(@2+pTce`o!SM3kl&$YTVV5HUe zU6~IGJD1=8n|{yKPA#1+U*B%HeZD2S|7>4(-&C<$jG#5Vp1))%78pYy z53GZ1`H?)p=dc0G!E5jv4}CG{hQaTzwzrG2Q$fgIfEYq3E*edScG`_*9`YacxQ;OWlAT`Ap%|M~Fd zGq)bxdgw;X4cm@=_kO)MOm9;g)iUy@E4ci2>4PXer*%Weck<4K84X|dJb=~kE4+%? zNSMOms#mQaDV@N!%73kgnO4sGZc^QV`Of3*2g=2bacyxuCU_GaA&a`v39Dzh%v?A1 zduZj~)m8(}c0wL3gFx`*V4i}JR8F&0Egl0~425oJ#2$>IN^pcKj)F)I;Rf)6MohegcwrWHa~!90nnBo~&zY)4?I3qihk4Kq$z5QhIdpVFpiGsFz@2^~}ZEzzTv&| z`|hFxWz#W?$5M#z$G*{hN0G4{-P9hg;TwDg%JC%Gi@9R1?9@l-Bh+Hv$on8&zQZF$ zln^3D4AdSMFR5-RrLXu(IE!5z!d~p9)){Uqvj_+{j|;hw!e|Yxfg;F*JjiEH_7tbY zOfgemr|;Hx(?UafrGZnx28uaR{@NYiozXkhy1+WulwtLURkP-8?K{;&E3 zXNb2rU9GotHxP=;V3`b-Q{)u7W9U8E8goEf^g=JWLq^MJEI@^dX3!KGKoi6YdWr1m zRk8QEt~hqCDipTcCQlkZ4twFHx~4Z$YSCV0T6|Gp9vy3*Puci11p;Ky&jTXA&xKZ|cY z_RkYI9RM1iyAg5txhH+yAD=wca%PLMNp6>~%B^HC2Ge+MWLMbYHGX`qXD*-Qdu$_f zWUo9-)5w;_LoHZwx;m*!IsDJ}>#o7? zIB6O*!VqZWBz0IR%%xKzmmL6Lqgo<|=w~UCV#Qlr07Y!+zA{w{#6uS3K`l5z1O##t zJAfZ9gC2HMj!J4!hA|if84vLAh3`1YoOL|iDx$rNyd z4d{sW)J%qDHMU^^_Fx1?QV9)I;c~aMK`&U(0T2p~n8R_f1a8A>F0~vP?uFHmih+;? z@$ATHm_eJxdn#D2)JMVrm=7y&Prsu&XLgP0uIjy`T@bVJutTR~m%}*mi*TWbxmgA9 ze3>RU_J3fh&zM1(hKyV^&*%B-nA$DZaV@WgMD2!lU1*%dksPV241I^^Rfn{K^)v;h zUe^dncvo5lyuZsk^a z&3sljK>#>mCiuewwN!0Y&#UMEotMFUbOl$a;1D=~QB*=DkjP!!r8XHmn9D36j;FvB zo`TMZ7=e{s27&!;{i*$fu^R2sPaRVo%EdlqVD}&aW#oY`(G_}$K89Qlfe=W7ZIDS} zcpg#Np&i)a_jHx6@>WYXiZd|`!?4NH8L1s=x7^KNsyv>ev%D{V)Bb1bH$6hB)Jz2w zOVJb!IdBqA%CmB>xodSUkCFb-n} zp3iAe1a=TQcAqzTmn_x?ogQ@ei(A=Y`)->X`iAuamSX{C4blcZGq7mxs`&|Fv*i-= zm*!s!<9__;=hq&*dB3P*vTBABh^$G!vAp*VXYzG+P|;k&A#;v~)jhe(Wz<89Y|cTl zv6AHgfPNgV&dE5a0tKm9fSHuRaWIqX*aq_807gI>C{E-!K7c1_CmrD>YQF3QP{FvI z*F!Dr*EobLlq*&lf)58U7fYcEoS+dLVGsLoo^n)&AOeD6BY46r_z9w*&A1*5h1HM= znNY;5xl1`qBs<1)7*%u!PF9 z1kX2g9ee2t9^vEs5kyfeRg(vA;8>o?UF^t#qC}h&C%Hm3DLcG?3ozHv4xb6j45h8r zDoRB`6=ZUp-eq2-FQQ%=PwT120zDu{WkMzw@*dv9Zt_R@qpPdinUOz?_}jJ2b>EGj zn_Zh{-SfHkd&l$lK5JXnc4WvTXZvBxIZjQJ`_T*QC=$OC*Q8FJ?h8&IUW|f*92zLu4HK7^V{2jHMHVc4+5yDvj4+ z0?k5a4uqwUD$mKWY6*9PEqLHyEWiZ2?`^-goy9HkKhg;n!@Hah1`&jp8cLzb;7|Sl z(8=9c4#iNc9Qg#F;KN2_@?@UOp6tjiK+UV)s)H%6PG>Imy35LQx9?PCQlO1Tg zB_+{Z+D_Z`Yx>{%-?$2=;}G#3rOg(|3G2X%ocv@cNcrH{?($d-0Q3(dY2FS*2$bjXr!tFVCHFA#2_+ei;-M`*fpH_eI{)h*= zyC#~BSid~roc61BPJ7gP(tssaLtI7;OLw_5_{AaL3_WhW-*$*y@xTj%{YMQ0;Rj4Ww9}9c2LLF9?lz5I?xCJ-k1!}@t%rpdX{J>9*Q%Wh0 zHiiR{V9Q1PE*}FQOi|S;cldWB?u_{L&9j?3HtzWD>-@{#Kl{h=^SjP>b&Tss?=1YL zDyKfrZCB8)wVPjk<%|CYJhS7VN8Z@u;rku;`xlgTJ?Na@S%_QlU91*g(LnU`A3yop zqn_XeA3~C{QLMso891OL+TjtJ%Z@x>T>=XZ>A^?{!D6VzNNhHQ1{2YpZPZ9rs6S^O ziZQr==5Ra&K@9prK5Wo18@7Qvba9)dD&S1~*J&&XoA6_}%C2gkQYGne4`8oCSg42%)fymq9p0K@4U9sHvSA!=zsv^~`rr=L_2k%f1HB+Yu*Kr}Y7#&NP%2(y896Vt)q^laW zL3xlrbyEnr(-lKpt{SQ#!}4fiC>CReO5r`Klxqy8croQtE@$xpKA=mzL+>ENJe^AD z8Tv_%l$kPV3r!Hs+14s zb1J9mcG6LIGz?uA|0^j2ZIB5e)J`74mY$@!w1SsG96PIgbz9Y8q(Sv8H=<_q+Mc{*hS|J1&ep}hxA*_>;O|M%`GiAsbdb_@sYaj^h^ppKx^e;3YH9s(4yC(nayOs&_AeAp* z0(xW8^gjZV0)E%8>u;Jbhz;U>?fuU3&ZFHC9LAko&e0se`|ZGPk?nZ3o+t4tF61&U zQUm@p#@0>F@&7!pmrf-{YT3 zsR63QbkiiXCttX`9jQU{OM@e6leZBsF3%6`po*3~^ z;ERi2kLaFt>bruXn6vMk*jgT0RXeG8{PNLvLZ5o-Gk-VLqNkb7 z6$Gon%h31s;zrKmr4R*mn2BysfX-0MtJQ5T;yAFuI_e>SKqb{iC<0eb1vkFT2fzc9 zIGPV|Af%x)g<&paf;+}w4rQw}h=Ep!0ylIZv0j^hw+ggxxadw4yz!h5^`@~Mit zxmAH$Wq!E-M*l@FWH(O7e5}D`_BZU`vCj~%YhP)TRf9UAn&f;LEu*z!tzB!!R&=Bd zYNJ}BP5?s}*r5{^LApo~fnqhf;4ECjdpMCBXcui4$OpKPXNrkpo|p#_und;*a|T`Z z03>oa<Gn&As1i4FXRZlL!T~|izMMJoXCSt%av-CEVY&c zULEkgO_xon^>KZsd`C{#NA%C=Jq?*q33KFr`KAmuy1vU%QDSYQ7}_s2z9=cf)Sj3T@biO%{luHp!v# zgf`U_W(t$f%Q!hh2<@U6E&XJSTt_6rD2%VLCwQt-HBOD==|<0$!+&s+2(Sb%=CMj* z30@~ha03T$fCGjUOaUIDBl>gtqxvK*RSOXzYN)JN$HW9}iv0U2tJ;X>#HHBdwLgoL`4`Idf5_**^`<*FRi0_Xw&pr7bGomXiV0BiNE)fTHQ zW|R3Z^Is5Vi2eRE|)W)oJws_0T|SGQVLSZhn&+xgIL<95eun(WZE-SLg`#fOo^^ z*M>9%gB`TOix@;FDIrLE(l+R0xB6~q&vKmvpK9}#gF3) zJBJ>3%y-=0huTE~ZD%`f z#~dhzI*3Ov^1^J6R-Lj_->*NfOH9K$M02`1U)73t)NOTe-iYTG&HcDK{G5G--dWMn z*je%S(|^5Lzwhexh9xai$L6?|IhI&G1ii3yfm_%z|M?3Bg$DW+@qB(q?S~?juV=v# z@C0AB;S631Hc+K9*j65s-(V44p*^roI8!A1sWRmS0lb6%W_R=gg+UmP{+wq}&GRq< z-Ek+lgB`kKBL-@aicPc(LUAS{H*pmEKmc!5^&AfVbR7T`FjR6av|th@K>@od8@b=$ zh1WnWMqn<6Q5`u$48(vp)}a%fhm(*BO_+-rP^AjF5_V7wE{7&A&!c`@5E{2 zr_@GWPOVvRf6~2#`!60JQZ(|Xg?1fwwYFv6-%i0P9*&JJONQ-7XYI6xa;01)PhdPo zVNkDU-&=i`A6n#K9y+VV^PlLJOqzuRhQIqwH(Whd%Zk}g;>}`vuGACgI*p*JAQii4rKf4zNWqveJk|S`fz=rHdy;f`$=t6N0jB$Aqc`S zgDSB`3(|tX4wk_atM^pu#!*ehboS~hD(FB^nS)9sI zn1pt;88=`mIx%w>cR>MGL$w?#C(GNyUwDuQq(TW6gDWhI`bx`$dP zSI8+a2fK0dz>fwE8g!5zLpQXS`&7MJ>T=4dVQ9;UUq{wIoO~(x_fKmRF^Il`=Bv}{ zys!K@RXD{tFU6%02tjZKa={J5F^}3X2)dz(j^IL(qmR(9>C-LmlhO@2axe4B{@HyA zciw70duNMyTnM6xzfamU@hB{V+0dvKsCrdTC+P}Z8T*fC-lJAWzAzW+R@%>|IIX(r zYSZ}Z-t|lCJDb}3Uo^Yxv&AU^R00`r1J}`V{0wS1m!IMNe1OCG=P@@Q%@}j{v5QlV zO}6*$oA}!Jsdg9bzP5Wycji~wgY0Pqq~RTmfgt=CKc~;Z0?n71oBG}RhfTiZGih?- z$(&L?SzsEhRg1{7oRcqB)js;@qF3j*LL(sD#yaw*E0B#*FdID8HV{yUCE$;Cc#5(a zRj7~(5kx`}Ct^E>;Rbf)8kGpe*p5exwBpAR?4~NX7D9L_WWhG)xEbC=N*L~)*+!%J1C+RbsC1##$W>|%RyA;*E`*r!7rSMnxWPSrFX;y91@ zKoF$B0gMNL2mtg(?C$Q|-&KP>P=`5KfJqSH81Izmumg%Yihb2Mby*#Tc+9|Jd06g~ z`*0hbqC0Z8TqRc-E^=LBp7xgZ*1wZ1W>u>8sQJ89E#(?xdDuZ6ldIH^>Rt6NEu&;g zRu0NW*{~1$vM)AcHCA(#q1oy?*|)aOqF;>iqur=Z{6?Pv_B3`&pYO62um9gZv=^ zvM~3dF9)9)r1538;fVf){g0cU>gagz?EP;L(FvWzcDinW+%D*%FxpN>c!nius&Eca z4w#L*aF-ln35R$Jv($T4mj%lH5s5Pf2x7>GgSM}FvGEYr-THM9nU zAP@rO0C`Tn*_GGX+0~*tRWLVLePnt=8?IJyoQmUCOKMPxhpM6SX=yF3sh394D78av zR2#9)xMGjC1c&ee=%$^tSDeCXtimcfX&9*{8-&AFqsJ+sc1w}NEXZQT4cx%t9KZo; zpIoQbfx96H*bUuKsUOwf(BIIX({=q1wO{R*`%Py|XS6f_zvkj+>4{XdTB4TVzkGVb zV@AHCuhrM;KkAG0Nmy*?-0zp0UdsvQF@QPE8 zv%ixcgj#+B^oDX+=IH2b@4RjD?Z^A3&QeP_jl1Mnxs}H?tpDqozpc$@`cCz17B7iW zVlR|~EryCUVk(tzD>pLhN6inJCy9ZgPi#gH%m+WM&-8*eu`%lJt&NS^``SV=*N_-2 zn!MJhV)EZsPgvcunnr7AAuXgxIzm@|{^aMM%N!vGlhFg*!I2x~yK=H}P{GParI^0f zz7h!r;hND7n;-)szz^MM7kx#9O|+MiuUY+d=5OnQy@xgyw5O&f$G$uAqvNUY!CLdT z{g3zEp#P)kyu+$Gw!gpD%sy1HQ0z1{3K*jTmZ)Hg3TP4q^d?cz6a`IgBADxq0-B;= za*dQU1x-=FGy#oKup}yI5*4sSDHfU)IA9{3vuCaMIosdM{Nv+;?sN9uvu1r~*7|%m zSl@ryBPVOiIEsCkX&p^d_h~x~SLeo$^4~mhH#R^KB&un2k`gZeP?2002U!>ik=P>2 z#Z%nO*5{tdnamK2E$E4eN#Yi#8N69X`5)~)u~&X7-vBqLglP1`HMk$j&@2KW&7g^v z;To}@OF18!xsDg8a|X?|3hKcZ@*v&1kYSh@E2=fv%$eZGWfZL(u^Ec7#3(rQ!1U7K$s}kgl3J&-uQrBsf&&n-WwYr%HWh{ zLmT??3|dV)Fi*sY3JAqmDC1D}cS;Ks4 z4j<~*um4Z|H$sV+jk7Tk(s3*$8n5Y0Oh9X}sONeaC*P6b>acoS4OeCAPxYr!QFRhR zAe_U&gMH9b&Xw=T|FGZ={*F_O%r1df@Ct~78SJ78shPIPzvMsiA8`~TFhVTFmAJC^ zrQVUfBMlA|KpLb%8ph&M++u(YUB`JpF>mam>>8CoziIPejA#|_h_RwUJ}%dYYBgQu z(}GKV%9AejwmG%G(2+3p^Pu4A6V+ukfVSZaqE&!HLZ3lS%K*R;Rzf<=gNT2kTHk+= z?0BS4o>QUaZHvWXksG9^^fY?)!l|9yDMZAJSP`sPeV{_bQPCutETi={y-hBY;nIiJ zs|mCmcZyS@Tm)i0)^iG{Q!JE22X=@oOcGvPWW8^^oJR9#=`OFyPR@j1p$s+bgmhco zcB^SJL~sa%U_GW|DLTS_oFx91J4HVlVa)>IG`2vCWw_SOvg;lgM7A1tpEt1ZE80y7mlJ@cuH?=t+p1HSwj_VS98@|48>^7 z5p%>uSSUt_SH#CS5C`Me>UH%R&Bd7*jYo|`kR8@Z#ua*vepAhoOXYVm6c=I$hHwy% zA*I%+B(;}A_${6V5tu82Arq`q%-*`};b?B7b8OEO*ao{G5x*SyVAN3WMmFOCQ3O7a zOr^E2-2UO#%G*!cJZ1VtOZ><2 z@2CeocmKZYdHRvALB^sl z6k|M>Kp5s#EW=7ulA{J98CXK4jL3g4zECg zUebxvp`0_Jh$A2bQ?ML@IfogdtU-$Fp&5eElU>P0?Z5!&#AMFl^JLF)>?Fe>5d7Jf z{n?cgFcj9~InLyh{39elK6GFtdg5uW=U7e#57-Cc7>`{Tiw)oj$!tvn6TugPIRNIc zJGHe%bVPQ~<+pe%X8~gfWcJMOS=sX(egHu@nL~`av1}*>t0>wDJ9S(AzPc~dsnv#yU^)<-#$-#{>0yEy;`lm;85ha z#eRqQukgZgJcsQevE{fq-yH1zlJ{Qkz0eG49D`xF1t-xGN~J?a6HFimVjpy57Y^h= z!xzs_B+C?;LZ@gBtwtY%F<`XjP$@)k1XOY{?0^YYF$;6#@34Xoa|oZuY7r~?=so&t z`gm+Z9Vg-U_!S=GpT#k4G`PYP@DK})X5)+2+>`8$F1k77PVLx?l~^gWWwyKxEo{9C zgLUyK!4!zbow!EqWDl<33XbChPQarWjRjOl&XkHVn2gE#Nqw~CCy_y~kv)A+wRA|n zC;g>A8=M3EFa2A~^QK5sn9WhnI@}U3|1x z%+mU5&zY)WGQ0xmI1*pPcy)yur~y)7C+yV1G(XLc$MRFW3Q`~$qIfa6(*|~;&uH}c zdlMH=@VKgHJcsRHWzBAdS=X2_D`>~A}Bf90J{{J`+9?)r6 zl27Df2;qe0;^wVpH~mZfL;XX;cXvIy;Bbl*VMchJY0dkTt*TK??7=m>Lu{14i{I5g z{atlfWQb@H4Za4!Jy2}G4R8Sfy44B&lxpIMyqV@;6yFh3FbAVCnqF4J)o|`oAM1;# zfNwx0#Bl;-;ype`*P#Qq!gu6DN63RKt+Et64O!ir9GTM88ll>pb27; zh!7Ew555q=q3p`hkLVHHVU>P35a;4tjK?Lo1aBGk6UQ;o^a^dJO==$k?13wKp5?OT zeaJ$@NY0}+TCaXr8`Rsd99{-jJdFErAAX9va2KqBL`alzvRsx^C1q2#I3m`_HTp39 zu;m06iC!@h1FSNc*BC<3jYBwuR#;`M7%S$Ax!lC<+z$2zi6q^~Lkrl2n?e%iR?nVx zwea#Ye~q&A?|rVP`0D4Er(Qije5*&{@It$DcGvBidQ*D;=>7S=+ufLZO*}^((vxY6 z9IRaz>*Z$cF8R|KwHYEXA4|xWQrHzLp`LxkWw}utw7h7!sb0qqMHhC$Z7x-1Pzl!{ zoHy}iE^N&;A2Oe{SS-IL*mAS}(s6wW1cI#TMMsd${Lj?^dy2rptNWts}d=yZIFF<;V3U`m>fN z;4o~)CFp^%&>`YPi)g_!q8q#AAkkNTq?W6<)h4&JL9+%r{e7ghwI%%CpZD#W9V}m~ z!|15}z=wGc24fXYf+7y*PR`^~D#R4b$AMhO`QXE;5X76Xic7E*>d}WgF$JSB7|Ov7 zq76?hJM;io%w#CR+~H-je@s9_537hY7#qbUSEp@_?% z1Q7RfI26Gt^rU)fMl<@N15H;;bcJOyN;Y5)q+>aRz-F=|prxe16AHl&Tf{~=4iGZI z6_WWptpLXLcpi6($+9naLLb}(=gEzp#1=e`mpPeTXb<}^Km*3h3>gDiRu%lu!v`<@ zUCtpqhW9}@bV5h>+3t#-*Vz#&0k9mCL@s(lI;M+O?P)1+Cb)qc4`W-l8wNo)T)^8n1xJ)^J(pFQBTq;t(W}qV=ct(AE6-e- znI(m`NZUX1xyKHT`r0paThvwjXUlH-8nvVt=Bm3LH!=~f4cv( z5WpSmLDul((eOpx)cO^045Fc($FMUy!)kWtb`-)_`0KX%H2oeP7GB~fk!~Fr4Sg|` zUZr*#DSSn!7=%X*d-qdnG-cBgYE!#SCesj`A@nr8L7!nd#^d+UVZCqimOLVl$a&gq z(^c)F7!8-Gj+;5DdrkM)-XWqAQ$&iGDusB%l4iMQxhF>$;Fn1TJF^+epbP@6vQean zbnzXBunW7;eFMXiAvTFkki+?$|H$QttGI|$+0_92IddbG$qZSoZ4@J|1Ki*Q1Y5qb zylPoyfOG5dSNU(*A2To)%f$sTNDN|s7A!c2GdT_&MVN@iz4*usLV?s*R;5CqUQr(;;h4R(bY?2Az_35y$(8&&gWwN6b? zw(JALd3ZZ@*mqume9Xc>Aq{-N({ZQc8;+Iy3ju8vW93IO4Pqe&z7t19u1KPjw2$`D z4%);1m;_O577s+VSgTx&UV;PK2>BljfB+02JGDvKu{#uj9mGKtB!dHlaGWR?)v}tl z(}%PJo4E-6*q2+`D8+w*1!=p?!{jK!o9&o-8s& z1Xgk`w?aPcBkT1-jIL8NBJPx*$~!!UB55}Sp(A$DdI8-sBF&nvTrh&oumBrmtLSA1 z_NDXS%Vk^%01J4DI3uNeijxfUmUu8jkOrO1*1*FvXaKL^R9J#(;xy02VssUgtVtX< zK>%*RouXNezy$8#5{`m2EP*cYfE=j6cu0UsC=e~e8$96x$3PJPdO{*N;3)*mz)lE< zEI0#!kj$B)S%%91_Te%47K|XRC1g-40LMhhr$t0LIf`m3f6b@4uh!{VRHR+f?r6s;n3mEO@H2S!6R`%%p$rhBFiCuh$1H7@XD!b@g7#q!=3=gL z)_>AJl!LT4w9jBC)WRxNqUNg-jK))Vih=97h}o!5+72JWhw@p|I+GnFKpI9sHmu8bOk%+VrRP4{cZLXr0q8xlY2#|I-!)BCN79DIaVB#7sN6-UB08)4{R8`eV|u!X;Za1z1^qXz9ZyL z*{v(LZwdv=UxQ=3 z-F5AKcIjQ;{W_F_9jpNdQ7n?c0qP+E{p2nwxt==oB8b9rafWx$K8k}BXy!7mXJ78X z2nYm#ER2T?&LLNI4uT-W*s>{w2si@en8MAJ!<%6SwNouriA+q@sFcU3HgEtR7+_eU zX5lUL#$&u0a@YqtpqXbtCUalK)uKib0LRHH~;|67>dc@%ds5Dp2{BrAw?wFJZINv zmkh@t7GpUYiolV&*`D7~m8w&nNqZ2f7mO1Mf_Of*=ViMR&W2tN@h0y&HW z*eV9%AP#*Y9V;OW9UvH&iY;ObFQ76i<02^LVuLHcnmynEL@=|?m*pFBo;HGm{%=R7 z6FS+S?YWIYX+F)De`r&*S56k5%shRj@Zx^&pMN(^HC4+Ij(r?QIHZJmy%0R_Rq=~9 zSU$%~cnR+}03$T#sN$%vMb|YdH7Vr!X;@C%sK6RL! zRgLkw0sB}NKCb55d<+2cU^A_ua_W*}t-+Lb@@YP;&gy5?S#-q$1i45|l9SZiDpeI4 zuG*E>2`>)tQQpt2X6rSo215*z>M3i$f(}4HYEviaq$rVLvW(uLEwqKkP%Rxcy=VH= z^r>#6|E2#WSIQ)rM5SCrMQXM_K)uZ08nELo?7}j&O?{~fc`h&GWiT5?!)R`2cb&v90&zp6i`e51ZN+bR|PF#zSatfaOb7W==AP z{r%pZ6}2t3EyLH12po9{lSHd16F%aqkhFsesZJEbNC@RlT!X12ydN7dD#*OJ=|ARB z`HoBy1Aq8BH}HqY9~(P*`^c!j{<)ZNd9lqXnI$~CHmK25bZ6Y{*X|wvZ1T33KYfD= zc{Nw+ujxOjaP&cM>>8Lp=$S!Fy+0kX+jHRH?}kPV8SJyr?`@xxEl=U4fL_+BK5JL<4nuJSpTuQD2~QU75tj>HGTTZD;sa2~ee;Ff*n z{MJDjhLfRs@V{JR`mMG6yX&vcneq&dfl}-e`62}Z=;*m8Rdr+eq4M7kGo+45tiE3(B)AiTULH1}}P|VFxj`3oP=%Pmbgnk0677_0)4RIP~d@A3Mbl~wENI*l>gPq_a?oj zj_4cp0ni5=!I503p6bPZ*&MOaXvRL+ht{ceY8~WT z1%l=+FK8nu6`!JQYQCPJ`)D6)n#n@jXcGnTIwHC%daSEG8Vx66GM+|p{94h)<0Fe- z`StG;CroA&YFQ4hj%LS{SNp~Izxu9&hl7WGA^30zd`8Z6Rh?v=@^~};CL%@dr0yp# zPnl9Q@L>6&Xzav3aO=j@$|Kdj6IVU?#nd|ko^-bBKdb&kon3v3TA(cvZ`{4r@JfBt z(8q_*8#a$>X*<1*C0L0bP!9qmkLG4BhEgu#LRF1pagjDc2FgH+q9wG1iwxHLKnRC$ zjKE+FR!!=pz8wM}9<$Vs>V!IhFJV4Ds!htb`1fY_KJDD(vf9<--s(GZZwIu`=&0{n z(le$ju=8CwhN)r;Btj^7vM2j;1kENzS8S%(?y}vbe3VI<&_BfgZPf4c`T2G5{~0IVHOv}1y!pWRij7{IU+{{%K#Zb&g4$+x~pZBzFB)tdsBN; zOVK{oDlOD|xOW?5V>AXq0W^T4*ejRHKuk8?%n+-1M^_BN3|dVW=z?}Z^VNKnn{_|} zZ#K|E+c*YM{3-(3gFC1L%3utP!2|dme#iCD0UhW9@emJHcp6S~9i8G+%3Y07WBTUy zjqek>wkI)o?Q>ZHpJ`vrxxeoIfaXfEP;?`pRcs=*U=BoFTUI%#dYPwsY|7}bDG)q3 z1|1+3i?9wO@B+D$nH%UTFJT@zWb`MaOjj~5=xJ-Xt@voKf`5xtD2=INfa~1N;ww=u7vyYJs|wASczuynC6+y_RWb+?>4!M z3=xejKR=NVKMxsPGFZBr1D+2o@ei1^YWhpl)`_R&5$$QRqpP$X;(&nUWVuT|ATmyM z8w@gi1#jZT%8`cC@Gk0bXwPhkYW}M^jvhh~?7lPPUO;pG)*t?NdB^fMzxj9mvev#M zoG$g*3!NBya=x zp>Evm5#}Q`FV$>q@Z|q;3=~&%T-{XpYOGUp_A8%D2UKuz`aRDt3x9!s)98J05(!_VUmRXDs4@ z_&Pt}X11_}J9r&A(-!qKF~4V=0A@iV+=UyMg(Y&IJR}dXKWls)%n$|fI21?WC@@>| zBlsU?VHSVQ*Xd`7#&j_XGhrv}#BZ(H517CeT-8c7Sxx5mVJ)u}_r)Ob6Z^0~hoBAK z#Dj1U4)QNJ0NuooPz(p*PXi^Lg3sbF_=~|d4S+=aAO4T7(g+%XPvToR(3|H)x&kmh&YK{@2ihL;lkcUE^J&a2lS*{@QUlSzNQ*Wb=-;*>X@1CCSZ@3Q>;V zI5^lXnDtWF3n3j(Or3ase5B_G-gaJpy8i9wDf=Q9QTEKHHLoYk#|;@;RKp#s;wx2f(}d1-uC!Y|XBQVlX^u{ax4!e?v0{qYVb* zUuY7u#CXgx3}j2-IdFh4@JBoWzi<&)H0U&rVgoS}eA$CeQ5rM@LK%8uh$zJ)m;nXU zq-u0`?1MY73sNvvcw&o)#BL6uG_C<(s00Kv?1!V6h$V;+31wIS5h4;Lu45O7F-` z_}y)1NBogM45vlWS2b^$THQ;q{fI=w7 zrMOcBKr#d=SC|6dLlAml8J6KL@ezL1??Ar|egAY$aoI88r?!0$Q`-h~ecILCv#6r@ z!lLrvp(f8 z?7<(FnD(1SncjrKPzpI%irE;^QQP@@cb#6Lm+2EA9djVZU~^RACOnU$p%V%@48uf` z@WZ2c0WZJ|xCOV!gHEX(7>PNe4@4PGHY+)alSH7HDQ0pM*K<9jz-lxbb|zWmLT%K> z!}%s{g_j`(t00DbnZc2RFo-H)H5FUsJ;Y%bc48O#&h=M3?;06xlEKcJ92!u6QPZP*lotHDV0dhE-cCZV( z^Lh*uLf!#K2m}WM#We}5tm{47(+qXeaI7h2R~kcC(Mx`0bnwMvFsG{N$`-Rki&zCY zm<~zo${yT=4UmQD*d@oxW0)weX}#%dGDG$)$jQlRIM7!N!X!)+Ug9*)#DzEsyXYJ_ zb7{X{`yT_ z+?d)N_u!SkN4D8LIM9;RlH0iY{eP9`{3z+{uF)0QEWgzbb|(Muy{&D z`qSjSfnjq8&AkscZ_*#+YAD2raet%p$uKi17KoIzXp!ep%4NgY|pOjY7~F$H<)G>Tnx?J3_)xU_C#u*TC9C-8fyAC z&EPRK#@J$KL~DuzX`Bd&P-@hZr$7+6a)mXBoLc?cze`Gf`{3x(V{;)JiaBfkzR1(z zJN)-e%Jn}?_H;^Z*FMrT(~CIJ$jj^vaLswOO)VGi$OG~kO|%Yeq;=3b+D_Zah1#iI z4wjSUWXLnHf`t@LK8CqNJ$e~F&%5Mz@;lXN2-QxUX`K5yfP-4DhO6P|hy$_3Fw}4~ zDsqmnKl`u`E)^M=!7kL!?T`p*kfy(&PtYewC)p~`808cmScMJPfL+EjiZX`5B{ljb zHW{STEpnlJN6dv_NWxqILmn=*icPvoyJ@$qmVIT<2lo^B{g--Q?Y-1987i&!+O_z( zi?NaR4_%ycjE=5<(n>2=LD>-_`oyPtKS&|H&`Za(Y{B zmji{j^v0Ej&k(ac+y9@SGeZev(NA@ z%nsbd_Lv3bMg>+9U$%@Ybf- zoaG`;=28gYJ-iLF#9rZz$q)~22D-V@a5iqfChLovva{BF^XLAfr@e}!&((^fq7RzE z3ml+y^J_u1$a28rz?i_`lLOs>8F)d!_dUwT?Ea?U2uD z5MxXSFR@U}6a%?}JvangumDrkS#?6)x8@04A%u8Jtl}o?^uFBhqWo)XUgq~d|1_WN zjdQwqhz39HJ=r7r*o-&*Aun_mbyr*Z%R6$Bm^)+N%-k96vwsTn3R(Tgvg%P2jA~Pd z)F;AvSC z;pFTKoltgRZdvlhMFlNC{+Tz2rc*d=o_!^(A!NnOrO!MQ91F1+5Aj?`3y9=iDYZqE z$9a^e&696vZ|INd{q_E0m;6+I3IXgu^<2aiz!-$F7|WGlkWpzOPc)uVhuCQ3KFQV~ zLUXKP&c?>}E!(%$3%aiBU^ZH69<^JQ8AZYd*9ily;mnx&_{dhCR?~Ev{-|}}dgE!L{+QzP6 zhD4DhjtXz?pyliW!5qUL0%8o_x|!o34ICkyJ1~-q7&wGxs1s1n#W2G_wYVAeAih|F zW#|PAW+>(JJdq}1sCC^lelrHLxRjk?26ymk$b>jP1)02uDu>K{ zVzTo)_Pv|o_xR+o`$v6?)`^rXABbhRMvN8v(agjh;K3oB48?MhHds`do;Up_N0^?p z>8Jf{bIx>AZWPNzi@`=shh!{+BLnE$`ZF?6Y1C zvvptXev(r;n+q`o;xV3PkUI^>0?fz!M>bP5gWBo58m*?O*TqJ8Odba1-P01GToJvX)&GR#dU?(4_^ORtP+!i zH?6k5vK-C}c!Ah2x`j8c!DtLL$mJ8ckW+bu@WWsci~)v4SP-UQf#EBe$}W7KvoRXq z!FRCAI+bY-&!*X&$BTI)R6+-MxTd+KxTS#uS8|bIYhKXo+W6D`b=J?y_UelMiF#dq zsZZ1IGqVFbU^IkcsO2?FKg(SXG0a@^sF;e?IjSW)8m)G#(T^INjeq+j7jZu4i&im7 zEESPB2`51aL_may6f@CHoz*|kKj0$6L9)p(2g|j73eh6EMK>fGvM$@2$*Q;ML;CR1 zpSgp90hVwmd!QTk!9Iooml={U8Im7$CTjoDzSq82SJioH7c((X1RCb1>2!(#fEun&vI}^*`g%q? znuosT{Smux2yu zAf|%@x{0IcD3XLzpEZsp_Nn44@s!B8zvtep`;Cxd@Hd;eluKzb`IA3v=6uN4{-qt% z4njS2f+yra8FcC|=%e+~u$dDeftw7HM*=K?CC1{Njegc#6&#GZMYd+>yxbsU8Lpi; z$r{qhp2pA^@s!*vFWkP?5O!<7LEBjEw9(0_&#kGy&TyZeYIoKCs(lETvOULK_5aJZ z5~xrfu=xTV#TpDYJSg2T2uj(5vS~G)$6ygDPGh9}RQAOq!V?D?U5AnACo+T+9)(!L z(x#4G41G|EIZ%Po%#Ll9nDc~7>a(P z6#^cCLwGaWV<$FX6}aIoSqvqx66%?$kO+dY49B7enjW&Qpg?eSTuUzph+)=X$D`BbH#9lkAh>SQz-fDNj#Y*y!A} z+Pv}ZueaCTtx*$nVd;{CwacaxjuRY)+x3@=wD+_>oS@GIhrnU$JUTtT9R1K|@0hm- zj~iAz^mEJ9Uf13!)?k1E@*`_5zCjK)iFjb39gJDVC~6wNGslEWCv`GY{w4 zn2$-|M?2{jIf*7&D{HYF{SZZlSc74Z0$JEc>=f5UE_ZSj_2)9SW@Mh&0P!@2+{wMK zUB924u327F>*%n}lcxV_`~O+|VCCO=?7^PHahhwoz`I2w)0t}q;i zi?IgrA`^A)k?5da9`I7qhra(w!;TRN`Z zb=l>NW3UPPH0-Pww{E%Yzx2oDCw80f5us5B0DJj+>s)?#N~I zF&B!KVfWorhpoQ3>-weIm(Nz5+4lSQH%?ZUS8p2e{bRF6eKBO@kgUNMhp+Q!ci%Vk z;ILUkx9XoL>*D7jl0=fpPohLBv=UMt$O!|%?cZMAr z`c`e_P5q{4?{k*E>dCuP?rm)e9`>?pM4xQPge9EGC0LCqmo#? z5NgQkN{)sZxEW%^8ez@9*7FPqhhz@GL~w*ih@@^ypHV9QLK_N?9WS#>zalAytcV7%zSbC#H{l_c4|HFDf@K&(-ql0 zY2uZ@NfR6Uzu>&WL8hg>GZ{qc;Tp!$rIm{2>M+_2c>{`X^$xSR>Zx zHu_D=FP4S9D|_EVKMY1|xb_1-nnPVwA__!`C@}iyT*NKx64NP!nkYo<5vN2pv_ctV z(|q-|+JoiNTlhgd9D{hS=S+^HT0-)ZFUn`+6SqF9k7#I+W5rS7MNOPd^%&fL*Wf}+ zh}TU2Vf_}3EtrwpS${nAt8EWIc(}adlqeET;>fjED&DWEno>Mt;*;Asjx)i>AV|5u zBxASbH8QDhzQ2BZBNG()F2t@&wpw0T;OI;^fLf+LWNNqVVn)?K08 zDx^fLb>KDOB%IidR?})s!7au{sV8{i5*&yF@hc-p6{|+o2w#aJQ3N}1HNgN~Lu)U#6iSrWtA>O&_lh(}&>#9F58VA3E}4 zYNpv3ASR2+YJyTqnW9ahHv1cgH4bd5z^8EyrodeGWw4v#^nAa+yR#skli>)YVWJvN zr^uz}4f{Bk^?eUK_RWOlZLi&Brn)9$2%Lc` zA#f!Y;8;9{^>7d(Ao$AnWijOr*ZWo%-TWWM;1Ud>*)$r$n^T&jT0+e~H9y^QQ;ZV7 z;Sip{rQEMC^!u~#N5l3F|8)4vKCX{Xe!Ok$_Hp0&rHC$p!mPiozplTo4c4ZbE^{|G zaW}irRdrRb(VtX@v{Y@gRtdIT3|V@O{;dwOQwGXtZKO6*jy(0@Z>NjX;BSb5c_Ljz zqSu%TANkm0-~vm*bJ*x%i-%}8w_TrqW4b9yW30Zv?|x6yxnZSl`A!D_*o{M>8J@ye z=!86WhdlNKFKoe`BAtD?f!2d3=76i=*xki>?8+4w4Srn79Y#}G0lRS>_*(Nauvb^9 z3jCmyD+Kt0CmKfD&~jANM9Xk&~078F1wmw=f&tfB{s*`Dfz8}5|v zU_7o7x7eQS=o|#YPV8cL%HwQo5KF}t$mb5O13>EpuhO2dAvKLvxQz)57i-6qb91w#$E=%WLu)1Vb#u8nBe~M;2GSo;UG6@s8Li zHj1%gl31qoHN9#2TSouisISt`T7J=g0Rsafk_^Iwi--g<^v^Bf5mQ z>8{CSGT~ms?!S;yDHW2zDn<-|wz{w0(r57kb|z>22YtNdrYIM;#4XF+-hcIK5W`Dg ziO~^R!^sAU&w7W`sgADD6^O-0{?@?|44q&GGvs0eHt552UDs(04X5GS1Je|3%Kuq} zWb=7G4`u&HLkr;$d0gJr`iTsAMxL>Xb9tMHwyRlOZ2hKku$(97iB;kwaRAe?5&;`f zpx_F_D$FXOQy+ZX+SBUYS>9gJzQACOC&&v@ilrDh+{PFFe{N-jE#rq$xsL7S6IA<$<>c^USIFXa^|nB3sDeajHgHSs5OXWE_GInqOC!s} zF0O?#2!IsF5Jy-0?5X3X*-RPX)8v~nX0>b9!2ND!ZG<*O`(6J)e}S&HZU5(khhJ6% zT>YfN_yd=0f+yk=EynOZER+rds-Ow()#RunW87M{=25_Grfn zVzKJ~F?H=Oz?CcHSj`~f%&3sf zlm{Fu5vQ`GrYUYtUckl_C0D_LsJN(2D#es>(NQ?Z8U-O{hKtB~pS@?D*ZHCM{STgJ zul;;J`||ly|@o`@5rTbFsMdny7 z`I0OBBHlZ}K(bREBAua?vO;{N6>QQ09T-oQG>o}eg8^QI)yju46`bIZYUu!fO2a(p zmVMq=6xv{`ED)EJNf1WBI2;FnaVdvX2tg-QK!rE}paXlbf}-e8lE4jokX~1bYCX3} z9M?mz?1K&rrA(~AOsK>oVuJzfgHlL<6Ih9vms|T= zR?`=Xz>X)8Btmj!H|0^BS_Iip0J&1g4$gY&!F5MgR<$ku^uEukphPzCp}yiPv3-s+ zk)JMU9beM5EHo)pcJn@Y4Ti83IwVEr%3O2yRR2_i@waI|p7wJ%Zkdms#CB{KC)^LF zxzD^}egGz#VB!EAz-NtQqrfOIA2k=6Sx^A^kgwbp{9dI;^*Icr7HZ+mypGq=1?r+M z+M)hP@AGc0^+jr@d9?5S(b}!2V(=B*4=?NU9KVV9lV2Lz=kEh%*X&Dma%H^B`nk0q)idv`yP^6vuE33cN|#GLemv z8gYmNiXaBv;DTy!z~itPwoJV})qMM^v0be)X0n@8xCy&46rV;H0s`-{ippK&PySdg zHBtlD@Gv^0Ut0Nqo^IAlkJ@A;8_9SAPQz&oz@3Pk!{6|W#txrmpX<`V^<0mW6phh5 zs1vwP#^jhBlNV*Rtj2A)hisOnSTD!$ES_Z_`xR@F`W8`B;&CE#FErCJIpoB zo`X41tT%hwM*T)(Ci2gm{dl0|a`KlepH3YDKZt{2sKW}V`Fj1A_jcZ;>vbP*3Ah!| z7%-e){@ioVER%BCBQ|=F*3%XByy{WUS_1})Z;#xD{chLwqu=gyCAt#7`s#~^ zJKq})7-|{*a)57qK5*(Mc`+xcUuSxwoxAiF{U2(81hq@bItv1S&f(R=s;&8L}a+uBdE$Jegq{gy6#7KiZ+-%I`*{6M~!rLY0_ zK{0Ifgv&A2B;`6xheHQdsyL|TZ@9(MYl(w)s!{9F4#|)xvmrx5AOeP@QWi)9AH)*O z#6^-W8BhT2Fads4LQcx%TB+w)%P3_jlwt&JB*}##t8S2oRp_Qf%;PL(W~hY*hyxd- zp&Lu#rga%MX#=Muh?h2=#73}FDMm^z*K!sV0YDqNF`n{xDKF7?;{@HKlEIFRkS*zw z4TZdwm+Mh+VK(@on@-a_a9aJt3`vI}aCx&Tq(TX{Ln+;&9T-Ws)QD7rQ-YyZR>+`4 zNGrEk#lo#}LApdN&8>;~Z~kcS?Ab-`9k2iPy65-i*E)<>ORyaNXvCTQ@y@QSzaRbj zrx<8$5jvz;idD1shaNGJ&E zHJ9Gi7rke!59o`$g_?}-e0+Ww^w^wsVT$r5U=A{j+F)} zy=YNQ8BZE74Ia92H^>gNn=KbG4F`PDH zNRW)liiMxXo`2v&%A-1}QH#B32n(?iD>=s-3+7eNJDyjJ5uXpHwFIpT845~&cwu68 zLhEm?Z%fX%ZR#xW+x&lzJhCF;(O1O@PI1yO9l=aGtD@CkCEeOI*ghi0z)L+yHQ9|~zr;@G(*aMORPU^jSxn7RL6hdjX?Sb8a zjkF$<5c!Zah>PaXHtL0q^u3C*WPff#H-=yjRAUiEVIxLDj|eP~aR@;-2Eqw(%52Nh zrW{JppX}g-d`X85Pz=Cr(ja3Pj~gKp@?nVbzzGwQ{oeop0RR6BeDm$*H&dek0000< KMNUMnLSTY%X7W7% diff --git a/server/src/main/resources/static/img/google.png b/server/src/main/resources/static/img/google.png deleted file mode 100644 index 7e764e3df43361ce8955c00b741efa25fbc555f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9107 zcmcIqi$BwS^#5*H(~P0aE!QN)=9WuIGbOoIx}mm6c_z6fcWTo{g>;dTgxq>cwQ3O? z%|vNpD3;J9QjJz5CHy{np6B-;{C-}qz3}~h&gFAH=X1{coU^k#e72&OYA=N#2))gH zvo8d}xv~!uA$wAP%oY4njB@vnfgt6lvJdRaT4fywGJv*icG;b9ZDR0cbj-6W2=SP^ zXZV#gYzMD`kjqa2Ku^v@tX6z)1EcSmFB4{Bi^-U z?)zxx0RtlbPk#cP@nxqVsv^m45!xpifex~E!f(G_L0;^6U*#}97ksL)LErD3x5{&g zz*5JIJM+M5W%4Pgn^g+4N!F(9Q+oL57_wz(6E#%CqG}DGYv2ke zUqKN@l8O?&Y=!9>immC8l5S8LRFkAq2gmIkkPrMC$|Kn=5vy9@c6BYlb3{tiuz&;I z!UcHbF^GD>Gl`F{e(A09#p)KXJIk4uH1L6iu)sN$%rpKBh%PWMpf)Hzxp28q>w8_U z(FeZ&zTv)P6R!?$B}Yn3sp=hkduKLkO6_>5%`kTCLkQ!Srau+Ra^g7+lv^$d$H`ax zV9XBWV+!rCt8V;YUTzF@!cRPYQHs!q&uG<~d_suxCE3n@!r3 zVLD*I%HE4bdcH+<)>(-iU9kD-V${z%L$NdXGf#}tuTvEpyI|d2rHse{NSa-ODmR&Z zupXs@} z$@P;TJY==`2o*%`Qj@i)tL<5MNH?&ZcFa7)y8AQyTKR;DdS?DHc0IH_G`WojUxm7Z zBN+FSL;f1J-r#~QQEy`U`?K2HXW=}kRX0Do0xV_hJoT;rN|?^v%W+ z_)4Q`HJXC^GB-`=lJ$EYMMF=O5)0EPnK-g6^L!Uz%Rn|{1vXio18lcwMVHv4vQ>dx zO7f>xqcZy-EH$2p-{==OeTWXDa*t zGRw@hJET$C5GgS~+t^&=JTRVRRtr}l^aj9zk4bvNhX<(u4pDS+ z34E(4<4i38lh&qC72s_@? zw@LzM>6XTC#Ld(LRq{-x@3t_=msu#@cluE5<5*twXs0AgE-Niq3*_avO>5-@b& z!1HT>#9`a`%jPZZTSW>~5BiYq0p0=d@TS*zQE?_S1Mta4HpIjX9|v|Su_}!xk+fTO|cQhPgWh_dXx>2&L8E(r9#Q)VljCvcD#QJ`}frB(7=*u*3K{m`63@bne>xyx$UBEs!*xb~_(r17} zU^Fpu6Zyje*mX&+(e%urbSy-Dm2zX;lY@d%N=CywGvDR_nFA)%HAl)}vUiupUBNr2 ziqY_Anf=$nyJJSvH9_U2cLeVie&uv+l7lSI_Fa-#&s06%DwYYQ)vf?LW)ib^!Kl)JNkOwkSy>Ji<$S+2~XZw zsJB}^2}W(855;8JLePb)+n2^l)~9<3P>`fzbYn+m+)$}aJcA4c;!A&O8lz=KQRh2W zHSYBOcgf)PW)ws?>bycX{?=e>*{A|E)BSyNgt5NTLzHSf4Tq8y8{TS#f`q%ScjF!@8Dr*Vr`cc@$DzVP#c11@%L7Nz3@~Qf6eGK?{C_YvPL=q{J9Jb@yKpE*L$Rh zU^WaY!O`M%%9J32A9$voAlD5Ty$bFf32W{rO>rTfUP$8C9w}V538)iYJos8Bh?2PJ z-)GZ{B5B;u>^v+aauv*7Opk~AghP;nkGKKv1uV~E#w2d?jXkAdC(#DaXDs`r+3E2{ zZU8FGs3&Y%`gRfyNsh3Gb!hR{0c41pnpgbkvGpEK*mc10=nsGLKL%FhzVmuLnRf_i zE1LZNYdlVGjX$7@^xn!U=C84CWDKBCme2I^|8%~_YS>Gu-o`Q1^sx#IN2V^s6K zkqK21@$V>!LDgTM%-&Afu=Hlz!R-rP)a`r26DKma{h2RbjIzVq&Kz#mO{^I$+mu{q zZ+m@ia=(yfaz{5|7n?)SQL%d#svN$!@TK_2E`vtoW)@wbzv&87{}d

$$!f&&=AqkhQI|ZNh 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∨vV|};RGXogc6S6b(LJA159BkX$@k^$xN~SJ*a8GIhUd*B0xv_7!fbbVxhv3 zoNuCuLsJ+X$ZNAGyG^0sbS3sCIQk!ooai^-Ajth?)rCY#B5e&QIE&-#?KneD?Ne|9 zfgh2A>qxO};05)QZhDG}PNQs1OR*wv5u=_ym?)YbJIsYa2(`G6px7`67_Pcq*#Qqg zxKhbrQBLN%O;cRnEr$NPUA|Y<%PjV%6(71r8>GZ+6x~Muz5-lFAaupR7 z4_acq@zCy-k})xSto#$BB{Y86Dac+0YlSbxYM4m)iTns$TnjhL9^%3H5ulzR81<8= zosz6NSw*m9BEknZuH8PxhqF0R=UIi!!D#eVGL%3Ry?n_aWmaM@0elzDk2r~&Orn$B z-9(HL`rtZ|NnbG7$T=2A4Vy^1N_GcZd4}YK-QRWBvj@eyL?8U|SVTz*^6e*ywu?xY zQ9fowyw_F{W1c=3`^L(wB|#2qQ{;rj4A)hDVkiQzs2_yotm?CEfXa;|zULHpsZ3vw zt{Ea22#ChkjprGC*5V@DD&%K$Qq2Q* z0HzqNbW(zs=!go6VUyyb0LowfUyVD`z^LgcX!srB<5DVLkFMUZW?e!)np#OG$wpJ# zdQi(L@x#VJ%!iU&ugX6ul&lG2YMUDbNG)}A%Lf^ePS@PLHB>FMqNywBq*=Gyp5bx( z0<7OdJRBcq;kRSDm}*8mxik9bA`xetWB2xYst0=UzRpV1&kM8!$KA z4SH7Ae4dwt-~JphAh>DfXER<%*bVf`K@;^mxQ7n{+lW$=l{BW3JPAxUFJ))+BV9!% zaw-)UX$oF${bV900k*+12!iAsW#0uTDA>#NGCyS9Gh>~_6121H4+mRaE~;Tp(n%o| zuVu5;J;=rr#!_n?7pBDg*xulF8aS4a;1t?&KXJADTTdG}{-N;m1GUU+R}>p}n@FtX zT$s!KC1Civ6s!wOq3!XXmS)#5cY*eD&u7_m*I6y)jkm~TjSCilcwTB6zEF|7*?644 zH9|hYM>=X2#B`S@J?=!q-Xa?ZOr&bG650GXP~UXD)JJ3jH^uY%z+}0&1{=g2X4kWd z3gyqMr(jn}t#PEGMve4H_|KI&Q_;&UB$9y@GrPh*nr5+lrp9u8aDtFTLd zczcK{z!d$}s9Rc%{JYI`Qh@r2QndZDX~Wr72GVZ8D!lG~3BD=b7x)meUN$Ii#iW=T zOMNewpi=9)0?kb~co9Su2vZynjMmS%e$mP03AFkTXm-9_@{N)zryA!-EJWRZtY~O= z=PQAaOAd`OTxGwq-Q~*$Do>!%f8b1Kw&GOR?>Ga=_vPWZ_Ga&;Q{BJqR!Wb9F}`fx zuS+{elmg&LM4w6#3+l)_LQC7Nb@Y#9N)3lSJ(?+nA#Oqg5P^yym+^eV6! zZk?=o2Gkv|ramH%xaM25%*kCigGT!e_Tyin7>X$j%5-A3wsT*ilV h@a>WRw;y5iqMRjb=9S-Z{$ZJrZCiXc-z5g7{SU0g7ta6y diff --git a/server/src/main/resources/static/img/kakao.png b/server/src/main/resources/static/img/kakao.png deleted file mode 100644 index 8a96f5a0bead5c2c93ce98ff05bfb7f041f3fa6a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28977 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 diff --git a/server/src/main/resources/static/img/naver.png b/server/src/main/resources/static/img/naver.png deleted file mode 100644 index 952fa42d4e9e8bf36abf18d2a1edacdebb0aa666..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1926 zcmV;12YL93P)JK(pt$s z?VEWsv-Zv?^&^7B>I##lworR0L-DCrs|80%PSgXMmewRU+JRD9`$ZiaJFVb?a*4SZ$nYQGl_9Qbhudn66;~hwZ5iG;6!c*05mB zYgQb@An>dqFRDBhYh+ePRU1H6Vp^O z;qAI7&cmeDr|ww^QGK_o_2MNc<}>y)91+Ib75QStY~9eHGkUvOdbdkF23-9v{@OAhp0&v~^5l;{LJ)K#Pu}=5 z%fK3hegv2uk13nhc;hNDAa-76#zEG#r{ym#92QG7ttZ{g*zILpdz5+UGwsF|8MF4X z?!hZVR;)ix=dd!r%#_vAZ}EAoKYBQp=+CwCSltxJigB!i)D>&dhV{IMV{K_q0drY9 zhd5TEUE_0E-OCil67A_jKI^w0j@8heKIF5;E2=qbpt}~!XN`ZKIV;wkJ`}LVvS`i{ zAZw@Zf;DB!8v8;ER#o%qLlJBD7{;n=K7A-+buVG87|Hsa;#p05)~#{Tg7tmMwPw%i z9-vrl%~ek^>q81+36!m% zA*>k9>ZFvcr$CnASkDg;EJ3rnUpQI+_eC<+-Z6?bg=BR*2v!x&`j$mD*0+jE$GWMg zbgZYGtec8z&zixqZYruBYhCBp;F7HCzG%mab^cmYlC{^TWL;;Gko8{{30d7fC+q9- znvu2Jqhx&@7b#hvQ%creFFfmg2J6tC)wE;nWc@+piyf<7{^?dO>mcj=BBl9e1&vgGVfN>)P3 zdV^=p+OwpCWew~X`!}rDP}aKrqmgTt^q{ONigk8D$x8MqS>>|mpLKQwWYuA;QWaI_ zZYd0_4`eAAtK3g7{#nUuN>-V^sKtsftj-mR6(d>azf-d06wA5?Vs(x{tYs)GIf1e& zAeJ11SPj5me}-6R`xutOvT`2enzGhVtVzpjy}y;sqgWjXYZJ|yG%f~WO{%DRtO&-M zP*FYpOLv4NuYoLuW2FF=!n2AiswQg<#*(+ztTyDOhg+;SSGKGcj{n(0a*o)x^!QgoJF$wR;&T$ zmE19woL$+l8kiTD$64pEY*?Vzy~kN{V!=v4FUM!Fax1F#%kdGCl^mI~Vu!4ED3(I9 za#tGK+fXF6D`%ztgQ;*1$)y-f@-;@>WZy3}0D>Hf-m!4-|dZ|_8%TBraS#g|I z@{-I;`{x>yE$YpPShsE>-RO#RTQJg1)L6Hin-9<0ygS<&l5YOUx>aTrnib!rZCFV+ ztqq;U_-<{8OuBtE>t@>#W7DGD$UWrkitpYb0i;JNkRCw6dRzzT;U=s{!H^!DLwd|m zn2I|2P$tqNtVj?1Vm%&>^e{QrqxVP;DkMGDk@XNvzfU7toCmCGoJUJmO34rJB|Ylc z-|f;ocAT(f5g$5EdPKXQ)}?=-y^qeA=&O9Jfd&RF_Y40m6P5d4Th@(Ou5Xvw%~ER0 z>8yUcYRGcC2zsQ9gQ}8xnLgJoSng_ltgOP(w7zAha`lk6YRPgl!x<>+SmY_BZj0)6 zRUv|?VmYi5F^Gk2URw$lO`eM7sCgk(xWTawWvL2e#gQ}=I3QE#Wd)Bpd)0z+5Vm>5 zXAm-*SXPc=I_2qun7Z0_sWPvt%5mH`L48!tqq4!$1uYY)JjGn_8k!SJWl<~2Z*b-B zd(?iOWO`6jL1JmGpnk5XfWec!+sRz0Jsbvos?`=0QLL`29}y(}1&$R7Bmzgu$N&HU M07*qoM6N<$f=&ys7ytkO diff --git a/server/src/main/resources/templates/login.html b/server/src/main/resources/templates/login.html deleted file mode 100644 index fe82441..0000000 --- a/server/src/main/resources/templates/login.html +++ /dev/null @@ -1,75 +0,0 @@ - - - - - OneBite 간편 로그인 - - - - -

- - - - -
OneBite 간편 로그인
- - -
- SIGNED IN - user님으로 로그인되었습니다. -
- - -
- - 계정 바꾸기 -
- - - - - - - - - -
-
이미 로그인되어 있습니다. 다른 계정으로 로그인하려면 계정 바꾸기를 누르세요.
- -
- -
- - -
- 로그인 중 문제가 발생했습니다. 다시 시도해주세요. - (error) -
-
- - From 7e4b3a641334657cbc5b082b0c57908e88b91ed3 Mon Sep 17 00:00:00 2001 From: Staty Date: Wed, 19 Nov 2025 17:51:22 +0900 Subject: [PATCH 002/198] =?UTF-8?q?ON-79=20GoogleVerifier=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EA=B5=AC=EA=B8=80=20idToken=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EC=B8=A1=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/security/GoogleVerifier.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/server/src/main/java/oba/backend/server/security/GoogleVerifier.java b/server/src/main/java/oba/backend/server/security/GoogleVerifier.java index 621ab28..e0b4e97 100644 --- a/server/src/main/java/oba/backend/server/security/GoogleVerifier.java +++ b/server/src/main/java/oba/backend/server/security/GoogleVerifier.java @@ -1,4 +1,40 @@ package oba.backend.server.security; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Collections; + +@Component +@RequiredArgsConstructor public class GoogleVerifier { + + @Value("${google.client-id}") + private String googleClientId; + + public GoogleIdToken.Payload verify(String idTokenString) { + try { + GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder( + new NetHttpTransport(), + new GsonFactory() + ) + .setAudience(Collections.singletonList(googleClientId)) + .build(); + + GoogleIdToken idToken = verifier.verify(idTokenString); + if (idToken == null) { + throw new RuntimeException("Invalid Google ID Token"); + } + + return idToken.getPayload(); + + } catch (Exception e) { + throw new RuntimeException("Google token verification failed", e); + } + } } From d2e071a7a97112c5c18193769e99ea0a494d343f Mon Sep 17 00:00:00 2001 From: Staty Date: Wed, 19 Nov 2025 17:51:22 +0900 Subject: [PATCH 003/198] =?UTF-8?q?ON-79=20KakaoVerifier=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20accessToken=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EC=A0=95=EB=B3=B4=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/security/KakaoVerifier.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/server/src/main/java/oba/backend/server/security/KakaoVerifier.java b/server/src/main/java/oba/backend/server/security/KakaoVerifier.java index e407c83..8328eea 100644 --- a/server/src/main/java/oba/backend/server/security/KakaoVerifier.java +++ b/server/src/main/java/oba/backend/server/security/KakaoVerifier.java @@ -1,4 +1,43 @@ package oba.backend.server.security; +import lombok.RequiredArgsConstructor; +import oba.backend.server.security.OAuthAttributes; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Component +@RequiredArgsConstructor public class KakaoVerifier { + + private final RestTemplate restTemplate = new RestTemplate(); + + public OAuthAttributes verify(String accessToken) { + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + "https://kapi.kakao.com/v2/user/me", + HttpMethod.GET, + entity, + Map.class + ); + + Map body = response.getBody(); + Map account = (Map) body.get("kakao_account"); + Map profile = (Map) account.get("profile"); + + return new OAuthAttributes( + String.valueOf(body.get("id")), + (String) account.get("email"), + (String) profile.get("nickname"), + (String) profile.get("profile_image_url"), + body + ); + } } From 5830f5b0b82d1a0e47c4d899bfb61a9ada646bec Mon Sep 17 00:00:00 2001 From: Staty Date: Wed, 19 Nov 2025 17:51:23 +0900 Subject: [PATCH 004/198] =?UTF-8?q?ON-79=20NaverVerifier=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EB=84=A4=EC=9D=B4=EB=B2=84=20accessToken=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EC=A0=95=EB=B3=B4=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/security/NaverVerifier.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/server/src/main/java/oba/backend/server/security/NaverVerifier.java b/server/src/main/java/oba/backend/server/security/NaverVerifier.java index 03bfc98..b345adf 100644 --- a/server/src/main/java/oba/backend/server/security/NaverVerifier.java +++ b/server/src/main/java/oba/backend/server/security/NaverVerifier.java @@ -1,4 +1,41 @@ package oba.backend.server.security; +import lombok.RequiredArgsConstructor; +import oba.backend.server.security.OAuthAttributes; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Component +@RequiredArgsConstructor public class NaverVerifier { + + private final RestTemplate restTemplate = new RestTemplate(); + + public OAuthAttributes verify(String accessToken) { + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + "https://openapi.naver.com/v1/nid/me", + HttpMethod.GET, + entity, + Map.class + ); + + Map body = (Map) response.getBody().get("response"); + + return new OAuthAttributes( + (String) body.get("id"), + (String) body.get("email"), + (String) body.get("name"), + (String) body.get("profile_image"), + body + ); + } } From cc211db8938154b069e334c288a0d36d6d66b22b Mon Sep 17 00:00:00 2001 From: Staty Date: Wed, 19 Nov 2025 17:51:26 +0900 Subject: [PATCH 005/198] =?UTF-8?q?ON-79=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EB=B6=84=EB=A6=AC=EC=9A=A9=20config-env.properties?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/resources/config-env.properties | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/main/resources/config-env.properties b/server/src/main/resources/config-env.properties index e69de29..c8102a3 100644 --- a/server/src/main/resources/config-env.properties +++ b/server/src/main/resources/config-env.properties @@ -0,0 +1,2 @@ +JWT_SECRET=Zt7G9R3kLmP2qX8vB4s9Y1cV7wR5nE2kF6dH3aJ8tU4mN9bC5xZ1pQ7rT3uV4 +AI_FASTAPI_URL=http://localhost:8000/generate_daily_gpt_results From c3d19f6d0a37e19a75db5cbe51484435102e2937 Mon Sep 17 00:00:00 2001 From: Staty Date: Wed, 19 Nov 2025 17:52:45 +0900 Subject: [PATCH 006/198] =?UTF-8?q?ON-79=20SecurityConfig=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EC=9B=B9=20OAuth=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20JWT=20=EA=B8=B0=EB=B0=98=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/config/SecurityConfig.java | 41 ++++++------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/server/src/main/java/oba/backend/server/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/config/SecurityConfig.java index 48a3bed..589c39b 100644 --- a/server/src/main/java/oba/backend/server/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/config/SecurityConfig.java @@ -1,55 +1,40 @@ package oba.backend.server.config; import lombok.RequiredArgsConstructor; -import oba.backend.server.security.CustomAuthorizationRequestResolver; -import oba.backend.server.security.CustomOAuth2UserService; +import oba.backend.server.common.jwt.JwtAuthenticationFilter; 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.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private final CustomOAuth2UserService customOAuth2UserService; - private final CustomAuthorizationRequestResolver customAuthorizationRequestResolver; + private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - AuthenticationFailureHandler keepSessionFailure = - (request, response, exception) -> - response.sendRedirect("/login?error=" + exception.getClass().getSimpleName()); - http .csrf(csrf -> csrf.disable()) - .formLogin(form -> form.disable()) .httpBasic(b -> b.disable()) - .authorizeHttpRequests(auth -> auth - .requestMatchers("/ai/**").permitAll() - - .requestMatchers("/", "/login", "/error", - "/oauth2/**", "/css/**", "/js/**", "/images/**", "/img/**").permitAll() + .formLogin(f -> f.disable()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/auth/mobile/**", // 모바일 로그인 허용 + "/ai/**" + ).permitAll() .anyRequest().authenticated() ) - .oauth2Login(o -> o - .loginPage("/login") - .authorizationEndpoint(a -> a.authorizationRequestResolver(customAuthorizationRequestResolver)) - .userInfoEndpoint(u -> u.userService(customOAuth2UserService)) - .defaultSuccessUrl("/login?loggedIn", true) - .failureHandler(keepSessionFailure) - ) - .logout(l -> l - .logoutUrl("/logout") - .logoutSuccessUrl("/login?logout") - .deleteCookies("JSESSIONID","refresh_token") - .invalidateHttpSession(true) - ); + + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } From 5fcabe2e69177c1f6475634996f658e7005d93c1 Mon Sep 17 00:00:00 2001 From: Staty Date: Wed, 19 Nov 2025 17:52:49 +0900 Subject: [PATCH 007/198] =?UTF-8?q?ON-79=20JwtProvider=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=EC=9A=A9=20JWT=20=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/common/jwt/JwtProvider.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java index b0ecd15..6cbf47e 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java @@ -23,7 +23,7 @@ public class JwtProvider { private final long accessTokenValidity; // AccessToken 유효기간 private final long refreshTokenValidity; // RefreshToken 유효기간 - // ✅ application.properties / 환경변수에서 값 주입 + // application.properties / 환경변수에서 값 주입 public JwtProvider( @Value("${jwt.secret}") String secret, @Value("${jwt.access-token-expiration-ms:1800000}") long accessTokenValidity, @@ -34,7 +34,7 @@ public JwtProvider( this.refreshTokenValidity = refreshTokenValidity; } - // ✅ 토큰 생성 (Access, Refresh 동시 발급) + // 토큰 생성 (Access, Refresh 동시 발급) public TokenResponse generateToken(Authentication authentication) { String accessToken = createToken(authentication.getName(), "access", accessTokenValidity); String refreshToken = createToken(authentication.getName(), "refresh", refreshTokenValidity); @@ -54,7 +54,7 @@ private String createToken(String subject, String type, long validity) { .compact(); } - // ✅ 토큰 검증 + // 토큰 검증 public boolean validateToken(String token) { try { Jwts.parserBuilder() @@ -67,7 +67,7 @@ public boolean validateToken(String token) { } } - // ✅ Authentication 추출 + // Authentication 추출 public Authentication getAuthentication(String token) { Claims claims = getClaims(token); String username = claims.getSubject(); @@ -76,7 +76,7 @@ public Authentication getAuthentication(String token) { return new UsernamePasswordAuthenticationToken(principal, token, authorities); } - // ✅ Claims 가져오기 + // Claims 가져오기 public Claims getClaims(String token) { return Jwts.parserBuilder() .setSigningKey(key) From 92189bb5e752925a16a8519d6ca2b2199d4d946b Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 09:21:38 +0900 Subject: [PATCH 008/198] =?UTF-8?q?ON-79=20JwtAuthenticationFilter=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20JWT=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=95=84=ED=84=B0=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/common/jwt/JwtAuthenticationFilter.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java index 7a1051f..c79bbaf 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java @@ -24,19 +24,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - // ✅ 1. Access Token 먼저 꺼내오기 (쿠키에서) + // Access Token 먼저 꺼내오기 (쿠키에서) String token = resolveTokenFromCookies(request); if (token != null && jwtProvider.validateToken(token)) { var claims = jwtProvider.getClaims(token); - // ✅ 2. Refresh Token이면 인증 불가 → 그냥 다음 필터로 + // Refresh Token이면 인증 불가 → 그냥 다음 필터로 if ("refresh".equals(claims.get("type"))) { filterChain.doFilter(request, response); return; } - // ✅ 3. Access Token이면 SecurityContext에 인증정보 저장 + // 3. Access Token이면 SecurityContext에 인증정보 저장 Authentication authentication = jwtProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); } From 7f9d93019f295eb3ba8f8e889b897023c4e3b516 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 09:25:20 +0900 Subject: [PATCH 009/198] =?UTF-8?q?ON-79=20OAuthAttributes=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20Google/Kakao/Naver=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=8C=80=EC=9D=91=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/security/OAuthAttributes.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/security/OAuthAttributes.java b/server/src/main/java/oba/backend/server/security/OAuthAttributes.java index 070dab9..7a5c074 100644 --- a/server/src/main/java/oba/backend/server/security/OAuthAttributes.java +++ b/server/src/main/java/oba/backend/server/security/OAuthAttributes.java @@ -1,4 +1,16 @@ package oba.backend.server.security; -public class OAuthAttributes { -} +import java.util.Map; + +/** + * 모바일 소셜 로그인(Google/Kakao/Naver) 공통 사용자 정보 DTO + * - MobileAuthController에서 DB 저장 및 JWT 발급에 사용 + * - Verifier들이 반환하는 공통 구조 + */ +public record OAuthAttributes( + String id, + String email, + String name, + String picture, + Map attributes +) { } From 41ce1a8d41fed1a1b15bc762302d8ec0366e1049 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 09:22:13 +0900 Subject: [PATCH 010/198] =?UTF-8?q?ON-79=20AI=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4/=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC/=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/controller/AiController.java | 2 +- .../java/oba/backend/server/scheduler/AiScheduler.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/oba/backend/server/controller/AiController.java b/server/src/main/java/oba/backend/server/controller/AiController.java index f37419d..d868b75 100644 --- a/server/src/main/java/oba/backend/server/controller/AiController.java +++ b/server/src/main/java/oba/backend/server/controller/AiController.java @@ -15,7 +15,7 @@ public class AiController { // 수동 실행 API (Postman 테스트용, 관리자용) @PostMapping("/generate/daily") public ResponseEntity runDailyAi() { - System.out.println("[Spring] /ai/generate/daily 요청 들어옴"); + System.out.println("/ai/generate/daily 요청 들어옴"); String result = aiService.runDailyAiJob(); return ResponseEntity.ok(result); } diff --git a/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java b/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java index 2614f3d..1372f28 100644 --- a/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java +++ b/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java @@ -12,14 +12,14 @@ public class AiScheduler { private final AiService aiService; /** - * 매일 새벽 4시 자동 실행 + * 매일 새벽 0시 자동 실행 * cron 형식: 초 분 시 일 월 요일 - * "0 0 4 * * *" = 매일 00:00:00 + * "0 0 0 * * *" = 매일 00:00:00 */ @Scheduled(cron = "0 0 0 * * *") public void autoDailyGptUpdate() { - System.out.println("🔥 [SCHEDULER] Daily GPT Update 실행 시작"); + System.out.println("[SCHEDULER] Daily GPT Update 실행 시작"); String result = aiService.runDailyAiJob(); - System.out.println("✅ [SCHEDULER] 실행 완료: " + result); + System.out.println("[SCHEDULER] 실행 완료: " + result); } } From e16f8b6f9cb499723ca89681a1470cee0ac22ae3 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 09:25:32 +0900 Subject: [PATCH 011/198] =?UTF-8?q?t=20commit=20--amend=20--reset-author?= =?UTF-8?q?=20ON-79=20ServerApplication=20=EC=88=98=EC=A0=95=20-=20?= =?UTF-8?q?=EC=9B=B9=20OAuth=20=EC=A0=9C=EA=B1=B0=20=ED=9B=84=20=EB=B9=88?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/java/oba/backend/server/ServerApplication.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/ServerApplication.java b/server/src/main/java/oba/backend/server/ServerApplication.java index e9c2b29..b7c40a8 100644 --- a/server/src/main/java/oba/backend/server/ServerApplication.java +++ b/server/src/main/java/oba/backend/server/ServerApplication.java @@ -7,7 +7,7 @@ @SpringBootApplication @EnableJpaAuditing -@EnableScheduling // ← 추가 +@EnableScheduling public class ServerApplication { public static void main(String[] args) { SpringApplication.run(ServerApplication.class, args); From 6a3edcf6ade0f8567cac838d6056f1e49209875b Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 09:25:38 +0900 Subject: [PATCH 012/198] =?UTF-8?q?ON-79=20build.gradle=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20Google=20API=20/=20JWT=20/=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20OAuth=20=EC=97=B0=EB=8F=99=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/build.gradle b/server/build.gradle index a0f8657..30451ed 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -49,6 +49,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + + implementation 'com.google.api-client:google-api-client:2.2.0' + implementation 'com.google.oauth-client:google-oauth-client-jetty:1.34.1' + implementation 'com.google.http-client:google-http-client-gson:1.43.3' } From 27d35010cb14c7a8e0d34acf3f581afd2f92f8f9 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:35:22 +0900 Subject: [PATCH 013/198] =?UTF-8?q?ON-79=20Dockerfile=20=EC=8B=A0=EA=B7=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/Dockerfile | 0 .../server/common/jwt/JwtTokenProvider.java | 4 +++ .../server/config/RestTemplateConfig.java | 0 .../oba/backend/server/config/WebConfig.java | 4 +++ .../server/controller/ArticleController.java | 4 +++ .../controller/GptResultController.java | 4 +++ .../backend/server/domain/gpt/GptResult.java | 4 +++ .../server/dto/ArticleDetailResponse.java | 4 +++ .../server/dto/ArticleSummaryResponse.java | 14 ++++++++ .../server/entity/mongo/GptDocument.java | 4 +++ .../backend/server/entity/mysql/Article.java | 33 +++++++++++++++++++ .../server/entity/mysql/ArticleCategory.java | 13 ++++++++ .../entity/mysql/ArticleCategoryId.java | 16 +++++++++ .../backend/server/entity/mysql/Category.java | 17 ++++++++++ .../repository/GptResultRepository.java | 4 +++ .../repository/mongo/GptMongoRepository.java | 4 +++ .../mysql/ArticleCategoryRepository.java | 4 +++ .../repository/mysql/ArticleRepository.java | 6 ++++ .../repository/mysql/CategoryRepository.java | 4 +++ .../oauth/CustomOAuth2UserService.java | 4 +++ .../security/oauth/OAuth2SuccessHandler.java | 4 +++ .../security/oauth/dto/CustomOAuth2User.java | 4 +++ .../security/oauth/dto/OAuthAttributes.java | 4 +++ .../server/service/ArticleDetailService.java | 4 +++ .../server/service/ArticleSummaryService.java | 4 +++ .../server/service/GptResultService.java | 4 +++ .../src/main/resources/application-prod.yml | 0 .../src/main/resources/config-env.properties | 2 -- 28 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 server/Dockerfile create mode 100644 server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java create mode 100644 server/src/main/java/oba/backend/server/config/RestTemplateConfig.java create mode 100644 server/src/main/java/oba/backend/server/config/WebConfig.java create mode 100644 server/src/main/java/oba/backend/server/controller/ArticleController.java create mode 100644 server/src/main/java/oba/backend/server/controller/GptResultController.java create mode 100644 server/src/main/java/oba/backend/server/domain/gpt/GptResult.java create mode 100644 server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java create mode 100644 server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java create mode 100644 server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java create mode 100644 server/src/main/java/oba/backend/server/entity/mysql/Article.java create mode 100644 server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java create mode 100644 server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java create mode 100644 server/src/main/java/oba/backend/server/entity/mysql/Category.java create mode 100644 server/src/main/java/oba/backend/server/repository/GptResultRepository.java create mode 100644 server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java create mode 100644 server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java create mode 100644 server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java create mode 100644 server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java create mode 100644 server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java create mode 100644 server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java create mode 100644 server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java create mode 100644 server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java create mode 100644 server/src/main/java/oba/backend/server/service/ArticleDetailService.java create mode 100644 server/src/main/java/oba/backend/server/service/ArticleSummaryService.java create mode 100644 server/src/main/java/oba/backend/server/service/GptResultService.java create mode 100644 server/src/main/resources/application-prod.yml delete mode 100644 server/src/main/resources/config-env.properties diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java b/server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..7517103 --- /dev/null +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java @@ -0,0 +1,4 @@ +package oba.backend.server.common.jwt; + +public class JwtTokenProvider { +} diff --git a/server/src/main/java/oba/backend/server/config/RestTemplateConfig.java b/server/src/main/java/oba/backend/server/config/RestTemplateConfig.java new file mode 100644 index 0000000..e69de29 diff --git a/server/src/main/java/oba/backend/server/config/WebConfig.java b/server/src/main/java/oba/backend/server/config/WebConfig.java new file mode 100644 index 0000000..ade2871 --- /dev/null +++ b/server/src/main/java/oba/backend/server/config/WebConfig.java @@ -0,0 +1,4 @@ +package oba.backend.server.config; + +public class WebConfig { +} diff --git a/server/src/main/java/oba/backend/server/controller/ArticleController.java b/server/src/main/java/oba/backend/server/controller/ArticleController.java new file mode 100644 index 0000000..759f291 --- /dev/null +++ b/server/src/main/java/oba/backend/server/controller/ArticleController.java @@ -0,0 +1,4 @@ +package oba.backend.server.controller; + +public class ArticleController { +} diff --git a/server/src/main/java/oba/backend/server/controller/GptResultController.java b/server/src/main/java/oba/backend/server/controller/GptResultController.java new file mode 100644 index 0000000..5352795 --- /dev/null +++ b/server/src/main/java/oba/backend/server/controller/GptResultController.java @@ -0,0 +1,4 @@ +package oba.backend.server.controller; + +public class GptResultController { +} diff --git a/server/src/main/java/oba/backend/server/domain/gpt/GptResult.java b/server/src/main/java/oba/backend/server/domain/gpt/GptResult.java new file mode 100644 index 0000000..eec1c55 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/gpt/GptResult.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.gpt; + +public class GptResult { +} diff --git a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java new file mode 100644 index 0000000..b755d36 --- /dev/null +++ b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java @@ -0,0 +1,4 @@ +package oba.backend.server.dto; + +public class ArticleDetailResponse { +} diff --git a/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java new file mode 100644 index 0000000..cce0038 --- /dev/null +++ b/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java @@ -0,0 +1,14 @@ +package oba.backend.server.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class ArticleSummaryResponse { + private Long id; + private String title; + private List bullets; +} diff --git a/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java b/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java new file mode 100644 index 0000000..f7ee7c0 --- /dev/null +++ b/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java @@ -0,0 +1,4 @@ +package oba.backend.server.entity.mongo; + +public class GptDocument { +} diff --git a/server/src/main/java/oba/backend/server/entity/mysql/Article.java b/server/src/main/java/oba/backend/server/entity/mysql/Article.java new file mode 100644 index 0000000..2650764 --- /dev/null +++ b/server/src/main/java/oba/backend/server/entity/mysql/Article.java @@ -0,0 +1,33 @@ +package oba.backend.server.domain.mysql; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "Articles") +public class Article { + + @Id + @Column(name = "article_id") + private Long articleId; + + private String url; + + @Column(name = "crawling_time") + private LocalDateTime crawlingTime; + + @Column(name = "updated_time") + private LocalDateTime updatedTime; + + @Column(name = "dup_cnt") + private Integer dupCnt; + + private BigDecimal ordering; + + @Column(name = "is_used") + private Boolean isUsed; +} diff --git a/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java b/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java new file mode 100644 index 0000000..33dab9d --- /dev/null +++ b/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java @@ -0,0 +1,13 @@ +package oba.backend.server.domain.mysql; + +import jakarta.persistence.*; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "Article_Categories") +public class ArticleCategory { + + @EmbeddedId + private ArticleCategoryId id; +} diff --git a/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java b/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java new file mode 100644 index 0000000..daa9e6e --- /dev/null +++ b/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java @@ -0,0 +1,16 @@ +package oba.backend.server.domain.mysql; + +import jakarta.persistence.Embeddable; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@NoArgsConstructor +@Embeddable +public class ArticleCategoryId implements Serializable { + + private Long articleId; + private Integer categoryId; +} diff --git a/server/src/main/java/oba/backend/server/entity/mysql/Category.java b/server/src/main/java/oba/backend/server/entity/mysql/Category.java new file mode 100644 index 0000000..06caba5 --- /dev/null +++ b/server/src/main/java/oba/backend/server/entity/mysql/Category.java @@ -0,0 +1,17 @@ +package oba.backend.server.domain.mysql; + +import jakarta.persistence.*; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "Categories") +public class Category { + + @Id + @Column(name = "category_id") + private Integer categoryId; + + @Column(name = "category_name") + private String categoryName; +} diff --git a/server/src/main/java/oba/backend/server/repository/GptResultRepository.java b/server/src/main/java/oba/backend/server/repository/GptResultRepository.java new file mode 100644 index 0000000..71afd8c --- /dev/null +++ b/server/src/main/java/oba/backend/server/repository/GptResultRepository.java @@ -0,0 +1,4 @@ +package oba.backend.server.repository; + +public class GptResultRepository { +} diff --git a/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java b/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java new file mode 100644 index 0000000..e4a3a96 --- /dev/null +++ b/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java @@ -0,0 +1,4 @@ +package oba.backend.server.repository.mongo; + +public class GptMongoRepository { +} diff --git a/server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java b/server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java new file mode 100644 index 0000000..495410e --- /dev/null +++ b/server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java @@ -0,0 +1,4 @@ +package oba.backend.server.repository.mysql; + +public class ArticleCategoryRepository { +} diff --git a/server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java b/server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java new file mode 100644 index 0000000..4a77a50 --- /dev/null +++ b/server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java @@ -0,0 +1,6 @@ +package oba.backend.server.repository; + +import oba.backend.server.domain.mysql.Article; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ArticleRepository extends JpaRepository {} diff --git a/server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java b/server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java new file mode 100644 index 0000000..aeee2f9 --- /dev/null +++ b/server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java @@ -0,0 +1,4 @@ +package oba.backend.server.repository.mysql; + +public class CategoryRepository { +} diff --git a/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java new file mode 100644 index 0000000..8122e69 --- /dev/null +++ b/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java @@ -0,0 +1,4 @@ +package oba.backend.server.security.oauth; + +public class CustomOAuth2UserService { +} diff --git a/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java b/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java new file mode 100644 index 0000000..49915c1 --- /dev/null +++ b/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java @@ -0,0 +1,4 @@ +package oba.backend.server.security.oauth; + +public class OAuth2SuccessHandler { +} diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java b/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java new file mode 100644 index 0000000..afa8365 --- /dev/null +++ b/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java @@ -0,0 +1,4 @@ +package oba.backend.server.security.oauth.dto; + +public class CustomOAuth2User { +} diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java b/server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java new file mode 100644 index 0000000..2d75523 --- /dev/null +++ b/server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java @@ -0,0 +1,4 @@ +package oba.backend.server.security.oauth.dto; + +public class OAuthAttributes { +} diff --git a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java new file mode 100644 index 0000000..b740a12 --- /dev/null +++ b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java @@ -0,0 +1,4 @@ +package oba.backend.server.service; + +public class ArticleDetailService { +} diff --git a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java new file mode 100644 index 0000000..0742f21 --- /dev/null +++ b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java @@ -0,0 +1,4 @@ +package oba.backend.server.service; + +public class ArticleSummaryService { +} diff --git a/server/src/main/java/oba/backend/server/service/GptResultService.java b/server/src/main/java/oba/backend/server/service/GptResultService.java new file mode 100644 index 0000000..b19ae58 --- /dev/null +++ b/server/src/main/java/oba/backend/server/service/GptResultService.java @@ -0,0 +1,4 @@ +package oba.backend.server.service; + +public class GptResultService { +} diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml new file mode 100644 index 0000000..e69de29 diff --git a/server/src/main/resources/config-env.properties b/server/src/main/resources/config-env.properties deleted file mode 100644 index c8102a3..0000000 --- a/server/src/main/resources/config-env.properties +++ /dev/null @@ -1,2 +0,0 @@ -JWT_SECRET=Zt7G9R3kLmP2qX8vB4s9Y1cV7wR5nE2kF6dH3aJ8tU4mN9bC5xZ1pQ7rT3uV4 -AI_FASTAPI_URL=http://localhost:8000/generate_daily_gpt_results From 535ef4e5e2ab5980ebd95503b70d28f7d1aa3dba Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:36:14 +0900 Subject: [PATCH 014/198] =?UTF-8?q?ON-79=20Dockerfile=20=EC=8B=A0=EA=B7=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/Dockerfile | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/Dockerfile b/server/Dockerfile index e69de29..dd06804 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -0,0 +1,19 @@ +# ----------- 1단계: Build stage ----------- +FROM gradle:8.5-jdk17 AS builder +WORKDIR /app + +COPY build.gradle settings.gradle ./ +COPY gradle ./gradle +RUN gradle dependencies --no-daemon + +COPY . . +RUN gradle bootJar --no-daemon + +# ----------- 2단계: Run stage ----------- +FROM eclipse-temurin:17-jdk +WORKDIR /app + +COPY --from=builder /app/build/libs/*.jar app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] From 43d1de608f2f03684cbbfcf63f4b226cdd732014 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:11 +0900 Subject: [PATCH 015/198] =?UTF-8?q?ON-79=20RestTemplateConfig=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20RestTemplate=20=EC=84=A4=EC=A0=95=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/config/RestTemplateConfig.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/src/main/java/oba/backend/server/config/RestTemplateConfig.java b/server/src/main/java/oba/backend/server/config/RestTemplateConfig.java index e69de29..d2f8695 100644 --- a/server/src/main/java/oba/backend/server/config/RestTemplateConfig.java +++ b/server/src/main/java/oba/backend/server/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package oba.backend.server.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} From a81c7b29a840f1b095fca2f1024af17b0f40e768 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:15 +0900 Subject: [PATCH 016/198] =?UTF-8?q?ON-79=20WebConfig=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20-=20CORS=20=EB=B0=8F=20Formatter=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/config/WebConfig.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/config/WebConfig.java b/server/src/main/java/oba/backend/server/config/WebConfig.java index ade2871..68d7be1 100644 --- a/server/src/main/java/oba/backend/server/config/WebConfig.java +++ b/server/src/main/java/oba/backend/server/config/WebConfig.java @@ -1,4 +1,19 @@ package oba.backend.server.config; -public class WebConfig { +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("/**") + .allowedOrigins("*") // 모든 Origin 허용 (필요시 특정 도메인으로 변경 가능) + .allowedMethods("*") // GET, POST, PUT, DELETE 모두 허용 + .allowedHeaders("*") + .allowCredentials(false); + } } From ed115be1416082bb847b640316078034b5ec44ab Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:17 +0900 Subject: [PATCH 017/198] =?UTF-8?q?ON-79=20ArticleController=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EA=B8=B0=EC=82=AC=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/controller/ArticleController.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/src/main/java/oba/backend/server/controller/ArticleController.java b/server/src/main/java/oba/backend/server/controller/ArticleController.java index 759f291..b327d50 100644 --- a/server/src/main/java/oba/backend/server/controller/ArticleController.java +++ b/server/src/main/java/oba/backend/server/controller/ArticleController.java @@ -1,4 +1,23 @@ package oba.backend.server.controller; +import lombok.RequiredArgsConstructor; +import oba.backend.server.dto.ArticleSummaryResponse; +import oba.backend.server.service.ArticleSummaryService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/articles") +@RequiredArgsConstructor public class ArticleController { + + private final ArticleSummaryService articleSummaryService; + + // 홈 화면 최신 기사 조회 + @GetMapping("/latest") + public ResponseEntity> getLatest() { + return ResponseEntity.ok(articleSummaryService.getLatestArticles(5)); + } } From 25bbc3c6bf121f4c40c29b95278d6626e6a2da6c Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:19 +0900 Subject: [PATCH 018/198] =?UTF-8?q?ON-79=20GptResultController=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20GPT=20=EB=B6=84=EC=84=9D=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20API=20=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/GptResultController.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/server/src/main/java/oba/backend/server/controller/GptResultController.java b/server/src/main/java/oba/backend/server/controller/GptResultController.java index 5352795..5f32ebc 100644 --- a/server/src/main/java/oba/backend/server/controller/GptResultController.java +++ b/server/src/main/java/oba/backend/server/controller/GptResultController.java @@ -1,4 +1,26 @@ package oba.backend.server.controller; +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.gpt.GptResult; +import oba.backend.server.service.GptResultService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/gpt") +@RequiredArgsConstructor public class GptResultController { + + private final GptResultService gptResultService; + + @GetMapping("/latest") + public ResponseEntity getLatestReport() { + GptResult result = gptResultService.getLatestGptResult(); + + if (result == null) { + return ResponseEntity.noContent().build(); + } + + return ResponseEntity.ok(result); + } } From 94efdf7715e56ac40a481d2eeeed43cf2769955c Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:21 +0900 Subject: [PATCH 019/198] =?UTF-8?q?ON-79=20GptResult=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/domain/gpt/GptResult.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/server/src/main/java/oba/backend/server/domain/gpt/GptResult.java b/server/src/main/java/oba/backend/server/domain/gpt/GptResult.java index eec1c55..2b00320 100644 --- a/server/src/main/java/oba/backend/server/domain/gpt/GptResult.java +++ b/server/src/main/java/oba/backend/server/domain/gpt/GptResult.java @@ -1,4 +1,25 @@ package oba.backend.server.domain.gpt; +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import java.time.LocalDateTime; +import java.util.List; + +@Document(collection = "gpt_results") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder public class GptResult { + + @Id + private String id; + + private String date; + private String title; + private String summary; + private List keywords; + private LocalDateTime createdAt; } From 288c4a450145ee1037cd71d6e975cfc231fa8e4b Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:23 +0900 Subject: [PATCH 020/198] =?UTF-8?q?ON-79=20ArticleDetailResponse=20DTO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/dto/ArticleDetailResponse.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java index b755d36..590bcfb 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java +++ b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java @@ -1,4 +1,42 @@ package oba.backend.server.dto; +import lombok.Builder; +import lombok.Data; +import oba.backend.server.domain.mongo.GptDocument; +import oba.backend.server.entity.mysql.Article; + +import java.util.List; + +@Data +@Builder public class ArticleDetailResponse { + + private Long id; + private List category; + private String title; + private String date; + private String url; + + private Object content; + private Object subtitle; + + private String summary; + private List keywords; + private List quizzes; + + public static ArticleDetailResponse of(Article a, List c, GptDocument d) { + + return ArticleDetailResponse.builder() + .id(a.getArticleId()) + .category(c) + .title(d.getTitle()) + .date(d.getPublishTime()) + .url(a.getUrl()) + .content(d.getContentCol()) + .subtitle(d.getSubCol()) + .summary(d.getGptResult().getSummary()) + .keywords(d.getGptResult().getKeywords()) + .quizzes(d.getGptResult().getQuizzes()) + .build(); + } } From e953a58829f9bc14222fe467498c16641d9f8971 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:25 +0900 Subject: [PATCH 021/198] =?UTF-8?q?ON-79=20ArticleSummaryResponse=20DTO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/dto/ArticleSummaryResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java index cce0038..14f32c4 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java +++ b/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.dto.response; +package oba.backend.server.dto; import lombok.Builder; import lombok.Data; From 7879a499e733626ff343745a1e0b00a8a097b07e Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:27 +0900 Subject: [PATCH 022/198] =?UTF-8?q?ON-79=20GptDocument=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20MongoDB=20GPT=20=EB=AC=B8=EC=84=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/entity/mongo/GptDocument.java | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java b/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java index f7ee7c0..8b66ad4 100644 --- a/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java +++ b/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java @@ -1,4 +1,59 @@ -package oba.backend.server.entity.mongo; +package oba.backend.server.domain.mongo; +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.util.List; + +@Document(collection = "Documents") +@Data public class GptDocument { + + @Id + private String id; + + // Mongo 필드: article_id + // Java 필드: articleId + @Field("article_id") + private Long articleId; + + private String title; + + @Field("publish_time") + private String publishTime; + + @Field("serving_date") + private String servingDate; + + @Field("content_col") + private Object contentCol; + + @Field("sub_col") + private Object subCol; + + @Field("gpt_result") + private GptResult gptResult; + + @Data + public static class GptResult { + private String summary; + private List keywords; + private List quizzes; + + @Data + public static class Keyword { + private String keyword; + private String description; + } + + @Data + public static class Quiz { + private String question; + private List options; + private String answer; + private String explanation; + } + } } From 27487e92e1a73dd13054c03e064107afebe4e1ee Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:29 +0900 Subject: [PATCH 023/198] =?UTF-8?q?ON-79=20Article=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/oba/backend/server/entity/mysql/Article.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/entity/mysql/Article.java b/server/src/main/java/oba/backend/server/entity/mysql/Article.java index 2650764..4c4bf4f 100644 --- a/server/src/main/java/oba/backend/server/entity/mysql/Article.java +++ b/server/src/main/java/oba/backend/server/entity/mysql/Article.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.mysql; +package oba.backend.server.entity.mysql; import jakarta.persistence.*; import lombok.Getter; From 6cdfbd0a27b137ee2b06b23d4917d4d40eb41210 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:31 +0900 Subject: [PATCH 024/198] =?UTF-8?q?ON-79=20ArticleCategory=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/entity/mysql/ArticleCategory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java b/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java index 33dab9d..3640b63 100644 --- a/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java +++ b/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.mysql; +package oba.backend.server.entity.mysql; import jakarta.persistence.*; import lombok.Getter; From 3cb68984a4a9e37b3d6022187fe7b4fba68fd8c4 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:32 +0900 Subject: [PATCH 025/198] =?UTF-8?q?ON-79=20ArticleCategoryId=20=EB=B3=B5?= =?UTF-8?q?=ED=95=A9=ED=82=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/entity/mysql/ArticleCategoryId.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java b/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java index daa9e6e..effe255 100644 --- a/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java +++ b/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.mysql; +package oba.backend.server.entity.mysql; import jakarta.persistence.Embeddable; import lombok.Getter; From 4c88196e65948294f8dd4c736e666a8373303065 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:35 +0900 Subject: [PATCH 026/198] =?UTF-8?q?ON-79=20Category=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/oba/backend/server/entity/mysql/Category.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/entity/mysql/Category.java b/server/src/main/java/oba/backend/server/entity/mysql/Category.java index 06caba5..d10d0cf 100644 --- a/server/src/main/java/oba/backend/server/entity/mysql/Category.java +++ b/server/src/main/java/oba/backend/server/entity/mysql/Category.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.mysql; +package oba.backend.server.entity.mysql; import jakarta.persistence.*; import lombok.Getter; From b287d26d71f6b1541f82cd5a08377b1745a9b88f Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:37 +0900 Subject: [PATCH 027/198] =?UTF-8?q?ON-79=20GptResultRepository=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/repository/GptResultRepository.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/repository/GptResultRepository.java b/server/src/main/java/oba/backend/server/repository/GptResultRepository.java index 71afd8c..733a283 100644 --- a/server/src/main/java/oba/backend/server/repository/GptResultRepository.java +++ b/server/src/main/java/oba/backend/server/repository/GptResultRepository.java @@ -1,4 +1,9 @@ package oba.backend.server.repository; -public class GptResultRepository { +import oba.backend.server.domain.gpt.GptResult; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface GptResultRepository extends MongoRepository { + + GptResult findTopByOrderByCreatedAtDesc(); } From 38ee7b32d40936e3b4d07d2cea11bbd751d33bf7 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:39 +0900 Subject: [PATCH 028/198] =?UTF-8?q?ON-79=20GptMongoRepository=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/repository/mongo/GptMongoRepository.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java b/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java index e4a3a96..6d6fed8 100644 --- a/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java +++ b/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java @@ -1,4 +1,10 @@ package oba.backend.server.repository.mongo; -public class GptMongoRepository { +import oba.backend.server.domain.mongo.GptDocument; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.Optional; + +public interface GptMongoRepository extends MongoRepository { + Optional findByArticleId(Long articleId); } From ad57eabd674781b97faf93dd2d7ecc43ef565546 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:41 +0900 Subject: [PATCH 029/198] =?UTF-8?q?ON-79=20ArticleCategoryRepository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/mysql/ArticleCategoryRepository.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java b/server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java index 495410e..fccc66d 100644 --- a/server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java +++ b/server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java @@ -1,4 +1,11 @@ package oba.backend.server.repository.mysql; -public class ArticleCategoryRepository { +import oba.backend.server.entity.mysql.ArticleCategory; +import oba.backend.server.entity.mysql.ArticleCategoryId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ArticleCategoryRepository extends JpaRepository { + List findByIdArticleId(Long articleId); } From 02bb1d06d20a0380174bc3eb21c309f05e895b16 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:44 +0900 Subject: [PATCH 030/198] =?UTF-8?q?ON-79=20ArticleRepository=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/repository/mysql/ArticleRepository.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java b/server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java index 4a77a50..3d2a2d6 100644 --- a/server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java +++ b/server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java @@ -1,6 +1,14 @@ -package oba.backend.server.repository; +package oba.backend.server.repository.mysql; -import oba.backend.server.domain.mysql.Article; +import oba.backend.server.entity.mysql.Article; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface ArticleRepository extends JpaRepository {} +import java.util.List; + +public interface ArticleRepository extends JpaRepository { + + @Query("SELECT a FROM Article a WHERE a.isUsed = TRUE ORDER BY a.crawlingTime DESC") + List
findLatestArticles(Pageable pageable); +} From 7d478158f8d90fedd9e7c6407406a51f883a66d7 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:47 +0900 Subject: [PATCH 031/198] =?UTF-8?q?ON-79=20CategoryRepository=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/repository/mysql/CategoryRepository.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java b/server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java index aeee2f9..78d88c7 100644 --- a/server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java +++ b/server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java @@ -1,4 +1,6 @@ package oba.backend.server.repository.mysql; -public class CategoryRepository { -} +import oba.backend.server.entity.mysql.Category; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryRepository extends JpaRepository {} From 49561c2031e87074bd4c00208011a615568970c7 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:49 +0900 Subject: [PATCH 032/198] =?UTF-8?q?ON-79=20CustomOAuth2UserService=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/CustomOAuth2UserService.java | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java index 8122e69..bafe6f8 100644 --- a/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java +++ b/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java @@ -1,4 +1,54 @@ package oba.backend.server.security.oauth; -public class CustomOAuth2UserService { +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.user.ProviderInfo; +import oba.backend.server.domain.user.Role; +import oba.backend.server.domain.user.User; +import oba.backend.server.domain.user.UserRepository; +import oba.backend.server.security.oauth.dto.OAuthAttributes; +import oba.backend.server.security.oauth.dto.CustomOAuth2User; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest request) { + OAuth2User oAuth2User = super.loadUser(request); + + String provider = request.getClientRegistration().getRegistrationId(); + OAuthAttributes attributes = OAuthAttributes.of(provider, oAuth2User.getAttributes()); + + User user = saveOrUpdate(attributes); + + return new CustomOAuth2User(user, oAuth2User.getAttributes()); + } + + private User saveOrUpdate(OAuthAttributes attr) { + + String identifier = attr.getProvider() + ":" + attr.getEmail(); + + return userRepository.findByIdentifier(identifier) + .map(user -> { + user.updateInfo(attr.getEmail(), attr.getName(), attr.getPicture()); + return userRepository.save(user); + }) + .orElseGet(() -> { + User newUser = User.builder() + .identifier(identifier) + .email(attr.getEmail()) + .name(attr.getName()) + .picture(attr.getPicture()) + .provider(ProviderInfo.valueOf(attr.getProvider().toUpperCase())) + .role(Role.USER) + .build(); + return userRepository.save(newUser); + }); + } } From 424f294a217be4cf15344d3ae1dc8cee7810061a Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:50 +0900 Subject: [PATCH 033/198] =?UTF-8?q?ON-79=20OAuth2SuccessHandler=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20OAuth=20=EC=9D=B8=EC=A6=9D=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/oauth/OAuth2SuccessHandler.java | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java b/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java index 49915c1..83ce70b 100644 --- a/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java +++ b/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java @@ -1,4 +1,40 @@ package oba.backend.server.security.oauth; -public class OAuth2SuccessHandler { +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import oba.backend.server.security.jwt.JwtTokenProvider; +import oba.backend.server.security.oauth.dto.CustomOAuth2User; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + + CustomOAuth2User oAuthUser = (CustomOAuth2User) authentication.getPrincipal(); + String jwt = jwtTokenProvider.generateToken(oAuthUser.getUserId()); + + // 앱에서 전달한 redirect_uri 받기 + String redirectUri = request.getParameter("redirect_uri"); + + // 없다면 기본값 + if (redirectUri == null) redirectUri = "myapp://oauth"; + + String targetUrl = redirectUri + "?token=" + jwt; + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } } From 8ed140f832ed424f12ac577f4d2857ffb2a29065 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:52 +0900 Subject: [PATCH 034/198] =?UTF-8?q?ON-79=20CustomOAuth2User=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/oauth/dto/CustomOAuth2User.java | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java b/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java index afa8365..3d22682 100644 --- a/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java +++ b/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java @@ -1,4 +1,40 @@ package oba.backend.server.security.oauth.dto; -public class CustomOAuth2User { +import lombok.Getter; +import oba.backend.server.domain.user.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Map; + +@Getter +public class CustomOAuth2User implements OAuth2User { + + private final User user; + private final Map attributes; + + public CustomOAuth2User(User user, Map attributes) { + this.user = user; + this.attributes = attributes; + } + + public Long getUserId() { + return user.getId(); + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return null; + } + + @Override + public String getName() { + return user.getName(); + } } From d454fdfc91e8402b8a306f3c5f8c6a090057e93f Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:54 +0900 Subject: [PATCH 035/198] =?UTF-8?q?ON-79=20OAuthAttributes=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20OAuth=20=EC=A0=9C=EA=B3=B5=EC=9E=90=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/oauth/dto/OAuthAttributes.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java b/server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java index 2d75523..c55a4e2 100644 --- a/server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java +++ b/server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java @@ -1,4 +1,76 @@ package oba.backend.server.security.oauth.dto; +import lombok.Builder; +import lombok.Getter; +import oba.backend.server.domain.user.ProviderInfo; +import oba.backend.server.domain.user.User; + +import java.util.Map; + +@Getter +@Builder public class OAuthAttributes { + + private Map attributes; + private String name; + private String email; + private String picture; + private String provider; // google / kakao / naver + + public static OAuthAttributes of(String provider, Map attributes) { + switch (provider.toLowerCase()) { + case "google": + return ofGoogle(attributes); + case "kakao": + return ofKakao(attributes); + case "naver": + return ofNaver(attributes); + } + throw new RuntimeException("지원하지 않는 provider: " + provider); + } + + private static OAuthAttributes ofGoogle(Map attr) { + return OAuthAttributes.builder() + .name((String) attr.get("name")) + .email((String) attr.get("email")) + .picture((String) attr.get("picture")) + .provider("google") + .attributes(attr) + .build(); + } + + private static OAuthAttributes ofKakao(Map attr) { + Map kakaoAccount = (Map) attr.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + return OAuthAttributes.builder() + .name((String) profile.get("nickname")) + .email((String) kakaoAccount.get("email")) + .picture((String) profile.get("profile_image_url")) + .provider("kakao") + .attributes(attr) + .build(); + } + + private static OAuthAttributes ofNaver(Map attr) { + Map res = (Map) attr.get("response"); + + return OAuthAttributes.builder() + .name((String) res.get("name")) + .email((String) res.get("email")) + .picture((String) res.get("profile_image")) + .provider("naver") + .attributes(attr) + .build(); + } + + public User toEntity() { + return User.builder() + .identifier(provider + ":" + email) + .email(email) + .name(name) + .picture(picture) + .provider(ProviderInfo.valueOf(provider.toUpperCase())) + .build(); + } } From 5e5b651396c12d12a9e0d78c579dff0132c778b2 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:57 +0900 Subject: [PATCH 036/198] =?UTF-8?q?ON-79=20ArticleDetailService=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EA=B8=B0=EC=82=AC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/service/ArticleDetailService.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java index b740a12..dd35fc3 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java +++ b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java @@ -1,4 +1,41 @@ package oba.backend.server.service; +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.mongo.GptDocument; +import oba.backend.server.entity.mysql.Article; +import oba.backend.server.entity.mysql.ArticleCategory; +import oba.backend.server.repository.mongo.GptMongoRepository; +import oba.backend.server.repository.mysql.ArticleCategoryRepository; +import oba.backend.server.repository.mysql.ArticleRepository; +import oba.backend.server.repository.mysql.CategoryRepository; +import oba.backend.server.dto.ArticleDetailResponse; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor public class ArticleDetailService { + + private final ArticleRepository articleRepository; + private final CategoryRepository categoryRepository; + private final ArticleCategoryRepository articleCategoryRepository; + private final GptMongoRepository gptMongoRepository; + + public ArticleDetailResponse getArticleDetail(Long articleId) { + + Article article = articleRepository.findById(articleId) + .orElseThrow(() -> new RuntimeException("Article not found")); + + List mapping = articleCategoryRepository.findByIdArticleId(articleId); + + List categories = mapping.stream() + .map(m -> categoryRepository.findById(m.getId().getCategoryId()).get().getCategoryName()) + .toList(); + + GptDocument doc = gptMongoRepository.findByArticleId(articleId) + .orElseThrow(() -> new RuntimeException("GPT Result not found")); + + return ArticleDetailResponse.of(article, categories, doc); + } } From bcc172cfebd52abd68a45511051de841674ae210 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:37:59 +0900 Subject: [PATCH 037/198] =?UTF-8?q?ON-79=20ArticleSummaryService=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EC=9A=94=EC=95=BD=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/service/ArticleSummaryService.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java index 0742f21..0a90bfe 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java +++ b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java @@ -1,4 +1,49 @@ package oba.backend.server.service; +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.mongo.GptDocument; +import oba.backend.server.repository.mongo.GptMongoRepository; +import oba.backend.server.repository.mysql.ArticleRepository; +import oba.backend.server.dto.ArticleSummaryResponse; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Service +@RequiredArgsConstructor public class ArticleSummaryService { + + private final ArticleRepository articleRepository; + private final GptMongoRepository gptMongoRepository; + + public List getLatestArticles(int limit) { + + var latest = articleRepository.findLatestArticles(PageRequest.of(0, limit)); + + return latest.stream().map(a -> { + + GptDocument doc = gptMongoRepository.findByArticleId(a.getArticleId()) + .orElse(null); + + List bullets = new ArrayList<>(); + + if (doc != null && doc.getGptResult() != null) { + String s = doc.getGptResult().getSummary(); + if (s != null) { + bullets = Arrays.stream(s.split(" ")) + .limit(5) + .toList(); + } + } + + return ArticleSummaryResponse.builder() + .id(a.getArticleId()) + .title(doc != null ? doc.getTitle() : "(제목 없음)") + .bullets(bullets) + .build(); + }).toList(); + } } From ff618c5d1f13649a50af2e3a75c02cb51c05ec61 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:38:01 +0900 Subject: [PATCH 038/198] =?UTF-8?q?ON-79=20GptResultService=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/service/GptResultService.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/src/main/java/oba/backend/server/service/GptResultService.java b/server/src/main/java/oba/backend/server/service/GptResultService.java index b19ae58..3651656 100644 --- a/server/src/main/java/oba/backend/server/service/GptResultService.java +++ b/server/src/main/java/oba/backend/server/service/GptResultService.java @@ -1,4 +1,17 @@ package oba.backend.server.service; +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.gpt.GptResult; +import oba.backend.server.repository.GptResultRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor public class GptResultService { + + private final GptResultRepository gptResultRepository; + + public GptResult getLatestGptResult() { + return gptResultRepository.findTopByOrderByCreatedAtDesc(); + } } From 4db61529c11ed4ef364c31ae0e8beeca91776e34 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:38:03 +0900 Subject: [PATCH 039/198] =?UTF-8?q?ON-79=20application-prod.yml=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-prod.yml | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index e69de29..69085c3 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -0,0 +1,67 @@ +spring: + config: + import: optional:file:.env + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://newdbinstance.cvewiy4wkmyb.ap-northeast-2.rds.amazonaws.com:3306/oba_backend?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: admin + password: obaoba12 + + data: + mongodb: + uri: ${MONGODB_URI} + database: OneBitArticle + + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/google" + scope: + - profile + - email + + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/kakao" + scope: + - profile_nickname + - profile_image + - account_email + + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/naver" + scope: + - name + - email + + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + +jwt: + secret: ${JWT_SECRET} + +server: + port: 8080 From 1237b0a42aaee3640961dc7e7aeb6c0ee0be5622 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:40:02 +0900 Subject: [PATCH 040/198] =?UTF-8?q?ON-79=20JwtTokenProvider=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20JWT=20=EC=83=9D=EC=84=B1/=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/common/jwt/JwtTokenProvider.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java b/server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java index 7517103..33d0245 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java @@ -1,4 +1,24 @@ package oba.backend.server.common.jwt; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secret; + + public String generateToken(Long userId) { + return Jwts.builder() + .setSubject(String.valueOf(userId)) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000L * 60 * 60 * 24 * 7)) // 7일 + .signWith(SignatureAlgorithm.HS256, secret) + .compact(); + } } From a5ba56a166d0649c2e5df0d09ad8eedff4e63bc1 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:40:06 +0900 Subject: [PATCH 041/198] =?UTF-8?q?ON-79=20AiService=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20-=20GPT=20=EC=9A=94=EC=B2=AD/=EC=9D=91=EB=8B=B5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/oba/backend/server/service/AiService.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/oba/backend/server/service/AiService.java b/server/src/main/java/oba/backend/server/service/AiService.java index 91d0ee8..d5d6b72 100644 --- a/server/src/main/java/oba/backend/server/service/AiService.java +++ b/server/src/main/java/oba/backend/server/service/AiService.java @@ -11,11 +11,10 @@ public class AiService { private final RestTemplate restTemplate = new RestTemplate(); - // FastAPI 주소 - private final String FASTAPI_URL = "http://localhost:8000/generate_daily_gpt_results"; + // Docker Compose 내부 FastAPI 주소 + private final String FASTAPI_URL = "http://ai_backend:8000/generate_daily_gpt_results"; - // FastAPI 호출 로직 - public String runDailyAiJob() { + public String runDailyGptTask() { System.out.println("[Spring] FastAPI 호출 시작 → " + FASTAPI_URL); ResponseEntity response = From 6a71a1149c73cd4203ad1bd2db772cce1bd00ef9 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:40:09 +0900 Subject: [PATCH 042/198] =?UTF-8?q?ON-79=20UserRepository=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/oba/backend/server/domain/user/UserRepository.java | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/main/java/oba/backend/server/domain/user/UserRepository.java b/server/src/main/java/oba/backend/server/domain/user/UserRepository.java index 40e5a66..435512b 100644 --- a/server/src/main/java/oba/backend/server/domain/user/UserRepository.java +++ b/server/src/main/java/oba/backend/server/domain/user/UserRepository.java @@ -6,4 +6,5 @@ public interface UserRepository extends JpaRepository { Optional findByIdentifier(String identifier); + Optional findByEmail(String email); } From 6eedf56599d9387e28cc98d740d408f85113e1e2 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:40:14 +0900 Subject: [PATCH 043/198] =?UTF-8?q?ON-79=20AiScheduler=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20GPT=20=EC=9E=90=EB=8F=99=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/scheduler/AiScheduler.java | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java b/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java index 1372f28..4808724 100644 --- a/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java +++ b/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java @@ -1,25 +1,29 @@ package oba.backend.server.scheduler; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import oba.backend.server.service.AiService; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class AiScheduler { private final AiService aiService; - /** - * 매일 새벽 0시 자동 실행 - * cron 형식: 초 분 시 일 월 요일 - * "0 0 0 * * *" = 매일 00:00:00 - */ - @Scheduled(cron = "0 0 0 * * *") - public void autoDailyGptUpdate() { - System.out.println("[SCHEDULER] Daily GPT Update 실행 시작"); - String result = aiService.runDailyAiJob(); - System.out.println("[SCHEDULER] 실행 완료: " + result); + // 매일 0시 실행 + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + public void runDailyAiTask() { + + log.info("[Scheduler] FastAPI GPT 자동 실행 시작"); + + try { + String result = aiService.runDailyGptTask(); + log.info("[Scheduler] FastAPI 응답: {}", result); + } catch (Exception e) { + log.error("[Scheduler] FastAPI 호출 실패", e); + } } } From 0b6781ec4dd076cfec15dbf50cc73244ec1e99eb Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:40:28 +0900 Subject: [PATCH 044/198] =?UTF-8?q?ON-79=20ServerApplication=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EC=95=A0=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EA=B5=AC=EB=8F=99=20=EC=84=A4=EC=A0=95=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/java/oba/backend/server/ServerApplication.java | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/ServerApplication.java b/server/src/main/java/oba/backend/server/ServerApplication.java index b7c40a8..bf90e65 100644 --- a/server/src/main/java/oba/backend/server/ServerApplication.java +++ b/server/src/main/java/oba/backend/server/ServerApplication.java @@ -13,4 +13,3 @@ public static void main(String[] args) { SpringApplication.run(ServerApplication.class, args); } } - From 3a7dc5fd1721f1b54bda78456e118f4006c930a2 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:40:30 +0900 Subject: [PATCH 045/198] =?UTF-8?q?ON-79=20Gradle=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B9=8C=EB=93=9C=20=EC=84=A4=EC=A0=95=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/build.gradle | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/build.gradle b/server/build.gradle index 30451ed..f2f7467 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -53,6 +53,11 @@ dependencies { implementation 'com.google.api-client:google-api-client:2.2.0' implementation 'com.google.oauth-client:google-oauth-client-jetty:1.34.1' implementation 'com.google.http-client:google-http-client-gson:1.43.3' + + // 일정 실행 + implementation 'org.springframework.boot:spring-boot-starter-quartz' + + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' } From 9ea679b4de81d35db2759bebe6a8c8eba53dfc6e Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:41:23 +0900 Subject: [PATCH 046/198] =?UTF-8?q?ON-79=20SecurityConfig=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20OAuth2=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=8F=20CORS=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/config/SecurityConfig.java | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/server/src/main/java/oba/backend/server/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/config/SecurityConfig.java index 589c39b..3636169 100644 --- a/server/src/main/java/oba/backend/server/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/config/SecurityConfig.java @@ -1,40 +1,54 @@ package oba.backend.server.config; import lombok.RequiredArgsConstructor; -import oba.backend.server.common.jwt.JwtAuthenticationFilter; +import oba.backend.server.security.oauth.CustomOAuth2UserService; +import oba.backend.server.security.oauth.OAuth2SuccessHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; 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.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf.disable()) - .httpBasic(b -> b.disable()) - .formLogin(f -> f.disable()) + .csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers( - "/auth/mobile/**", // 모바일 로그인 허용 - "/ai/**" + "/", + "/auth/**", + "/oauth2/**", + "/login**", + "/articles/**", + "/category/**" ).permitAll() .anyRequest().authenticated() ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .oauth2Login(o -> o + .authorizationEndpoint(a -> a.baseUri("/oauth2/authorization")) + .redirectionEndpoint(r -> r.baseUri("/oauth2/callback/*")) + .userInfoEndpoint(u -> u.userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + ); return http.build(); } From 91f8fd4f0fe42486e26d2c5ed766d9e3500a8742 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:44:03 +0900 Subject: [PATCH 047/198] =?UTF-8?q?ON-79=20config:=20application.yml=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/application.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 server/application.yml diff --git a/server/application.yml b/server/application.yml new file mode 100644 index 0000000..bd8cebd --- /dev/null +++ b/server/application.yml @@ -0,0 +1,34 @@ +server: + port: 8080 + +spring: + application: + name: server + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://newdbinstance.cvewiy4wkmyb.ap-northeast-2.rds.amazonaws.com:3306/oba_backend?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: admin + password: obaoba12 + + data: + mongodb: + uri: mongodb://ai_mongo:27017/OneBitArticle + + jpa: + hibernate: + ddl-auto: update + database-platform: org.hibernate.dialect.MySQLDialect + show-sql: true + properties: + hibernate: + format_sql: true + +jwt: + secret: ${JWT_SECRET} + access-token-expiration-ms: 1800000 + refresh-token-expiration-ms: 604800000 + +ai: + server: + url: http://ai_backend:8000 From 764564d2e21cbe3cbd4771c10f5c039dcddd59b7 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:44:05 +0900 Subject: [PATCH 048/198] =?UTF-8?q?ON-79=20AiController=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20FastAPI=20=EC=97=B0=EB=8F=99=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/controller/AiController.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/oba/backend/server/controller/AiController.java b/server/src/main/java/oba/backend/server/controller/AiController.java index d868b75..45050ee 100644 --- a/server/src/main/java/oba/backend/server/controller/AiController.java +++ b/server/src/main/java/oba/backend/server/controller/AiController.java @@ -12,11 +12,15 @@ public class AiController { private final AiService aiService; - // 수동 실행 API (Postman 테스트용, 관리자용) + // 수동 실행 API (Postman/관리자 테스트용) @PostMapping("/generate/daily") public ResponseEntity runDailyAi() { - System.out.println("/ai/generate/daily 요청 들어옴"); - String result = aiService.runDailyAiJob(); + + System.out.println("▶ /ai/generate/daily 요청 들어옴"); + + // AiService의 실제 메서드 호출 + String result = aiService.runDailyGptTask(); + return ResponseEntity.ok(result); } } From 2877c9bd5aedf56b4fb015899baeb8e8608fd617 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:44:08 +0900 Subject: [PATCH 049/198] =?UTF-8?q?ON-79=20OAuth=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/service/CustomOAuth2UserServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/test/java/oba/backend/server/service/CustomOAuth2UserServiceTest.java b/server/src/test/java/oba/backend/server/service/CustomOAuth2UserServiceTest.java index 9d33215..5d39d69 100644 --- a/server/src/test/java/oba/backend/server/service/CustomOAuth2UserServiceTest.java +++ b/server/src/test/java/oba/backend/server/service/CustomOAuth2UserServiceTest.java @@ -4,7 +4,7 @@ import oba.backend.server.domain.user.UserRepository; import oba.backend.server.domain.user.ProviderInfo; import oba.backend.server.domain.user.Role; -import oba.backend.server.security.CustomOAuth2UserService; +import oba.backend.server.security.oauth.CustomOAuth2UserService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; From 5597394df474dac232e39c56e19e8b982f7537bd Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 20 Nov 2025 16:44:10 +0900 Subject: [PATCH 050/198] =?UTF-8?q?ON-79=20docker-compose.yml=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..496323c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + spring: + container_name: spring_app + build: + context: ./server + ports: + - "8080:8080" + env_file: + - ./server/.env + environment: + SPRING_PROFILES_ACTIVE: prod + SPRING_DATA_MONGODB_URI: mongodb://ai_mongo:27017/OneBitArticle + FASTAPI_URL: "http://ai_backend:8000/generate_daily_gpt_results" + depends_on: + - backend + - ai_mongo # MongoDB 의존성 추가 + networks: + - ai_network + + backend: + container_name: ai_backend + build: + context: ../oba_ai_service + ports: + - "8000:8000" + env_file: + - ../oba_ai_service/.env + networks: + - ai_network + command: [ + "uvicorn", + "app:app", + "--host", "0.0.0.0", + "--port", "8000", + "--reload" + ] + + ai_mongo: + image: mongo:7.0 + container_name: ai_mongo + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + environment: + MONGO_INITDB_DATABASE: OneBitArticle + networks: + - ai_network + +networks: + ai_network: + driver: bridge + +volumes: + mongo_data: From 9c6a50ac3ab9cb4960d841748cbf2844f10b38de Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:12 +0900 Subject: [PATCH 051/198] =?UTF-8?q?ON-79=20server/.env.example=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/.env.example | 25 ----- server/.gitignore | 58 ----------- server/application.yml | 74 +++++++++++--- .../server/common/jwt/JwtTokenProvider.java | 24 ----- .../server/config/EnvVarPostProcessor.java | 4 + .../main/resources/META-INF/spring.factories | 0 .../src/main/resources/application-prod.yml | 67 ------------- .../service/CustomOAuth2UserServiceTest.java | 97 ------------------- .../server/service/TestOAuth2Utils.java | 22 ----- 9 files changed, 65 insertions(+), 306 deletions(-) delete mode 100644 server/.env.example delete mode 100644 server/.gitignore delete mode 100644 server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java create mode 100644 server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java create mode 100644 server/src/main/resources/META-INF/spring.factories delete mode 100644 server/src/main/resources/application-prod.yml delete mode 100644 server/src/test/java/oba/backend/server/service/CustomOAuth2UserServiceTest.java delete mode 100644 server/src/test/java/oba/backend/server/service/TestOAuth2Utils.java diff --git a/server/.env.example b/server/.env.example deleted file mode 100644 index 8efa412..0000000 --- a/server/.env.example +++ /dev/null @@ -1,25 +0,0 @@ -# Database Config -DB_URL=YOUR_DB_URL -DB_USERNAME=YOUR_DB_USERNAME -DB_PASSWORD=YOUR_DB_PASSWORD - -# MongoDB -MONGODB_URI=YOUR_MONGODB_URI -MONGODB_DATABASE=YOUR_MONGODB_DATABASE - -# JWT -JWT_SECRET=YOUR_JWT_SECRET -JWT_ACCESS_EXP=1800000 -JWT_REFRESH_EXP=604800000 - -# OAuth Google -GOOGLE_CLIENT_ID=YOUR_GOOGLE_ID -GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_SECRET - -# OAuth Kakao -KAKAO_CLIENT_ID=YOUR_KAKAO_ID -KAKAO_CLIENT_SECRET=YOUR_KAKAO_SECRET - -# OAuth Naver -NAVER_CLIENT_ID=YOUR_NAVER_ID -NAVER_CLIENT_SECRET=YOUR_NAVER_SECRET diff --git a/server/.gitignore b/server/.gitignore deleted file mode 100644 index 9d20f01..0000000 --- a/server/.gitignore +++ /dev/null @@ -1,58 +0,0 @@ -HELP.md -.gradle -build/ -!gradle/wrapper/gradle-wrapper.jar -!**/src/main/**/build/ -!**/src/test/**/build/ - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache -bin/ -!**/src/main/**/bin/ -!**/src/test/**/bin/ - -### IntelliJ IDEA ### -.idea/ -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ - -### VS Code ### -.vscode/ - -### Gradle ### -/gradle.properties - -### Logs & Temp ### -*.log -*.tmp -*.swp - -### OS-specific ### -.DS_Store -Thumbs.db - -### 🔒 민감정보 (환경설정) ### -# application 설정 파일 (민감정보 포함 가능) -src/main/resources/application.yml -src/main/resources/application.properties - -# 로컬 환경변수 파일 (.env 등) -.env -.env.local diff --git a/server/application.yml b/server/application.yml index bd8cebd..99ffedd 100644 --- a/server/application.yml +++ b/server/application.yml @@ -1,34 +1,82 @@ -server: - port: 8080 - spring: - application: - name: server + config: + import: "optional:file:.env" + + profiles: + active: prod datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://newdbinstance.cvewiy4wkmyb.ap-northeast-2.rds.amazonaws.com:3306/oba_backend?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: admin - password: obaoba12 + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} data: mongodb: - uri: mongodb://ai_mongo:27017/OneBitArticle + uri: ${MONGODB_URI} + database: OneBitArticle jpa: hibernate: ddl-auto: update - database-platform: org.hibernate.dialect.MySQLDialect show-sql: true properties: hibernate: format_sql: true + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: + - profile + - email + redirect-uri: "{baseUrl}/login/oauth2/code/google" + authorization-grant-type: authorization_code + + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/kakao" + scope: + - profile_nickname + - profile_image + - account_email + + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/naver" + scope: + - name + - email + + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + jwt: secret: ${JWT_SECRET} - access-token-expiration-ms: 1800000 - refresh-token-expiration-ms: 604800000 ai: server: - url: http://ai_backend:8000 + url: ${AI_SERVER_URL} + +server: + port: 8080 diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java b/server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java deleted file mode 100644 index 33d0245..0000000 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtTokenProvider.java +++ /dev/null @@ -1,24 +0,0 @@ -package oba.backend.server.common.jwt; - -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.util.Date; - -@Component -public class JwtTokenProvider { - - @Value("${jwt.secret}") - private String secret; - - public String generateToken(Long userId) { - return Jwts.builder() - .setSubject(String.valueOf(userId)) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + 1000L * 60 * 60 * 24 * 7)) // 7일 - .signWith(SignatureAlgorithm.HS256, secret) - .compact(); - } -} diff --git a/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java b/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java new file mode 100644 index 0000000..6a84944 --- /dev/null +++ b/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java @@ -0,0 +1,4 @@ +package oba.backend.server.config; + +public class EnvVarPostProcessor { +} diff --git a/server/src/main/resources/META-INF/spring.factories b/server/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..e69de29 diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml deleted file mode 100644 index 69085c3..0000000 --- a/server/src/main/resources/application-prod.yml +++ /dev/null @@ -1,67 +0,0 @@ -spring: - config: - import: optional:file:.env - - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://newdbinstance.cvewiy4wkmyb.ap-northeast-2.rds.amazonaws.com:3306/oba_backend?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: admin - password: obaoba12 - - data: - mongodb: - uri: ${MONGODB_URI} - database: OneBitArticle - - security: - oauth2: - client: - registration: - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/google" - scope: - - profile - - email - - kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET} - client-authentication-method: client_secret_post - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/kakao" - scope: - - profile_nickname - - profile_image - - account_email - - naver: - client-id: ${NAVER_CLIENT_ID} - client-secret: ${NAVER_CLIENT_SECRET} - client-authentication-method: client_secret_post - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/naver" - scope: - - name - - email - - provider: - kakao: - authorization-uri: https://kauth.kakao.com/oauth/authorize - token-uri: https://kauth.kakao.com/oauth/token - user-info-uri: https://kapi.kakao.com/v2/user/me - user-name-attribute: id - - naver: - authorization-uri: https://nid.naver.com/oauth2.0/authorize - token-uri: https://nid.naver.com/oauth2.0/token - user-info-uri: https://openapi.naver.com/v1/nid/me - user-name-attribute: response - -jwt: - secret: ${JWT_SECRET} - -server: - port: 8080 diff --git a/server/src/test/java/oba/backend/server/service/CustomOAuth2UserServiceTest.java b/server/src/test/java/oba/backend/server/service/CustomOAuth2UserServiceTest.java deleted file mode 100644 index 5d39d69..0000000 --- a/server/src/test/java/oba/backend/server/service/CustomOAuth2UserServiceTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package oba.backend.server.service; - -import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.UserRepository; -import oba.backend.server.domain.user.ProviderInfo; -import oba.backend.server.domain.user.Role; -import oba.backend.server.security.oauth.CustomOAuth2UserService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2User; - -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -class CustomOAuth2UserServiceTest { - - private UserRepository userRepository; - private CustomOAuth2UserService customOAuth2UserService; - - @BeforeEach - void setUp() { - userRepository = mock(UserRepository.class); - customOAuth2UserService = new CustomOAuth2UserService(userRepository); - } - - @Test - void 새로운_사용자가_DB에_저장된다() { - // given - Map attributes = Map.of( - "sub", "1234567890", - "email", "test@example.com", - "name", "테스트 유저", - "picture", "http://test.com/profile.png" - ); - - // 최소 ROLE_USER 권한 부여 (IllegalArgumentException 방지) - OAuth2User oAuth2User = new DefaultOAuth2User( - Set.of(new SimpleGrantedAuthority("ROLE_USER")), - attributes, - "sub" - ); - assertThat(oAuth2User.getAttributes().get("email")).isEqualTo("test@example.com"); - - OAuth2UserRequest userRequest = mock(OAuth2UserRequest.class); - var clientRegistration = TestOAuth2Utils.createClientRegistration("google"); - when(userRequest.getClientRegistration()).thenReturn(clientRegistration); - - // super.loadUser() Mocking - CustomOAuth2UserService service = new CustomOAuth2UserService(userRepository) { - @Override - public OAuth2User loadUser(OAuth2UserRequest ignored) { - String registrationId = "google"; - - String id = (String) attributes.get("sub"); - String email = (String) attributes.get("email"); - String name = (String) attributes.get("name"); - String picture = (String) attributes.get("picture"); - - User user = userRepository.findByIdentifier(id) - .orElse(User.builder() - .identifier(id) - .provider(ProviderInfo.valueOf(registrationId.toUpperCase())) - .role(Role.USER) - .build()); - - user.updateInfo(email, name, picture); - userRepository.save(user); - - return oAuth2User; - } - }; - - when(userRepository.findByIdentifier("1234567890")).thenReturn(Optional.empty()); - - // when - service.loadUser(userRequest); - - // then - ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); - verify(userRepository, times(1)).save(captor.capture()); - User savedUser = captor.getValue(); - - assertThat(savedUser.getIdentifier()).isEqualTo("1234567890"); - assertThat(savedUser.getEmail()).isEqualTo("test@example.com"); - assertThat(savedUser.getName()).isEqualTo("테스트 유저"); - assertThat(savedUser.getProvider()).isEqualTo(ProviderInfo.GOOGLE); - assertThat(savedUser.getRole()).isEqualTo(Role.USER); - } -} diff --git a/server/src/test/java/oba/backend/server/service/TestOAuth2Utils.java b/server/src/test/java/oba/backend/server/service/TestOAuth2Utils.java deleted file mode 100644 index 256b826..0000000 --- a/server/src/test/java/oba/backend/server/service/TestOAuth2Utils.java +++ /dev/null @@ -1,22 +0,0 @@ -package oba.backend.server.service; - -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.core.AuthorizationGrantType; - -public class TestOAuth2Utils { - - public static ClientRegistration createClientRegistration(String registrationId) { - return ClientRegistration.withRegistrationId(registrationId) - .clientId("test-client-id") - .clientSecret("test-client-secret") - .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") - .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .scope("email", "profile") - .authorizationUri("http://localhost/auth") - .tokenUri("http://localhost/token") - .userInfoUri("http://localhost/userinfo") - .userNameAttributeName("sub") // Google 기본값 - .clientName("Test " + registrationId) - .build(); - } -} From 683d07a8dc38ce5cddc9453caf904a8d426caa09 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:20 +0900 Subject: [PATCH 052/198] =?UTF-8?q?ON-79=20application.yml=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/application.yml | 82 ------------------------------------------ 1 file changed, 82 deletions(-) delete mode 100644 server/application.yml diff --git a/server/application.yml b/server/application.yml deleted file mode 100644 index 99ffedd..0000000 --- a/server/application.yml +++ /dev/null @@ -1,82 +0,0 @@ -spring: - config: - import: "optional:file:.env" - - profiles: - active: prod - - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} - - data: - mongodb: - uri: ${MONGODB_URI} - database: OneBitArticle - - jpa: - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - format_sql: true - - security: - oauth2: - client: - registration: - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - scope: - - profile - - email - redirect-uri: "{baseUrl}/login/oauth2/code/google" - authorization-grant-type: authorization_code - - kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET} - client-authentication-method: client_secret_post - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/kakao" - scope: - - profile_nickname - - profile_image - - account_email - - naver: - client-id: ${NAVER_CLIENT_ID} - client-secret: ${NAVER_CLIENT_SECRET} - client-authentication-method: client_secret_post - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/naver" - scope: - - name - - email - - provider: - kakao: - authorization-uri: https://kauth.kakao.com/oauth/authorize - token-uri: https://kauth.kakao.com/oauth/token - user-info-uri: https://kapi.kakao.com/v2/user/me - user-name-attribute: id - - naver: - authorization-uri: https://nid.naver.com/oauth2.0/authorize - token-uri: https://nid.naver.com/oauth2.0/token - user-info-uri: https://openapi.naver.com/v1/nid/me - user-name-attribute: response - -jwt: - secret: ${JWT_SECRET} - -ai: - server: - url: ${AI_SERVER_URL} - -server: - port: 8080 From e93f23dfbdf0bca5c62fbc23a447c668ca62cadd Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:25 +0900 Subject: [PATCH 053/198] =?UTF-8?q?ON-79=20EnvVarPostProcessor=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=EC=B6=94=EA=B0=80=20-=20.env=20=EC=A1=B0=EA=B8=B0?= =?UTF-8?q?=20=EB=A1=9C=EB=94=A9=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/config/EnvVarPostProcessor.java | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java b/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java index 6a84944..3d160cc 100644 --- a/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java +++ b/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java @@ -1,4 +1,55 @@ -package oba.backend.server.config; +package oba.backend.server.config.env; -public class EnvVarPostProcessor { +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.Ordered; +import org.springframework.core.io.ClassPathResource; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.util.*; + +public class EnvVarPostProcessor implements EnvironmentPostProcessor, Ordered { + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, org.springframework.boot.SpringApplication application) { + try { + var resource = new ClassPathResource(".env"); + if (!resource.exists()) return; + + Map map = new HashMap<>(); + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(resource.getInputStream()))) { + + String line; + while ((line = reader.readLine()) != null) { + // 공백 제거 + BOM 제거 + line = line.replace("\uFEFF", "").trim(); + + if (line.isEmpty() || line.startsWith("#")) continue; + if (!line.contains("=")) continue; + + String[] parts = line.split("=", 2); + + String key = parts[0].replace("\r", "").trim(); + String value = parts[1].replace("\r", "").trim(); + + map.put(key, value); + } + } + + environment.getPropertySources() + .addFirst(new MapPropertySource("customEnvVars", map)); + + } catch (Exception e) { + System.out.println("EnvVarPostProcessor error: " + e.getMessage()); + } + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } } From 00505fb8011f60982691c4feaa2921272d46dd78 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:28 +0900 Subject: [PATCH 054/198] =?UTF-8?q?ON-79=20spring.factories=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20-=20EnvVarPostProcessor=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/resources/META-INF/spring.factories | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/main/resources/META-INF/spring.factories b/server/src/main/resources/META-INF/spring.factories index e69de29..9ddf9c1 100644 --- a/server/src/main/resources/META-INF/spring.factories +++ b/server/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +oba.backend.server.config.env.EnvVarPostProcessor From f6e1c8be603ac79bdf652332407c9fefeca61ad3 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:35 +0900 Subject: [PATCH 055/198] =?UTF-8?q?ON-79=20.gitignore=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=20-=20=EB=AF=BC=EA=B0=90=EC=A0=95=EB=B3=B4=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=ED=8C=8C=EC=9D=BC=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index b8b7680..0586e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,81 @@ -# Local configuration files -application-local.properties +# Global Ignore + +HELP.md +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +# STS / Eclipse +.apt_generated +.classpath +.factorypath +.project +.settings/ +.springBeans/ +.sts4-cache/ +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +# IntelliJ IDEA +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +# VS Code +.vscode/ + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# Gradle +gradle-app.setting +/gradle.properties + +# Logs & Temp +logs/ +*.log +*.tmp +*.swp + +# OS-specific +.DS_Store +Thumbs.db + +# Docker +/docker-data/ +/docker-volume/ +/docker/volumes/ + +# Build artifacts (server module) +/server/build/ +/server/.gradle/ +/server/.idea/ + +# Security: Sensitive Config Files + +# 루트 .env .env +.env.* + +# server 내부 src/main/resources/.env +server/src/main/resources/.env + +# application-local*, application-prod* (민감정보) +**/application-local.yml +**/application-local.properties +**/application-prod.yml +**/application-prod.properties +# Unique Project Settings +*.pid From 91107b6d4e15f2aff21e0c97eba084706ac52ca4 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:37 +0900 Subject: [PATCH 056/198] =?UTF-8?q?ON-79=20docker-compose.yml=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EA=B5=AC=EC=A1=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=EC=84=B1=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 496323c..89ac353 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,15 +9,13 @@ services: - ./server/.env environment: SPRING_PROFILES_ACTIVE: prod - SPRING_DATA_MONGODB_URI: mongodb://ai_mongo:27017/OneBitArticle - FASTAPI_URL: "http://ai_backend:8000/generate_daily_gpt_results" depends_on: - - backend - - ai_mongo # MongoDB 의존성 추가 + - ai_backend + - ai_mongo networks: - ai_network - backend: + ai_backend: container_name: ai_backend build: context: ../oba_ai_service @@ -31,8 +29,7 @@ services: "uvicorn", "app:app", "--host", "0.0.0.0", - "--port", "8000", - "--reload" + "--port", "8000" ] ai_mongo: From 5026439482db7731364ae8c2ddb99d43db441641 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:42 +0900 Subject: [PATCH 057/198] =?UTF-8?q?ON-79=20JwtAuthenticationFilter=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20Access/Refresh=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/jwt/JwtAuthenticationFilter.java | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java index c79bbaf..fe2f8eb 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java @@ -24,34 +24,61 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - // Access Token 먼저 꺼내오기 (쿠키에서) - String token = resolveTokenFromCookies(request); - if (token != null && jwtProvider.validateToken(token)) { - var claims = jwtProvider.getClaims(token); + String accessToken = resolveAccessToken(request); + String refreshToken = getCookie(request, "refresh_token"); - // Refresh Token이면 인증 불가 → 그냥 다음 필터로 - if ("refresh".equals(claims.get("type"))) { + // Access 정상 → 인증 설정 + if (accessToken != null && jwtProvider.validateToken(accessToken)) { + authenticate(accessToken); + filterChain.doFilter(request, response); + return; + } + + // Access 만료 + Refresh 정상 → Access 재발급 + if (refreshToken != null && jwtProvider.validateToken(refreshToken)) { + + var claims = jwtProvider.getClaims(refreshToken); + if (!"refresh".equals(claims.get("type"))) { filterChain.doFilter(request, response); return; } - // 3. Access Token이면 SecurityContext에 인증정보 저장 - Authentication authentication = jwtProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); + String username = claims.getSubject(); + String newAccessToken = jwtProvider.createAccessToken(username); + + Cookie cookie = new Cookie("access_token", newAccessToken); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge(60 * 30); + response.addCookie(cookie); + + authenticate(newAccessToken); } filterChain.doFilter(request, response); } - /** - * AccessToken을 HttpOnly 쿠키에서 가져오기 - */ - private String resolveTokenFromCookies(HttpServletRequest request) { + private String resolveAccessToken(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + + if (header != null && header.startsWith("Bearer ")) { + return header.substring(7); + } + + return getCookie(request, "access_token"); + } + + private void authenticate(String token) { + Authentication auth = jwtProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + private String getCookie(HttpServletRequest request, String name) { if (request.getCookies() == null) return null; return Arrays.stream(request.getCookies()) - .filter(cookie -> "access_token".equals(cookie.getName())) + .filter(c -> name.equals(c.getName())) .map(Cookie::getValue) .findFirst() .orElse(null); From 1cc4d61d9bb64de1637b95996162998697ca4fe3 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:44 +0900 Subject: [PATCH 058/198] =?UTF-8?q?ON-79=20JwtProvider=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20BASE64=20=ED=82=A4=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/common/jwt/JwtProvider.java | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java index 6cbf47e..6661919 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java @@ -12,7 +12,6 @@ import org.springframework.stereotype.Component; import javax.crypto.SecretKey; -import java.util.Collections; import java.util.Date; import java.util.List; @@ -20,33 +19,40 @@ public class JwtProvider { private final SecretKey key; - private final long accessTokenValidity; // AccessToken 유효기간 - private final long refreshTokenValidity; // RefreshToken 유효기간 + private final long accessTokenValidity; + private final long refreshTokenValidity; - // application.properties / 환경변수에서 값 주입 public JwtProvider( @Value("${jwt.secret}") String secret, - @Value("${jwt.access-token-expiration-ms:1800000}") long accessTokenValidity, - @Value("${jwt.refresh-token-expiration-ms:604800000}") long refreshTokenValidity + @Value("${jwt.access-token-expiration-ms}") long accessTokenValidity, + @Value("${jwt.refresh-token-expiration-ms}") long refreshTokenValidity ) { + // BASE64 decode (반드시 Base64 로 인코딩 후 .env 에 저장해야 함) this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); this.accessTokenValidity = accessTokenValidity; this.refreshTokenValidity = refreshTokenValidity; } - // 토큰 생성 (Access, Refresh 동시 발급) public TokenResponse generateToken(Authentication authentication) { String accessToken = createToken(authentication.getName(), "access", accessTokenValidity); String refreshToken = createToken(authentication.getName(), "refresh", refreshTokenValidity); return new TokenResponse(accessToken, refreshToken); } - private String createToken(String subject, String type, long validity) { + public String createAccessToken(String username) { + return createToken(username, "access", accessTokenValidity); + } + + public String createRefreshToken(String username) { + return createToken(username, "refresh", refreshTokenValidity); + } + + private String createToken(String username, String type, long validity) { Date now = new Date(); Date expiry = new Date(now.getTime() + validity); return Jwts.builder() - .setSubject(subject) + .setSubject(username) .setIssuedAt(now) .setExpiration(expiry) .claim("type", type) @@ -54,34 +60,26 @@ private String createToken(String subject, String type, long validity) { .compact(); } - // 토큰 검증 public boolean validateToken(String token) { try { - Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token); + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); return true; - } catch (JwtException | IllegalArgumentException e) { + } catch (Exception e) { return false; } } - // Authentication 추출 + public Claims getClaims(String token) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + } + public Authentication getAuthentication(String token) { Claims claims = getClaims(token); String username = claims.getSubject(); + var authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + User principal = new User(username, "", authorities); return new UsernamePasswordAuthenticationToken(principal, token, authorities); } - - // Claims 가져오기 - public Claims getClaims(String token) { - return Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); - } } From 4cc69591ad70a85fc3ba8df1804bd7675396a9ee Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:49 +0900 Subject: [PATCH 059/198] =?UTF-8?q?ON-79=20SecurityConfig=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20JWT=20=ED=95=84=ED=84=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/config/SecurityConfig.java | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/server/src/main/java/oba/backend/server/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/config/SecurityConfig.java index 3636169..bf5e4b0 100644 --- a/server/src/main/java/oba/backend/server/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/config/SecurityConfig.java @@ -1,55 +1,57 @@ package oba.backend.server.config; import lombok.RequiredArgsConstructor; -import oba.backend.server.security.oauth.CustomOAuth2UserService; -import oba.backend.server.security.oauth.OAuth2SuccessHandler; +import oba.backend.server.common.jwt.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; 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.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; + +import java.util.List; @Configuration -@EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { - private final CustomOAuth2UserService customOAuth2UserService; - private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final JwtAuthenticationFilter jwtFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf(AbstractHttpConfigurer::disable) - .cors(Customizer.withDefaults()) + .cors(c -> c.configurationSource(req -> { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOrigins(List.of( + "http://localhost:3000" + )); + config.setAllowedHeaders(List.of("*")); + config.setAllowedMethods(List.of("*")); + return config; + })) + .csrf(csrf -> csrf.disable()) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .logout(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/", - "/auth/**", - "/oauth2/**", - "/login**", - "/articles/**", - "/category/**" - ).permitAll() + .requestMatchers("/auth/**", "/oauth2/**", "/login/**").permitAll() + .requestMatchers(HttpMethod.GET, "/public/**").permitAll() .anyRequest().authenticated() ) - .oauth2Login(o -> o - .authorizationEndpoint(a -> a.baseUri("/oauth2/authorization")) - .redirectionEndpoint(r -> r.baseUri("/oauth2/callback/*")) - .userInfoEndpoint(u -> u.userService(customOAuth2UserService)) - .successHandler(oAuth2SuccessHandler) - ); + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) + throws Exception { + return config.getAuthenticationManager(); + } } From a848d168a729e17f99fb3d969e6f2e2519879943 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:51 +0900 Subject: [PATCH 060/198] =?UTF-8?q?ON-79=20MobileAuthController=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20OAuth=20?= =?UTF-8?q?=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/controller/MobileAuthController.java | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/server/src/main/java/oba/backend/server/controller/MobileAuthController.java b/server/src/main/java/oba/backend/server/controller/MobileAuthController.java index 46497b4..4af22d2 100644 --- a/server/src/main/java/oba/backend/server/controller/MobileAuthController.java +++ b/server/src/main/java/oba/backend/server/controller/MobileAuthController.java @@ -39,18 +39,14 @@ public class MobileAuthController { @PostMapping("/google") public ResponseEntity googleLogin(@RequestBody Map body) { - String idToken = body.get("idToken"); - var payload = googleVerifier.verify(idToken); + var payload = googleVerifier.verify(body.get("idToken")); + String identifier = "google:" + payload.getSubject(); - return ResponseEntity.ok( - processLogin( - "google", - payload.getSubject(), - payload.getEmail(), - (String) payload.get("name"), - (String) payload.get("picture") - ) + Authentication auth = new UsernamePasswordAuthenticationToken( + identifier, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) ); + + return ResponseEntity.ok(jwtProvider.generateToken(auth)); } /** From 249b72104b760f546b22c48de2c7302fec089edd Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:54 +0900 Subject: [PATCH 061/198] =?UTF-8?q?ON-79=20ArticleDetailResponse=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/oba/backend/server/dto/ArticleDetailResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java index 590bcfb..5c84d02 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java +++ b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java @@ -2,7 +2,7 @@ import lombok.Builder; import lombok.Data; -import oba.backend.server.domain.mongo.GptDocument; +import oba.backend.server.entity.mongo.GptDocument; import oba.backend.server.entity.mysql.Article; import java.util.List; From 1d05ecbfe595c4c5435f354d637a2334d428bdb8 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:57 +0900 Subject: [PATCH 062/198] =?UTF-8?q?ON-79=20GptDocument=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20Mongo=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/oba/backend/server/entity/mongo/GptDocument.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java b/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java index 8b66ad4..22f5daa 100644 --- a/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java +++ b/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.mongo; +package oba.backend.server.entity.mongo; import lombok.Data; import org.springframework.data.annotation.Id; From f189688011cb5605b4128a7b35d8646c705f2063 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:25:59 +0900 Subject: [PATCH 063/198] =?UTF-8?q?ON-79=20GptMongoRepository=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/repository/mongo/GptMongoRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java b/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java index 6d6fed8..d504b17 100644 --- a/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java +++ b/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java @@ -1,6 +1,6 @@ package oba.backend.server.repository.mongo; -import oba.backend.server.domain.mongo.GptDocument; +import oba.backend.server.entity.mongo.GptDocument; import org.springframework.data.mongodb.repository.MongoRepository; import java.util.Optional; From 592875c352c63edeafa3b9ff7de0af084d2f4573 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:26:03 +0900 Subject: [PATCH 064/198] =?UTF-8?q?ON-79=20GoogleVerifier=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EA=B5=AC=EA=B8=80=20ID=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/security/GoogleVerifier.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/security/GoogleVerifier.java b/server/src/main/java/oba/backend/server/security/GoogleVerifier.java index e0b4e97..8b845a2 100644 --- a/server/src/main/java/oba/backend/server/security/GoogleVerifier.java +++ b/server/src/main/java/oba/backend/server/security/GoogleVerifier.java @@ -14,11 +14,15 @@ @RequiredArgsConstructor public class GoogleVerifier { - @Value("${google.client-id}") + @Value("${GOOGLE_CLIENT_ID}") private String googleClientId; public GoogleIdToken.Payload verify(String idTokenString) { try { + if (googleClientId == null || googleClientId.isBlank()) { + throw new IllegalStateException("GOOGLE_CLIENT_ID is missing. Check your .env file."); + } + GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder( new NetHttpTransport(), new GsonFactory() From b6d41081cbc47b41ef0d07d6a719b723052d550f Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:26:06 +0900 Subject: [PATCH 065/198] =?UTF-8?q?ON-79=20OAuth2SuccessHandler=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20JWT=20=EB=B0=9C=EA=B8=89=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=EC=85=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/oauth/OAuth2SuccessHandler.java | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java b/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java index 83ce70b..a5f92f3 100644 --- a/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java +++ b/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java @@ -1,13 +1,14 @@ package oba.backend.server.security.oauth; +import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import oba.backend.server.security.jwt.JwtTokenProvider; +import oba.backend.server.common.jwt.JwtProvider; import oba.backend.server.security.oauth.dto.CustomOAuth2User; +import org.springframework.stereotype.Component; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; -import org.springframework.stereotype.Component; import java.io.IOException; @@ -15,26 +16,20 @@ @RequiredArgsConstructor public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - private final JwtTokenProvider jwtTokenProvider; + private final JwtProvider jwtProvider; @Override - public void onAuthenticationSuccess( - HttpServletRequest request, - HttpServletResponse response, - Authentication authentication - ) throws IOException { - - CustomOAuth2User oAuthUser = (CustomOAuth2User) authentication.getPrincipal(); - String jwt = jwtTokenProvider.generateToken(oAuthUser.getUserId()); - - // 앱에서 전달한 redirect_uri 받기 - String redirectUri = request.getParameter("redirect_uri"); + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) + throws IOException, ServletException { - // 없다면 기본값 - if (redirectUri == null) redirectUri = "myapp://oauth"; + CustomOAuth2User user = (CustomOAuth2User) authentication.getPrincipal(); + String identifier = "google:" + user.getUserId(); // 실제 provider + id 로 구성 - String targetUrl = redirectUri + "?token=" + jwt; + String access = jwtProvider.createAccessToken(identifier); + String refresh = jwtProvider.createRefreshToken(identifier); - getRedirectStrategy().sendRedirect(request, response, targetUrl); + response.sendRedirect("/login/success?access=" + access + "&refresh=" + refresh); } } From efc52e23ebdc414d774426fab01df0021ff61c90 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:26:08 +0900 Subject: [PATCH 066/198] =?UTF-8?q?ON-79=20ArticleDetailService=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/service/ArticleDetailService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java index dd35fc3..539914b 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java +++ b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java @@ -1,7 +1,7 @@ package oba.backend.server.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.mongo.GptDocument; +import oba.backend.server.entity.mongo.GptDocument; import oba.backend.server.entity.mysql.Article; import oba.backend.server.entity.mysql.ArticleCategory; import oba.backend.server.repository.mongo.GptMongoRepository; From a28607984b6ceca291f891046988a0610310b667 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:26:10 +0900 Subject: [PATCH 067/198] =?UTF-8?q?ON-79=20ArticleSummaryService=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/service/ArticleSummaryService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java index 0a90bfe..7bddb75 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java +++ b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java @@ -1,7 +1,6 @@ package oba.backend.server.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.mongo.GptDocument; import oba.backend.server.repository.mongo.GptMongoRepository; import oba.backend.server.repository.mysql.ArticleRepository; import oba.backend.server.dto.ArticleSummaryResponse; @@ -25,7 +24,7 @@ public List getLatestArticles(int limit) { return latest.stream().map(a -> { - GptDocument doc = gptMongoRepository.findByArticleId(a.getArticleId()) + oba.backend.server.entity.mongo.GptDocument doc = gptMongoRepository.findByArticleId(a.getArticleId()) .orElse(null); List bullets = new ArrayList<>(); From 5b06cfa7529b85907de4331b9d073956fa5a4dc9 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 14:26:22 +0900 Subject: [PATCH 068/198] =?UTF-8?q?ON-79=20=EC=8B=A0=EA=B7=9C=20applicatio?= =?UTF-8?q?n.yml=20=EC=83=9D=EC=84=B1=20-=20=ED=99=98=EA=B2=BD=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EA=B8=B0=EB=B0=98=20=EC=84=A4=EC=A0=95=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/resources/application.yml | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 server/src/main/resources/application.yml diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml new file mode 100644 index 0000000..c052783 --- /dev/null +++ b/server/src/main/resources/application.yml @@ -0,0 +1,100 @@ +spring: + application: + name: oba-backend + + profiles: + active: prod + + # resources/.env 자동 로딩은 EnvVarPostProcessor 에서 처리하므로 여기선 불필요 + # config: + # import: optional:file:.env + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + data: + mongodb: + uri: ${MONGODB_URI} + database: OneBitArticle + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + + security: + oauth2: + client: + + provider: + + google: + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + user-name-attribute: sub + + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + + registration: + + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/google" + scope: + - profile + - email + provider: google + + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/kakao" + scope: + - profile_nickname + - profile_image + - account_email + provider: kakao + + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/naver" + scope: + - name + - email + provider: naver + +jwt: + secret: ${JWT_SECRET} + access-token-expiration-ms: 1800000 # 30 min + refresh-token-expiration-ms: 604800000 # 7 days + +ai: + server: + url: ${AI_SERVER_URL} + +server: + port: 8080 From 669b6237b0beaa58e2a0db783706a3b1ef4a2751 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 16:49:17 +0900 Subject: [PATCH 069/198] =?UTF-8?q?docker-compose=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 89ac353..a472a95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,11 @@ services: ai_backend: container_name: ai_backend build: - context: ../oba_ai_service + context: ../oba_AI ports: - "8000:8000" env_file: - - ../oba_ai_service/.env + - ../oba_AI/.env networks: - ai_network command: [ From 9d402c71a6ac3aa1f8254dafa89fe9319ba1d124 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Tue, 25 Nov 2025 17:37:55 +0900 Subject: [PATCH 070/198] =?UTF-8?q?doMySQ=EB=93=9C=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/build.gradle b/server/build.gradle index f2f7467..3c84eff 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -58,6 +58,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-quartz' implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + + implementation 'mysql:mysql-connector-j:8.2.0' } From 48fb9b3c20aebbdd2aee2f036357d012f7c3b3b8 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 27 Nov 2025 17:08:43 +0900 Subject: [PATCH 071/198] =?UTF-8?q?gradle=EC=88=98=EC=A0=95,=20application?= =?UTF-8?q?-prod=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/build.gradle b/server/build.gradle index 3c84eff..28a2bfe 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -59,7 +59,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' - implementation 'mysql:mysql-connector-j:8.2.0' + runtimeOnly 'com.mysql:mysql-connector-j' } From 340c4bfd324ba98919d6d3cb427dac8659f83690 Mon Sep 17 00:00:00 2001 From: ByunJihun Date: Thu, 27 Nov 2025 17:18:59 +0900 Subject: [PATCH 072/198] ON-79 add application-prod.yml for prod profile --- .../src/main/resources/application-prod.yml | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 server/src/main/resources/application-prod.yml diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml new file mode 100644 index 0000000..5cfc148 --- /dev/null +++ b/server/src/main/resources/application-prod.yml @@ -0,0 +1,43 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + data: + mongodb: + uri: ${MONGODB_URI} + database: OneBitArticle + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + +security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + +jwt: + secret: ${JWT_SECRET} + +ai: + server: + url: ${AI_SERVER_URL} + +server: + port: 8080 \ No newline at end of file From e649e829ae16375649dd91efedc283b9bfd66fbf Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:15:01 +0900 Subject: [PATCH 073/198] =?UTF-8?q?ON-79=20MyQuizController=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=80=B4?= =?UTF-8?q?=EC=A6=88=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/controller/MyQuizController.java | 42 +++++++++++++++++++ .../server/controller/QuizController.java | 4 ++ .../controller/QuizResultController.java | 4 ++ .../server/controller/TokenController.java | 4 ++ .../server/controller/UserController.java | 4 ++ .../quiz/IncorrectArticlesRepository.java | 4 ++ .../domain/quiz/IncorrectQuizRepository.java | 4 ++ .../server/domain/quiz/QuizResultRequest.java | 4 ++ .../server/domain/quiz/QuizResultService.java | 4 ++ .../backend/server/dto/QuizSubmitRequest.java | 4 ++ .../backend/server/entity/mysql/Article.java | 33 --------------- .../server/entity/mysql/ArticleCategory.java | 13 ------ .../backend/server/entity/mysql/Category.java | 17 -------- .../mysql/ArticleCategoryRepository.java | 11 ----- .../repository/mysql/ArticleRepository.java | 14 ------- .../repository/mysql/CategoryRepository.java | 6 --- .../oauth/dto/SolvedArticleResponse.java | 4 ++ .../oauth/dto/WrongArticleResponse.java | 4 ++ .../server/service/QuizQueryService.java | 4 ++ .../backend/server/service/QuizService.java | 4 ++ 20 files changed, 94 insertions(+), 94 deletions(-) create mode 100644 server/src/main/java/oba/backend/server/controller/MyQuizController.java create mode 100644 server/src/main/java/oba/backend/server/controller/QuizController.java create mode 100644 server/src/main/java/oba/backend/server/controller/QuizResultController.java create mode 100644 server/src/main/java/oba/backend/server/controller/TokenController.java create mode 100644 server/src/main/java/oba/backend/server/controller/UserController.java create mode 100644 server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java create mode 100644 server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java create mode 100644 server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java create mode 100644 server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java create mode 100644 server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java delete mode 100644 server/src/main/java/oba/backend/server/entity/mysql/Article.java delete mode 100644 server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java delete mode 100644 server/src/main/java/oba/backend/server/entity/mysql/Category.java delete mode 100644 server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java delete mode 100644 server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java delete mode 100644 server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java create mode 100644 server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java create mode 100644 server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java create mode 100644 server/src/main/java/oba/backend/server/service/QuizQueryService.java create mode 100644 server/src/main/java/oba/backend/server/service/QuizService.java diff --git a/server/src/main/java/oba/backend/server/controller/MyQuizController.java b/server/src/main/java/oba/backend/server/controller/MyQuizController.java new file mode 100644 index 0000000..4389c94 --- /dev/null +++ b/server/src/main/java/oba/backend/server/controller/MyQuizController.java @@ -0,0 +1,42 @@ +package oba.backend.server.controller; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.common.jwt.JwtProvider; +import oba.backend.server.domain.quiz.dto.SolvedArticleResponse; +import oba.backend.server.domain.quiz.dto.WrongArticleResponse; +import oba.backend.server.service.QuizQueryService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/my") +@RequiredArgsConstructor +public class MyQuizController { + + private final QuizQueryService quizQueryService; + private final JwtProvider jwtProvider; + + private Long extractUserId(String token) { + String jwt = token.replace("Bearer ", ""); + String subject = jwtProvider.getClaims(jwt).getSubject(); // google:123 + return Long.valueOf(subject.split(":")[1]); + } + + @GetMapping("/solved") + public ResponseEntity> solved( + @RequestHeader("Authorization") String token + ) { + Long userId = extractUserId(token); + return ResponseEntity.ok(quizQueryService.getSolved(userId)); + } + + @GetMapping("/wrong") + public ResponseEntity> wrong( + @RequestHeader("Authorization") String token + ) { + Long userId = extractUserId(token); + return ResponseEntity.ok(quizQueryService.getWrong(userId)); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/controller/QuizController.java b/server/src/main/java/oba/backend/server/controller/QuizController.java new file mode 100644 index 0000000..dcc6f1f --- /dev/null +++ b/server/src/main/java/oba/backend/server/controller/QuizController.java @@ -0,0 +1,4 @@ +package oba.backend.server.controller; + +public class QuizController { +} diff --git a/server/src/main/java/oba/backend/server/controller/QuizResultController.java b/server/src/main/java/oba/backend/server/controller/QuizResultController.java new file mode 100644 index 0000000..b96b1d0 --- /dev/null +++ b/server/src/main/java/oba/backend/server/controller/QuizResultController.java @@ -0,0 +1,4 @@ +package oba.backend.server.controller; + +public class QuizResultController { +} diff --git a/server/src/main/java/oba/backend/server/controller/TokenController.java b/server/src/main/java/oba/backend/server/controller/TokenController.java new file mode 100644 index 0000000..f688cb7 --- /dev/null +++ b/server/src/main/java/oba/backend/server/controller/TokenController.java @@ -0,0 +1,4 @@ +package oba.backend.server.controller; + +public class TokenController { +} diff --git a/server/src/main/java/oba/backend/server/controller/UserController.java b/server/src/main/java/oba/backend/server/controller/UserController.java new file mode 100644 index 0000000..e61d17a --- /dev/null +++ b/server/src/main/java/oba/backend/server/controller/UserController.java @@ -0,0 +1,4 @@ +package oba.backend.server.controller; + +public class UserController { +} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java new file mode 100644 index 0000000..e87c02b --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.quiz; + +public class IncorrectArticlesRepository { +} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java new file mode 100644 index 0000000..258afad --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.quiz; + +public class IncorrectQuizRepository { +} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java b/server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java new file mode 100644 index 0000000..fb7c859 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.quiz; + +public class QuizResultRequest { +} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java b/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java new file mode 100644 index 0000000..88a560c --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.quiz; + +public class QuizResultService { +} diff --git a/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java b/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java new file mode 100644 index 0000000..1dadf10 --- /dev/null +++ b/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java @@ -0,0 +1,4 @@ +package oba.backend.server.dto; + +public class QuizSubmitRequest { +} diff --git a/server/src/main/java/oba/backend/server/entity/mysql/Article.java b/server/src/main/java/oba/backend/server/entity/mysql/Article.java deleted file mode 100644 index 4c4bf4f..0000000 --- a/server/src/main/java/oba/backend/server/entity/mysql/Article.java +++ /dev/null @@ -1,33 +0,0 @@ -package oba.backend.server.entity.mysql; - -import jakarta.persistence.*; -import lombok.Getter; - -import java.math.BigDecimal; -import java.time.LocalDateTime; - -@Getter -@Entity -@Table(name = "Articles") -public class Article { - - @Id - @Column(name = "article_id") - private Long articleId; - - private String url; - - @Column(name = "crawling_time") - private LocalDateTime crawlingTime; - - @Column(name = "updated_time") - private LocalDateTime updatedTime; - - @Column(name = "dup_cnt") - private Integer dupCnt; - - private BigDecimal ordering; - - @Column(name = "is_used") - private Boolean isUsed; -} diff --git a/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java b/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java deleted file mode 100644 index 3640b63..0000000 --- a/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategory.java +++ /dev/null @@ -1,13 +0,0 @@ -package oba.backend.server.entity.mysql; - -import jakarta.persistence.*; -import lombok.Getter; - -@Getter -@Entity -@Table(name = "Article_Categories") -public class ArticleCategory { - - @EmbeddedId - private ArticleCategoryId id; -} diff --git a/server/src/main/java/oba/backend/server/entity/mysql/Category.java b/server/src/main/java/oba/backend/server/entity/mysql/Category.java deleted file mode 100644 index d10d0cf..0000000 --- a/server/src/main/java/oba/backend/server/entity/mysql/Category.java +++ /dev/null @@ -1,17 +0,0 @@ -package oba.backend.server.entity.mysql; - -import jakarta.persistence.*; -import lombok.Getter; - -@Getter -@Entity -@Table(name = "Categories") -public class Category { - - @Id - @Column(name = "category_id") - private Integer categoryId; - - @Column(name = "category_name") - private String categoryName; -} diff --git a/server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java b/server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java deleted file mode 100644 index fccc66d..0000000 --- a/server/src/main/java/oba/backend/server/repository/mysql/ArticleCategoryRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package oba.backend.server.repository.mysql; - -import oba.backend.server.entity.mysql.ArticleCategory; -import oba.backend.server.entity.mysql.ArticleCategoryId; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface ArticleCategoryRepository extends JpaRepository { - List findByIdArticleId(Long articleId); -} diff --git a/server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java b/server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java deleted file mode 100644 index 3d2a2d6..0000000 --- a/server/src/main/java/oba/backend/server/repository/mysql/ArticleRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package oba.backend.server.repository.mysql; - -import oba.backend.server.entity.mysql.Article; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -import java.util.List; - -public interface ArticleRepository extends JpaRepository { - - @Query("SELECT a FROM Article a WHERE a.isUsed = TRUE ORDER BY a.crawlingTime DESC") - List
findLatestArticles(Pageable pageable); -} diff --git a/server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java b/server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java deleted file mode 100644 index 78d88c7..0000000 --- a/server/src/main/java/oba/backend/server/repository/mysql/CategoryRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package oba.backend.server.repository.mysql; - -import oba.backend.server.entity.mysql.Category; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CategoryRepository extends JpaRepository {} diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java b/server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java new file mode 100644 index 0000000..568bf8b --- /dev/null +++ b/server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java @@ -0,0 +1,4 @@ +package oba.backend.server.security.oauth.dto; + +public class SolvedArticleResponse { +} diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java b/server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java new file mode 100644 index 0000000..95e0632 --- /dev/null +++ b/server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java @@ -0,0 +1,4 @@ +package oba.backend.server.security.oauth.dto; + +public class WrongArticleResponse { +} diff --git a/server/src/main/java/oba/backend/server/service/QuizQueryService.java b/server/src/main/java/oba/backend/server/service/QuizQueryService.java new file mode 100644 index 0000000..6578fc0 --- /dev/null +++ b/server/src/main/java/oba/backend/server/service/QuizQueryService.java @@ -0,0 +1,4 @@ +package oba.backend.server.service; + +public class QuizQueryService { +} diff --git a/server/src/main/java/oba/backend/server/service/QuizService.java b/server/src/main/java/oba/backend/server/service/QuizService.java new file mode 100644 index 0000000..5cc91fd --- /dev/null +++ b/server/src/main/java/oba/backend/server/service/QuizService.java @@ -0,0 +1,4 @@ +package oba.backend.server.service; + +public class QuizService { +} From bea89376e2d27eb7a71d65b5596812876f5b5eb9 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:15:04 +0900 Subject: [PATCH 074/198] =?UTF-8?q?ON-79=20QuizController=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=ED=80=B4=EC=A6=88=20=EC=A0=9C=EC=B6=9C=20API?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/controller/QuizController.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/server/src/main/java/oba/backend/server/controller/QuizController.java b/server/src/main/java/oba/backend/server/controller/QuizController.java index dcc6f1f..e288463 100644 --- a/server/src/main/java/oba/backend/server/controller/QuizController.java +++ b/server/src/main/java/oba/backend/server/controller/QuizController.java @@ -1,4 +1,24 @@ package oba.backend.server.controller; +import lombok.RequiredArgsConstructor; +import oba.backend.server.dto.QuizSubmitRequest; +import oba.backend.server.service.QuizService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/quiz") +@RequiredArgsConstructor public class QuizController { + + private final QuizService quizService; + + @PostMapping("/submit") + public ResponseEntity submitQuiz( + @RequestHeader("Authorization") String token, + @RequestBody QuizSubmitRequest request + ) { + quizService.submitQuiz(token.replace("Bearer ", ""), request); + return ResponseEntity.ok().build(); + } } From a3c368c9ff65f56ea41e39dfef2edc25f99565d2 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:15:08 +0900 Subject: [PATCH 075/198] =?UTF-8?q?ON-79=20QuizResultController=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=ED=80=B4=EC=A6=88=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=A0=80=EC=9E=A5=20API=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/QuizResultController.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/server/src/main/java/oba/backend/server/controller/QuizResultController.java b/server/src/main/java/oba/backend/server/controller/QuizResultController.java index b96b1d0..2c59c11 100644 --- a/server/src/main/java/oba/backend/server/controller/QuizResultController.java +++ b/server/src/main/java/oba/backend/server/controller/QuizResultController.java @@ -1,4 +1,24 @@ package oba.backend.server.controller; +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.quiz.QuizResultRequest; +import oba.backend.server.domain.quiz.QuizResultService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/quiz-result") +@RequiredArgsConstructor public class QuizResultController { + + private final QuizResultService quizResultService; + + @PostMapping("/save") + public ResponseEntity saveQuizResult( + @RequestHeader("Authorization") String token, + @RequestBody QuizResultRequest request + ) { + quizResultService.saveQuizResult(token.replace("Bearer ", ""), request); + return ResponseEntity.ok().build(); + } } From db6ddda413123533bef0c12ffcdf4e926370bd3c Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:15:11 +0900 Subject: [PATCH 076/198] =?UTF-8?q?ON-79=20TokenController=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=95=A1=EC=84=B8=EC=8A=A4/=EB=A6=AC=ED=94=84?= =?UTF-8?q?=EB=A0=88=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?API=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/controller/TokenController.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/server/src/main/java/oba/backend/server/controller/TokenController.java b/server/src/main/java/oba/backend/server/controller/TokenController.java index f688cb7..c0af926 100644 --- a/server/src/main/java/oba/backend/server/controller/TokenController.java +++ b/server/src/main/java/oba/backend/server/controller/TokenController.java @@ -1,4 +1,38 @@ package oba.backend.server.controller; +import lombok.RequiredArgsConstructor; +import oba.backend.server.common.jwt.JwtProvider; +import oba.backend.server.domain.user.User; +import oba.backend.server.domain.user.UserRepository; +import oba.backend.server.dto.TokenResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor public class TokenController { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + + @PostMapping("/refresh") + public ResponseEntity refresh(@RequestHeader("Authorization") String refreshToken) { + + String token = refreshToken.replace("Bearer ", ""); + + if (!jwtProvider.validateToken(token)) { + return ResponseEntity.status(401).body("Invalid Refresh Token"); + } + + String identifier = jwtProvider.getClaims(token).getSubject(); + + User user = userRepository.findByIdentifier(identifier) + .orElseThrow(() -> new RuntimeException("User not found")); + + String newAccess = jwtProvider.createAccessToken(identifier); + String newRefresh = jwtProvider.createRefreshToken(identifier); + + return ResponseEntity.ok(new TokenResponse(newAccess, newRefresh)); + } } From 2c56e00730528a27a4fb073065d1426057b6c3b5 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:15:15 +0900 Subject: [PATCH 077/198] =?UTF-8?q?ON-79=20UserController=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/controller/UserController.java | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/controller/UserController.java b/server/src/main/java/oba/backend/server/controller/UserController.java index e61d17a..0aba317 100644 --- a/server/src/main/java/oba/backend/server/controller/UserController.java +++ b/server/src/main/java/oba/backend/server/controller/UserController.java @@ -1,4 +1,44 @@ -package oba.backend.server.controller; +package oba.backend.server.security.oauth; +import lombok.RequiredArgsConstructor; +import oba.backend.server.common.jwt.JwtProvider; +import oba.backend.server.domain.user.User; +import oba.backend.server.domain.user.UserRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor public class UserController { + + private final UserRepository userRepository; + private final JwtProvider jwtProvider; + + @GetMapping("/me") + public ResponseEntity me(@RequestHeader("Authorization") String bearer) { + + String token = bearer.replace("Bearer ", ""); + String identifier = jwtProvider.getUserId(token); + + User user = userRepository.findByIdentifier(identifier) + .orElseThrow(); + + // DTO로 반환 + return ResponseEntity.ok(new UserProfileResponse( + user.getIdentifier(), + user.getEmail(), + user.getName(), + user.getPicture(), + user.getProvider().name() + )); + } + + public record UserProfileResponse( + String identifier, + String email, + String name, + String picture, + String provider + ) {} } From dc64e9692dc02e58a45b0f209848e729492a6ed6 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:15:20 +0900 Subject: [PATCH 078/198] =?UTF-8?q?ON-79=20IncorrectArticlesRepository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EC=98=A4=EB=8B=B5=20=EA=B8=B0?= =?UTF-8?q?=EC=82=AC=20=EC=A0=80=EC=9E=A5=20Repository=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/quiz/IncorrectArticlesRepository.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java index e87c02b..2fe6c80 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java @@ -1,4 +1,11 @@ package oba.backend.server.domain.quiz; -public class IncorrectArticlesRepository { +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface IncorrectArticlesRepository extends JpaRepository { + + List findByUserId(Long userId); + + void deleteByUserIdAndArticleId(Long userId, Long articleId); } From 6ed8179ed839c26bef6ebb39d49d604fa872aa2f Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:15:22 +0900 Subject: [PATCH 079/198] =?UTF-8?q?ON-79=20IncorrectQuizRepository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EC=98=A4=EB=8B=B5=20=ED=80=B4?= =?UTF-8?q?=EC=A6=88=20=EC=A0=80=EC=9E=A5=20Repository=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/quiz/IncorrectQuizRepository.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java index 258afad..ab631f2 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java @@ -1,4 +1,11 @@ package oba.backend.server.domain.quiz; -public class IncorrectQuizRepository { +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface IncorrectQuizRepository extends JpaRepository { + + List findByUserId(Long userId); + + void deleteByUserIdAndArticleId(Long userId, Long articleId); } From afeefb4078cac27732839a71a069c2f7bd87dfbb Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:15:25 +0900 Subject: [PATCH 080/198] =?UTF-8?q?ON-79=20QuizResultRequest=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=ED=80=B4=EC=A6=88=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20DTO=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/domain/quiz/QuizResultRequest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java b/server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java index fb7c859..829a01d 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java @@ -1,4 +1,9 @@ package oba.backend.server.domain.quiz; +import lombok.Getter; + +@Getter public class QuizResultRequest { + private Long articleId; + private boolean[] quizResults; // true=정답, false=오답 } From 55d143760e0086c7cc172bcdadc31cac29ee1bf5 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:15:28 +0900 Subject: [PATCH 081/198] =?UTF-8?q?ON-79=20QuizResultService=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=ED=80=B4=EC=A6=88=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/quiz/QuizResultService.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java b/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java index 88a560c..c3b22d2 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java @@ -1,4 +1,57 @@ package oba.backend.server.domain.quiz; +import lombok.RequiredArgsConstructor; +import oba.backend.server.common.jwt.JwtProvider; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor public class QuizResultService { + + private final JwtProvider jwtProvider; + private final IncorrectQuizRepository incorrectQuizRepository; + private final IncorrectArticlesRepository incorrectArticlesRepository; + + @Transactional + public void saveQuizResult(String jwt, QuizResultRequest request) { + + Long userId = Long.parseLong(jwtProvider.getUserId(jwt)); + boolean[] results = request.getQuizResults(); + + // 기존 기록 제거 + incorrectQuizRepository.deleteByUserIdAndArticleId(userId, request.getArticleId()); + + IncorrectQuiz quiz = IncorrectQuiz.builder() + .userId(userId) + .articleId(request.getArticleId()) + .quiz1(results.length > 0 ? results[0] : null) + .quiz2(results.length > 1 ? results[1] : null) + .quiz3(results.length > 2 ? results[2] : null) + .quiz4(results.length > 3 ? results[3] : null) + .quiz5(results.length > 4 ? results[4] : null) + .build(); + + incorrectQuizRepository.save(quiz); + + // 오답 기사 저장 + boolean hasWrong = false; + for (boolean r : results) { + if (!r) { + hasWrong = true; + break; + } + } + + if (hasWrong) { + incorrectArticlesRepository.deleteByUserIdAndArticleId(userId, request.getArticleId()); + + IncorrectArticles article = IncorrectArticles.builder() + .userId(userId) + .articleId(request.getArticleId()) + .build(); + + incorrectArticlesRepository.save(article); + } + } } From 3256bea9dbde9edb3a91a363553711744f61084a Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:15:33 +0900 Subject: [PATCH 082/198] =?UTF-8?q?ON-79=20QuizSubmitRequest=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=ED=80=B4=EC=A6=88=20=EC=A0=9C=EC=B6=9C=20DTO?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/oba/backend/server/dto/QuizSubmitRequest.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java b/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java index 1dadf10..e78a6e4 100644 --- a/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java +++ b/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java @@ -1,4 +1,10 @@ package oba.backend.server.dto; +import lombok.Data; +import java.util.List; + +@Data public class QuizSubmitRequest { + private Long articleId; + private List answers; // quiz1~quiz5 순서 } From 077cd067876c3bd2d172f3516f50879135faff04 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:15:55 +0900 Subject: [PATCH 083/198] =?UTF-8?q?ON-79=20docker-compose.yml=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a472a95..8799ad9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,17 @@ +version: "3.9" + services: spring: container_name: spring_app build: context: ./server ports: - - "8080:8080" + - "9000:9000" env_file: - ./server/.env environment: SPRING_PROFILES_ACTIVE: prod + SERVER_PORT: 9000 # 서버 포트 강제 depends_on: - ai_backend - ai_mongo @@ -23,14 +26,13 @@ services: - "8000:8000" env_file: - ../oba_AI/.env - networks: - - ai_network command: [ - "uvicorn", - "app:app", + "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000" ] + networks: + - ai_network ai_mongo: image: mongo:7.0 @@ -49,4 +51,4 @@ networks: driver: bridge volumes: - mongo_data: + mongo_data: \ No newline at end of file From 2e5196ef71fa26c858d4f0776c141f636614ce27 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:16:00 +0900 Subject: [PATCH 084/198] =?UTF-8?q?ON-79=20Dockerfile=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20-=20=EB=B9=8C=EB=93=9C=20=EB=B0=8F=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/Dockerfile | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index dd06804..08bcc0b 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,19 +1,13 @@ -# ----------- 1단계: Build stage ----------- FROM gradle:8.5-jdk17 AS builder WORKDIR /app - COPY build.gradle settings.gradle ./ COPY gradle ./gradle -RUN gradle dependencies --no-daemon - -COPY . . +RUN gradle dependencies --no-daemon || true +COPY src ./src RUN gradle bootJar --no-daemon -# ----------- 2단계: Run stage ----------- FROM eclipse-temurin:17-jdk WORKDIR /app - COPY --from=builder /app/build/libs/*.jar app.jar - -EXPOSE 8080 +EXPOSE 9000 ENTRYPOINT ["java", "-jar", "app.jar"] From 2b0df4bc6392b801c8534a9d174e5539fdbb6e00 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:16:04 +0900 Subject: [PATCH 085/198] =?UTF-8?q?ON-79=20build.gradle=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EC=84=A4=EC=A0=95=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/build.gradle | 72 +++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/server/build.gradle b/server/build.gradle index 28a2bfe..08bde53 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -1,68 +1,70 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.4' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.5.4' + id 'io.spring.dependency-management' version '1.1.7' } group = 'oba.backend' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'com.google.api-client:google-api-client:2.2.0' + implementation 'com.google.http-client:google-http-client-gson:1.43.3' - // MySQL 라인을 추가 - runtimeOnly 'com.mysql:mysql-connector-j' - // JWT Library (jjwt) - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + /* --- Core --- */ + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - // testDB -> h2 - testRuntimeOnly 'com.h2database:h2' + /* --- Database --- */ + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + runtimeOnly 'com.mysql:mysql-connector-j' - implementation 'org.springframework.boot:spring-boot-starter-security' + /* --- OAuth2 (Google Login) --- */ implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + /* Google API Client 통일 (버전 동일 유지) */ implementation 'com.google.api-client:google-api-client:2.2.0' - implementation 'com.google.oauth-client:google-oauth-client-jetty:1.34.1' implementation 'com.google.http-client:google-http-client-gson:1.43.3' - // 일정 실행 + /* --- JWT --- */ + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + /* --- Scheduler --- */ implementation 'org.springframework.boot:spring-boot-starter-quartz' - implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + /* --- Lombok --- */ + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' + /* --- Test --- */ + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.h2database:h2' } - tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } From 102ca4357bf3adf8dad4fd8a81572befbb398ca6 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:16:08 +0900 Subject: [PATCH 086/198] =?UTF-8?q?ON-79=20JwtAuthenticationFilter=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20JWT=20=EC=9D=B8=EC=A6=9D=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/jwt/JwtAuthenticationFilter.java | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java index fe2f8eb..80c9508 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.util.Arrays; +import java.util.List; @Component @RequiredArgsConstructor @@ -20,25 +21,48 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; + // 🔥 JWT를 적용하지 않을 경로들 + private static final List EXCLUDE_URLS = List.of( + "/articles", + "/auth", + "/oauth2", + "/public", + "/gpt", + "/ai" + ); + + private boolean isExcluded(HttpServletRequest request) { + String uri = request.getRequestURI(); + return EXCLUDE_URLS.stream().anyMatch(uri::startsWith); + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { + FilterChain filterChain) + throws ServletException, IOException { + + // 1️⃣ 허용 경로는 JWT 검증 건너뛰기 (중요!) + if (isExcluded(request)) { + filterChain.doFilter(request, response); + return; + } + // 2️⃣ Access Token 확인 String accessToken = resolveAccessToken(request); String refreshToken = getCookie(request, "refresh_token"); - // Access 정상 → 인증 설정 if (accessToken != null && jwtProvider.validateToken(accessToken)) { authenticate(accessToken); filterChain.doFilter(request, response); return; } - // Access 만료 + Refresh 정상 → Access 재발급 + // 3️⃣ Access 만료 + Refresh 정상 → 재발급 if (refreshToken != null && jwtProvider.validateToken(refreshToken)) { var claims = jwtProvider.getClaims(refreshToken); + if (!"refresh".equals(claims.get("type"))) { filterChain.doFilter(request, response); return; @@ -54,8 +78,12 @@ protected void doFilterInternal(HttpServletRequest request, response.addCookie(cookie); authenticate(newAccessToken); + + filterChain.doFilter(request, response); + return; } + // 4️⃣ 둘 다 없으면 인증 없이 통과 filterChain.doFilter(request, response); } From 9c71d2f8bae658432af902506babf1da123ab2b1 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:16:11 +0900 Subject: [PATCH 087/198] =?UTF-8?q?ON-79=20JwtProvider=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/common/jwt/JwtProvider.java | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java index 6661919..baecfb2 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java @@ -27,27 +27,21 @@ public JwtProvider( @Value("${jwt.access-token-expiration-ms}") long accessTokenValidity, @Value("${jwt.refresh-token-expiration-ms}") long refreshTokenValidity ) { - // BASE64 decode (반드시 Base64 로 인코딩 후 .env 에 저장해야 함) this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); this.accessTokenValidity = accessTokenValidity; this.refreshTokenValidity = refreshTokenValidity; } - public TokenResponse generateToken(Authentication authentication) { - String accessToken = createToken(authentication.getName(), "access", accessTokenValidity); - String refreshToken = createToken(authentication.getName(), "refresh", refreshTokenValidity); - return new TokenResponse(accessToken, refreshToken); - } - + // ---- CREATE TOKEN ---- public String createAccessToken(String username) { - return createToken(username, "access", accessTokenValidity); + return createToken(username, accessTokenValidity); } public String createRefreshToken(String username) { - return createToken(username, "refresh", refreshTokenValidity); + return createToken(username, refreshTokenValidity); } - private String createToken(String username, String type, long validity) { + private String createToken(String username, long validity) { Date now = new Date(); Date expiry = new Date(now.getTime() + validity); @@ -55,11 +49,11 @@ private String createToken(String username, String type, long validity) { .setSubject(username) .setIssuedAt(now) .setExpiration(expiry) - .claim("type", type) .signWith(key, SignatureAlgorithm.HS256) .compact(); } + // ---- VALIDATE ---- public boolean validateToken(String token) { try { Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); @@ -69,10 +63,15 @@ public boolean validateToken(String token) { } } + // ---- PARSE ---- public Claims getClaims(String token) { return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); } + public String getUserId(String token) { + return getClaims(token).getSubject(); + } + public Authentication getAuthentication(String token) { Claims claims = getClaims(token); String username = claims.getSubject(); @@ -82,4 +81,14 @@ public Authentication getAuthentication(String token) { User principal = new User(username, "", authorities); return new UsernamePasswordAuthenticationToken(principal, token, authorities); } + + // ---- NEW: MobileAuthController 에서 필요 ---- + public TokenResponse generateToken(Authentication authentication) { + String userId = authentication.getName(); // subject = userId + + String accessToken = createAccessToken(userId); + String refreshToken = createRefreshToken(userId); + + return new TokenResponse(accessToken, refreshToken); + } } From ed047183aaa4d27bc7edcb057c7e4541b27e408c Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:16:19 +0900 Subject: [PATCH 088/198] =?UTF-8?q?ON-79=20EnvVarPostProcessor=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20.env=20=EA=B2=BD=EB=A1=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/config/EnvVarPostProcessor.java | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java b/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java index 3d160cc..119de3f 100644 --- a/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java +++ b/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java @@ -1,40 +1,43 @@ package oba.backend.server.config.env; +import org.springframework.boot.SpringApplication; import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.Ordered; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.MapPropertySource; -import org.springframework.core.Ordered; -import org.springframework.core.io.ClassPathResource; import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.util.*; +import java.io.File; +import java.io.FileReader; +import java.util.HashMap; +import java.util.Map; public class EnvVarPostProcessor implements EnvironmentPostProcessor, Ordered { @Override - public void postProcessEnvironment(ConfigurableEnvironment environment, org.springframework.boot.SpringApplication application) { + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + try { - var resource = new ClassPathResource(".env"); - if (!resource.exists()) return; + File envFile = new File(".env"); // ★ 실행 위치(server/)의 .env 를 로드 - Map map = new HashMap<>(); + if (!envFile.exists()) { + System.out.println("[EnvPostProcessor] .env not found in working directory"); + return; + } - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(resource.getInputStream()))) { + Map map = new HashMap<>(); + try (BufferedReader reader = new BufferedReader(new FileReader(envFile))) { String line; while ((line = reader.readLine()) != null) { - // 공백 제거 + BOM 제거 - line = line.replace("\uFEFF", "").trim(); - + line = line.trim(); if (line.isEmpty() || line.startsWith("#")) continue; + if (!line.contains("=")) continue; String[] parts = line.split("=", 2); - - String key = parts[0].replace("\r", "").trim(); - String value = parts[1].replace("\r", "").trim(); + String key = parts[0].trim(); + String value = parts.length > 1 ? parts[1].trim() : ""; map.put(key, value); } @@ -43,8 +46,10 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, org.spri environment.getPropertySources() .addFirst(new MapPropertySource("customEnvVars", map)); + System.out.println("[EnvPostProcessor] .env loaded successfully from server/"); + } catch (Exception e) { - System.out.println("EnvVarPostProcessor error: " + e.getMessage()); + System.out.println("[EnvPostProcessor] Error loading .env: " + e.getMessage()); } } From 0b986418e07ef241310f8efbe8fa5311f176e3d9 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:16:23 +0900 Subject: [PATCH 089/198] =?UTF-8?q?ON-79=20SecurityConfig=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EC=9D=B8=EC=A6=9D/=EC=9D=B8=EA=B0=80=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=ED=95=84=ED=84=B0=20=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/config/SecurityConfig.java | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/oba/backend/server/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/config/SecurityConfig.java index bf5e4b0..5a7f00c 100644 --- a/server/src/main/java/oba/backend/server/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/config/SecurityConfig.java @@ -2,6 +2,8 @@ import lombok.RequiredArgsConstructor; import oba.backend.server.common.jwt.JwtAuthenticationFilter; +import oba.backend.server.security.oauth.CustomOAuth2UserService; +import oba.backend.server.security.oauth.OAuth2SuccessHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -20,6 +22,8 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtFilter; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -28,22 +32,30 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .cors(c -> c.configurationSource(req -> { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); - config.setAllowedOrigins(List.of( - "http://localhost:3000" - )); + config.setAllowedOrigins(List.of("*")); config.setAllowedHeaders(List.of("*")); config.setAllowedMethods(List.of("*")); return config; })) + .csrf(csrf -> csrf.disable()) - .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + .sessionManagement(s -> + s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/**", "/oauth2/**", "/login/**").permitAll() - .requestMatchers(HttpMethod.GET, "/public/**").permitAll() + .requestMatchers(HttpMethod.GET, "/articles/**").permitAll() + .requestMatchers(HttpMethod.GET, "/gpt/**").permitAll() + .requestMatchers(HttpMethod.GET, "/ai/**").permitAll() .anyRequest().authenticated() ) + .oauth2Login(oauth -> oauth + .userInfoEndpoint(c -> c.userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); From fc2d6fb2b1387d575813ae4b3977459915d3cc31 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:16:27 +0900 Subject: [PATCH 090/198] =?UTF-8?q?ON-79=20ArticleController=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EA=B8=B0=EC=82=AC=20=EC=83=81=EC=84=B8/?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/controller/ArticleController.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/server/src/main/java/oba/backend/server/controller/ArticleController.java b/server/src/main/java/oba/backend/server/controller/ArticleController.java index b327d50..541fd13 100644 --- a/server/src/main/java/oba/backend/server/controller/ArticleController.java +++ b/server/src/main/java/oba/backend/server/controller/ArticleController.java @@ -1,23 +1,26 @@ package oba.backend.server.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.dto.ArticleSummaryResponse; +import oba.backend.server.service.ArticleDetailService; import oba.backend.server.service.ArticleSummaryService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequestMapping("/articles") @RequiredArgsConstructor public class ArticleController { - private final ArticleSummaryService articleSummaryService; + private final ArticleSummaryService summaryService; + private final ArticleDetailService detailService; - // 홈 화면 최신 기사 조회 @GetMapping("/latest") - public ResponseEntity> getLatest() { - return ResponseEntity.ok(articleSummaryService.getLatestArticles(5)); + public ResponseEntity getLatest() { + return ResponseEntity.ok(summaryService.getLatestArticles(5)); + } + + @GetMapping("/{id}") + public ResponseEntity getDetail(@PathVariable Long id) { + return ResponseEntity.ok(detailService.getArticleDetail(id)); } } From 11f1c0f6b4109928be45fabeee136696ed9c6213 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:16:55 +0900 Subject: [PATCH 091/198] =?UTF-8?q?ON-79=20ArticleDetailResponse=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20DTO=20=ED=95=84=EB=93=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EA=B5=AC=EC=A1=B0=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/dto/ArticleDetailResponse.java | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java index 5c84d02..858c83a 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java +++ b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java @@ -3,7 +3,6 @@ import lombok.Builder; import lombok.Data; import oba.backend.server.entity.mongo.GptDocument; -import oba.backend.server.entity.mysql.Article; import java.util.List; @@ -11,32 +10,15 @@ @Builder public class ArticleDetailResponse { - private Long id; - private List category; + private Long articleId; private String title; - private String date; - private String url; + private String publishTime; + private String servingDate; private Object content; private Object subtitle; private String summary; - private List keywords; - private List quizzes; - - public static ArticleDetailResponse of(Article a, List c, GptDocument d) { - - return ArticleDetailResponse.builder() - .id(a.getArticleId()) - .category(c) - .title(d.getTitle()) - .date(d.getPublishTime()) - .url(a.getUrl()) - .content(d.getContentCol()) - .subtitle(d.getSubCol()) - .summary(d.getGptResult().getSummary()) - .keywords(d.getGptResult().getKeywords()) - .quizzes(d.getGptResult().getQuizzes()) - .build(); - } + private List keywords; + private List quizzes; } From 34d88310d722d8ba0579e973310c38ea750db1c1 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:01 +0900 Subject: [PATCH 092/198] =?UTF-8?q?ON-79=20ArticleSummaryResponse=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EC=9A=94=EC=95=BD=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/dto/ArticleSummaryResponse.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java index 14f32c4..d370f8f 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java +++ b/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java @@ -8,7 +8,9 @@ @Data @Builder public class ArticleSummaryResponse { - private Long id; + + private Long articleId; private String title; - private List bullets; + private List summaryBullets; + private String servingDate; } From e5ba7edc2c7ed3029a0c67855cef0697bfb49664 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:06 +0900 Subject: [PATCH 093/198] =?UTF-8?q?ON-79=20GptDocument=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20MongoDB=20=EB=AC=B8=EC=84=9C=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/entity/mongo/GptDocument.java | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java b/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java index 22f5daa..f3ff43e 100644 --- a/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java +++ b/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java @@ -7,15 +7,13 @@ import java.util.List; -@Document(collection = "Documents") +@Document(collection = "Selected_Articles") @Data public class GptDocument { @Id private String id; - // Mongo 필드: article_id - // Java 필드: articleId @Field("article_id") private Long articleId; @@ -28,14 +26,15 @@ public class GptDocument { private String servingDate; @Field("content_col") - private Object contentCol; + private Object content; @Field("sub_col") - private Object subCol; + private Object subtitle; @Field("gpt_result") private GptResult gptResult; + // --- GPT Result --- @Data public static class GptResult { private String summary; @@ -56,4 +55,16 @@ public static class Quiz { private String explanation; } } + + public String getSummary() { + return gptResult != null ? gptResult.getSummary() : null; + } + + public List getKeywords() { + return gptResult != null ? gptResult.getKeywords() : null; + } + + public List getQuizzes() { + return gptResult != null ? gptResult.getQuizzes() : null; + } } From 0b7ebce3f0cd67027871a30dcf9f467c8627c52d Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:09 +0900 Subject: [PATCH 094/198] =?UTF-8?q?ON-79=20GptMongoRepository=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20Mongo=20=EC=BF=BC=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/repository/mongo/GptMongoRepository.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java b/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java index d504b17..02d2d4b 100644 --- a/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java +++ b/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java @@ -1,10 +1,15 @@ package oba.backend.server.repository.mongo; import oba.backend.server.entity.mongo.GptDocument; +import org.springframework.data.domain.Pageable; import org.springframework.data.mongodb.repository.MongoRepository; +import java.util.List; import java.util.Optional; public interface GptMongoRepository extends MongoRepository { + Optional findByArticleId(Long articleId); + + List findByOrderByServingDateDesc(Pageable pageable); } From 93bff26bdc099186f5d497079413b57bc39cce43 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:12 +0900 Subject: [PATCH 095/198] =?UTF-8?q?ON-79=20GoogleVerifier=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20Google=20ID=20=ED=86=A0=ED=81=B0=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/security/GoogleVerifier.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/oba/backend/server/security/GoogleVerifier.java b/server/src/main/java/oba/backend/server/security/GoogleVerifier.java index 8b845a2..635412c 100644 --- a/server/src/main/java/oba/backend/server/security/GoogleVerifier.java +++ b/server/src/main/java/oba/backend/server/security/GoogleVerifier.java @@ -17,16 +17,16 @@ public class GoogleVerifier { @Value("${GOOGLE_CLIENT_ID}") private String googleClientId; + private static final NetHttpTransport transport = new NetHttpTransport(); + private static final GsonFactory jsonFactory = new GsonFactory(); + public GoogleIdToken.Payload verify(String idTokenString) { try { if (googleClientId == null || googleClientId.isBlank()) { - throw new IllegalStateException("GOOGLE_CLIENT_ID is missing. Check your .env file."); + throw new IllegalStateException("GOOGLE_CLIENT_ID is missing. Check your .env or application.yml"); } - GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder( - new NetHttpTransport(), - new GsonFactory() - ) + GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory) .setAudience(Collections.singletonList(googleClientId)) .build(); From f833502dccb670a5556b2fc91a8e192bc22047d0 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:16 +0900 Subject: [PATCH 096/198] =?UTF-8?q?ON-79=20OAuth2SuccessHandler=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/oauth/OAuth2SuccessHandler.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java b/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java index a5f92f3..e263927 100644 --- a/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java +++ b/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java @@ -1,6 +1,5 @@ package oba.backend.server.security.oauth; -import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -22,14 +21,19 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) - throws IOException, ServletException { + throws IOException { CustomOAuth2User user = (CustomOAuth2User) authentication.getPrincipal(); - String identifier = "google:" + user.getUserId(); // 실제 provider + id 로 구성 + String identifier = "oauth:" + user.getUserId(); String access = jwtProvider.createAccessToken(identifier); String refresh = jwtProvider.createRefreshToken(identifier); - response.sendRedirect("/login/success?access=" + access + "&refresh=" + refresh); + // 🔥 Expo Redirect URI + String redirect = "exp://localhost:8081/oauth" + + "?access=" + access + + "&refresh=" + refresh; + + getRedirectStrategy().sendRedirect(request, response, redirect); } -} +} \ No newline at end of file From ecec615b00da1063fc79bdfcd74c4bd24a0a899a Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:19 +0900 Subject: [PATCH 097/198] =?UTF-8?q?ON-79=20SolvedArticleResponse=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20DTO=20=EA=B5=AC=EC=A1=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/oauth/dto/SolvedArticleResponse.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java b/server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java index 568bf8b..34b76b0 100644 --- a/server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java +++ b/server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java @@ -1,4 +1,13 @@ -package oba.backend.server.security.oauth.dto; +package oba.backend.server.domain.quiz.dto; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder public class SolvedArticleResponse { + private Long articleId; + private LocalDateTime solvedAt; } From a29307574af1d3a33554e296fc06f29f52f9107f Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:22 +0900 Subject: [PATCH 098/198] =?UTF-8?q?ON-79=20WrongArticleResponse=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EC=98=A4=EB=8B=B5=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20DTO=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/security/oauth/dto/WrongArticleResponse.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java b/server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java index 95e0632..89ba39c 100644 --- a/server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java +++ b/server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java @@ -1,4 +1,11 @@ -package oba.backend.server.security.oauth.dto; +package oba.backend.server.domain.quiz.dto; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder public class WrongArticleResponse { + private Long articleId; + private boolean[] wrongList; // ex: [false, true, false, true, false] } From 7aee15aaf3d6df95c20eebecbb3e6041c2a02b92 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:25 +0900 Subject: [PATCH 099/198] =?UTF-8?q?ON-79=20ArticleDetailService=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/service/ArticleDetailService.java | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java index 539914b..3556bf7 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java +++ b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java @@ -1,41 +1,32 @@ package oba.backend.server.service; import lombok.RequiredArgsConstructor; +import oba.backend.server.dto.ArticleDetailResponse; import oba.backend.server.entity.mongo.GptDocument; -import oba.backend.server.entity.mysql.Article; -import oba.backend.server.entity.mysql.ArticleCategory; import oba.backend.server.repository.mongo.GptMongoRepository; -import oba.backend.server.repository.mysql.ArticleCategoryRepository; -import oba.backend.server.repository.mysql.ArticleRepository; -import oba.backend.server.repository.mysql.CategoryRepository; -import oba.backend.server.dto.ArticleDetailResponse; import org.springframework.stereotype.Service; -import java.util.List; - @Service @RequiredArgsConstructor public class ArticleDetailService { - private final ArticleRepository articleRepository; - private final CategoryRepository categoryRepository; - private final ArticleCategoryRepository articleCategoryRepository; private final GptMongoRepository gptMongoRepository; public ArticleDetailResponse getArticleDetail(Long articleId) { - Article article = articleRepository.findById(articleId) - .orElseThrow(() -> new RuntimeException("Article not found")); - - List mapping = articleCategoryRepository.findByIdArticleId(articleId); - - List categories = mapping.stream() - .map(m -> categoryRepository.findById(m.getId().getCategoryId()).get().getCategoryName()) - .toList(); - GptDocument doc = gptMongoRepository.findByArticleId(articleId) - .orElseThrow(() -> new RuntimeException("GPT Result not found")); + .orElseThrow(() -> new RuntimeException("Article not found")); - return ArticleDetailResponse.of(article, categories, doc); + return ArticleDetailResponse.builder() + .articleId(doc.getArticleId()) + .title(doc.getTitle()) + .publishTime(doc.getPublishTime()) + .servingDate(doc.getServingDate()) + .content(doc.getContent()) + .subtitle(doc.getSubtitle()) + .summary(doc.getSummary()) + .keywords(doc.getKeywords()) + .quizzes(doc.getQuizzes()) + .build(); } } From 974a4662dfc7d05b0618175e5e1dbd058828b647 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:28 +0900 Subject: [PATCH 100/198] =?UTF-8?q?ON-79=20ArticleSummaryService=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EA=B8=B0=EC=82=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=9A=94=EC=95=BD=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/service/ArticleSummaryService.java | 36 ++++++++----------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java index 7bddb75..9446e3f 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java +++ b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java @@ -1,13 +1,13 @@ package oba.backend.server.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.repository.mongo.GptMongoRepository; -import oba.backend.server.repository.mysql.ArticleRepository; import oba.backend.server.dto.ArticleSummaryResponse; +import oba.backend.server.entity.mongo.GptDocument; +import oba.backend.server.repository.mongo.GptMongoRepository; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -15,33 +15,27 @@ @RequiredArgsConstructor public class ArticleSummaryService { - private final ArticleRepository articleRepository; private final GptMongoRepository gptMongoRepository; public List getLatestArticles(int limit) { - var latest = articleRepository.findLatestArticles(PageRequest.of(0, limit)); - - return latest.stream().map(a -> { - - oba.backend.server.entity.mongo.GptDocument doc = gptMongoRepository.findByArticleId(a.getArticleId()) - .orElse(null); + Pageable pageable = PageRequest.of(0, limit); + List docs = gptMongoRepository.findByOrderByServingDateDesc(pageable); - List bullets = new ArrayList<>(); + return docs.stream().map(doc -> { + List bullets = null; - if (doc != null && doc.getGptResult() != null) { - String s = doc.getGptResult().getSummary(); - if (s != null) { - bullets = Arrays.stream(s.split(" ")) - .limit(5) - .toList(); - } + if (doc.getSummary() != null) { + bullets = Arrays.stream(doc.getSummary().split(" ")) + .limit(3) + .toList(); } return ArticleSummaryResponse.builder() - .id(a.getArticleId()) - .title(doc != null ? doc.getTitle() : "(제목 없음)") - .bullets(bullets) + .articleId(doc.getArticleId()) + .title(doc.getTitle()) + .summaryBullets(bullets) + .servingDate(doc.getServingDate()) .build(); }).toList(); } From bc66c7b22bb09fc93ca75297b7a43a64702f82dc Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:32 +0900 Subject: [PATCH 101/198] =?UTF-8?q?ON-79=20QuizQueryService=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=ED=80=B4=EC=A6=88=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/service/QuizQueryService.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/server/src/main/java/oba/backend/server/service/QuizQueryService.java b/server/src/main/java/oba/backend/server/service/QuizQueryService.java index 6578fc0..63aa806 100644 --- a/server/src/main/java/oba/backend/server/service/QuizQueryService.java +++ b/server/src/main/java/oba/backend/server/service/QuizQueryService.java @@ -1,4 +1,45 @@ package oba.backend.server.service; +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.quiz.IncorrectArticlesRepository; +import oba.backend.server.domain.quiz.IncorrectQuizRepository; +import oba.backend.server.domain.quiz.dto.SolvedArticleResponse; +import oba.backend.server.domain.quiz.dto.WrongArticleResponse; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor public class QuizQueryService { + + private final IncorrectArticlesRepository incorrectArticlesRepository; + private final IncorrectQuizRepository incorrectQuizRepository; + + public List getSolved(Long userId) { + return incorrectArticlesRepository.findByUserId(userId) + .stream() + .map(r -> SolvedArticleResponse.builder() + .articleId(r.getArticleId()) + .solvedAt(r.getSolDate()) + .build()) + .collect(Collectors.toList()); + } + + public List getWrong(Long userId) { + return incorrectQuizRepository.findByUserId(userId) + .stream() + .map(q -> WrongArticleResponse.builder() + .articleId(q.getArticleId()) + .wrongList(new boolean[]{ + !q.getQuiz1(), + !q.getQuiz2(), + !q.getQuiz3(), + !q.getQuiz4(), + !q.getQuiz5() + }) + .build()) + .collect(Collectors.toList()); + } } From aa8ed3901ee7ee21bc3b91d56b4daa5bc99eb220 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:36 +0900 Subject: [PATCH 102/198] =?UTF-8?q?ON-79=20QuizService=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=ED=80=B4=EC=A6=88=20=EC=83=9D=EC=84=B1/?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/service/QuizService.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/server/src/main/java/oba/backend/server/service/QuizService.java b/server/src/main/java/oba/backend/server/service/QuizService.java index 5cc91fd..8501cef 100644 --- a/server/src/main/java/oba/backend/server/service/QuizService.java +++ b/server/src/main/java/oba/backend/server/service/QuizService.java @@ -1,4 +1,47 @@ package oba.backend.server.service; +import lombok.RequiredArgsConstructor; +import oba.backend.server.common.jwt.JwtProvider; +import oba.backend.server.domain.quiz.*; +import oba.backend.server.dto.QuizSubmitRequest; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor public class QuizService { + + private final JwtProvider jwtProvider; + private final IncorrectArticlesRepository incorrectArticlesRepository; + private final IncorrectQuizRepository incorrectQuizRepository; + + public void submitQuiz(String token, QuizSubmitRequest request) { + + Long userId = Long.valueOf(jwtProvider.getUserId(token)); + Long articleId = request.getArticleId(); + + // 🔵 1) solved(푼 문제) 저장 — Incorrect_Articles + IncorrectArticles solved = IncorrectArticles.builder() + .userId(userId) + .articleId(articleId) + .solDate(LocalDateTime.now()) + .build(); + + incorrectArticlesRepository.save(solved); + + + // 🔵 2) 정오답 기록 저장 — Incorrect_Quiz + IncorrectQuiz quiz = IncorrectQuiz.builder() + .userId(userId) + .articleId(articleId) + .quiz1(request.getAnswers().get(0)) + .quiz2(request.getAnswers().get(1)) + .quiz3(request.getAnswers().get(2)) + .quiz4(request.getAnswers().get(3)) + .quiz5(request.getAnswers().get(4)) + .build(); + + incorrectQuizRepository.save(quiz); + } } From dfaca0955bc019b56cc68bf1892ef9b8675b756a Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:39 +0900 Subject: [PATCH 103/198] =?UTF-8?q?ON-79=20application-prod.yml=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=ED=94=84=EB=A1=9C=EB=8D=95=EC=85=98?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B5=9C=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-prod.yml | 46 ++++++------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index 5cfc148..59776dd 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -1,3 +1,15 @@ +server: + port: ${SERVER_PORT:9000} + +jwt: + secret: ${JWT_SECRET} + access-token-expiration-ms: ${JWT_ACCESS_TOKEN_EXPIRATION_MS:1800000} + refresh-token-expiration-ms: ${JWT_REFRESH_TOKEN_EXPIRATION_MS:1209600000} + +ai: + server: + url: ${AI_SERVER_URL} + spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver @@ -10,34 +22,6 @@ spring: uri: ${MONGODB_URI} database: OneBitArticle - jpa: - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - format_sql: true - -security: - oauth2: - client: - registration: - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET} - naver: - client-id: ${NAVER_CLIENT_ID} - client-secret: ${NAVER_CLIENT_SECRET} - -jwt: - secret: ${JWT_SECRET} - -ai: - server: - url: ${AI_SERVER_URL} - -server: - port: 8080 \ No newline at end of file +logging: + level: + root: INFO From 4dc9b76f141c3242a1db9bd9c09e0987df01efad Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Tue, 2 Dec 2025 00:17:43 +0900 Subject: [PATCH 104/198] =?UTF-8?q?ON-79=20application.yml=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EA=B3=B5=ED=86=B5=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/resources/application.yml | 53 +---------------------- 1 file changed, 1 insertion(+), 52 deletions(-) diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index c052783..1267f93 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -5,10 +5,6 @@ spring: profiles: active: prod - # resources/.env 자동 로딩은 EnvVarPostProcessor 에서 처리하므로 여기선 불필요 - # config: - # import: optional:file:.env - datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: ${DB_URL} @@ -31,70 +27,23 @@ spring: security: oauth2: client: - provider: - google: authorization-uri: https://accounts.google.com/o/oauth2/v2/auth token-uri: https://oauth2.googleapis.com/token user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo user-name-attribute: sub - - kakao: - authorization-uri: https://kauth.kakao.com/oauth/authorize - token-uri: https://kauth.kakao.com/oauth/token - user-info-uri: https://kapi.kakao.com/v2/user/me - user-name-attribute: id - - naver: - authorization-uri: https://nid.naver.com/oauth2.0/authorize - token-uri: https://nid.naver.com/oauth2.0/token - user-info-uri: https://openapi.naver.com/v1/nid/me - user-name-attribute: response - registration: - google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/google" - scope: - - profile - - email - provider: google - - kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET} - client-authentication-method: client_secret_post - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/kakao" - scope: - - profile_nickname - - profile_image - - account_email - provider: kakao - - naver: - client-id: ${NAVER_CLIENT_ID} - client-secret: ${NAVER_CLIENT_SECRET} - client-authentication-method: client_secret_post - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/naver" - scope: - - name - - email - provider: naver jwt: secret: ${JWT_SECRET} - access-token-expiration-ms: 1800000 # 30 min - refresh-token-expiration-ms: 604800000 # 7 days ai: server: url: ${AI_SERVER_URL} server: - port: 8080 + port: ${SERVER_PORT:9000} From 3976a0b1386a174eb26faa47631899a5702dd8ca Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Wed, 3 Dec 2025 11:08:37 +0900 Subject: [PATCH 105/198] commit --- server/Dockerfile | 2 +- "server/ERROR\357\200\272" | 8 ++ server/build.gradle | 6 +- server/netsh | 8 ++ .../auth/google/GoogleTokenVerifier.java | 40 ++++++ .../common/jwt/JwtAuthenticationFilter.java | 89 ++---------- .../server/common/jwt/JwtProvider.java | 89 ++++++------ .../oba/backend/server/config/CorsConfig.java | 24 ++++ .../backend/server/config/SecurityConfig.java | 38 +++--- .../controller/MobileAuthController.java | 129 ++++-------------- .../server/controller/QuizController.java | 3 +- .../server/controller/UserController.java | 3 +- .../server/domain/quiz/QuizResultService.java | 33 ++--- .../oba/backend/server/dto/LoginRequest.java | 8 ++ .../oba/backend/server/dto/TokenResponse.java | 13 +- .../oauth/CustomOAuth2UserService.java | 36 ++--- .../security/oauth/OAuth2SuccessHandler.java | 11 +- .../security/oauth/dto/CustomOAuth2User.java | 20 ++- .../backend/server/service/QuizService.java | 25 +++- .../backend/server/service/UserService.java | 25 ++++ .../src/main/resources/application-prod.yml | 2 +- server/src/main/resources/application.yml | 28 +++- 22 files changed, 328 insertions(+), 312 deletions(-) create mode 100644 "server/ERROR\357\200\272" create mode 100644 server/netsh create mode 100644 server/src/main/java/oba/backend/server/auth/google/GoogleTokenVerifier.java create mode 100644 server/src/main/java/oba/backend/server/config/CorsConfig.java create mode 100644 server/src/main/java/oba/backend/server/dto/LoginRequest.java create mode 100644 server/src/main/java/oba/backend/server/service/UserService.java diff --git a/server/Dockerfile b/server/Dockerfile index 08bcc0b..4141dea 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -9,5 +9,5 @@ RUN gradle bootJar --no-daemon FROM eclipse-temurin:17-jdk WORKDIR /app COPY --from=builder /app/build/libs/*.jar app.jar -EXPOSE 9000 +EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] diff --git "a/server/ERROR\357\200\272" "b/server/ERROR\357\200\272" new file mode 100644 index 0000000..c0e8750 --- /dev/null +++ "b/server/ERROR\357\200\272" @@ -0,0 +1,8 @@ + PID PPID PGID WINPID TTY UID STIME COMMAND + 1483 1 1483 319596 cons2 197609 20:42:39 /usr/bin/bash + 2954 1 2954 336312 cons3 197609 03:29:20 /usr/bin/bash + 3582 2954 3582 395704 cons3 197609 10:39:59 /usr/bin/PS + 3495 3487 3487 276496 cons2 197609 10:33:18 /c/Program Files/nodejs/node + 3487 1483 3487 386764 cons2 197609 10:33:16 /usr/bin/bash + 1394 1 1394 226624 cons0 197609 20:42:33 /usr/bin/bash + 1399 1 1399 324196 cons1 197609 20:42:34 /usr/bin/bash diff --git a/server/build.gradle b/server/build.gradle index 08bde53..96ca08a 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -28,7 +28,8 @@ dependencies { implementation 'com.google.api-client:google-api-client:2.2.0' implementation 'com.google.http-client:google-http-client-gson:1.43.3' - + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' /* --- Core --- */ implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -63,6 +64,9 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'com.h2database:h2' + + // Google ID Token 검증 + implementation 'com.google.api-client:google-api-client:2.2.0' } tasks.named('test') { diff --git a/server/netsh b/server/netsh new file mode 100644 index 0000000..bda8043 --- /dev/null +++ b/server/netsh @@ -0,0 +1,8 @@ + PID PPID PGID WINPID TTY UID STIME COMMAND + 1483 1 1483 319596 cons2 197609 20:42:39 /usr/bin/bash + 2954 1 2954 336312 cons3 197609 03:29:20 /usr/bin/bash + 3495 3487 3487 276496 cons2 197609 10:33:18 /c/Program Files/nodejs/node + 3487 1483 3487 386764 cons2 197609 10:33:16 /usr/bin/bash + 3646 2954 3646 394660 cons3 197609 10:40:08 /usr/bin/PS + 1394 1 1394 226624 cons0 197609 20:42:33 /usr/bin/bash + 1399 1 1399 324196 cons1 197609 20:42:34 /usr/bin/bash diff --git a/server/src/main/java/oba/backend/server/auth/google/GoogleTokenVerifier.java b/server/src/main/java/oba/backend/server/auth/google/GoogleTokenVerifier.java new file mode 100644 index 0000000..8a8baf1 --- /dev/null +++ b/server/src/main/java/oba/backend/server/auth/google/GoogleTokenVerifier.java @@ -0,0 +1,40 @@ +package oba.backend.server.auth.google; + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import org.springframework.stereotype.Component; + +import java.util.Collections; + +@Component +public class GoogleTokenVerifier { + + // Firebase 프로젝트의 Web Client ID 넣기 + private static final String CLIENT_ID = + "774547640000-xxx.apps.googleusercontent.com"; + + public GoogleIdToken.Payload verify(String idTokenString) { + + try { + GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder( + new NetHttpTransport(), + new GsonFactory() + ) + .setAudience(Collections.singletonList(CLIENT_ID)) + .build(); + + GoogleIdToken idToken = verifier.verify(idTokenString); + + if (idToken != null) { + return idToken.getPayload(); + } + + throw new RuntimeException("Invalid Google ID Token"); + + } catch (Exception e) { + throw new RuntimeException("Google Token Verification Failed", e); + } + } +} diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java index 80c9508..0115eba 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java @@ -1,8 +1,8 @@ package oba.backend.server.common.jwt; +import io.jsonwebtoken.Claims; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -12,8 +12,6 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.util.Arrays; -import java.util.List; @Component @RequiredArgsConstructor @@ -21,94 +19,35 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; - // 🔥 JWT를 적용하지 않을 경로들 - private static final List EXCLUDE_URLS = List.of( - "/articles", - "/auth", - "/oauth2", - "/public", - "/gpt", - "/ai" - ); - - private boolean isExcluded(HttpServletRequest request) { - String uri = request.getRequestURI(); - return EXCLUDE_URLS.stream().anyMatch(uri::startsWith); - } - @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - // 1️⃣ 허용 경로는 JWT 검증 건너뛰기 (중요!) - if (isExcluded(request)) { - filterChain.doFilter(request, response); - return; - } - - // 2️⃣ Access Token 확인 - String accessToken = resolveAccessToken(request); - String refreshToken = getCookie(request, "refresh_token"); + String uri = request.getRequestURI(); - if (accessToken != null && jwtProvider.validateToken(accessToken)) { - authenticate(accessToken); + // JWT 필터 예외 경로 — 인증 절대 없음 + if (uri.startsWith("/articles") + || uri.startsWith("/auth") + || uri.startsWith("/oauth2") + || uri.startsWith("/login")) { filterChain.doFilter(request, response); return; } - // 3️⃣ Access 만료 + Refresh 정상 → 재발급 - if (refreshToken != null && jwtProvider.validateToken(refreshToken)) { - - var claims = jwtProvider.getClaims(refreshToken); - - if (!"refresh".equals(claims.get("type"))) { - filterChain.doFilter(request, response); - return; - } - - String username = claims.getSubject(); - String newAccessToken = jwtProvider.createAccessToken(username); + // WT 토큰 추출 + String token = jwtProvider.resolveToken(request); - Cookie cookie = new Cookie("access_token", newAccessToken); - cookie.setHttpOnly(true); - cookie.setPath("/"); - cookie.setMaxAge(60 * 30); - response.addCookie(cookie); + // JWT 유효성 검사 + if (token != null && jwtProvider.validateToken(token)) { - authenticate(newAccessToken); + Claims claims = jwtProvider.getClaims(token); - filterChain.doFilter(request, response); - return; + Authentication auth = jwtProvider.getAuthentication(claims.getSubject()); + SecurityContextHolder.getContext().setAuthentication(auth); } - // 4️⃣ 둘 다 없으면 인증 없이 통과 filterChain.doFilter(request, response); } - - private String resolveAccessToken(HttpServletRequest request) { - String header = request.getHeader("Authorization"); - - if (header != null && header.startsWith("Bearer ")) { - return header.substring(7); - } - - return getCookie(request, "access_token"); - } - - private void authenticate(String token) { - Authentication auth = jwtProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(auth); - } - - private String getCookie(HttpServletRequest request, String name) { - if (request.getCookies() == null) return null; - - return Arrays.stream(request.getCookies()) - .filter(c -> name.equals(c.getName())) - .map(Cookie::getValue) - .findFirst() - .orElse(null); - } } diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java index baecfb2..15c2d91 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java @@ -10,6 +10,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.stereotype.Component; +import jakarta.servlet.http.HttpServletRequest; import javax.crypto.SecretKey; import java.util.Date; @@ -32,63 +33,73 @@ public JwtProvider( this.refreshTokenValidity = refreshTokenValidity; } - // ---- CREATE TOKEN ---- - public String createAccessToken(String username) { - return createToken(username, accessTokenValidity); - } - - public String createRefreshToken(String username) { - return createToken(username, refreshTokenValidity); + public String resolveToken(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + return header.substring(7); + } + return null; } - private String createToken(String username, long validity) { - Date now = new Date(); - Date expiry = new Date(now.getTime() + validity); - - return Jwts.builder() - .setSubject(username) - .setIssuedAt(now) - .setExpiration(expiry) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); + /** 구버전 jjwt 문법에 맞춘 Claims 파싱 */ + public Claims getClaims(String token) { + return Jwts.parser() + .setSigningKey(key) + .parseClaimsJws(token) + .getBody(); } - // ---- VALIDATE ---- public boolean validateToken(String token) { try { - Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + getClaims(token); return true; - } catch (Exception e) { + } catch (JwtException | IllegalArgumentException e) { return false; } } - // ---- PARSE ---- - public Claims getClaims(String token) { - return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + public String createAccessToken(String identifier) { + long now = System.currentTimeMillis(); + return Jwts.builder() + .setSubject(identifier) + .setExpiration(new Date(now + accessTokenValidity)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); } - public String getUserId(String token) { - return getClaims(token).getSubject(); + public String createRefreshToken(String identifier) { + long now = System.currentTimeMillis(); + return Jwts.builder() + .setSubject(identifier) + .setExpiration(new Date(now + refreshTokenValidity)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); } - public Authentication getAuthentication(String token) { - Claims claims = getClaims(token); - String username = claims.getSubject(); - - var authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); - - User principal = new User(username, "", authorities); - return new UsernamePasswordAuthenticationToken(principal, token, authorities); + public TokenResponse generateTokens(String identifier) { + return new TokenResponse( + createAccessToken(identifier), + createRefreshToken(identifier) + ); } - // ---- NEW: MobileAuthController 에서 필요 ---- - public TokenResponse generateToken(Authentication authentication) { - String userId = authentication.getName(); // subject = userId + public Authentication getAuthentication(String identifier) { - String accessToken = createAccessToken(userId); - String refreshToken = createRefreshToken(userId); + User principal = new User( + identifier, + "", + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + return new UsernamePasswordAuthenticationToken( + principal, + "", + principal.getAuthorities() + ); + } - return new TokenResponse(accessToken, refreshToken); + /** 필요 시 Google Id Token 검증용 */ + public String verifyGoogleIdToken(String idToken) { + return getClaims(idToken).getSubject(); } } diff --git a/server/src/main/java/oba/backend/server/config/CorsConfig.java b/server/src/main/java/oba/backend/server/config/CorsConfig.java new file mode 100644 index 0000000..bbb88cc --- /dev/null +++ b/server/src/main/java/oba/backend/server/config/CorsConfig.java @@ -0,0 +1,24 @@ +package oba.backend.server.config; + +import org.springframework.context.annotation.Bean; +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 CorsConfig { + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("*") + .allowedHeaders("*") + .allowCredentials(true); + } + }; + } +} diff --git a/server/src/main/java/oba/backend/server/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/config/SecurityConfig.java index 5a7f00c..a1b6293 100644 --- a/server/src/main/java/oba/backend/server/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/config/SecurityConfig.java @@ -7,8 +7,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @@ -29,41 +27,41 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .cors(c -> c.configurationSource(req -> { + .csrf(csrf -> csrf.disable()) + + // --- CORS 완전 허용 --- + .cors(cors -> cors.configurationSource(request -> { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); - config.setAllowedOrigins(List.of("*")); - config.setAllowedHeaders(List.of("*")); + config.setAllowedOriginPatterns(List.of("*")); config.setAllowedMethods(List.of("*")); + config.setAllowedHeaders(List.of("*")); return config; })) - .csrf(csrf -> csrf.disable()) - - .sessionManagement(s -> - s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // --- 인증 필요 없는 경로 --- .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/**", "/oauth2/**", "/login/**").permitAll() - .requestMatchers(HttpMethod.GET, "/articles/**").permitAll() - .requestMatchers(HttpMethod.GET, "/gpt/**").permitAll() - .requestMatchers(HttpMethod.GET, "/ai/**").permitAll() + .requestMatchers( + "/auth/**", + "/oauth2/**", + "/login/**", + "/articles/**" // 🔥🔥 완전 Public + ).permitAll() .anyRequest().authenticated() ) - .oauth2Login(oauth -> oauth + // --- OAuth2 --- + .oauth2Login(o -> o + .loginPage("/oauth2/authorization/google") .userInfoEndpoint(c -> c.userService(customOAuth2UserService)) .successHandler(oAuth2SuccessHandler) ) + // --- JWT 필터 --- .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration config) - throws Exception { - return config.getAuthenticationManager(); - } } diff --git a/server/src/main/java/oba/backend/server/controller/MobileAuthController.java b/server/src/main/java/oba/backend/server/controller/MobileAuthController.java index 4af22d2..73fc009 100644 --- a/server/src/main/java/oba/backend/server/controller/MobileAuthController.java +++ b/server/src/main/java/oba/backend/server/controller/MobileAuthController.java @@ -2,135 +2,54 @@ import lombok.RequiredArgsConstructor; import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.domain.user.ProviderInfo; -import oba.backend.server.domain.user.Role; -import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.UserRepository; +import oba.backend.server.dto.LoginRequest; import oba.backend.server.dto.TokenResponse; -import oba.backend.server.security.GoogleVerifier; -import oba.backend.server.security.KakaoVerifier; -import oba.backend.server.security.NaverVerifier; -import oba.backend.server.security.OAuthAttributes; +import oba.backend.server.security.oauth.dto.CustomOAuth2User; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.web.bind.annotation.*; -import java.util.List; -import java.util.Map; - @RestController @RequiredArgsConstructor -@RequestMapping("/auth/mobile") +@RequestMapping("/auth") public class MobileAuthController { private final JwtProvider jwtProvider; - private final UserRepository userRepository; - - private final GoogleVerifier googleVerifier; - private final KakaoVerifier kakaoVerifier; - private final NaverVerifier naverVerifier; /** - * 🔹 Google 모바일 로그인 - * RN → idToken 전달 + * 🔥 모바일 앱 구글 로그인 + * Expo → Firebase → Google ID Token → 백엔드 검증 → JWT 발급 */ @PostMapping("/google") - public ResponseEntity googleLogin(@RequestBody Map body) { - - var payload = googleVerifier.verify(body.get("idToken")); - String identifier = "google:" + payload.getSubject(); - - Authentication auth = new UsernamePasswordAuthenticationToken( - identifier, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); - - return ResponseEntity.ok(jwtProvider.generateToken(auth)); - } - - /** - * 🔹 Kakao 모바일 로그인 - * RN → accessToken 전달 - */ - @PostMapping("/kakao") - public ResponseEntity kakaoLogin(@RequestBody Map body) { + public ResponseEntity googleLogin(@RequestBody LoginRequest request) { - String accessToken = body.get("accessToken"); - OAuthAttributes kakao = kakaoVerifier.verify(accessToken); - - return ResponseEntity.ok( - processLogin( - "kakao", - kakao.id(), - kakao.email(), - kakao.name(), - kakao.picture() - ) - ); - } - - /** - * 🔹 Naver 모바일 로그인 - * RN → accessToken 전달 - */ - @PostMapping("/naver") - public ResponseEntity naverLogin(@RequestBody Map body) { + // request.getIdToken() = Firebase Google ID Token + String googleSubject = jwtProvider.verifyGoogleIdToken(request.getIdToken()); + // 반환 예: "google:123456789" - String accessToken = body.get("accessToken"); - OAuthAttributes naver = naverVerifier.verify(accessToken); + // Access + Refresh 동시 발급 + TokenResponse tokens = jwtProvider.generateTokens(googleSubject); - return ResponseEntity.ok( - processLogin( - "naver", - naver.id(), - naver.email(), - naver.name(), - naver.picture() - ) - ); + return ResponseEntity.ok(tokens); } /** - * 🔥 공통 로그인 처리 메서드 - * - DB 조회 및 생성 - * - JWT 발급 + * 🔥 OAuth2 (브라우저) 성공 후 JWT 반환용 + * 이 컨트롤러는 모바일 앱에는 사용되지 않지만 + * 기존 웹 로그인 흐름이 필요하면 유지 */ - private TokenResponse processLogin( - String provider, - String providerId, - String email, - String name, - String picture - ) { + @GetMapping("/oauth/success") + public ResponseEntity oauthSuccess(Authentication authentication) { - String identifier = provider + ":" + providerId; + OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication; + CustomOAuth2User user = (CustomOAuth2User) oauthToken.getPrincipal(); - // DB 조회 또는 생성 - User user = userRepository.findByIdentifier(identifier) - .map(u -> { - u.updateInfo(email, name, picture); - return u; - }) - .orElseGet(() -> userRepository.save( - User.builder() - .identifier(identifier) - .email(email) - .name(name) - .picture(picture) - .provider(ProviderInfo.from(provider)) - .role(Role.USER) - .build() - )); + // 예: google:12345 + String identifier = "google:" + user.getUserId(); - // Spring Security Authentication 생성 - Authentication auth = new UsernamePasswordAuthenticationToken( - user.getIdentifier(), - null, - List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); + TokenResponse tokens = jwtProvider.generateTokens(identifier); - // JWT 발급(JSON 반환) - return jwtProvider.generateToken(auth); + return ResponseEntity.ok(tokens); } } diff --git a/server/src/main/java/oba/backend/server/controller/QuizController.java b/server/src/main/java/oba/backend/server/controller/QuizController.java index e288463..8a64b1e 100644 --- a/server/src/main/java/oba/backend/server/controller/QuizController.java +++ b/server/src/main/java/oba/backend/server/controller/QuizController.java @@ -18,7 +18,8 @@ public ResponseEntity submitQuiz( @RequestHeader("Authorization") String token, @RequestBody QuizSubmitRequest request ) { - quizService.submitQuiz(token.replace("Bearer ", ""), request); + String jwt = token.replace("Bearer ", ""); + quizService.submit(jwt, request); return ResponseEntity.ok().build(); } } diff --git a/server/src/main/java/oba/backend/server/controller/UserController.java b/server/src/main/java/oba/backend/server/controller/UserController.java index 0aba317..d972f99 100644 --- a/server/src/main/java/oba/backend/server/controller/UserController.java +++ b/server/src/main/java/oba/backend/server/controller/UserController.java @@ -19,12 +19,11 @@ public class UserController { public ResponseEntity me(@RequestHeader("Authorization") String bearer) { String token = bearer.replace("Bearer ", ""); - String identifier = jwtProvider.getUserId(token); + String identifier = jwtProvider.getClaims(token).getSubject(); User user = userRepository.findByIdentifier(identifier) .orElseThrow(); - // DTO로 반환 return ResponseEntity.ok(new UserProfileResponse( user.getIdentifier(), user.getEmail(), diff --git a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java b/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java index c3b22d2..173e3db 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java @@ -1,7 +1,10 @@ package oba.backend.server.domain.quiz; +import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import oba.backend.server.common.jwt.JwtProvider; +import oba.backend.server.domain.user.User; +import oba.backend.server.domain.user.UserRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -10,16 +13,22 @@ public class QuizResultService { private final JwtProvider jwtProvider; + private final UserRepository userRepository; private final IncorrectQuizRepository incorrectQuizRepository; private final IncorrectArticlesRepository incorrectArticlesRepository; @Transactional public void saveQuizResult(String jwt, QuizResultRequest request) { - Long userId = Long.parseLong(jwtProvider.getUserId(jwt)); + Claims claims = jwtProvider.getClaims(jwt); + String identifier = claims.getSubject(); + + User user = userRepository.findByIdentifier(identifier) + .orElseThrow(() -> new RuntimeException("User not found")); + Long userId = user.getId(); + boolean[] results = request.getQuizResults(); - // 기존 기록 제거 incorrectQuizRepository.deleteByUserIdAndArticleId(userId, request.getArticleId()); IncorrectQuiz quiz = IncorrectQuiz.builder() @@ -33,25 +42,5 @@ public void saveQuizResult(String jwt, QuizResultRequest request) { .build(); incorrectQuizRepository.save(quiz); - - // 오답 기사 저장 - boolean hasWrong = false; - for (boolean r : results) { - if (!r) { - hasWrong = true; - break; - } - } - - if (hasWrong) { - incorrectArticlesRepository.deleteByUserIdAndArticleId(userId, request.getArticleId()); - - IncorrectArticles article = IncorrectArticles.builder() - .userId(userId) - .articleId(request.getArticleId()) - .build(); - - incorrectArticlesRepository.save(article); - } } } diff --git a/server/src/main/java/oba/backend/server/dto/LoginRequest.java b/server/src/main/java/oba/backend/server/dto/LoginRequest.java new file mode 100644 index 0000000..3ed0ee7 --- /dev/null +++ b/server/src/main/java/oba/backend/server/dto/LoginRequest.java @@ -0,0 +1,8 @@ +package oba.backend.server.dto; + +import lombok.Data; + +@Data +public class LoginRequest { + private String idToken; +} diff --git a/server/src/main/java/oba/backend/server/dto/TokenResponse.java b/server/src/main/java/oba/backend/server/dto/TokenResponse.java index 7b0b16b..00366ff 100644 --- a/server/src/main/java/oba/backend/server/dto/TokenResponse.java +++ b/server/src/main/java/oba/backend/server/dto/TokenResponse.java @@ -1,6 +1,11 @@ package oba.backend.server.dto; -public record TokenResponse( - String accessToken, - String refreshToken -) {} +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class TokenResponse { + private String accessToken; + private String refreshToken; +} diff --git a/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java index bafe6f8..0c0db02 100644 --- a/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java +++ b/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java @@ -20,14 +20,15 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { @Override public OAuth2User loadUser(OAuth2UserRequest request) { - OAuth2User oAuth2User = super.loadUser(request); + + OAuth2User oauth = super.loadUser(request); String provider = request.getClientRegistration().getRegistrationId(); - OAuthAttributes attributes = OAuthAttributes.of(provider, oAuth2User.getAttributes()); + OAuthAttributes attr = OAuthAttributes.of(provider, oauth.getAttributes()); - User user = saveOrUpdate(attributes); + User user = saveOrUpdate(attr); - return new CustomOAuth2User(user, oAuth2User.getAttributes()); + return new CustomOAuth2User(user, oauth.getAttributes()); } private User saveOrUpdate(OAuthAttributes attr) { @@ -35,20 +36,19 @@ private User saveOrUpdate(OAuthAttributes attr) { String identifier = attr.getProvider() + ":" + attr.getEmail(); return userRepository.findByIdentifier(identifier) - .map(user -> { - user.updateInfo(attr.getEmail(), attr.getName(), attr.getPicture()); - return userRepository.save(user); + .map(u -> { + u.updateInfo(attr.getEmail(), attr.getName(), attr.getPicture()); + return userRepository.save(u); }) - .orElseGet(() -> { - User newUser = User.builder() - .identifier(identifier) - .email(attr.getEmail()) - .name(attr.getName()) - .picture(attr.getPicture()) - .provider(ProviderInfo.valueOf(attr.getProvider().toUpperCase())) - .role(Role.USER) - .build(); - return userRepository.save(newUser); - }); + .orElseGet(() -> userRepository.save( + User.builder() + .identifier(identifier) + .email(attr.getEmail()) + .name(attr.getName()) + .picture(attr.getPicture()) + .provider(ProviderInfo.from(attr.getProvider())) + .role(Role.USER) + .build() + )); } } diff --git a/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java b/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java index e263927..928ecbe 100644 --- a/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java +++ b/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java @@ -24,16 +24,15 @@ public void onAuthenticationSuccess(HttpServletRequest request, throws IOException { CustomOAuth2User user = (CustomOAuth2User) authentication.getPrincipal(); - String identifier = "oauth:" + user.getUserId(); + String identifier = "google:" + user.getUserId(); + // Access Token만 생성 String access = jwtProvider.createAccessToken(identifier); - String refresh = jwtProvider.createRefreshToken(identifier); - // 🔥 Expo Redirect URI + // Expo Dev 클라이언트 Redirect URI String redirect = "exp://localhost:8081/oauth" - + "?access=" + access - + "&refresh=" + refresh; + + "?access=" + access; getRedirectStrategy().sendRedirect(request, response, redirect); } -} \ No newline at end of file +} diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java b/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java index 3d22682..418a27c 100644 --- a/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java +++ b/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java @@ -2,10 +2,13 @@ import lombok.Getter; import oba.backend.server.domain.user.User; +import oba.backend.server.domain.user.Role; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.core.user.OAuth2User; import java.util.Collection; +import java.util.List; import java.util.Map; @Getter @@ -19,8 +22,9 @@ public CustomOAuth2User(User user, Map attributes) { this.attributes = attributes; } - public Long getUserId() { - return user.getId(); + // ⭐ 식별자(google:123) 반환 + public String getUserId() { + return user.getIdentifier(); } @Override @@ -28,13 +32,17 @@ public Map getAttributes() { return attributes; } + // ⭐ OAuth2User 인터페이스에서 required @Override - public Collection getAuthorities() { - return null; + public String getName() { + return user.getName(); } + // ⭐ Spring Security가 요구하는 권한 목록 @Override - public String getName() { - return user.getName(); + public Collection getAuthorities() { + // User 엔티티의 Role(USER, ADMIN 등) 사용 + Role role = user.getRole(); + return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); } } diff --git a/server/src/main/java/oba/backend/server/service/QuizService.java b/server/src/main/java/oba/backend/server/service/QuizService.java index 8501cef..5855916 100644 --- a/server/src/main/java/oba/backend/server/service/QuizService.java +++ b/server/src/main/java/oba/backend/server/service/QuizService.java @@ -1,8 +1,14 @@ package oba.backend.server.service; +import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.domain.quiz.*; +import oba.backend.server.domain.quiz.IncorrectArticles; +import oba.backend.server.domain.quiz.IncorrectArticlesRepository; +import oba.backend.server.domain.quiz.IncorrectQuiz; +import oba.backend.server.domain.quiz.IncorrectQuizRepository; +import oba.backend.server.domain.user.User; +import oba.backend.server.domain.user.UserRepository; import oba.backend.server.dto.QuizSubmitRequest; import org.springframework.stereotype.Service; @@ -13,15 +19,23 @@ public class QuizService { private final JwtProvider jwtProvider; + private final UserRepository userRepository; private final IncorrectArticlesRepository incorrectArticlesRepository; private final IncorrectQuizRepository incorrectQuizRepository; - public void submitQuiz(String token, QuizSubmitRequest request) { + public void submit(String jwt, QuizSubmitRequest request) { - Long userId = Long.valueOf(jwtProvider.getUserId(token)); + // 🔥 JWT subject = identifier + Claims claims = jwtProvider.getClaims(jwt); + String identifier = claims.getSubject(); + + User user = userRepository.findByIdentifier(identifier) + .orElseThrow(() -> new RuntimeException("User not found")); + + Long userId = user.getId(); Long articleId = request.getArticleId(); - // 🔵 1) solved(푼 문제) 저장 — Incorrect_Articles + // 🔵 solved 저장 IncorrectArticles solved = IncorrectArticles.builder() .userId(userId) .articleId(articleId) @@ -30,8 +44,7 @@ public void submitQuiz(String token, QuizSubmitRequest request) { incorrectArticlesRepository.save(solved); - - // 🔵 2) 정오답 기록 저장 — Incorrect_Quiz + // 🔵 정오답 저장 IncorrectQuiz quiz = IncorrectQuiz.builder() .userId(userId) .articleId(articleId) diff --git a/server/src/main/java/oba/backend/server/service/UserService.java b/server/src/main/java/oba/backend/server/service/UserService.java new file mode 100644 index 0000000..884d877 --- /dev/null +++ b/server/src/main/java/oba/backend/server/service/UserService.java @@ -0,0 +1,25 @@ +package oba.backend.server.service; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.user.User; +import oba.backend.server.domain.user.UserRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + public User findOrCreate(String email, String name) { + + return userRepository.findByEmail(email) + .orElseGet(() -> { + User user = User.builder() + .email(email) + .name(name) + .build(); + return userRepository.save(user); + }); + } +} diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index 59776dd..22dd54a 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -1,5 +1,5 @@ server: - port: ${SERVER_PORT:9000} + port: ${SERVER_PORT:8080} jwt: secret: ${JWT_SECRET} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 1267f93..bbcd728 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -27,23 +27,41 @@ spring: security: oauth2: client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: + - email + - profile + # ★ ngrok 사용 시 baseUrl 반드시 필요 + redirect-uri: "{baseUrl}/login/oauth2/code/google" + provider: google: authorization-uri: https://accounts.google.com/o/oauth2/v2/auth token-uri: https://oauth2.googleapis.com/token user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo user-name-attribute: sub - registration: - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} jwt: secret: ${JWT_SECRET} + access-token-expiration-ms: ${JWT_ACCESS_TOKEN_EXPIRATION_MS:1800000} + refresh-token-expiration-ms: ${JWT_REFRESH_TOKEN_EXPIRATION_MS:1209600000} ai: server: url: ${AI_SERVER_URL} server: - port: ${SERVER_PORT:9000} + port: ${SERVER_PORT:8080} + + # ngrok HTTPS → Spring Boot HTTP proxy 처리용 + forward-headers-strategy: framework + + # 핵심: ngrok이 로컬 스프링 서버에 연결되려면 반드시 필요 + address: 0.0.0.0 + +logging: + level: + root: INFO From 5488b1db3a0cc7a820f496cd46df2a3d27e053ad Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Thu, 4 Dec 2025 15:45:27 +0900 Subject: [PATCH 106/198] =?UTF-8?q?ON-79=20GoogleTokenVerifier=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20-=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EA=B2=80=EC=A6=9D=20=EB=B0=A9=EC=8B=9D=20=EC=A0=84?= =?UTF-8?q?=EB=A9=B4=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/google/GoogleTokenVerifier.java | 40 ---------- .../server/config/EnvVarPostProcessor.java | 60 +-------------- .../server/config/RestTemplateConfig.java | 14 ---- .../oba/backend/server/config/WebConfig.java | 19 ----- .../controller/GptResultController.java | 26 ------- .../server/controller/UserController.java | 43 ----------- .../backend/server/domain/gpt/GptResult.java | 25 ------ .../quiz/IncorrectArticlesRepository.java | 11 --- .../domain/quiz/IncorrectQuizRepository.java | 11 --- .../server/domain/quiz/QuizResultRequest.java | 9 --- .../server/dto/ArticleDetailResponse.java | 24 +++--- .../server/dto/ArticleSummaryResponse.java | 10 +-- .../oba/backend/server/dto/LoginRequest.java | 6 +- .../backend/server/dto/QuizResultRequest.java | 12 +++ .../backend/server/dto/QuizSubmitRequest.java | 11 ++- .../server/dto/SolvedArticleResponse.java | 17 +++++ .../oba/backend/server/dto/TokenResponse.java | 6 +- .../server/dto/WrongArticleResponse.java | 18 +++++ .../server/entity/{ => mongo}/BaseEntity.java | 0 .../entity/mysql/ArticleCategoryId.java | 16 ---- .../repository/GptResultRepository.java | 9 --- .../quiz/IncorrectArticlesRepository.java | 14 ++++ .../quiz/IncorrectQuizRepository.java | 14 ++++ .../user/UserRepository.java | 0 .../server/security/GoogleVerifier.java | 44 ----------- .../server/security/KakaoVerifier.java | 43 ----------- .../server/security/NaverVerifier.java | 41 ---------- .../server/security/OAuthAttributes.java | 16 ---- .../oauth/CustomOAuth2UserService.java | 54 ------------- .../security/oauth/OAuth2SuccessHandler.java | 38 ---------- .../security/oauth/dto/CustomOAuth2User.java | 48 ------------ .../security/oauth/dto/OAuthAttributes.java | 76 ------------------- .../oauth/dto/SolvedArticleResponse.java | 13 ---- .../oauth/dto/WrongArticleResponse.java | 11 --- .../server/service/GptResultService.java | 17 ----- .../quiz => service}/QuizResultService.java | 3 +- .../backend/server/service/UserService.java | 25 ------ .../main/resources/META-INF/spring.factories | 2 - 38 files changed, 109 insertions(+), 737 deletions(-) delete mode 100644 server/src/main/java/oba/backend/server/auth/google/GoogleTokenVerifier.java delete mode 100644 server/src/main/java/oba/backend/server/config/RestTemplateConfig.java delete mode 100644 server/src/main/java/oba/backend/server/config/WebConfig.java delete mode 100644 server/src/main/java/oba/backend/server/controller/GptResultController.java delete mode 100644 server/src/main/java/oba/backend/server/controller/UserController.java delete mode 100644 server/src/main/java/oba/backend/server/domain/gpt/GptResult.java delete mode 100644 server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java delete mode 100644 server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java delete mode 100644 server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java create mode 100644 server/src/main/java/oba/backend/server/dto/QuizResultRequest.java create mode 100644 server/src/main/java/oba/backend/server/dto/SolvedArticleResponse.java create mode 100644 server/src/main/java/oba/backend/server/dto/WrongArticleResponse.java rename server/src/main/java/oba/backend/server/entity/{ => mongo}/BaseEntity.java (100%) delete mode 100644 server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java delete mode 100644 server/src/main/java/oba/backend/server/repository/GptResultRepository.java create mode 100644 server/src/main/java/oba/backend/server/repository/quiz/IncorrectArticlesRepository.java create mode 100644 server/src/main/java/oba/backend/server/repository/quiz/IncorrectQuizRepository.java rename server/src/main/java/oba/backend/server/{domain => repository}/user/UserRepository.java (100%) delete mode 100644 server/src/main/java/oba/backend/server/security/GoogleVerifier.java delete mode 100644 server/src/main/java/oba/backend/server/security/KakaoVerifier.java delete mode 100644 server/src/main/java/oba/backend/server/security/NaverVerifier.java delete mode 100644 server/src/main/java/oba/backend/server/security/OAuthAttributes.java delete mode 100644 server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java delete mode 100644 server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java delete mode 100644 server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java delete mode 100644 server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java delete mode 100644 server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java delete mode 100644 server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java delete mode 100644 server/src/main/java/oba/backend/server/service/GptResultService.java rename server/src/main/java/oba/backend/server/{domain/quiz => service}/QuizResultService.java (93%) delete mode 100644 server/src/main/java/oba/backend/server/service/UserService.java diff --git a/server/src/main/java/oba/backend/server/auth/google/GoogleTokenVerifier.java b/server/src/main/java/oba/backend/server/auth/google/GoogleTokenVerifier.java deleted file mode 100644 index 8a8baf1..0000000 --- a/server/src/main/java/oba/backend/server/auth/google/GoogleTokenVerifier.java +++ /dev/null @@ -1,40 +0,0 @@ -package oba.backend.server.auth.google; - -import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; -import com.google.api.client.http.javanet.NetHttpTransport; -import com.google.api.client.json.gson.GsonFactory; -import org.springframework.stereotype.Component; - -import java.util.Collections; - -@Component -public class GoogleTokenVerifier { - - // Firebase 프로젝트의 Web Client ID 넣기 - private static final String CLIENT_ID = - "774547640000-xxx.apps.googleusercontent.com"; - - public GoogleIdToken.Payload verify(String idTokenString) { - - try { - GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder( - new NetHttpTransport(), - new GsonFactory() - ) - .setAudience(Collections.singletonList(CLIENT_ID)) - .build(); - - GoogleIdToken idToken = verifier.verify(idTokenString); - - if (idToken != null) { - return idToken.getPayload(); - } - - throw new RuntimeException("Invalid Google ID Token"); - - } catch (Exception e) { - throw new RuntimeException("Google Token Verification Failed", e); - } - } -} diff --git a/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java b/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java index 119de3f..6a84944 100644 --- a/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java +++ b/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java @@ -1,60 +1,4 @@ -package oba.backend.server.config.env; +package oba.backend.server.config; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.env.EnvironmentPostProcessor; -import org.springframework.core.Ordered; -import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.MapPropertySource; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.util.HashMap; -import java.util.Map; - -public class EnvVarPostProcessor implements EnvironmentPostProcessor, Ordered { - - @Override - public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { - - try { - File envFile = new File(".env"); // ★ 실행 위치(server/)의 .env 를 로드 - - if (!envFile.exists()) { - System.out.println("[EnvPostProcessor] .env not found in working directory"); - return; - } - - Map map = new HashMap<>(); - - try (BufferedReader reader = new BufferedReader(new FileReader(envFile))) { - String line; - while ((line = reader.readLine()) != null) { - line = line.trim(); - if (line.isEmpty() || line.startsWith("#")) continue; - - if (!line.contains("=")) continue; - - String[] parts = line.split("=", 2); - String key = parts[0].trim(); - String value = parts.length > 1 ? parts[1].trim() : ""; - - map.put(key, value); - } - } - - environment.getPropertySources() - .addFirst(new MapPropertySource("customEnvVars", map)); - - System.out.println("[EnvPostProcessor] .env loaded successfully from server/"); - - } catch (Exception e) { - System.out.println("[EnvPostProcessor] Error loading .env: " + e.getMessage()); - } - } - - @Override - public int getOrder() { - return Ordered.HIGHEST_PRECEDENCE; - } +public class EnvVarPostProcessor { } diff --git a/server/src/main/java/oba/backend/server/config/RestTemplateConfig.java b/server/src/main/java/oba/backend/server/config/RestTemplateConfig.java deleted file mode 100644 index d2f8695..0000000 --- a/server/src/main/java/oba/backend/server/config/RestTemplateConfig.java +++ /dev/null @@ -1,14 +0,0 @@ -package oba.backend.server.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; - -@Configuration -public class RestTemplateConfig { - - @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); - } -} diff --git a/server/src/main/java/oba/backend/server/config/WebConfig.java b/server/src/main/java/oba/backend/server/config/WebConfig.java deleted file mode 100644 index 68d7be1..0000000 --- a/server/src/main/java/oba/backend/server/config/WebConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package oba.backend.server.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("/**") - .allowedOrigins("*") // 모든 Origin 허용 (필요시 특정 도메인으로 변경 가능) - .allowedMethods("*") // GET, POST, PUT, DELETE 모두 허용 - .allowedHeaders("*") - .allowCredentials(false); - } -} diff --git a/server/src/main/java/oba/backend/server/controller/GptResultController.java b/server/src/main/java/oba/backend/server/controller/GptResultController.java deleted file mode 100644 index 5f32ebc..0000000 --- a/server/src/main/java/oba/backend/server/controller/GptResultController.java +++ /dev/null @@ -1,26 +0,0 @@ -package oba.backend.server.controller; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.gpt.GptResult; -import oba.backend.server.service.GptResultService; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/gpt") -@RequiredArgsConstructor -public class GptResultController { - - private final GptResultService gptResultService; - - @GetMapping("/latest") - public ResponseEntity getLatestReport() { - GptResult result = gptResultService.getLatestGptResult(); - - if (result == null) { - return ResponseEntity.noContent().build(); - } - - return ResponseEntity.ok(result); - } -} diff --git a/server/src/main/java/oba/backend/server/controller/UserController.java b/server/src/main/java/oba/backend/server/controller/UserController.java deleted file mode 100644 index d972f99..0000000 --- a/server/src/main/java/oba/backend/server/controller/UserController.java +++ /dev/null @@ -1,43 +0,0 @@ -package oba.backend.server.security.oauth; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.UserRepository; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/auth") -@RequiredArgsConstructor -public class UserController { - - private final UserRepository userRepository; - private final JwtProvider jwtProvider; - - @GetMapping("/me") - public ResponseEntity me(@RequestHeader("Authorization") String bearer) { - - String token = bearer.replace("Bearer ", ""); - String identifier = jwtProvider.getClaims(token).getSubject(); - - User user = userRepository.findByIdentifier(identifier) - .orElseThrow(); - - return ResponseEntity.ok(new UserProfileResponse( - user.getIdentifier(), - user.getEmail(), - user.getName(), - user.getPicture(), - user.getProvider().name() - )); - } - - public record UserProfileResponse( - String identifier, - String email, - String name, - String picture, - String provider - ) {} -} diff --git a/server/src/main/java/oba/backend/server/domain/gpt/GptResult.java b/server/src/main/java/oba/backend/server/domain/gpt/GptResult.java deleted file mode 100644 index 2b00320..0000000 --- a/server/src/main/java/oba/backend/server/domain/gpt/GptResult.java +++ /dev/null @@ -1,25 +0,0 @@ -package oba.backend.server.domain.gpt; - -import lombok.*; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import java.time.LocalDateTime; -import java.util.List; - -@Document(collection = "gpt_results") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class GptResult { - - @Id - private String id; - - private String date; - private String title; - private String summary; - private List keywords; - private LocalDateTime createdAt; -} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java deleted file mode 100644 index 2fe6c80..0000000 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package oba.backend.server.domain.quiz; - -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - -public interface IncorrectArticlesRepository extends JpaRepository { - - List findByUserId(Long userId); - - void deleteByUserIdAndArticleId(Long userId, Long articleId); -} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java deleted file mode 100644 index ab631f2..0000000 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package oba.backend.server.domain.quiz; - -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - -public interface IncorrectQuizRepository extends JpaRepository { - - List findByUserId(Long userId); - - void deleteByUserIdAndArticleId(Long userId, Long articleId); -} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java b/server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java deleted file mode 100644 index 829a01d..0000000 --- a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package oba.backend.server.domain.quiz; - -import lombok.Getter; - -@Getter -public class QuizResultRequest { - private Long articleId; - private boolean[] quizResults; // true=정답, false=오답 -} diff --git a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java index 858c83a..a59ba78 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java +++ b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java @@ -1,24 +1,22 @@ -package oba.backend.server.dto; +package oba.backend.server.domain.quiz.dto; import lombok.Builder; -import lombok.Data; -import oba.backend.server.entity.mongo.GptDocument; +import lombok.Getter; +import java.time.LocalDateTime; import java.util.List; +import java.util.Map; -@Data +@Getter @Builder public class ArticleDetailResponse { - private Long articleId; private String title; - private String publishTime; - private String servingDate; - - private Object content; - private Object subtitle; - + private LocalDateTime publishTime; + private LocalDateTime servingDate; + private String content; + private String subtitle; private String summary; - private List keywords; - private List quizzes; + private List keywords; + private List> quizzes; } diff --git a/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java index d370f8f..b0ab58c 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java +++ b/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java @@ -1,16 +1,16 @@ -package oba.backend.server.dto; +package oba.backend.server.domain.quiz.dto; import lombok.Builder; -import lombok.Data; +import lombok.Getter; +import java.time.LocalDateTime; import java.util.List; -@Data +@Getter @Builder public class ArticleSummaryResponse { - private Long articleId; private String title; private List summaryBullets; - private String servingDate; + private LocalDateTime servingDate; } diff --git a/server/src/main/java/oba/backend/server/dto/LoginRequest.java b/server/src/main/java/oba/backend/server/dto/LoginRequest.java index 3ed0ee7..a3aaa03 100644 --- a/server/src/main/java/oba/backend/server/dto/LoginRequest.java +++ b/server/src/main/java/oba/backend/server/dto/LoginRequest.java @@ -1,8 +1,10 @@ package oba.backend.server.dto; -import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; -@Data +@Getter +@NoArgsConstructor public class LoginRequest { private String idToken; } diff --git a/server/src/main/java/oba/backend/server/dto/QuizResultRequest.java b/server/src/main/java/oba/backend/server/dto/QuizResultRequest.java new file mode 100644 index 0000000..5b76861 --- /dev/null +++ b/server/src/main/java/oba/backend/server/dto/QuizResultRequest.java @@ -0,0 +1,12 @@ +package oba.backend.server.domain.quiz.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class QuizResultRequest { + private Long articleId; + private boolean correct; + private int selectedOption; +} diff --git a/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java b/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java index e78a6e4..a5141d1 100644 --- a/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java +++ b/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java @@ -1,10 +1,13 @@ -package oba.backend.server.dto; +package oba.backend.server.domain.quiz.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; -import lombok.Data; import java.util.List; -@Data +@Getter +@NoArgsConstructor public class QuizSubmitRequest { private Long articleId; - private List answers; // quiz1~quiz5 순서 + private List answers; } diff --git a/server/src/main/java/oba/backend/server/dto/SolvedArticleResponse.java b/server/src/main/java/oba/backend/server/dto/SolvedArticleResponse.java new file mode 100644 index 0000000..b6aa7d5 --- /dev/null +++ b/server/src/main/java/oba/backend/server/dto/SolvedArticleResponse.java @@ -0,0 +1,17 @@ +package oba.backend.server.domain.quiz.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SolvedArticleResponse { + private Long articleId; + private String title; + private String summary; + private String solvedAt; +} diff --git a/server/src/main/java/oba/backend/server/dto/TokenResponse.java b/server/src/main/java/oba/backend/server/dto/TokenResponse.java index 00366ff..cfd5e0d 100644 --- a/server/src/main/java/oba/backend/server/dto/TokenResponse.java +++ b/server/src/main/java/oba/backend/server/dto/TokenResponse.java @@ -1,9 +1,9 @@ -package oba.backend.server.dto; +package oba.backend.server.domain.quiz.dto; import lombok.AllArgsConstructor; -import lombok.Data; +import lombok.Getter; -@Data +@Getter @AllArgsConstructor public class TokenResponse { private String accessToken; diff --git a/server/src/main/java/oba/backend/server/dto/WrongArticleResponse.java b/server/src/main/java/oba/backend/server/dto/WrongArticleResponse.java new file mode 100644 index 0000000..538116c --- /dev/null +++ b/server/src/main/java/oba/backend/server/dto/WrongArticleResponse.java @@ -0,0 +1,18 @@ +package oba.backend.server.domain.quiz.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class WrongArticleResponse { + private Long articleId; + private String title; + private String summary; + private boolean[] incorrectAnswers; + private String solvedAt; +} diff --git a/server/src/main/java/oba/backend/server/entity/BaseEntity.java b/server/src/main/java/oba/backend/server/entity/mongo/BaseEntity.java similarity index 100% rename from server/src/main/java/oba/backend/server/entity/BaseEntity.java rename to server/src/main/java/oba/backend/server/entity/mongo/BaseEntity.java diff --git a/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java b/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java deleted file mode 100644 index effe255..0000000 --- a/server/src/main/java/oba/backend/server/entity/mysql/ArticleCategoryId.java +++ /dev/null @@ -1,16 +0,0 @@ -package oba.backend.server.entity.mysql; - -import jakarta.persistence.Embeddable; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.io.Serializable; - -@Getter -@NoArgsConstructor -@Embeddable -public class ArticleCategoryId implements Serializable { - - private Long articleId; - private Integer categoryId; -} diff --git a/server/src/main/java/oba/backend/server/repository/GptResultRepository.java b/server/src/main/java/oba/backend/server/repository/GptResultRepository.java deleted file mode 100644 index 733a283..0000000 --- a/server/src/main/java/oba/backend/server/repository/GptResultRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package oba.backend.server.repository; - -import oba.backend.server.domain.gpt.GptResult; -import org.springframework.data.mongodb.repository.MongoRepository; - -public interface GptResultRepository extends MongoRepository { - - GptResult findTopByOrderByCreatedAtDesc(); -} diff --git a/server/src/main/java/oba/backend/server/repository/quiz/IncorrectArticlesRepository.java b/server/src/main/java/oba/backend/server/repository/quiz/IncorrectArticlesRepository.java new file mode 100644 index 0000000..2290269 --- /dev/null +++ b/server/src/main/java/oba/backend/server/repository/quiz/IncorrectArticlesRepository.java @@ -0,0 +1,14 @@ +package oba.backend.server.repository.mysql; + +import oba.backend.server.domain.quiz.IncorrectArticles; +import oba.backend.server.domain.quiz.IncorrectArticlesId; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface IncorrectArticlesRepository + extends JpaRepository { + + List findByUserId(Long userId); + + void deleteByUserIdAndArticleId(Long userId, Long articleId); +} diff --git a/server/src/main/java/oba/backend/server/repository/quiz/IncorrectQuizRepository.java b/server/src/main/java/oba/backend/server/repository/quiz/IncorrectQuizRepository.java new file mode 100644 index 0000000..0edb171 --- /dev/null +++ b/server/src/main/java/oba/backend/server/repository/quiz/IncorrectQuizRepository.java @@ -0,0 +1,14 @@ +package oba.backend.server.repository.mysql; + +import oba.backend.server.domain.quiz.IncorrectQuiz; +import oba.backend.server.domain.quiz.IncorrectQuizId; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface IncorrectQuizRepository + extends JpaRepository { + + List findByUserId(Long userId); + + void deleteByUserIdAndArticleId(Long userId, Long articleId); +} diff --git a/server/src/main/java/oba/backend/server/domain/user/UserRepository.java b/server/src/main/java/oba/backend/server/repository/user/UserRepository.java similarity index 100% rename from server/src/main/java/oba/backend/server/domain/user/UserRepository.java rename to server/src/main/java/oba/backend/server/repository/user/UserRepository.java diff --git a/server/src/main/java/oba/backend/server/security/GoogleVerifier.java b/server/src/main/java/oba/backend/server/security/GoogleVerifier.java deleted file mode 100644 index 635412c..0000000 --- a/server/src/main/java/oba/backend/server/security/GoogleVerifier.java +++ /dev/null @@ -1,44 +0,0 @@ -package oba.backend.server.security; - -import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; -import com.google.api.client.http.javanet.NetHttpTransport; -import com.google.api.client.json.gson.GsonFactory; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.util.Collections; - -@Component -@RequiredArgsConstructor -public class GoogleVerifier { - - @Value("${GOOGLE_CLIENT_ID}") - private String googleClientId; - - private static final NetHttpTransport transport = new NetHttpTransport(); - private static final GsonFactory jsonFactory = new GsonFactory(); - - public GoogleIdToken.Payload verify(String idTokenString) { - try { - if (googleClientId == null || googleClientId.isBlank()) { - throw new IllegalStateException("GOOGLE_CLIENT_ID is missing. Check your .env or application.yml"); - } - - GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory) - .setAudience(Collections.singletonList(googleClientId)) - .build(); - - GoogleIdToken idToken = verifier.verify(idTokenString); - if (idToken == null) { - throw new RuntimeException("Invalid Google ID Token"); - } - - return idToken.getPayload(); - - } catch (Exception e) { - throw new RuntimeException("Google token verification failed", e); - } - } -} diff --git a/server/src/main/java/oba/backend/server/security/KakaoVerifier.java b/server/src/main/java/oba/backend/server/security/KakaoVerifier.java deleted file mode 100644 index 8328eea..0000000 --- a/server/src/main/java/oba/backend/server/security/KakaoVerifier.java +++ /dev/null @@ -1,43 +0,0 @@ -package oba.backend.server.security; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.security.OAuthAttributes; -import org.springframework.http.*; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.util.Map; - -@Component -@RequiredArgsConstructor -public class KakaoVerifier { - - private final RestTemplate restTemplate = new RestTemplate(); - - public OAuthAttributes verify(String accessToken) { - - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + accessToken); - - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity response = restTemplate.exchange( - "https://kapi.kakao.com/v2/user/me", - HttpMethod.GET, - entity, - Map.class - ); - - Map body = response.getBody(); - Map account = (Map) body.get("kakao_account"); - Map profile = (Map) account.get("profile"); - - return new OAuthAttributes( - String.valueOf(body.get("id")), - (String) account.get("email"), - (String) profile.get("nickname"), - (String) profile.get("profile_image_url"), - body - ); - } -} diff --git a/server/src/main/java/oba/backend/server/security/NaverVerifier.java b/server/src/main/java/oba/backend/server/security/NaverVerifier.java deleted file mode 100644 index b345adf..0000000 --- a/server/src/main/java/oba/backend/server/security/NaverVerifier.java +++ /dev/null @@ -1,41 +0,0 @@ -package oba.backend.server.security; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.security.OAuthAttributes; -import org.springframework.http.*; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.util.Map; - -@Component -@RequiredArgsConstructor -public class NaverVerifier { - - private final RestTemplate restTemplate = new RestTemplate(); - - public OAuthAttributes verify(String accessToken) { - - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + accessToken); - - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity response = restTemplate.exchange( - "https://openapi.naver.com/v1/nid/me", - HttpMethod.GET, - entity, - Map.class - ); - - Map body = (Map) response.getBody().get("response"); - - return new OAuthAttributes( - (String) body.get("id"), - (String) body.get("email"), - (String) body.get("name"), - (String) body.get("profile_image"), - body - ); - } -} diff --git a/server/src/main/java/oba/backend/server/security/OAuthAttributes.java b/server/src/main/java/oba/backend/server/security/OAuthAttributes.java deleted file mode 100644 index 7a5c074..0000000 --- a/server/src/main/java/oba/backend/server/security/OAuthAttributes.java +++ /dev/null @@ -1,16 +0,0 @@ -package oba.backend.server.security; - -import java.util.Map; - -/** - * 모바일 소셜 로그인(Google/Kakao/Naver) 공통 사용자 정보 DTO - * - MobileAuthController에서 DB 저장 및 JWT 발급에 사용 - * - Verifier들이 반환하는 공통 구조 - */ -public record OAuthAttributes( - String id, - String email, - String name, - String picture, - Map attributes -) { } diff --git a/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java deleted file mode 100644 index 0c0db02..0000000 --- a/server/src/main/java/oba/backend/server/security/oauth/CustomOAuth2UserService.java +++ /dev/null @@ -1,54 +0,0 @@ -package oba.backend.server.security.oauth; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.ProviderInfo; -import oba.backend.server.domain.user.Role; -import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.UserRepository; -import oba.backend.server.security.oauth.dto.OAuthAttributes; -import oba.backend.server.security.oauth.dto.CustomOAuth2User; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class CustomOAuth2UserService extends DefaultOAuth2UserService { - - private final UserRepository userRepository; - - @Override - public OAuth2User loadUser(OAuth2UserRequest request) { - - OAuth2User oauth = super.loadUser(request); - - String provider = request.getClientRegistration().getRegistrationId(); - OAuthAttributes attr = OAuthAttributes.of(provider, oauth.getAttributes()); - - User user = saveOrUpdate(attr); - - return new CustomOAuth2User(user, oauth.getAttributes()); - } - - private User saveOrUpdate(OAuthAttributes attr) { - - String identifier = attr.getProvider() + ":" + attr.getEmail(); - - return userRepository.findByIdentifier(identifier) - .map(u -> { - u.updateInfo(attr.getEmail(), attr.getName(), attr.getPicture()); - return userRepository.save(u); - }) - .orElseGet(() -> userRepository.save( - User.builder() - .identifier(identifier) - .email(attr.getEmail()) - .name(attr.getName()) - .picture(attr.getPicture()) - .provider(ProviderInfo.from(attr.getProvider())) - .role(Role.USER) - .build() - )); - } -} diff --git a/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java b/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java deleted file mode 100644 index 928ecbe..0000000 --- a/server/src/main/java/oba/backend/server/security/oauth/OAuth2SuccessHandler.java +++ /dev/null @@ -1,38 +0,0 @@ -package oba.backend.server.security.oauth; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.security.oauth.dto.CustomOAuth2User; -import org.springframework.stereotype.Component; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; - -import java.io.IOException; - -@Component -@RequiredArgsConstructor -public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - - private final JwtProvider jwtProvider; - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, - HttpServletResponse response, - Authentication authentication) - throws IOException { - - CustomOAuth2User user = (CustomOAuth2User) authentication.getPrincipal(); - String identifier = "google:" + user.getUserId(); - - // Access Token만 생성 - String access = jwtProvider.createAccessToken(identifier); - - // Expo Dev 클라이언트 Redirect URI - String redirect = "exp://localhost:8081/oauth" - + "?access=" + access; - - getRedirectStrategy().sendRedirect(request, response, redirect); - } -} diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java b/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java deleted file mode 100644 index 418a27c..0000000 --- a/server/src/main/java/oba/backend/server/security/oauth/dto/CustomOAuth2User.java +++ /dev/null @@ -1,48 +0,0 @@ -package oba.backend.server.security.oauth.dto; - -import lombok.Getter; -import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.Role; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.user.OAuth2User; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -@Getter -public class CustomOAuth2User implements OAuth2User { - - private final User user; - private final Map attributes; - - public CustomOAuth2User(User user, Map attributes) { - this.user = user; - this.attributes = attributes; - } - - // ⭐ 식별자(google:123) 반환 - public String getUserId() { - return user.getIdentifier(); - } - - @Override - public Map getAttributes() { - return attributes; - } - - // ⭐ OAuth2User 인터페이스에서 required - @Override - public String getName() { - return user.getName(); - } - - // ⭐ Spring Security가 요구하는 권한 목록 - @Override - public Collection getAuthorities() { - // User 엔티티의 Role(USER, ADMIN 등) 사용 - Role role = user.getRole(); - return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); - } -} diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java b/server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java deleted file mode 100644 index c55a4e2..0000000 --- a/server/src/main/java/oba/backend/server/security/oauth/dto/OAuthAttributes.java +++ /dev/null @@ -1,76 +0,0 @@ -package oba.backend.server.security.oauth.dto; - -import lombok.Builder; -import lombok.Getter; -import oba.backend.server.domain.user.ProviderInfo; -import oba.backend.server.domain.user.User; - -import java.util.Map; - -@Getter -@Builder -public class OAuthAttributes { - - private Map attributes; - private String name; - private String email; - private String picture; - private String provider; // google / kakao / naver - - public static OAuthAttributes of(String provider, Map attributes) { - switch (provider.toLowerCase()) { - case "google": - return ofGoogle(attributes); - case "kakao": - return ofKakao(attributes); - case "naver": - return ofNaver(attributes); - } - throw new RuntimeException("지원하지 않는 provider: " + provider); - } - - private static OAuthAttributes ofGoogle(Map attr) { - return OAuthAttributes.builder() - .name((String) attr.get("name")) - .email((String) attr.get("email")) - .picture((String) attr.get("picture")) - .provider("google") - .attributes(attr) - .build(); - } - - private static OAuthAttributes ofKakao(Map attr) { - Map kakaoAccount = (Map) attr.get("kakao_account"); - Map profile = (Map) kakaoAccount.get("profile"); - - return OAuthAttributes.builder() - .name((String) profile.get("nickname")) - .email((String) kakaoAccount.get("email")) - .picture((String) profile.get("profile_image_url")) - .provider("kakao") - .attributes(attr) - .build(); - } - - private static OAuthAttributes ofNaver(Map attr) { - Map res = (Map) attr.get("response"); - - return OAuthAttributes.builder() - .name((String) res.get("name")) - .email((String) res.get("email")) - .picture((String) res.get("profile_image")) - .provider("naver") - .attributes(attr) - .build(); - } - - public User toEntity() { - return User.builder() - .identifier(provider + ":" + email) - .email(email) - .name(name) - .picture(picture) - .provider(ProviderInfo.valueOf(provider.toUpperCase())) - .build(); - } -} diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java b/server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java deleted file mode 100644 index 34b76b0..0000000 --- a/server/src/main/java/oba/backend/server/security/oauth/dto/SolvedArticleResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package oba.backend.server.domain.quiz.dto; - -import lombok.Builder; -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -@Builder -public class SolvedArticleResponse { - private Long articleId; - private LocalDateTime solvedAt; -} diff --git a/server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java b/server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java deleted file mode 100644 index 89ba39c..0000000 --- a/server/src/main/java/oba/backend/server/security/oauth/dto/WrongArticleResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package oba.backend.server.domain.quiz.dto; - -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class WrongArticleResponse { - private Long articleId; - private boolean[] wrongList; // ex: [false, true, false, true, false] -} diff --git a/server/src/main/java/oba/backend/server/service/GptResultService.java b/server/src/main/java/oba/backend/server/service/GptResultService.java deleted file mode 100644 index 3651656..0000000 --- a/server/src/main/java/oba/backend/server/service/GptResultService.java +++ /dev/null @@ -1,17 +0,0 @@ -package oba.backend.server.service; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.gpt.GptResult; -import oba.backend.server.repository.GptResultRepository; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class GptResultService { - - private final GptResultRepository gptResultRepository; - - public GptResult getLatestGptResult() { - return gptResultRepository.findTopByOrderByCreatedAtDesc(); - } -} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java b/server/src/main/java/oba/backend/server/service/QuizResultService.java similarity index 93% rename from server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java rename to server/src/main/java/oba/backend/server/service/QuizResultService.java index 173e3db..00256b6 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/QuizResultService.java +++ b/server/src/main/java/oba/backend/server/service/QuizResultService.java @@ -3,8 +3,9 @@ import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import oba.backend.server.common.jwt.JwtProvider; +import oba.backend.server.domain.quiz.dto.QuizResultRequest; import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.UserRepository; +import oba.backend.server.repository.user.UserRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/server/src/main/java/oba/backend/server/service/UserService.java b/server/src/main/java/oba/backend/server/service/UserService.java deleted file mode 100644 index 884d877..0000000 --- a/server/src/main/java/oba/backend/server/service/UserService.java +++ /dev/null @@ -1,25 +0,0 @@ -package oba.backend.server.service; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.UserRepository; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserService { - - private final UserRepository userRepository; - - public User findOrCreate(String email, String name) { - - return userRepository.findByEmail(email) - .orElseGet(() -> { - User user = User.builder() - .email(email) - .name(name) - .build(); - return userRepository.save(user); - }); - } -} diff --git a/server/src/main/resources/META-INF/spring.factories b/server/src/main/resources/META-INF/spring.factories index 9ddf9c1..e69de29 100644 --- a/server/src/main/resources/META-INF/spring.factories +++ b/server/src/main/resources/META-INF/spring.factories @@ -1,2 +0,0 @@ -org.springframework.boot.env.EnvironmentPostProcessor=\ -oba.backend.server.config.env.EnvVarPostProcessor From c2c5d4d240f13f41610a0a8cc763a2cc7bd07cfc Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Thu, 4 Dec 2025 15:45:57 +0900 Subject: [PATCH 107/198] =?UTF-8?q?ON-79=20EnvVarPostProcessor=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/config/EnvVarPostProcessor.java | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java b/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java index 6a84944..4457889 100644 --- a/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java +++ b/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java @@ -1,4 +1,22 @@ package oba.backend.server.config; -public class EnvVarPostProcessor { +import io.github.cdimascio.dotenv.Dotenv; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; + +public class EnvVarPostProcessor implements EnvironmentPostProcessor { + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, + SpringApplication application) { + + Dotenv dotenv = Dotenv.configure() + .ignoreIfMissing() + .load(); + + dotenv.entries().forEach(entry -> { + environment.getSystemProperties().put(entry.getKey(), entry.getValue()); + }); + } } From f753e70b4fd9745014314f41a7e631399a586cdd Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Thu, 4 Dec 2025 15:46:01 +0900 Subject: [PATCH 108/198] =?UTF-8?q?ON-79=20QuizResultRequest=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=ED=80=B4?= =?UTF-8?q?=EC=A6=88=20=EC=A0=9C=EC=B6=9C=20=EC=9A=94=EC=B2=AD=20DTO=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/oba/backend/server/dto/QuizResultRequest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/oba/backend/server/dto/QuizResultRequest.java b/server/src/main/java/oba/backend/server/dto/QuizResultRequest.java index 5b76861..2cc393a 100644 --- a/server/src/main/java/oba/backend/server/dto/QuizResultRequest.java +++ b/server/src/main/java/oba/backend/server/dto/QuizResultRequest.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.quiz.dto; +package oba.backend.server.dto; import lombok.Getter; import lombok.NoArgsConstructor; @@ -7,6 +7,6 @@ @NoArgsConstructor public class QuizResultRequest { private Long articleId; - private boolean correct; - private int selectedOption; + private boolean correct; // 맞았는지 + private int selectedOption; // 사용자가 고른 선택지 } From 3cf3f544e06ae265b3e74fb3d7089974d66531c6 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Thu, 4 Dec 2025 15:46:07 +0900 Subject: [PATCH 109/198] =?UTF-8?q?ON-79=20SolvedArticleResponse=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=8B=B5=20=EA=B8=B0=EB=A1=9D=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/dto/SolvedArticleResponse.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/dto/SolvedArticleResponse.java b/server/src/main/java/oba/backend/server/dto/SolvedArticleResponse.java index b6aa7d5..af0c30f 100644 --- a/server/src/main/java/oba/backend/server/dto/SolvedArticleResponse.java +++ b/server/src/main/java/oba/backend/server/dto/SolvedArticleResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.quiz.dto; +package oba.backend.server.dto; import lombok.AllArgsConstructor; import lombok.Builder; @@ -6,9 +6,9 @@ import lombok.NoArgsConstructor; @Getter +@Builder @NoArgsConstructor @AllArgsConstructor -@Builder public class SolvedArticleResponse { private Long articleId; private String title; From 0f19a3c848c0f4c3bedd7bc64508036807bd2c5f Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Thu, 4 Dec 2025 15:46:12 +0900 Subject: [PATCH 110/198] =?UTF-8?q?ON-79=20WrongArticleResponse=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=ED=8B=80=EB=A6=B0=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/dto/WrongArticleResponse.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/dto/WrongArticleResponse.java b/server/src/main/java/oba/backend/server/dto/WrongArticleResponse.java index 538116c..a8983c1 100644 --- a/server/src/main/java/oba/backend/server/dto/WrongArticleResponse.java +++ b/server/src/main/java/oba/backend/server/dto/WrongArticleResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.quiz.dto; +package oba.backend.server.dto; import lombok.AllArgsConstructor; import lombok.Builder; @@ -6,9 +6,9 @@ import lombok.NoArgsConstructor; @Getter +@Builder @NoArgsConstructor @AllArgsConstructor -@Builder public class WrongArticleResponse { private Long articleId; private String title; From 07dae1f5f4a09c732df3935a589fdb6dc88dd8b1 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Thu, 4 Dec 2025 15:46:18 +0900 Subject: [PATCH 111/198] =?UTF-8?q?ON-79=20BaseEntity=20MongoDB=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=ED=8C=A8=ED=82=A4=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20-=20RDB=20=EA=B5=AC=EC=A1=B0=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/oba/backend/server/entity/mongo/BaseEntity.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/entity/mongo/BaseEntity.java b/server/src/main/java/oba/backend/server/entity/mongo/BaseEntity.java index 6182351..8155cf4 100644 --- a/server/src/main/java/oba/backend/server/entity/mongo/BaseEntity.java +++ b/server/src/main/java/oba/backend/server/entity/mongo/BaseEntity.java @@ -1,8 +1,7 @@ -package oba.backend.server.entity; +package oba.backend.server.entity.mongo; import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; From 88dd927ff47e4ac91972326fe13e6f0771a75878 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Thu, 4 Dec 2025 15:46:24 +0900 Subject: [PATCH 112/198] =?UTF-8?q?ON-79=20IncorrectArticles/Quiz=20Reposi?= =?UTF-8?q?tory=20=EC=B6=94=EA=B0=80=20-=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=98=A4=EB=8B=B5=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/repository/quiz/IncorrectArticlesRepository.java | 6 +++--- .../server/repository/quiz/IncorrectQuizRepository.java | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/src/main/java/oba/backend/server/repository/quiz/IncorrectArticlesRepository.java b/server/src/main/java/oba/backend/server/repository/quiz/IncorrectArticlesRepository.java index 2290269..18f3db2 100644 --- a/server/src/main/java/oba/backend/server/repository/quiz/IncorrectArticlesRepository.java +++ b/server/src/main/java/oba/backend/server/repository/quiz/IncorrectArticlesRepository.java @@ -1,12 +1,12 @@ -package oba.backend.server.repository.mysql; +package oba.backend.server.repository.quiz; import oba.backend.server.domain.quiz.IncorrectArticles; import oba.backend.server.domain.quiz.IncorrectArticlesId; import org.springframework.data.jpa.repository.JpaRepository; + import java.util.List; -public interface IncorrectArticlesRepository - extends JpaRepository { +public interface IncorrectArticlesRepository extends JpaRepository { List findByUserId(Long userId); diff --git a/server/src/main/java/oba/backend/server/repository/quiz/IncorrectQuizRepository.java b/server/src/main/java/oba/backend/server/repository/quiz/IncorrectQuizRepository.java index 0edb171..0907919 100644 --- a/server/src/main/java/oba/backend/server/repository/quiz/IncorrectQuizRepository.java +++ b/server/src/main/java/oba/backend/server/repository/quiz/IncorrectQuizRepository.java @@ -1,12 +1,12 @@ -package oba.backend.server.repository.mysql; +package oba.backend.server.repository.quiz; import oba.backend.server.domain.quiz.IncorrectQuiz; import oba.backend.server.domain.quiz.IncorrectQuizId; import org.springframework.data.jpa.repository.JpaRepository; + import java.util.List; -public interface IncorrectQuizRepository - extends JpaRepository { +public interface IncorrectQuizRepository extends JpaRepository { List findByUserId(Long userId); From fce6229b8603c2ba2e388e84c5fa82fd745029e2 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Thu, 4 Dec 2025 15:46:29 +0900 Subject: [PATCH 113/198] =?UTF-8?q?ON-79=20UserRepository=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99=20-=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/repository/user/UserRepository.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/repository/user/UserRepository.java b/server/src/main/java/oba/backend/server/repository/user/UserRepository.java index 435512b..957a0d2 100644 --- a/server/src/main/java/oba/backend/server/repository/user/UserRepository.java +++ b/server/src/main/java/oba/backend/server/repository/user/UserRepository.java @@ -1,5 +1,6 @@ -package oba.backend.server.domain.user; +package oba.backend.server.repository.user; +import oba.backend.server.domain.user.User; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; From 0817967f7875dfe4ea611f3d47e9c7bf05219c1f Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Thu, 4 Dec 2025 15:46:34 +0900 Subject: [PATCH 114/198] =?UTF-8?q?ON-79=20QuizResultService=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99=20-=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B3=84=EC=B8=B5=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=A0=95=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/service/QuizResultService.java | 40 ++++++------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/server/src/main/java/oba/backend/server/service/QuizResultService.java b/server/src/main/java/oba/backend/server/service/QuizResultService.java index 00256b6..fd7a0b9 100644 --- a/server/src/main/java/oba/backend/server/service/QuizResultService.java +++ b/server/src/main/java/oba/backend/server/service/QuizResultService.java @@ -1,47 +1,33 @@ -package oba.backend.server.domain.quiz; +package oba.backend.server.service; -import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.domain.quiz.dto.QuizResultRequest; -import oba.backend.server.domain.user.User; -import oba.backend.server.repository.user.UserRepository; +import oba.backend.server.dto.QuizResultRequest; +import oba.backend.server.domain.quiz.IncorrectArticles; +import oba.backend.server.domain.quiz.IncorrectArticlesId; +import oba.backend.server.repository.quiz.IncorrectArticlesRepository; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; @Service @RequiredArgsConstructor public class QuizResultService { - private final JwtProvider jwtProvider; - private final UserRepository userRepository; - private final IncorrectQuizRepository incorrectQuizRepository; private final IncorrectArticlesRepository incorrectArticlesRepository; + private final JwtProvider jwtProvider; - @Transactional public void saveQuizResult(String jwt, QuizResultRequest request) { - Claims claims = jwtProvider.getClaims(jwt); - String identifier = claims.getSubject(); - - User user = userRepository.findByIdentifier(identifier) - .orElseThrow(() -> new RuntimeException("User not found")); - Long userId = user.getId(); + Long userId = jwtProvider.getUserId(jwt); - boolean[] results = request.getQuizResults(); - - incorrectQuizRepository.deleteByUserIdAndArticleId(userId, request.getArticleId()); - - IncorrectQuiz quiz = IncorrectQuiz.builder() + IncorrectArticles incorrectArticles = IncorrectArticles.builder() .userId(userId) .articleId(request.getArticleId()) - .quiz1(results.length > 0 ? results[0] : null) - .quiz2(results.length > 1 ? results[1] : null) - .quiz3(results.length > 2 ? results[2] : null) - .quiz4(results.length > 3 ? results[3] : null) - .quiz5(results.length > 4 ? results[4] : null) + .solDate(LocalDateTime.now()) .build(); - incorrectQuizRepository.save(quiz); + incorrectArticlesRepository.save(incorrectArticles); } } + From 6fa77d52db5db34062cf55ae0698b4146a23cd93 Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Thu, 4 Dec 2025 15:47:50 +0900 Subject: [PATCH 115/198] =?UTF-8?q?ON-79=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20A?= =?UTF-8?q?PI=20=EC=A0=84=EC=B2=B4=20=EA=B5=AC=EC=A1=B0=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20-=20=EC=9D=B8=EC=A6=9D/=EA=B8=B0?= =?UTF-8?q?=EC=82=AC/=ED=80=B4=EC=A6=88/=EC=98=A4=EB=8B=B5/=EC=A0=95?= =?UTF-8?q?=EB=8B=B5=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B0=8F=20DTO/Service/Config=20=EC=A0=84=EB=A9=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/build.gradle | 29 +++---- .../common/jwt/JwtAuthenticationFilter.java | 15 ---- .../server/common/jwt/JwtProvider.java | 84 +++++++++++-------- .../backend/server/config/SecurityConfig.java | 22 +---- .../controller/MobileAuthController.java | 50 ++++------- .../server/controller/MyQuizController.java | 26 ++---- .../controller/QuizResultController.java | 4 +- .../server/controller/TokenController.java | 12 ++- .../server/domain/quiz/IncorrectArticles.java | 13 +-- .../domain/quiz/IncorrectArticlesId.java | 6 +- .../server/domain/quiz/IncorrectQuiz.java | 16 ++-- .../server/domain/quiz/IncorrectQuizId.java | 6 +- .../server/domain/user/ProviderInfo.java | 3 +- .../oba/backend/server/domain/user/User.java | 43 +++++++++- .../server/dto/ArticleDetailResponse.java | 7 +- .../server/dto/ArticleSummaryResponse.java | 5 +- .../backend/server/dto/QuizSubmitRequest.java | 5 +- .../oba/backend/server/dto/TokenResponse.java | 2 +- .../server/service/ArticleDetailService.java | 32 ++++++- .../server/service/ArticleSummaryService.java | 5 +- .../server/service/QuizQueryService.java | 35 ++++---- .../backend/server/service/QuizService.java | 52 ++++-------- .../main/resources/META-INF/spring.factories | 2 + server/src/main/resources/application.yml | 2 +- 24 files changed, 235 insertions(+), 241 deletions(-) diff --git a/server/build.gradle b/server/build.gradle index 96ca08a..394eab6 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -25,48 +25,41 @@ repositories { dependencies { + /* -------------------- Google OAuth / ID Token -------------------- */ + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'com.google.api-client:google-api-client:2.2.0' implementation 'com.google.http-client:google-http-client-gson:1.43.3' + /* -------------------- Spring Core -------------------- */ implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' - /* --- Core --- */ - implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - /* --- Database --- */ + /* -------------------- Database -------------------- */ implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' runtimeOnly 'com.mysql:mysql-connector-j' - /* --- OAuth2 (Google Login) --- */ - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - - /* Google API Client 통일 (버전 동일 유지) */ - implementation 'com.google.api-client:google-api-client:2.2.0' - implementation 'com.google.http-client:google-http-client-gson:1.43.3' - - /* --- JWT --- */ + /* -------------------- JWT -------------------- */ implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - /* --- Scheduler --- */ + /* -------------------- Scheduler -------------------- */ implementation 'org.springframework.boot:spring-boot-starter-quartz' - /* --- Lombok --- */ + /* -------------------- Dotenv (.env 로딩) -------------------- */ + implementation 'io.github.cdimascio:dotenv-java:3.0.0' + + /* -------------------- Lombok -------------------- */ compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - /* --- Test --- */ + /* -------------------- Test -------------------- */ testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'com.h2database:h2' - - // Google ID Token 검증 - implementation 'com.google.api-client:google-api-client:2.2.0' } tasks.named('test') { diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java index 0115eba..8aa599c 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java @@ -25,25 +25,10 @@ protected void doFilterInternal(HttpServletRequest request, FilterChain filterChain) throws ServletException, IOException { - String uri = request.getRequestURI(); - - // JWT 필터 예외 경로 — 인증 절대 없음 - if (uri.startsWith("/articles") - || uri.startsWith("/auth") - || uri.startsWith("/oauth2") - || uri.startsWith("/login")) { - filterChain.doFilter(request, response); - return; - } - - // WT 토큰 추출 String token = jwtProvider.resolveToken(request); - // JWT 유효성 검사 if (token != null && jwtProvider.validateToken(token)) { - Claims claims = jwtProvider.getClaims(token); - Authentication auth = jwtProvider.getAuthentication(claims.getSubject()); SecurityContextHolder.getContext().setAuthentication(auth); } diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java index 15c2d91..9ad9923 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java @@ -3,6 +3,7 @@ import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; import oba.backend.server.dto.TokenResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -10,29 +11,30 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.stereotype.Component; -import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequest; import javax.crypto.SecretKey; import java.util.Date; import java.util.List; @Component +@RequiredArgsConstructor public class JwtProvider { - private final SecretKey key; - private final long accessTokenValidity; - private final long refreshTokenValidity; - - public JwtProvider( - @Value("${jwt.secret}") String secret, - @Value("${jwt.access-token-expiration-ms}") long accessTokenValidity, - @Value("${jwt.refresh-token-expiration-ms}") long refreshTokenValidity - ) { - this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); - this.accessTokenValidity = accessTokenValidity; - this.refreshTokenValidity = refreshTokenValidity; + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-token-expiration-ms}") + private long accessTokenValidity; + + @Value("${jwt.refresh-token-expiration-ms}") + private long refreshTokenValidity; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); } + /* 인증 헤더에서 토큰 추출 */ public String resolveToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { @@ -41,14 +43,16 @@ public String resolveToken(HttpServletRequest request) { return null; } - /** 구버전 jjwt 문법에 맞춘 Claims 파싱 */ + /* Claims 파싱 */ public Claims getClaims(String token) { - return Jwts.parser() - .setSigningKey(key) + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() .parseClaimsJws(token) .getBody(); } + /* 유효성 검사 */ public boolean validateToken(String token) { try { getClaims(token); @@ -58,48 +62,60 @@ public boolean validateToken(String token) { } } - public String createAccessToken(String identifier) { + /* Access Token 생성 */ + public String createAccessToken(Long userId, String identifier) { long now = System.currentTimeMillis(); + return Jwts.builder() - .setSubject(identifier) + .setSubject(identifier) // sub = provider:xxxx + .claim("userId", userId) // payload: userId + .claim("type", "access") // token type .setExpiration(new Date(now + accessTokenValidity)) - .signWith(key, SignatureAlgorithm.HS256) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } - public String createRefreshToken(String identifier) { + /* Refresh Token 생성 */ + public String createRefreshToken(Long userId, String identifier) { long now = System.currentTimeMillis(); + return Jwts.builder() .setSubject(identifier) + .claim("userId", userId) + .claim("type", "refresh") .setExpiration(new Date(now + refreshTokenValidity)) - .signWith(key, SignatureAlgorithm.HS256) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } - public TokenResponse generateTokens(String identifier) { + /* Access + Refresh 묶음 */ + public TokenResponse generateTokens(Long userId, String identifier) { return new TokenResponse( - createAccessToken(identifier), - createRefreshToken(identifier) + createAccessToken(userId, identifier), + createRefreshToken(userId, identifier) ); } - public Authentication getAuthentication(String identifier) { + /* JWT → userId 추출 */ + public Long getUserId(String token) { + return getClaims(token).get("userId", Long.class); + } + + /* JWT → identifier 추출 */ + public String getIdentifier(String token) { + return getClaims(token).getSubject(); + } - User principal = new User( + /* Spring Security Authentication 생성 */ + public Authentication getAuthentication(String identifier) { + User user = new User( identifier, "", List.of(new SimpleGrantedAuthority("ROLE_USER")) ); return new UsernamePasswordAuthenticationToken( - principal, - "", - principal.getAuthorities() + user, "", user.getAuthorities() ); } - - /** 필요 시 Google Id Token 검증용 */ - public String verifyGoogleIdToken(String idToken) { - return getClaims(idToken).getSubject(); - } } diff --git a/server/src/main/java/oba/backend/server/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/config/SecurityConfig.java index a1b6293..f4bab5e 100644 --- a/server/src/main/java/oba/backend/server/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/config/SecurityConfig.java @@ -2,8 +2,6 @@ import lombok.RequiredArgsConstructor; import oba.backend.server.common.jwt.JwtAuthenticationFilter; -import oba.backend.server.security.oauth.CustomOAuth2UserService; -import oba.backend.server.security.oauth.OAuth2SuccessHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -20,8 +18,6 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtFilter; - private final CustomOAuth2UserService customOAuth2UserService; - private final OAuth2SuccessHandler oAuth2SuccessHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -29,37 +25,25 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) - // --- CORS 완전 허용 --- .cors(cors -> cors.configurationSource(request -> { CorsConfiguration config = new CorsConfiguration(); - config.setAllowCredentials(true); config.setAllowedOriginPatterns(List.of("*")); - config.setAllowedMethods(List.of("*")); config.setAllowedHeaders(List.of("*")); + config.setAllowedMethods(List.of("*")); + config.setAllowCredentials(true); return config; })) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - // --- 인증 필요 없는 경로 --- .authorizeHttpRequests(auth -> auth .requestMatchers( "/auth/**", - "/oauth2/**", - "/login/**", - "/articles/**" // 🔥🔥 완전 Public + "/articles/**" ).permitAll() .anyRequest().authenticated() ) - // --- OAuth2 --- - .oauth2Login(o -> o - .loginPage("/oauth2/authorization/google") - .userInfoEndpoint(c -> c.userService(customOAuth2UserService)) - .successHandler(oAuth2SuccessHandler) - ) - - // --- JWT 필터 --- .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/server/src/main/java/oba/backend/server/controller/MobileAuthController.java b/server/src/main/java/oba/backend/server/controller/MobileAuthController.java index 73fc009..2372bef 100644 --- a/server/src/main/java/oba/backend/server/controller/MobileAuthController.java +++ b/server/src/main/java/oba/backend/server/controller/MobileAuthController.java @@ -4,51 +4,35 @@ import oba.backend.server.common.jwt.JwtProvider; import oba.backend.server.dto.LoginRequest; import oba.backend.server.dto.TokenResponse; -import oba.backend.server.security.oauth.dto.CustomOAuth2User; +import oba.backend.server.domain.user.User; +import oba.backend.server.repository.user.UserRepository; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.web.bind.annotation.*; @RestController -@RequiredArgsConstructor @RequestMapping("/auth") +@RequiredArgsConstructor public class MobileAuthController { private final JwtProvider jwtProvider; + private final UserRepository userRepository; - /** - * 🔥 모바일 앱 구글 로그인 - * Expo → Firebase → Google ID Token → 백엔드 검증 → JWT 발급 - */ - @PostMapping("/google") - public ResponseEntity googleLogin(@RequestBody LoginRequest request) { - - // request.getIdToken() = Firebase Google ID Token - String googleSubject = jwtProvider.verifyGoogleIdToken(request.getIdToken()); - // 반환 예: "google:123456789" - - // Access + Refresh 동시 발급 - TokenResponse tokens = jwtProvider.generateTokens(googleSubject); - - return ResponseEntity.ok(tokens); - } - - /** - * 🔥 OAuth2 (브라우저) 성공 후 JWT 반환용 - * 이 컨트롤러는 모바일 앱에는 사용되지 않지만 - * 기존 웹 로그인 흐름이 필요하면 유지 - */ - @GetMapping("/oauth/success") - public ResponseEntity oauthSuccess(Authentication authentication) { + @PostMapping("/mobile/login") + public ResponseEntity login(@RequestBody LoginRequest request) { - OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication; - CustomOAuth2User user = (CustomOAuth2User) oauthToken.getPrincipal(); + String identifier = "mobile:" + request.getIdToken(); - // 예: google:12345 - String identifier = "google:" + user.getUserId(); + // 회원 조회 or 생성 + User user = userRepository.findByIdentifier(identifier) + .orElseGet(() -> userRepository.save( + User.createMobileUser(identifier) + )); - TokenResponse tokens = jwtProvider.generateTokens(identifier); + // JWT 생성 — userId 포함! + TokenResponse tokens = jwtProvider.generateTokens( + user.getId(), + identifier + ); return ResponseEntity.ok(tokens); } diff --git a/server/src/main/java/oba/backend/server/controller/MyQuizController.java b/server/src/main/java/oba/backend/server/controller/MyQuizController.java index 4389c94..297a067 100644 --- a/server/src/main/java/oba/backend/server/controller/MyQuizController.java +++ b/server/src/main/java/oba/backend/server/controller/MyQuizController.java @@ -1,9 +1,8 @@ package oba.backend.server.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.domain.quiz.dto.SolvedArticleResponse; -import oba.backend.server.domain.quiz.dto.WrongArticleResponse; +import oba.backend.server.dto.SolvedArticleResponse; +import oba.backend.server.dto.WrongArticleResponse; import oba.backend.server.service.QuizQueryService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -11,32 +10,19 @@ import java.util.List; @RestController -@RequestMapping("/my") +@RequestMapping("/api/my") @RequiredArgsConstructor public class MyQuizController { private final QuizQueryService quizQueryService; - private final JwtProvider jwtProvider; - - private Long extractUserId(String token) { - String jwt = token.replace("Bearer ", ""); - String subject = jwtProvider.getClaims(jwt).getSubject(); // google:123 - return Long.valueOf(subject.split(":")[1]); - } @GetMapping("/solved") - public ResponseEntity> solved( - @RequestHeader("Authorization") String token - ) { - Long userId = extractUserId(token); + public ResponseEntity> getSolved(@RequestParam Long userId) { return ResponseEntity.ok(quizQueryService.getSolved(userId)); } @GetMapping("/wrong") - public ResponseEntity> wrong( - @RequestHeader("Authorization") String token - ) { - Long userId = extractUserId(token); + public ResponseEntity> getWrong(@RequestParam Long userId) { return ResponseEntity.ok(quizQueryService.getWrong(userId)); } -} \ No newline at end of file +} diff --git a/server/src/main/java/oba/backend/server/controller/QuizResultController.java b/server/src/main/java/oba/backend/server/controller/QuizResultController.java index 2c59c11..e0453b7 100644 --- a/server/src/main/java/oba/backend/server/controller/QuizResultController.java +++ b/server/src/main/java/oba/backend/server/controller/QuizResultController.java @@ -1,8 +1,8 @@ package oba.backend.server.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.quiz.QuizResultRequest; -import oba.backend.server.domain.quiz.QuizResultService; +import oba.backend.server.dto.QuizResultRequest; +import oba.backend.server.service.QuizResultService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/server/src/main/java/oba/backend/server/controller/TokenController.java b/server/src/main/java/oba/backend/server/controller/TokenController.java index c0af926..e27f94d 100644 --- a/server/src/main/java/oba/backend/server/controller/TokenController.java +++ b/server/src/main/java/oba/backend/server/controller/TokenController.java @@ -3,7 +3,7 @@ import lombok.RequiredArgsConstructor; import oba.backend.server.common.jwt.JwtProvider; import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.UserRepository; +import oba.backend.server.repository.user.UserRepository; import oba.backend.server.dto.TokenResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -21,17 +21,21 @@ public ResponseEntity refresh(@RequestHeader("Authorization") String refreshT String token = refreshToken.replace("Bearer ", ""); + // RefreshToken 검증 if (!jwtProvider.validateToken(token)) { return ResponseEntity.status(401).body("Invalid Refresh Token"); } - String identifier = jwtProvider.getClaims(token).getSubject(); + // JWT 내부 정보 추출 + Long userId = jwtProvider.getUserId(token); + String identifier = jwtProvider.getIdentifier(token); User user = userRepository.findByIdentifier(identifier) .orElseThrow(() -> new RuntimeException("User not found")); - String newAccess = jwtProvider.createAccessToken(identifier); - String newRefresh = jwtProvider.createRefreshToken(identifier); + // 새 토큰 발급 (userId + identifier) + String newAccess = jwtProvider.createAccessToken(userId, identifier); + String newRefresh = jwtProvider.createRefreshToken(userId, identifier); return ResponseEntity.ok(new TokenResponse(newAccess, newRefresh)); } diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticles.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticles.java index 7dbf9ac..4e7dca1 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticles.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticles.java @@ -2,14 +2,15 @@ import jakarta.persistence.*; import lombok.*; -import oba.backend.server.entity.BaseEntity; + +import java.time.LocalDateTime; @Entity -@Getter @Setter -@NoArgsConstructor +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -@Table(name = "Incorrect_Articles") +@Table(name = "incorrect_articles") @IdClass(IncorrectArticlesId.class) public class IncorrectArticles { @@ -21,6 +22,6 @@ public class IncorrectArticles { @Column(name = "article_id") private Long articleId; - @Column(name = "sol_date") - private java.time.LocalDateTime solDate; + @Column(name = "sol_date", nullable = false) + private LocalDateTime solDate; } diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java index 74359cf..3fae53a 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java @@ -1,13 +1,13 @@ package oba.backend.server.domain.quiz; import lombok.*; - import java.io.Serializable; -@Getter @Setter +@Getter +@Setter @NoArgsConstructor @AllArgsConstructor public class IncorrectArticlesId implements Serializable { - private Long userId; + private String userId; private Long articleId; } diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuiz.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuiz.java index ec1c078..7345fab 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuiz.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuiz.java @@ -4,11 +4,11 @@ import lombok.*; @Entity -@Getter @Setter -@NoArgsConstructor +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -@Table(name = "Incorrect_Quiz") +@Table(name = "incorrect_quiz") @IdClass(IncorrectQuizId.class) public class IncorrectQuiz { @@ -20,9 +20,9 @@ public class IncorrectQuiz { @Column(name = "article_id") private Long articleId; - private Boolean quiz1; - private Boolean quiz2; - private Boolean quiz3; - private Boolean quiz4; - private Boolean quiz5; + private boolean quiz1; + private boolean quiz2; + private boolean quiz3; + private boolean quiz4; + private boolean quiz5; } diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java index 0f71f9c..9a0b15f 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java @@ -1,13 +1,13 @@ package oba.backend.server.domain.quiz; import lombok.*; - import java.io.Serializable; -@Getter @Setter +@Getter +@Setter @NoArgsConstructor @AllArgsConstructor public class IncorrectQuizId implements Serializable { - private Long userId; + private String userId; private Long articleId; } diff --git a/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java b/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java index ca1ba4b..f3a19f5 100644 --- a/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java +++ b/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java @@ -3,7 +3,8 @@ public enum ProviderInfo { GOOGLE, KAKAO, - NAVER; + NAVER, + MOBILE; public static ProviderInfo from(String provider) { return ProviderInfo.valueOf(provider.toUpperCase()); diff --git a/server/src/main/java/oba/backend/server/domain/user/User.java b/server/src/main/java/oba/backend/server/domain/user/User.java index 43f02d0..f406fa5 100644 --- a/server/src/main/java/oba/backend/server/domain/user/User.java +++ b/server/src/main/java/oba/backend/server/domain/user/User.java @@ -2,7 +2,6 @@ import jakarta.persistence.*; import lombok.*; -import oba.backend.server.entity.BaseEntity; @Entity @Getter @@ -10,19 +9,27 @@ @AllArgsConstructor @Builder @Table(name = "users") -public class User extends BaseEntity { +public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_id") // ERD와 정확히 매핑 + @Column(name = "user_id") private Long id; + /** 모바일: "mobile:xxxxx", 구글: "google:xxxx" */ @Column(nullable = false, unique = true) - private String identifier; // 예: "google:123456" + private String identifier; + @Column(nullable = false) private String email; + + @Column(nullable = false) private String name; + /** 신규 추가 — nickname(default null → DB 트리거로 자동 처리) */ + @Column + private String nickname; + @Column(length = 512) private String picture; @@ -34,10 +41,38 @@ public class User extends BaseEntity { @Column(nullable = false) private Role role; + @Column(nullable = false) + private boolean isDeleted = false; + /** 로그인 시 정보 업데이트 */ public void updateInfo(String email, String name, String picture) { this.email = email; this.name = name; this.picture = picture; } + + /** 모바일 회원 생성 */ + public static User createMobileUser(String identifier) { + return User.builder() + .identifier(identifier) + .email(identifier + "@mobile.user") + .name("모바일유저") + .nickname(null) // DB 트리거에서 자동 name → nickname + .provider(ProviderInfo.MOBILE) + .role(Role.USER) + .build(); + } + + /** 구글 로그인 신규 생성 */ + public static User createGoogleUser(String identifier, String email, String name, String picture) { + return User.builder() + .identifier(identifier) + .email(email) + .name(name) + .nickname(null) // 트리거에서 자동 설정 + .picture(picture) + .provider(ProviderInfo.GOOGLE) + .role(Role.USER) + .build(); + } } diff --git a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java index a59ba78..9b7957c 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java +++ b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java @@ -1,9 +1,8 @@ -package oba.backend.server.domain.quiz.dto; +package oba.backend.server.dto; import lombok.Builder; import lombok.Getter; -import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -12,8 +11,8 @@ public class ArticleDetailResponse { private Long articleId; private String title; - private LocalDateTime publishTime; - private LocalDateTime servingDate; + private String publishTime; + private String servingDate; private String content; private String subtitle; private String summary; diff --git a/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java index b0ab58c..9259de5 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java +++ b/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java @@ -1,9 +1,8 @@ -package oba.backend.server.domain.quiz.dto; +package oba.backend.server.dto; import lombok.Builder; import lombok.Getter; -import java.time.LocalDateTime; import java.util.List; @Getter @@ -12,5 +11,5 @@ public class ArticleSummaryResponse { private Long articleId; private String title; private List summaryBullets; - private LocalDateTime servingDate; + private String servingDate; // String으로 변경 } diff --git a/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java b/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java index a5141d1..fd662bc 100644 --- a/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java +++ b/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java @@ -1,13 +1,12 @@ -package oba.backend.server.domain.quiz.dto; +package oba.backend.server.dto; import lombok.Getter; import lombok.NoArgsConstructor; - import java.util.List; @Getter @NoArgsConstructor public class QuizSubmitRequest { private Long articleId; - private List answers; + private List answers; // 0 또는 1 값 } diff --git a/server/src/main/java/oba/backend/server/dto/TokenResponse.java b/server/src/main/java/oba/backend/server/dto/TokenResponse.java index cfd5e0d..fbd1e7e 100644 --- a/server/src/main/java/oba/backend/server/dto/TokenResponse.java +++ b/server/src/main/java/oba/backend/server/dto/TokenResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.quiz.dto; +package oba.backend.server.dto; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java index 3556bf7..9c6704a 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java +++ b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java @@ -6,6 +6,9 @@ import oba.backend.server.repository.mongo.GptMongoRepository; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; + @Service @RequiredArgsConstructor public class ArticleDetailService { @@ -17,16 +20,37 @@ public ArticleDetailResponse getArticleDetail(Long articleId) { GptDocument doc = gptMongoRepository.findByArticleId(articleId) .orElseThrow(() -> new RuntimeException("Article not found")); + // Keyword: List → List + List keywordList = null; + if (doc.getKeywords() != null) { + keywordList = doc.getKeywords().stream() + .map(k -> k.getKeyword()) + .toList(); + } + + // Quiz: List → List> + List> quizList = null; + if (doc.getQuizzes() != null) { + quizList = doc.getQuizzes().stream() + .map(q -> Map.of( + "question", q.getQuestion(), + "options", q.getOptions(), + "answer", q.getAnswer(), + "explanation", q.getExplanation() + )) + .toList(); + } + return ArticleDetailResponse.builder() .articleId(doc.getArticleId()) .title(doc.getTitle()) .publishTime(doc.getPublishTime()) .servingDate(doc.getServingDate()) - .content(doc.getContent()) - .subtitle(doc.getSubtitle()) + .content(String.valueOf(doc.getContent())) + .subtitle(String.valueOf(doc.getSubtitle())) .summary(doc.getSummary()) - .keywords(doc.getKeywords()) - .quizzes(doc.getQuizzes()) + .keywords(keywordList) + .quizzes(quizList) .build(); } } diff --git a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java index 9446e3f..a61fa47 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java +++ b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java @@ -23,8 +23,8 @@ public List getLatestArticles(int limit) { List docs = gptMongoRepository.findByOrderByServingDateDesc(pageable); return docs.stream().map(doc -> { - List bullets = null; + List bullets = null; if (doc.getSummary() != null) { bullets = Arrays.stream(doc.getSummary().split(" ")) .limit(3) @@ -35,8 +35,9 @@ public List getLatestArticles(int limit) { .articleId(doc.getArticleId()) .title(doc.getTitle()) .summaryBullets(bullets) - .servingDate(doc.getServingDate()) + .servingDate(doc.getServingDate()) // String OK .build(); + }).toList(); } } diff --git a/server/src/main/java/oba/backend/server/service/QuizQueryService.java b/server/src/main/java/oba/backend/server/service/QuizQueryService.java index 63aa806..18993cc 100644 --- a/server/src/main/java/oba/backend/server/service/QuizQueryService.java +++ b/server/src/main/java/oba/backend/server/service/QuizQueryService.java @@ -1,10 +1,10 @@ package oba.backend.server.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.quiz.IncorrectArticlesRepository; -import oba.backend.server.domain.quiz.IncorrectQuizRepository; -import oba.backend.server.domain.quiz.dto.SolvedArticleResponse; -import oba.backend.server.domain.quiz.dto.WrongArticleResponse; +import oba.backend.server.dto.SolvedArticleResponse; +import oba.backend.server.dto.WrongArticleResponse; +import oba.backend.server.repository.quiz.IncorrectArticlesRepository; +import oba.backend.server.repository.quiz.IncorrectQuizRepository; import org.springframework.stereotype.Service; import java.util.List; @@ -17,29 +17,34 @@ public class QuizQueryService { private final IncorrectArticlesRepository incorrectArticlesRepository; private final IncorrectQuizRepository incorrectQuizRepository; + /** 사용자가 맞힌 기사 리스트 */ public List getSolved(Long userId) { + return incorrectArticlesRepository.findByUserId(userId) .stream() - .map(r -> SolvedArticleResponse.builder() - .articleId(r.getArticleId()) - .solvedAt(r.getSolDate()) + .map(a -> SolvedArticleResponse.builder() + .articleId(a.getArticleId()) + .solvedAt(a.getSolDate().toString()) .build()) .collect(Collectors.toList()); } + /** 사용자가 틀린 문제 리스트 */ public List getWrong(Long userId) { + return incorrectQuizRepository.findByUserId(userId) .stream() .map(q -> WrongArticleResponse.builder() .articleId(q.getArticleId()) - .wrongList(new boolean[]{ - !q.getQuiz1(), - !q.getQuiz2(), - !q.getQuiz3(), - !q.getQuiz4(), - !q.getQuiz5() - }) - .build()) + .incorrectAnswers( + new boolean[]{ + q.isQuiz1(), + q.isQuiz2(), + q.isQuiz3(), + q.isQuiz4(), + q.isQuiz5() + } + ).build()) .collect(Collectors.toList()); } } diff --git a/server/src/main/java/oba/backend/server/service/QuizService.java b/server/src/main/java/oba/backend/server/service/QuizService.java index 5855916..f847838 100644 --- a/server/src/main/java/oba/backend/server/service/QuizService.java +++ b/server/src/main/java/oba/backend/server/service/QuizService.java @@ -1,60 +1,36 @@ package oba.backend.server.service; -import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.domain.quiz.IncorrectArticles; -import oba.backend.server.domain.quiz.IncorrectArticlesRepository; import oba.backend.server.domain.quiz.IncorrectQuiz; -import oba.backend.server.domain.quiz.IncorrectQuizRepository; -import oba.backend.server.domain.user.User; -import oba.backend.server.domain.user.UserRepository; import oba.backend.server.dto.QuizSubmitRequest; +import oba.backend.server.repository.quiz.IncorrectQuizRepository; import org.springframework.stereotype.Service; -import java.time.LocalDateTime; - @Service @RequiredArgsConstructor public class QuizService { - private final JwtProvider jwtProvider; - private final UserRepository userRepository; - private final IncorrectArticlesRepository incorrectArticlesRepository; private final IncorrectQuizRepository incorrectQuizRepository; + private final JwtProvider jwtProvider; public void submit(String jwt, QuizSubmitRequest request) { - // 🔥 JWT subject = identifier - Claims claims = jwtProvider.getClaims(jwt); - String identifier = claims.getSubject(); - - User user = userRepository.findByIdentifier(identifier) - .orElseThrow(() -> new RuntimeException("User not found")); - - Long userId = user.getId(); - Long articleId = request.getArticleId(); - - // 🔵 solved 저장 - IncorrectArticles solved = IncorrectArticles.builder() - .userId(userId) - .articleId(articleId) - .solDate(LocalDateTime.now()) - .build(); - - incorrectArticlesRepository.save(solved); + // 1) JWT → userId 추출 + Long userId = jwtProvider.getUserId(jwt); - // 🔵 정오답 저장 - IncorrectQuiz quiz = IncorrectQuiz.builder() + // 2) Entity 생성 + IncorrectQuiz incorrectQuiz = IncorrectQuiz.builder() .userId(userId) - .articleId(articleId) - .quiz1(request.getAnswers().get(0)) - .quiz2(request.getAnswers().get(1)) - .quiz3(request.getAnswers().get(2)) - .quiz4(request.getAnswers().get(3)) - .quiz5(request.getAnswers().get(4)) + .articleId(request.getArticleId()) + .quiz1(request.getAnswers().get(0) == 1) + .quiz2(request.getAnswers().get(1) == 1) + .quiz3(request.getAnswers().get(2) == 1) + .quiz4(request.getAnswers().get(3) == 1) + .quiz5(request.getAnswers().get(4) == 1) .build(); - incorrectQuizRepository.save(quiz); + // 3) 저장 + incorrectQuizRepository.save(incorrectQuiz); } } diff --git a/server/src/main/resources/META-INF/spring.factories b/server/src/main/resources/META-INF/spring.factories index e69de29..a0f6686 100644 --- a/server/src/main/resources/META-INF/spring.factories +++ b/server/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +oba.backend.server.config.EnvVarPostProcessor diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index bbcd728..415b3bb 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -34,7 +34,7 @@ spring: scope: - email - profile - # ★ ngrok 사용 시 baseUrl 반드시 필요 + # ngrok 사용 시 baseUrl 반드시 필요 redirect-uri: "{baseUrl}/login/oauth2/code/google" provider: From 60fd1214161e0edf4975c97263d1eb861c3bcb63 Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:35:51 +0900 Subject: [PATCH 116/198] =?UTF-8?q?ON-79=20OAuth2=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EA=B3=B5=ED=86=B5=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/auth/CustomOAuth2User.java | 36 +++++++++++++++++++ .../server/auth/CustomOAuth2UserService.java | 4 +++ .../auth/OAuth2LoginSuccessHandler.java | 4 +++ .../server/auth/OAuth2UserConverter.java | 4 +++ .../domain/user/ProviderInfoConverter.java | 4 +++ .../server/service/MobileAuthService.java | 4 +++ 6 files changed, 56 insertions(+) create mode 100644 server/src/main/java/oba/backend/server/auth/CustomOAuth2User.java create mode 100644 server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java create mode 100644 server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java create mode 100644 server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java create mode 100644 server/src/main/java/oba/backend/server/domain/user/ProviderInfoConverter.java create mode 100644 server/src/main/java/oba/backend/server/service/MobileAuthService.java diff --git a/server/src/main/java/oba/backend/server/auth/CustomOAuth2User.java b/server/src/main/java/oba/backend/server/auth/CustomOAuth2User.java new file mode 100644 index 0000000..d77cd9b --- /dev/null +++ b/server/src/main/java/oba/backend/server/auth/CustomOAuth2User.java @@ -0,0 +1,36 @@ +package oba.backend.server.auth; + +import lombok.Getter; +import oba.backend.server.domain.user.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Map; + +@Getter +public class CustomOAuth2User implements OAuth2User { + + private final OAuth2User oAuth2User; + private final User user; + + public CustomOAuth2User(OAuth2User oAuth2User, User user) { + this.oAuth2User = oAuth2User; + this.user = user; + } + + @Override + public Map getAttributes() { + return oAuth2User.getAttributes(); + } + + @Override + public Collection getAuthorities() { + return oAuth2User.getAuthorities(); + } + + @Override + public String getName() { + return oAuth2User.getName(); + } +} diff --git a/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java new file mode 100644 index 0000000..92744fb --- /dev/null +++ b/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java @@ -0,0 +1,4 @@ +package oba.backend.server.auth; + +public class CustomOAuth2UserService { +} diff --git a/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java b/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..958d7d5 --- /dev/null +++ b/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java @@ -0,0 +1,4 @@ +package oba.backend.server.auth; + +public class OAuth2LoginSuccessHandler { +} diff --git a/server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java b/server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java new file mode 100644 index 0000000..c4a7fe6 --- /dev/null +++ b/server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java @@ -0,0 +1,4 @@ +package oba.backend.server.auth; + +public class OAuth2UserConverter { +} diff --git a/server/src/main/java/oba/backend/server/domain/user/ProviderInfoConverter.java b/server/src/main/java/oba/backend/server/domain/user/ProviderInfoConverter.java new file mode 100644 index 0000000..e87614f --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/ProviderInfoConverter.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.user; + +public class ProviderInfoConverter { +} diff --git a/server/src/main/java/oba/backend/server/service/MobileAuthService.java b/server/src/main/java/oba/backend/server/service/MobileAuthService.java new file mode 100644 index 0000000..7c24b61 --- /dev/null +++ b/server/src/main/java/oba/backend/server/service/MobileAuthService.java @@ -0,0 +1,4 @@ +package oba.backend.server.service; + +public class MobileAuthService { +} From 69a7827eaf1229fe2893adf41c92be73c3815e97 Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:35:58 +0900 Subject: [PATCH 117/198] =?UTF-8?q?ON-79=20OAuth2=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20Google/Kakao/Naver=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EB=A1=9C=EB=94=A9=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/auth/CustomOAuth2UserService.java | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java index 92744fb..3eeb789 100644 --- a/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java +++ b/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java @@ -1,4 +1,84 @@ package oba.backend.server.auth; -public class CustomOAuth2UserService { +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.user.ProviderInfo; +import oba.backend.server.domain.user.Role; +import oba.backend.server.domain.user.User; +import oba.backend.server.repository.user.UserRepository; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest request) { + + OAuth2User oAuth2User = super.loadUser(request); + + String provider = request.getClientRegistration().getRegistrationId(); // google,kakao,naver + Map attributes = oAuth2User.getAttributes(); + + String identifier; + String email; + String name; + String picture; + + switch (provider) { + + case "google" -> { + identifier = "google:" + attributes.get("sub"); + email = (String) attributes.get("email"); + name = (String) attributes.get("name"); + picture = (String) attributes.get("picture"); + } + + case "kakao" -> { + identifier = "kakao:" + attributes.get("id"); + + Map kakaoAccount = + (Map) attributes.get("kakao_account"); + Map profile = + (Map) kakaoAccount.get("profile"); + + email = (String) kakaoAccount.get("email"); + name = (String) profile.get("nickname"); + picture = (String) profile.get("profile_image_url"); + } + + case "naver" -> { + Map response = + (Map) attributes.get("response"); + + identifier = "naver:" + response.get("id"); + email = (String) response.get("email"); + name = (String) response.get("name"); + picture = (String) response.get("profile_image"); + } + + default -> throw new IllegalArgumentException("Unsupported provider: " + provider); + } + + // 사용자 생성 혹은 업데이트 + User user = userRepository.findByIdentifier(identifier) + .orElseGet(() -> userRepository.save( + User.builder() + .identifier(identifier) + .email(email) + .name(name) + .picture(picture) + .authProvider(ProviderInfo.valueOf(provider.toUpperCase())) + .role(Role.USER) + .build() + )); + + return oAuth2User; + } } From e1e0ceeb8b27881b2ea3865dd9da5d237f8fe25b Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:36:03 +0900 Subject: [PATCH 118/198] =?UTF-8?q?ON-79=20OAuth2=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B1=EA=B3=B5=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20JWT=20=EB=B0=9C=EA=B8=89=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EB=94=A5=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/OAuth2LoginSuccessHandler.java | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java b/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java index 958d7d5..bc9fcf4 100644 --- a/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java +++ b/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java @@ -1,4 +1,57 @@ package oba.backend.server.auth; -public class OAuth2LoginSuccessHandler { +import lombok.RequiredArgsConstructor; +import oba.backend.server.common.jwt.JwtProvider; +import oba.backend.server.domain.user.User; +import oba.backend.server.repository.user.UserRepository; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + + String identifier; + + if (oAuth2User.getAttribute("sub") != null) { + identifier = "google:" + oAuth2User.getAttribute("sub"); + } else if (oAuth2User.getAttribute("id") != null) { + identifier = "kakao:" + oAuth2User.getAttribute("id"); + } else { + Map resp = (Map) oAuth2User.getAttribute("response"); + identifier = "naver:" + resp.get("id"); + } + + User user = userRepository.findByIdentifier(identifier) + .orElseThrow(() -> new RuntimeException("OAuth2 user not found")); + + String access = jwtProvider.createAccessToken(user.getId(), user.getIdentifier()); + String refresh = jwtProvider.createRefreshToken(user.getId(), user.getIdentifier()); + + // 👉 iOS/Android 앱으로 리다이렉트 + String redirectUrl = "myapp://oauth2redirect" + + "?access=" + access + + "&refresh=" + refresh; + + response.sendRedirect(redirectUrl); + } } From cdd680c53e83c811423c2b64ae71af0eb43420f3 Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:36:09 +0900 Subject: [PATCH 119/198] =?UTF-8?q?ON-79=20OAuth2=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=B0=94=EC=9D=B4=EB=8D=94=EB=B3=84=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EB=B3=80=ED=99=98=EA=B8=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20Google/Kakao/Naver=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=86=8D=EC=84=B1=20=ED=8C=8C=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/auth/OAuth2UserConverter.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java b/server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java index c4a7fe6..252e168 100644 --- a/server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java +++ b/server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java @@ -1,4 +1,27 @@ package oba.backend.server.auth; +import oba.backend.server.domain.user.ProviderInfo; +import oba.backend.server.domain.user.Role; +import oba.backend.server.domain.user.User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; + +@Component public class OAuth2UserConverter { + + public User convert(OAuth2User oAuth2User, String providerName) { + + ProviderInfo provider = ProviderInfo.from(providerName); + + String identifier = providerName + ":" + oAuth2User.getName(); + + return User.builder() + .identifier(identifier) + .email(oAuth2User.getAttribute("email")) + .name(oAuth2User.getAttribute("name")) + .picture(oAuth2User.getAttribute("picture")) + .authProvider(provider) + .role(Role.USER) + .build(); + } } From ff67ceabc7d45dc77d4047578d50f4a360a54318 Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:36:15 +0900 Subject: [PATCH 120/198] =?UTF-8?q?ON-79=20ProviderInfo=20JPA=20=EC=BB=A8?= =?UTF-8?q?=EB=B2=84=ED=84=B0=20=EC=B6=94=EA=B0=80=20-=20ENUM=20<->=20Stri?= =?UTF-8?q?ng=20=EC=9E=90=EB=8F=99=20=EB=B3=80=ED=99=98=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/ProviderInfoConverter.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/user/ProviderInfoConverter.java b/server/src/main/java/oba/backend/server/domain/user/ProviderInfoConverter.java index e87614f..7f16827 100644 --- a/server/src/main/java/oba/backend/server/domain/user/ProviderInfoConverter.java +++ b/server/src/main/java/oba/backend/server/domain/user/ProviderInfoConverter.java @@ -1,4 +1,18 @@ package oba.backend.server.domain.user; -public class ProviderInfoConverter { +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter(autoApply = true) +public class ProviderInfoConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(ProviderInfo attribute) { + return attribute == null ? null : attribute.name(); + } + + @Override + public ProviderInfo convertToEntityAttribute(String dbData) { + return dbData == null ? null : ProviderInfo.valueOf(dbData); + } } From c78d811f3e6ca914a3c9a64b559267c12145f78a Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:36:22 +0900 Subject: [PATCH 121/198] =?UTF-8?q?ON-79=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EB=94=94=EB=B0=94=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=ED=9A=8C=EC=9B=90=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=B0=8F=20JWT=20=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/service/MobileAuthService.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/src/main/java/oba/backend/server/service/MobileAuthService.java b/server/src/main/java/oba/backend/server/service/MobileAuthService.java index 7c24b61..fce61c9 100644 --- a/server/src/main/java/oba/backend/server/service/MobileAuthService.java +++ b/server/src/main/java/oba/backend/server/service/MobileAuthService.java @@ -1,4 +1,18 @@ package oba.backend.server.service; +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.user.User; +import oba.backend.server.repository.user.UserRepository; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor public class MobileAuthService { + + private final UserRepository userRepository; + + public User findOrCreateMobileUser(String identifier) { + return userRepository.findByIdentifier(identifier) + .orElseGet(() -> userRepository.save(User.createMobileUser(identifier))); + } } From e528aa3a747bfcef041013bdaf0797b843e47b4f Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:36:47 +0900 Subject: [PATCH 122/198] =?UTF-8?q?ON-79=20JWT=20Provider=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20provider=20=ED=8F=AC=ED=95=A8=ED=95=9C=20subjec?= =?UTF-8?q?t=20=EA=B5=AC=EC=A1=B0=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EA=B7=9C=EC=B9=99=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/common/jwt/JwtProvider.java | 142 +++++++++--------- 1 file changed, 72 insertions(+), 70 deletions(-) diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java index 9ad9923..099cfc4 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java @@ -3,92 +3,60 @@ import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; -import lombok.RequiredArgsConstructor; import oba.backend.server.dto.TokenResponse; import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.User; import org.springframework.stereotype.Component; import jakarta.servlet.http.HttpServletRequest; import javax.crypto.SecretKey; import java.util.Date; -import java.util.List; @Component -@RequiredArgsConstructor public class JwtProvider { - @Value("${jwt.secret}") - private String secret; - - @Value("${jwt.access-token-expiration-ms}") - private long accessTokenValidity; - - @Value("${jwt.refresh-token-expiration-ms}") - private long refreshTokenValidity; - - private SecretKey getSigningKey() { - return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + private final SecretKey key; + private final long accessTokenValidity; + private final long refreshTokenValidity; + + public JwtProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-expiration-ms}") long accessTokenValidity, + @Value("${jwt.refresh-token-expiration-ms}") long refreshTokenValidity + ) { + this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + this.accessTokenValidity = accessTokenValidity; + this.refreshTokenValidity = refreshTokenValidity; } - /* 인증 헤더에서 토큰 추출 */ - public String resolveToken(HttpServletRequest request) { - String header = request.getHeader("Authorization"); - if (header != null && header.startsWith("Bearer ")) { - return header.substring(7); - } - return null; - } - - /* Claims 파싱 */ - public Claims getClaims(String token) { - return Jwts.parserBuilder() - .setSigningKey(getSigningKey()) - .build() - .parseClaimsJws(token) - .getBody(); - } - - /* 유효성 검사 */ - public boolean validateToken(String token) { - try { - getClaims(token); - return true; - } catch (JwtException | IllegalArgumentException e) { - return false; - } - } - - /* Access Token 생성 */ + // ===================== + // Token 생성 + // ===================== public String createAccessToken(Long userId, String identifier) { - long now = System.currentTimeMillis(); + Date now = new Date(); + Date expiry = new Date(now.getTime() + accessTokenValidity); return Jwts.builder() - .setSubject(identifier) // sub = provider:xxxx - .claim("userId", userId) // payload: userId - .claim("type", "access") // token type - .setExpiration(new Date(now + accessTokenValidity)) - .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .claim("userId", userId) + .setSubject(identifier) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) .compact(); } - /* Refresh Token 생성 */ public String createRefreshToken(Long userId, String identifier) { - long now = System.currentTimeMillis(); + Date now = new Date(); + Date expiry = new Date(now.getTime() + refreshTokenValidity); return Jwts.builder() - .setSubject(identifier) .claim("userId", userId) - .claim("type", "refresh") - .setExpiration(new Date(now + refreshTokenValidity)) - .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .setSubject(identifier) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) .compact(); } - /* Access + Refresh 묶음 */ public TokenResponse generateTokens(Long userId, String identifier) { return new TokenResponse( createAccessToken(userId, identifier), @@ -96,25 +64,59 @@ public TokenResponse generateTokens(Long userId, String identifier) { ); } - /* JWT → userId 추출 */ + // ===================== + // Token Parsing + // ===================== + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + + public Claims getClaims(String token) { + return parseClaims(token).getBody(); + } + + private Jws parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + } + public Long getUserId(String token) { return getClaims(token).get("userId", Long.class); } - /* JWT → identifier 추출 */ public String getIdentifier(String token) { return getClaims(token).getSubject(); } - /* Spring Security Authentication 생성 */ - public Authentication getAuthentication(String identifier) { - User user = new User( - identifier, - "", - List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); + // ===================== + // Resolve Token + // ===================== + public String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (bearer == null || !bearer.startsWith("Bearer ")) return null; + return bearer.substring(7); + } + + // ===================== + // Authentication 생성 + // ===================== + public org.springframework.security.core.Authentication getAuthentication(String identifier) { + + org.springframework.security.core.userdetails.UserDetails user = + org.springframework.security.core.userdetails.User.builder() + .username(identifier) + .password("") // JWT 인증에서는 비밀번호 사용하지 않음 + .authorities("USER") + .build(); - return new UsernamePasswordAuthenticationToken( + return new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( user, "", user.getAuthorities() ); } From f3694ed8629c0739ca5cb993185f5b6a0e9b167f Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:36:53 +0900 Subject: [PATCH 123/198] =?UTF-8?q?ON-79=20Spring=20Security=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B0=9C=EC=84=A0=20-=20OAuth2Login=20+=20JWT=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=ED=86=B5=ED=95=A9=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/config/SecurityConfig.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/src/main/java/oba/backend/server/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/config/SecurityConfig.java index f4bab5e..aa729af 100644 --- a/server/src/main/java/oba/backend/server/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/config/SecurityConfig.java @@ -1,6 +1,8 @@ package oba.backend.server.config; import lombok.RequiredArgsConstructor; +import oba.backend.server.auth.CustomOAuth2UserService; +import oba.backend.server.auth.OAuth2LoginSuccessHandler; import oba.backend.server.common.jwt.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -18,6 +20,8 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtFilter; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -39,11 +43,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers( "/auth/**", + "/oauth2/**", + "/login/**", "/articles/**" ).permitAll() .anyRequest().authenticated() ) + .oauth2Login(oauth -> oauth + .userInfoEndpoint(info -> info.userService(customOAuth2UserService)) + .successHandler(oAuth2LoginSuccessHandler) + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); From 15792ac52385f30d1b43173bcdbe0ded0b21e0b0 Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:36:59 +0900 Subject: [PATCH 124/198] =?UTF-8?q?ON-79=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95=20-=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EB=B0=8F=20JWT=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=9D=91=EB=8B=B5=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/controller/MobileAuthController.java | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/server/src/main/java/oba/backend/server/controller/MobileAuthController.java b/server/src/main/java/oba/backend/server/controller/MobileAuthController.java index 2372bef..436ed50 100644 --- a/server/src/main/java/oba/backend/server/controller/MobileAuthController.java +++ b/server/src/main/java/oba/backend/server/controller/MobileAuthController.java @@ -4,8 +4,8 @@ import oba.backend.server.common.jwt.JwtProvider; import oba.backend.server.dto.LoginRequest; import oba.backend.server.dto.TokenResponse; +import oba.backend.server.service.MobileAuthService; import oba.backend.server.domain.user.User; -import oba.backend.server.repository.user.UserRepository; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -15,23 +15,18 @@ public class MobileAuthController { private final JwtProvider jwtProvider; - private final UserRepository userRepository; + private final MobileAuthService mobileAuthService; @PostMapping("/mobile/login") public ResponseEntity login(@RequestBody LoginRequest request) { String identifier = "mobile:" + request.getIdToken(); - // 회원 조회 or 생성 - User user = userRepository.findByIdentifier(identifier) - .orElseGet(() -> userRepository.save( - User.createMobileUser(identifier) - )); + User user = mobileAuthService.findOrCreateMobileUser(identifier); - // JWT 생성 — userId 포함! TokenResponse tokens = jwtProvider.generateTokens( user.getId(), - identifier + user.getIdentifier() ); return ResponseEntity.ok(tokens); From 04a0674067a62c6bb744ded0889b4e0e7cdc9a6c Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:37:04 +0900 Subject: [PATCH 125/198] =?UTF-8?q?ON-79=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EB=AA=A8=EB=B0=94=EC=9D=BC/=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EA=B3=B5=ED=86=B5=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=EA=B5=AC=EC=A1=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/controller/TokenController.java | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/server/src/main/java/oba/backend/server/controller/TokenController.java b/server/src/main/java/oba/backend/server/controller/TokenController.java index e27f94d..85ec110 100644 --- a/server/src/main/java/oba/backend/server/controller/TokenController.java +++ b/server/src/main/java/oba/backend/server/controller/TokenController.java @@ -2,8 +2,6 @@ import lombok.RequiredArgsConstructor; import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.domain.user.User; -import oba.backend.server.repository.user.UserRepository; import oba.backend.server.dto.TokenResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -14,29 +12,25 @@ public class TokenController { private final JwtProvider jwtProvider; - private final UserRepository userRepository; - @PostMapping("/refresh") - public ResponseEntity refresh(@RequestHeader("Authorization") String refreshToken) { + @PostMapping("/reissue") + public ResponseEntity reissue(@RequestHeader("Authorization") String refreshHeader) { - String token = refreshToken.replace("Bearer ", ""); + if (!refreshHeader.startsWith("Bearer ")) { + return ResponseEntity.badRequest().build(); + } + + String token = refreshHeader.substring(7); - // RefreshToken 검증 if (!jwtProvider.validateToken(token)) { - return ResponseEntity.status(401).body("Invalid Refresh Token"); + return ResponseEntity.status(401).build(); } - // JWT 내부 정보 추출 Long userId = jwtProvider.getUserId(token); String identifier = jwtProvider.getIdentifier(token); - User user = userRepository.findByIdentifier(identifier) - .orElseThrow(() -> new RuntimeException("User not found")); - - // 새 토큰 발급 (userId + identifier) - String newAccess = jwtProvider.createAccessToken(userId, identifier); - String newRefresh = jwtProvider.createRefreshToken(userId, identifier); + TokenResponse newTokens = jwtProvider.generateTokens(userId, identifier); - return ResponseEntity.ok(new TokenResponse(newAccess, newRefresh)); + return ResponseEntity.ok(newTokens); } } From d4431381207694491ec17d1c660b48f5b64dd22e Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:37:09 +0900 Subject: [PATCH 126/198] =?UTF-8?q?ON-79=20ProviderInfo=20ENUM=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20Google/Kakao/Naver=20=EB=B0=8F=20MOBILE=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/domain/user/ProviderInfo.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java b/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java index f3a19f5..df609a2 100644 --- a/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java +++ b/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java @@ -1,12 +1,14 @@ package oba.backend.server.domain.user; public enum ProviderInfo { + LOCAL, GOOGLE, KAKAO, NAVER, MOBILE; - public static ProviderInfo from(String provider) { - return ProviderInfo.valueOf(provider.toUpperCase()); + public static ProviderInfo from(String providerName) { + if (providerName == null) return LOCAL; + return ProviderInfo.valueOf(providerName.toUpperCase()); } } From 6e3434436760077bbaedf5414e35166633d7eeae Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:37:19 +0900 Subject: [PATCH 127/198] =?UTF-8?q?ON-79=20User=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=88=98=EC=A0=95=20-=20=EC=86=8C=EC=85=9C?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9D=EB=B3=84=EC=9E=90/?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84/Provider=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/domain/user/User.java | 26 ++++--------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/user/User.java b/server/src/main/java/oba/backend/server/domain/user/User.java index f406fa5..b2d8a3a 100644 --- a/server/src/main/java/oba/backend/server/domain/user/User.java +++ b/server/src/main/java/oba/backend/server/domain/user/User.java @@ -16,7 +16,6 @@ public class User { @Column(name = "user_id") private Long id; - /** 모바일: "mobile:xxxxx", 구글: "google:xxxx" */ @Column(nullable = false, unique = true) private String identifier; @@ -26,7 +25,6 @@ public class User { @Column(nullable = false) private String name; - /** 신규 추가 — nickname(default null → DB 트리거로 자동 처리) */ @Column private String nickname; @@ -34,44 +32,30 @@ public class User { private String picture; @Enumerated(EnumType.STRING) - @Column(nullable = false) - private ProviderInfo provider; + @Column(name = "auth_provider", nullable = false) + private ProviderInfo authProvider; @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role; + @Builder.Default @Column(nullable = false) private boolean isDeleted = false; - /** 로그인 시 정보 업데이트 */ public void updateInfo(String email, String name, String picture) { this.email = email; this.name = name; this.picture = picture; } - /** 모바일 회원 생성 */ public static User createMobileUser(String identifier) { return User.builder() .identifier(identifier) .email(identifier + "@mobile.user") .name("모바일유저") - .nickname(null) // DB 트리거에서 자동 name → nickname - .provider(ProviderInfo.MOBILE) - .role(Role.USER) - .build(); - } - - /** 구글 로그인 신규 생성 */ - public static User createGoogleUser(String identifier, String email, String name, String picture) { - return User.builder() - .identifier(identifier) - .email(email) - .name(name) - .nickname(null) // 트리거에서 자동 설정 - .picture(picture) - .provider(ProviderInfo.GOOGLE) + .picture(null) + .authProvider(ProviderInfo.MOBILE) .role(Role.USER) .build(); } From fb5352c62ed5474ff23b27dc23feec92172da3c4 Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:37:24 +0900 Subject: [PATCH 128/198] =?UTF-8?q?ON-79=20LoginRequest=20DTO=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=A0=84=EC=9A=A9=20idToken=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/java/oba/backend/server/dto/LoginRequest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/dto/LoginRequest.java b/server/src/main/java/oba/backend/server/dto/LoginRequest.java index a3aaa03..d79e316 100644 --- a/server/src/main/java/oba/backend/server/dto/LoginRequest.java +++ b/server/src/main/java/oba/backend/server/dto/LoginRequest.java @@ -1,10 +1,8 @@ package oba.backend.server.dto; import lombok.Getter; -import lombok.NoArgsConstructor; @Getter -@NoArgsConstructor public class LoginRequest { private String idToken; } From 89f36af6b496c1dc449d892b9bcdb6f9f232c8db Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:37:29 +0900 Subject: [PATCH 129/198] =?UTF-8?q?ON-79=20UserRepository=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20provider=20=EA=B8=B0=EB=B0=98=20identifier=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/repository/user/UserRepository.java | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/repository/user/UserRepository.java b/server/src/main/java/oba/backend/server/repository/user/UserRepository.java index 957a0d2..4543a5f 100644 --- a/server/src/main/java/oba/backend/server/repository/user/UserRepository.java +++ b/server/src/main/java/oba/backend/server/repository/user/UserRepository.java @@ -7,5 +7,4 @@ public interface UserRepository extends JpaRepository { Optional findByIdentifier(String identifier); - Optional findByEmail(String email); } From c9381fff64565f78c01186aa6f99c1d9d81a64e4 Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:37:38 +0900 Subject: [PATCH 130/198] =?UTF-8?q?ON-79=20Prod=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20OAuth2=20=EB=B0=8F=20JWT=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/resources/application-prod.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index 22dd54a..f912628 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -17,6 +17,14 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + data: mongodb: uri: ${MONGODB_URI} From 2af437908bd722081c9d46ffe1e4bda8fc11f3ec Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:37:42 +0900 Subject: [PATCH 131/198] =?UTF-8?q?ON-79=20OAuth2=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20Google/Kakao/Naver=20redirect=20=EB=B0=8F=20pro?= =?UTF-8?q?vider=20=EC=84=A4=EC=A0=95=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/resources/application.yml | 88 +++++++++-------------- 1 file changed, 34 insertions(+), 54 deletions(-) diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 415b3bb..86a0e2f 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -1,29 +1,4 @@ spring: - application: - name: oba-backend - - profiles: - active: prod - - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} - - data: - mongodb: - uri: ${MONGODB_URI} - database: OneBitArticle - - jpa: - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - format_sql: true - security: oauth2: client: @@ -31,37 +6,42 @@ spring: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: "myapp://oauth2redirect" + authorization-grant-type: authorization_code scope: - email - profile - # ngrok 사용 시 baseUrl 반드시 필요 - redirect-uri: "{baseUrl}/login/oauth2/code/google" - - provider: - google: - authorization-uri: https://accounts.google.com/o/oauth2/v2/auth - token-uri: https://oauth2.googleapis.com/token - user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo - user-name-attribute: sub -jwt: - secret: ${JWT_SECRET} - access-token-expiration-ms: ${JWT_ACCESS_TOKEN_EXPIRATION_MS:1800000} - refresh-token-expiration-ms: ${JWT_REFRESH_TOKEN_EXPIRATION_MS:1209600000} - -ai: - server: - url: ${AI_SERVER_URL} - -server: - port: ${SERVER_PORT:8080} - - # ngrok HTTPS → Spring Boot HTTP proxy 처리용 - forward-headers-strategy: framework - - # 핵심: ngrok이 로컬 스프링 서버에 연결되려면 반드시 필요 - address: 0.0.0.0 + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "myapp://oauth2redirect" + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: + - profile_nickname + - profile_image + - account_email + + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + redirect-uri: "myapp://oauth2redirect" + authorization-grant-type: authorization_code + scope: + - name + - email + - profile_image -logging: - level: - root: INFO + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response From 511664054aa48a9d3c62eb6a792f438c245b62ed Mon Sep 17 00:00:00 2001 From: jihun Date: Fri, 5 Dec 2025 11:39:33 +0900 Subject: [PATCH 132/198] =?UTF-8?q?ON-79=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=88=98=EC=A0=95=20-=20OAuth2=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=A0=9C=EC=99=B8=20=EB=B0=8F=20Authentic?= =?UTF-8?q?ation=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=A0=95?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/jwt/JwtAuthenticationFilter.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java index 8aa599c..7d6f03f 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java @@ -1,16 +1,17 @@ package oba.backend.server.common.jwt; import io.jsonwebtoken.Claims; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + import java.io.IOException; @Component @@ -22,17 +23,20 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) + FilterChain chain) throws ServletException, IOException { String token = jwtProvider.resolveToken(request); if (token != null && jwtProvider.validateToken(token)) { + Claims claims = jwtProvider.getClaims(token); - Authentication auth = jwtProvider.getAuthentication(claims.getSubject()); + String identifier = claims.getSubject(); + + Authentication auth = jwtProvider.getAuthentication(identifier); SecurityContextHolder.getContext().setAuthentication(auth); } - filterChain.doFilter(request, response); + chain.doFilter(request, response); } } From 5184a2358aba28bdc53785ceca611eea7438097c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Mon, 8 Dec 2025 16:49:03 +0900 Subject: [PATCH 133/198] commit --- .../auth/OAuth2LoginSuccessHandler.java | 12 ++++++++-- .../backend/server/config/SecurityConfig.java | 1 + server/src/main/resources/application.yml | 23 ++++++++++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java b/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java index bc9fcf4..fadd2d7 100644 --- a/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java +++ b/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java @@ -32,26 +32,34 @@ public void onAuthenticationSuccess( String identifier; + // 구글 OAuth (sub) if (oAuth2User.getAttribute("sub") != null) { identifier = "google:" + oAuth2User.getAttribute("sub"); + + // 카카오 OAuth (id) } else if (oAuth2User.getAttribute("id") != null) { identifier = "kakao:" + oAuth2User.getAttribute("id"); + + // 네이버 OAuth (response.id) } else { Map resp = (Map) oAuth2User.getAttribute("response"); identifier = "naver:" + resp.get("id"); } + // DB에서 사용자 조회 User user = userRepository.findByIdentifier(identifier) .orElseThrow(() -> new RuntimeException("OAuth2 user not found")); + // access & refresh 발급 String access = jwtProvider.createAccessToken(user.getId(), user.getIdentifier()); String refresh = jwtProvider.createRefreshToken(user.getId(), user.getIdentifier()); - // 👉 iOS/Android 앱으로 리다이렉트 - String redirectUrl = "myapp://oauth2redirect" + // Expo에서 리스닝하는 Redirect URI (앱으로 돌아옴) + String redirectUrl = "myapp://oauth" + "?access=" + access + "&refresh=" + refresh; + // 앱으로 리다이렉트 response.sendRedirect(redirectUrl); } } diff --git a/server/src/main/java/oba/backend/server/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/config/SecurityConfig.java index aa729af..d702241 100644 --- a/server/src/main/java/oba/backend/server/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/config/SecurityConfig.java @@ -45,6 +45,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/auth/**", "/oauth2/**", "/login/**", + "/api/auth/**", "/articles/**" ).permitAll() .anyRequest().authenticated() diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 86a0e2f..1d36285 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -1,8 +1,21 @@ +server: + port: 9000 + spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + data: + mongodb: + uri: ${MONGO_URI} + security: oauth2: client: registration: + google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} @@ -26,7 +39,10 @@ spring: naver: client-id: ${NAVER_CLIENT_ID} client-secret: ${NAVER_CLIENT_SECRET} - redirect-uri: "myapp://oauth2redirect" + + # ⭐ 반드시 실제 ngrok URL로 맞춰야 함 + redirect-uri: "https://5960fda907fd.ngrok-free.app/api/auth/naver/callback" + authorization-grant-type: authorization_code scope: - name @@ -45,3 +61,8 @@ spring: token-uri: https://nid.naver.com/oauth2.0/token user-info-uri: https://openapi.naver.com/v1/nid/me user-name-attribute: response + +jwt: + secret: ${JWT_SECRET} + access-token-expiration-ms: 3600000 + refresh-token-expiration-ms: 604800000 From 629c4a9299ee2f1ef6d1f669c7acbc26f5d2cdee Mon Sep 17 00:00:00 2001 From: Byunjihun Date: Wed, 10 Dec 2025 14:10:12 +0900 Subject: [PATCH 134/198] commit --- server/build.gradle | 16 +---- .../server/auth/CustomOAuth2UserService.java | 16 ++--- .../auth/OAuth2LoginSuccessHandler.java | 39 +++------- .../server/common/jwt/JwtProvider.java | 32 ++++----- .../backend/server/config/SecurityConfig.java | 9 +-- .../controller/OAuthBridgeController.java | 13 ++++ .../domain/quiz/IncorrectArticlesId.java | 18 ++++- .../server/domain/quiz/IncorrectQuizId.java | 18 ++++- .../main/resources/META-INF/spring.factories | 2 +- .../src/main/resources/application-prod.yml | 71 +++++++++++++++++-- server/src/main/resources/application.yml | 23 ++++-- .../resources/templates/oauth-bridge.html | 26 +++++++ 12 files changed, 193 insertions(+), 90 deletions(-) create mode 100644 server/src/main/java/oba/backend/server/controller/OAuthBridgeController.java create mode 100644 server/src/main/resources/templates/oauth-bridge.html diff --git a/server/build.gradle b/server/build.gradle index 394eab6..2588b88 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -1,22 +1,13 @@ plugins { - id 'java' id 'org.springframework.boot' version '3.5.4' - id 'io.spring.dependency-management' version '1.1.7' + id 'io.spring.dependency-management' version '1.1.5' + id 'java' } group = 'oba.backend' version = '0.0.1-SNAPSHOT' - java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} - -configurations { - compileOnly { - extendsFrom annotationProcessor - } + sourceCompatibility = '17' } repositories { @@ -24,7 +15,6 @@ repositories { } dependencies { - /* -------------------- Google OAuth / ID Token -------------------- */ implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'com.google.api-client:google-api-client:2.2.0' diff --git a/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java index 3eeb789..2cfd22c 100644 --- a/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java +++ b/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java @@ -23,7 +23,7 @@ public OAuth2User loadUser(OAuth2UserRequest request) { OAuth2User oAuth2User = super.loadUser(request); - String provider = request.getClientRegistration().getRegistrationId(); // google,kakao,naver + String provider = request.getClientRegistration().getRegistrationId(); // google / kakao / naver Map attributes = oAuth2User.getAttributes(); String identifier; @@ -43,10 +43,8 @@ public OAuth2User loadUser(OAuth2UserRequest request) { case "kakao" -> { identifier = "kakao:" + attributes.get("id"); - Map kakaoAccount = - (Map) attributes.get("kakao_account"); - Map profile = - (Map) kakaoAccount.get("profile"); + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); email = (String) kakaoAccount.get("email"); name = (String) profile.get("nickname"); @@ -54,8 +52,7 @@ public OAuth2User loadUser(OAuth2UserRequest request) { } case "naver" -> { - Map response = - (Map) attributes.get("response"); + Map response = (Map) attributes.get("response"); identifier = "naver:" + response.get("id"); email = (String) response.get("email"); @@ -66,7 +63,7 @@ public OAuth2User loadUser(OAuth2UserRequest request) { default -> throw new IllegalArgumentException("Unsupported provider: " + provider); } - // 사용자 생성 혹은 업데이트 + // DB 저장 또는 업데이트 User user = userRepository.findByIdentifier(identifier) .orElseGet(() -> userRepository.save( User.builder() @@ -79,6 +76,7 @@ public OAuth2User loadUser(OAuth2UserRequest request) { .build() )); - return oAuth2User; + // ★ 반드시 CustomOAuth2User를 반환해야 SuccessHandler와 연동됨 + return new CustomOAuth2User(oAuth2User, user); } } diff --git a/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java b/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java index fadd2d7..b6017e4 100644 --- a/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java +++ b/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java @@ -4,15 +4,14 @@ import oba.backend.server.common.jwt.JwtProvider; import oba.backend.server.domain.user.User; import oba.backend.server.repository.user.UserRepository; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.Map; @Component @RequiredArgsConstructor @@ -21,6 +20,9 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private final JwtProvider jwtProvider; private final UserRepository userRepository; + @Value("${app.mobile-redirect}") + private String mobileRedirectUri; + @Override public void onAuthenticationSuccess( HttpServletRequest request, @@ -28,38 +30,17 @@ public void onAuthenticationSuccess( Authentication authentication ) throws IOException { - OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); - - String identifier; - - // 구글 OAuth (sub) - if (oAuth2User.getAttribute("sub") != null) { - identifier = "google:" + oAuth2User.getAttribute("sub"); - - // 카카오 OAuth (id) - } else if (oAuth2User.getAttribute("id") != null) { - identifier = "kakao:" + oAuth2User.getAttribute("id"); - - // 네이버 OAuth (response.id) - } else { - Map resp = (Map) oAuth2User.getAttribute("response"); - identifier = "naver:" + resp.get("id"); - } - - // DB에서 사용자 조회 - User user = userRepository.findByIdentifier(identifier) - .orElseThrow(() -> new RuntimeException("OAuth2 user not found")); + CustomOAuth2User customUser = (CustomOAuth2User) authentication.getPrincipal(); + User user = customUser.getUser(); - // access & refresh 발급 + // JWT 생성 String access = jwtProvider.createAccessToken(user.getId(), user.getIdentifier()); String refresh = jwtProvider.createRefreshToken(user.getId(), user.getIdentifier()); - // Expo에서 리스닝하는 Redirect URI (앱으로 돌아옴) - String redirectUrl = "myapp://oauth" + String redirectUri = mobileRedirectUri + "?access=" + access + "&refresh=" + refresh; - // 앱으로 리다이렉트 - response.sendRedirect(redirectUrl); + response.sendRedirect(redirectUri); } } diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java index 099cfc4..81f70cd 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java +++ b/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java @@ -15,8 +15,8 @@ public class JwtProvider { private final SecretKey key; - private final long accessTokenValidity; - private final long refreshTokenValidity; + private final long accessTokenValidity; // ms 단위 + private final long refreshTokenValidity; // ms 단위 public JwtProvider( @Value("${jwt.secret}") String secret, @@ -29,32 +29,28 @@ public JwtProvider( } // ===================== - // Token 생성 + // Token 생성 (초 단위 exp/iat) // ===================== - public String createAccessToken(Long userId, String identifier) { - Date now = new Date(); - Date expiry = new Date(now.getTime() + accessTokenValidity); + private String createToken(Long userId, String identifier, long validityMs) { + + long nowSec = System.currentTimeMillis() / 1000; // 현재 시간 sec + long expSec = nowSec + (validityMs / 1000); // 만료 sec return Jwts.builder() .claim("userId", userId) .setSubject(identifier) - .setIssuedAt(now) - .setExpiration(expiry) + .claim("iat", nowSec) // 초 단위 issuedAt + .claim("exp", expSec) // 초 단위 expiration .signWith(key, SignatureAlgorithm.HS256) .compact(); } - public String createRefreshToken(Long userId, String identifier) { - Date now = new Date(); - Date expiry = new Date(now.getTime() + refreshTokenValidity); + public String createAccessToken(Long userId, String identifier) { + return createToken(userId, identifier, accessTokenValidity); + } - return Jwts.builder() - .claim("userId", userId) - .setSubject(identifier) - .setIssuedAt(now) - .setExpiration(expiry) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); + public String createRefreshToken(Long userId, String identifier) { + return createToken(userId, identifier, refreshTokenValidity); } public TokenResponse generateTokens(Long userId, String identifier) { diff --git a/server/src/main/java/oba/backend/server/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/config/SecurityConfig.java index d702241..a85235f 100644 --- a/server/src/main/java/oba/backend/server/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/config/SecurityConfig.java @@ -4,11 +4,13 @@ import oba.backend.server.auth.CustomOAuth2UserService; import oba.backend.server.auth.OAuth2LoginSuccessHandler; import oba.backend.server.common.jwt.JwtAuthenticationFilter; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; + import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; @@ -28,7 +30,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) - .cors(cors -> cors.configurationSource(request -> { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOriginPatterns(List.of("*")); @@ -37,7 +38,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { config.setAllowCredentials(true); return config; })) - .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth @@ -45,8 +45,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/auth/**", "/oauth2/**", "/login/**", - "/api/auth/**", - "/articles/**" + "/login/oauth2/**", + "/articles/**", + "/error" ).permitAll() .anyRequest().authenticated() ) diff --git a/server/src/main/java/oba/backend/server/controller/OAuthBridgeController.java b/server/src/main/java/oba/backend/server/controller/OAuthBridgeController.java new file mode 100644 index 0000000..478ca70 --- /dev/null +++ b/server/src/main/java/oba/backend/server/controller/OAuthBridgeController.java @@ -0,0 +1,13 @@ +package oba.backend.server.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class OAuthBridgeController { + + @GetMapping("/oauth/bridge") + public String oauthBridge() { + return "oauth-bridge"; + } +} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java index 3fae53a..7e84d79 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java @@ -2,12 +2,28 @@ import lombok.*; import java.io.Serializable; +import java.util.Objects; @Getter @Setter @NoArgsConstructor @AllArgsConstructor public class IncorrectArticlesId implements Serializable { - private String userId; + + private Long userId; private Long articleId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof IncorrectArticlesId)) return false; + IncorrectArticlesId that = (IncorrectArticlesId) o; + return Objects.equals(userId, that.userId) && + Objects.equals(articleId, that.articleId); + } + + @Override + public int hashCode() { + return Objects.hash(userId, articleId); + } } diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java index 9a0b15f..fc3600e 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java @@ -2,12 +2,28 @@ import lombok.*; import java.io.Serializable; +import java.util.Objects; @Getter @Setter @NoArgsConstructor @AllArgsConstructor public class IncorrectQuizId implements Serializable { - private String userId; + + private Long userId; private Long articleId; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof IncorrectQuizId)) return false; + IncorrectQuizId that = (IncorrectQuizId) o; + return Objects.equals(userId, that.userId) && + Objects.equals(articleId, that.articleId); + } + + @Override + public int hashCode() { + return Objects.hash(userId, articleId); + } } diff --git a/server/src/main/resources/META-INF/spring.factories b/server/src/main/resources/META-INF/spring.factories index a0f6686..5cbb97b 100644 --- a/server/src/main/resources/META-INF/spring.factories +++ b/server/src/main/resources/META-INF/spring.factories @@ -1,2 +1,2 @@ org.springframework.boot.env.EnvironmentPostProcessor=\ -oba.backend.server.config.EnvVarPostProcessor +oba.backend.server.config.EnvVarPostProcessor \ No newline at end of file diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index f912628..423af00 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -1,10 +1,11 @@ server: - port: ${SERVER_PORT:8080} + port: ${SERVER_PORT:9000} + forward-headers-strategy: framework jwt: secret: ${JWT_SECRET} - access-token-expiration-ms: ${JWT_ACCESS_TOKEN_EXPIRATION_MS:1800000} - refresh-token-expiration-ms: ${JWT_REFRESH_TOKEN_EXPIRATION_MS:1209600000} + access-token-expiration-ms: ${JWT_ACCESS_TOKEN_EXPIRATION_MS:3600000} + refresh-token-expiration-ms: ${JWT_REFRESH_TOKEN_EXPIRATION_MS:604800000} ai: server: @@ -20,7 +21,7 @@ spring: jpa: hibernate: ddl-auto: update - show-sql: true + show-sql: false properties: hibernate: format_sql: true @@ -30,6 +31,62 @@ spring: uri: ${MONGODB_URI} database: OneBitArticle -logging: - level: - root: INFO + security: + oauth2: + client: + registration: + + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/google" + authorization-grant-type: authorization_code + scope: + - email + - profile + + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/kakao" + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: + - profile_nickname + - profile_image + - account_email + + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/naver" + authorization-grant-type: authorization_code + scope: + - name + - email + - profile_image + + provider: + google: + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + user-name-attribute: sub + + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + +oauth: + bridge-url: ${OAUTH_BRIDGE_URL} + +app: + mobile-redirect: "myapp://oauth/naver" diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 1d36285..443d5d9 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -9,7 +9,7 @@ spring: data: mongodb: - uri: ${MONGO_URI} + uri: ${MONGODB_URI} security: oauth2: @@ -19,7 +19,7 @@ spring: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: "myapp://oauth2redirect" + redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/google" authorization-grant-type: authorization_code scope: - email @@ -28,7 +28,7 @@ spring: kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "myapp://oauth2redirect" + redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/kakao" client-authentication-method: client_secret_post authorization-grant-type: authorization_code scope: @@ -39,10 +39,7 @@ spring: naver: client-id: ${NAVER_CLIENT_ID} client-secret: ${NAVER_CLIENT_SECRET} - - # ⭐ 반드시 실제 ngrok URL로 맞춰야 함 - redirect-uri: "https://5960fda907fd.ngrok-free.app/api/auth/naver/callback" - + redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/naver" authorization-grant-type: authorization_code scope: - name @@ -50,6 +47,12 @@ spring: - profile_image provider: + google: + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + user-name-attribute: sub + kakao: authorization-uri: https://kauth.kakao.com/oauth/authorize token-uri: https://kauth.kakao.com/oauth/token @@ -66,3 +69,9 @@ jwt: secret: ${JWT_SECRET} access-token-expiration-ms: 3600000 refresh-token-expiration-ms: 604800000 + +oauth: + bridge-url: ${OAUTH_BRIDGE_URL} + +app: + mobile-redirect: "myapp://oauth/naver" diff --git a/server/src/main/resources/templates/oauth-bridge.html b/server/src/main/resources/templates/oauth-bridge.html new file mode 100644 index 0000000..b319d2e --- /dev/null +++ b/server/src/main/resources/templates/oauth-bridge.html @@ -0,0 +1,26 @@ + + + + + Connecting... + + +

앱으로 연결 중입니다...

+ + + + From 27d953127373226af67f5a6a2807f9c31d8725ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Mon, 15 Dec 2025 20:31:27 +0900 Subject: [PATCH 135/198] back & front connect --- .../server/dto/ArticleDetailResponse.java | 7 ++++-- .../server/service/ArticleDetailService.java | 6 ++--- .../server/service/ArticleSummaryService.java | 8 +++++-- .../server/service/QuizAnswerParser.java | 22 +++++++++++++++++++ .../src/main/resources/application-prod.yml | 8 +++---- server/src/main/resources/application.yml | 8 +++---- 6 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 server/src/main/java/oba/backend/server/service/QuizAnswerParser.java diff --git a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java index 9b7957c..69aa55a 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java +++ b/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java @@ -13,9 +13,12 @@ public class ArticleDetailResponse { private String title; private String publishTime; private String servingDate; - private String content; - private String subtitle; + + private Object content; // 원본 배열 구조 유지 + private Object subtitle; // 원본 배열 구조 유지 + private String summary; private List keywords; + private List> quizzes; } diff --git a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java index 9c6704a..b3c1822 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java +++ b/server/src/main/java/oba/backend/server/service/ArticleDetailService.java @@ -35,7 +35,7 @@ public ArticleDetailResponse getArticleDetail(Long articleId) { .map(q -> Map.of( "question", q.getQuestion(), "options", q.getOptions(), - "answer", q.getAnswer(), + "answer", QuizAnswerParser.toIndex(q.getAnswer()), "explanation", q.getExplanation() )) .toList(); @@ -46,8 +46,8 @@ public ArticleDetailResponse getArticleDetail(Long articleId) { .title(doc.getTitle()) .publishTime(doc.getPublishTime()) .servingDate(doc.getServingDate()) - .content(String.valueOf(doc.getContent())) - .subtitle(String.valueOf(doc.getSubtitle())) + .content(doc.getContent()) // 원본 구조 그대로 내려줌 + .subtitle(doc.getSubtitle()) .summary(doc.getSummary()) .keywords(keywordList) .quizzes(quizList) diff --git a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java index a61fa47..8cfe12d 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java +++ b/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java @@ -25,8 +25,12 @@ public List getLatestArticles(int limit) { return docs.stream().map(doc -> { List bullets = null; + if (doc.getSummary() != null) { - bullets = Arrays.stream(doc.getSummary().split(" ")) + // 문장 단위로 요약 분리 → 최대 3개 bullet + bullets = Arrays.stream(doc.getSummary().split("[\\.|·|\\n]")) + .map(String::trim) + .filter(s -> !s.isBlank()) .limit(3) .toList(); } @@ -35,7 +39,7 @@ public List getLatestArticles(int limit) { .articleId(doc.getArticleId()) .title(doc.getTitle()) .summaryBullets(bullets) - .servingDate(doc.getServingDate()) // String OK + .servingDate(doc.getServingDate()) .build(); }).toList(); diff --git a/server/src/main/java/oba/backend/server/service/QuizAnswerParser.java b/server/src/main/java/oba/backend/server/service/QuizAnswerParser.java new file mode 100644 index 0000000..32ca333 --- /dev/null +++ b/server/src/main/java/oba/backend/server/service/QuizAnswerParser.java @@ -0,0 +1,22 @@ +package oba.backend.server.service; + +public class QuizAnswerParser { + + /** + * GPT가 보낸 answer 문자열(예: "2", "정답: 2", "2번")을 + * 0~3 인덱스로 변환 + */ + public static int toIndex(String answer) { + + if (answer == null) return 0; + + String cleaned = answer.replaceAll("[^0-9]", "").trim(); + + try { + int num = Integer.parseInt(cleaned); + return Math.max(0, Math.min(3, num - 1)); // 1~4 → 0~3 + } catch (Exception e) { + return 0; + } + } +} diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index 423af00..d90e1de 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -39,7 +39,7 @@ spring: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/google" + redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/google" authorization-grant-type: authorization_code scope: - email @@ -48,7 +48,7 @@ spring: kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/kakao" + redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/kakao" client-authentication-method: client_secret_post authorization-grant-type: authorization_code scope: @@ -59,7 +59,7 @@ spring: naver: client-id: ${NAVER_CLIENT_ID} client-secret: ${NAVER_CLIENT_SECRET} - redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/naver" + redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/naver" authorization-grant-type: authorization_code scope: - name @@ -86,7 +86,7 @@ spring: user-name-attribute: response oauth: - bridge-url: ${OAUTH_BRIDGE_URL} + bridge-url: "https://313a0a887091.ngrok-free.app/oauth2/bridge" app: mobile-redirect: "myapp://oauth/naver" diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 443d5d9..aa841fe 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -19,7 +19,7 @@ spring: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/google" + redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/google" authorization-grant-type: authorization_code scope: - email @@ -28,7 +28,7 @@ spring: kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/kakao" + redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/kakao" client-authentication-method: client_secret_post authorization-grant-type: authorization_code scope: @@ -39,7 +39,7 @@ spring: naver: client-id: ${NAVER_CLIENT_ID} client-secret: ${NAVER_CLIENT_SECRET} - redirect-uri: "https://c122fb63e027.ngrok-free.app/login/oauth2/code/naver" + redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/naver" authorization-grant-type: authorization_code scope: - name @@ -71,7 +71,7 @@ jwt: refresh-token-expiration-ms: 604800000 oauth: - bridge-url: ${OAUTH_BRIDGE_URL} + bridge-url: "https://313a0a887091.ngrok-free.app/oauth2/bridge" app: mobile-redirect: "myapp://oauth/naver" From f3e5e5c5e3125fb99583086342a9c6a2ea86162d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:34:30 +0900 Subject: [PATCH 136/198] =?UTF-8?q?ON-79=20Const=20=EC=83=81=EC=88=98=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- "server/ERROR\357\200\272" | 8 -- .../server/auth/OAuth2UserConverter.java | 27 ------ .../server/config/EnvVarPostProcessor.java | 22 ----- .../server/controller/ArticleController.java | 26 ------ .../controller/MobileAuthController.java | 34 ------- .../server/controller/TokenController.java | 36 -------- .../ai}/controller/AiController.java | 6 -- .../ai}/scheduler/AiScheduler.java | 17 +++- .../{ => domain/ai}/service/AiService.java | 15 +-- .../article/controller/ArticleController.java | 32 +++++++ .../article}/dto/ArticleDetailResponse.java | 6 +- .../article}/dto/ArticleSummaryResponse.java | 4 +- .../article/entity}/GptDocument.java | 3 +- .../repository}/GptMongoRepository.java | 4 +- .../service/ArticleDetailService.java | 18 ++-- .../service/ArticleSummaryService.java | 12 +-- .../article}/service/QuizAnswerParser.java | 9 +- .../quiz}/controller/MyQuizController.java | 6 +- .../quiz}/controller/QuizController.java | 4 +- .../controller/QuizResultController.java | 4 +- .../domain/quiz/dto/QuizResultRequest.java | 12 +++ .../quiz}/dto/QuizSubmitRequest.java | 4 +- .../quiz}/dto/SolvedArticleResponse.java | 2 +- .../quiz}/dto/WrongArticleResponse.java | 2 +- .../quiz/{ => entity}/IncorrectArticles.java | 2 +- .../{ => entity}/IncorrectArticlesId.java | 2 +- .../quiz/{ => entity}/IncorrectQuiz.java | 2 +- .../quiz/{ => entity}/IncorrectQuizId.java | 2 +- .../IncorrectArticlesRepository.java | 6 +- .../repository}/IncorrectQuizRepository.java | 6 +- .../quiz}/service/QuizQueryService.java | 31 +++---- .../quiz}/service/QuizResultService.java | 13 +-- .../quiz}/service/QuizService.java | 14 +-- .../oba/backend/server/domain/user/Role.java | 6 -- .../user/{ => entity}/ProviderInfo.java | 2 +- .../{ => entity}/ProviderInfoConverter.java | 2 +- .../server/domain/user/entity/Role.java | 6 ++ .../server/domain/user/{ => entity}/User.java | 8 +- .../user/repository}/UserRepository.java | 4 +- .../domain/user/service/UserService.java | 46 ++++++++++ .../backend/server/dto/QuizResultRequest.java | 12 --- .../auth/controller/AuthController.java | 4 + .../controller/OAuthBridgeController.java | 0 .../{ => global/auth}/dto/LoginRequest.java | 0 .../{ => global/auth}/dto/TokenResponse.java | 0 .../auth}/jwt/JwtAuthenticationFilter.java | 2 - .../auth}/jwt/JwtProvider.java | 31 ++----- .../auth/oauth}/CustomOAuth2User.java | 12 +-- .../auth/oauth}/CustomOAuth2UserService.java | 52 +++++------ .../oauth}/OAuth2LoginSuccessHandler.java | 11 +-- .../mongo => global/common}/BaseEntity.java | 1 - .../backend/server/global/common/Const.java | 8 ++ .../server/global/config/CacheConfig.java | 35 +++++++ .../{ => global}/config/CorsConfig.java | 0 .../{ => global}/config/SecurityConfig.java | 7 +- .../server/global/error/CustomException.java | 4 + .../global/error/GlobalExceptionHandler.java | 4 + .../server/service/MobileAuthService.java | 18 ---- .../main/resources/META-INF/spring.factories | 2 - .../src/main/resources/application-prod.yml | 92 ------------------- .../resources/templates/oauth-bridge.html | 26 ------ .../src/test/resources/application-test.yml | 0 62 files changed, 291 insertions(+), 495 deletions(-) delete mode 100644 "server/ERROR\357\200\272" delete mode 100644 server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java delete mode 100644 server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java delete mode 100644 server/src/main/java/oba/backend/server/controller/ArticleController.java delete mode 100644 server/src/main/java/oba/backend/server/controller/MobileAuthController.java delete mode 100644 server/src/main/java/oba/backend/server/controller/TokenController.java rename server/src/main/java/oba/backend/server/{ => domain/ai}/controller/AiController.java (75%) rename server/src/main/java/oba/backend/server/{ => domain/ai}/scheduler/AiScheduler.java (60%) rename server/src/main/java/oba/backend/server/{ => domain/ai}/service/AiService.java (53%) create mode 100644 server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java rename server/src/main/java/oba/backend/server/{ => domain/article}/dto/ArticleDetailResponse.java (69%) rename server/src/main/java/oba/backend/server/{ => domain/article}/dto/ArticleSummaryResponse.java (71%) rename server/src/main/java/oba/backend/server/{entity/mongo => domain/article/entity}/GptDocument.java (95%) rename server/src/main/java/oba/backend/server/{repository/mongo => domain/article/repository}/GptMongoRepository.java (77%) rename server/src/main/java/oba/backend/server/{ => domain/article}/service/ArticleDetailService.java (74%) rename server/src/main/java/oba/backend/server/{ => domain/article}/service/ArticleSummaryService.java (75%) rename server/src/main/java/oba/backend/server/{ => domain/article}/service/QuizAnswerParser.java (72%) rename server/src/main/java/oba/backend/server/{ => domain/quiz}/controller/MyQuizController.java (80%) rename server/src/main/java/oba/backend/server/{ => domain/quiz}/controller/QuizController.java (84%) rename server/src/main/java/oba/backend/server/{ => domain/quiz}/controller/QuizResultController.java (84%) create mode 100644 server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java rename server/src/main/java/oba/backend/server/{ => domain/quiz}/dto/QuizSubmitRequest.java (66%) rename server/src/main/java/oba/backend/server/{ => domain/quiz}/dto/SolvedArticleResponse.java (88%) rename server/src/main/java/oba/backend/server/{ => domain/quiz}/dto/WrongArticleResponse.java (89%) rename server/src/main/java/oba/backend/server/domain/quiz/{ => entity}/IncorrectArticles.java (91%) rename server/src/main/java/oba/backend/server/domain/quiz/{ => entity}/IncorrectArticlesId.java (93%) rename server/src/main/java/oba/backend/server/domain/quiz/{ => entity}/IncorrectQuiz.java (91%) rename server/src/main/java/oba/backend/server/domain/quiz/{ => entity}/IncorrectQuizId.java (93%) rename server/src/main/java/oba/backend/server/{repository/quiz => domain/quiz/repository}/IncorrectArticlesRepository.java (64%) rename server/src/main/java/oba/backend/server/{repository/quiz => domain/quiz/repository}/IncorrectQuizRepository.java (64%) rename server/src/main/java/oba/backend/server/{ => domain/quiz}/service/QuizQueryService.java (57%) rename server/src/main/java/oba/backend/server/{ => domain/quiz}/service/QuizResultService.java (69%) rename server/src/main/java/oba/backend/server/{ => domain/quiz}/service/QuizService.java (72%) delete mode 100644 server/src/main/java/oba/backend/server/domain/user/Role.java rename server/src/main/java/oba/backend/server/domain/user/{ => entity}/ProviderInfo.java (85%) rename server/src/main/java/oba/backend/server/domain/user/{ => entity}/ProviderInfoConverter.java (91%) create mode 100644 server/src/main/java/oba/backend/server/domain/user/entity/Role.java rename server/src/main/java/oba/backend/server/domain/user/{ => entity}/User.java (87%) rename server/src/main/java/oba/backend/server/{repository/user => domain/user/repository}/UserRepository.java (68%) create mode 100644 server/src/main/java/oba/backend/server/domain/user/service/UserService.java delete mode 100644 server/src/main/java/oba/backend/server/dto/QuizResultRequest.java create mode 100644 server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java rename server/src/main/java/oba/backend/server/{ => global/auth}/controller/OAuthBridgeController.java (100%) rename server/src/main/java/oba/backend/server/{ => global/auth}/dto/LoginRequest.java (100%) rename server/src/main/java/oba/backend/server/{ => global/auth}/dto/TokenResponse.java (100%) rename server/src/main/java/oba/backend/server/{common => global/auth}/jwt/JwtAuthenticationFilter.java (99%) rename server/src/main/java/oba/backend/server/{common => global/auth}/jwt/JwtProvider.java (77%) rename server/src/main/java/oba/backend/server/{auth => global/auth/oauth}/CustomOAuth2User.java (69%) rename server/src/main/java/oba/backend/server/{auth => global/auth/oauth}/CustomOAuth2UserService.java (58%) rename server/src/main/java/oba/backend/server/{auth => global/auth/oauth}/OAuth2LoginSuccessHandler.java (85%) rename server/src/main/java/oba/backend/server/{entity/mongo => global/common}/BaseEntity.java (95%) create mode 100644 server/src/main/java/oba/backend/server/global/common/Const.java create mode 100644 server/src/main/java/oba/backend/server/global/config/CacheConfig.java rename server/src/main/java/oba/backend/server/{ => global}/config/CorsConfig.java (100%) rename server/src/main/java/oba/backend/server/{ => global}/config/SecurityConfig.java (98%) create mode 100644 server/src/main/java/oba/backend/server/global/error/CustomException.java create mode 100644 server/src/main/java/oba/backend/server/global/error/GlobalExceptionHandler.java delete mode 100644 server/src/main/java/oba/backend/server/service/MobileAuthService.java delete mode 100644 server/src/main/resources/META-INF/spring.factories delete mode 100644 server/src/main/resources/application-prod.yml delete mode 100644 server/src/main/resources/templates/oauth-bridge.html create mode 100644 server/src/test/resources/application-test.yml diff --git "a/server/ERROR\357\200\272" "b/server/ERROR\357\200\272" deleted file mode 100644 index c0e8750..0000000 --- "a/server/ERROR\357\200\272" +++ /dev/null @@ -1,8 +0,0 @@ - PID PPID PGID WINPID TTY UID STIME COMMAND - 1483 1 1483 319596 cons2 197609 20:42:39 /usr/bin/bash - 2954 1 2954 336312 cons3 197609 03:29:20 /usr/bin/bash - 3582 2954 3582 395704 cons3 197609 10:39:59 /usr/bin/PS - 3495 3487 3487 276496 cons2 197609 10:33:18 /c/Program Files/nodejs/node - 3487 1483 3487 386764 cons2 197609 10:33:16 /usr/bin/bash - 1394 1 1394 226624 cons0 197609 20:42:33 /usr/bin/bash - 1399 1 1399 324196 cons1 197609 20:42:34 /usr/bin/bash diff --git a/server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java b/server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java deleted file mode 100644 index 252e168..0000000 --- a/server/src/main/java/oba/backend/server/auth/OAuth2UserConverter.java +++ /dev/null @@ -1,27 +0,0 @@ -package oba.backend.server.auth; - -import oba.backend.server.domain.user.ProviderInfo; -import oba.backend.server.domain.user.Role; -import oba.backend.server.domain.user.User; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Component; - -@Component -public class OAuth2UserConverter { - - public User convert(OAuth2User oAuth2User, String providerName) { - - ProviderInfo provider = ProviderInfo.from(providerName); - - String identifier = providerName + ":" + oAuth2User.getName(); - - return User.builder() - .identifier(identifier) - .email(oAuth2User.getAttribute("email")) - .name(oAuth2User.getAttribute("name")) - .picture(oAuth2User.getAttribute("picture")) - .authProvider(provider) - .role(Role.USER) - .build(); - } -} diff --git a/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java b/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java deleted file mode 100644 index 4457889..0000000 --- a/server/src/main/java/oba/backend/server/config/EnvVarPostProcessor.java +++ /dev/null @@ -1,22 +0,0 @@ -package oba.backend.server.config; - -import io.github.cdimascio.dotenv.Dotenv; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.env.EnvironmentPostProcessor; -import org.springframework.core.env.ConfigurableEnvironment; - -public class EnvVarPostProcessor implements EnvironmentPostProcessor { - - @Override - public void postProcessEnvironment(ConfigurableEnvironment environment, - SpringApplication application) { - - Dotenv dotenv = Dotenv.configure() - .ignoreIfMissing() - .load(); - - dotenv.entries().forEach(entry -> { - environment.getSystemProperties().put(entry.getKey(), entry.getValue()); - }); - } -} diff --git a/server/src/main/java/oba/backend/server/controller/ArticleController.java b/server/src/main/java/oba/backend/server/controller/ArticleController.java deleted file mode 100644 index 541fd13..0000000 --- a/server/src/main/java/oba/backend/server/controller/ArticleController.java +++ /dev/null @@ -1,26 +0,0 @@ -package oba.backend.server.controller; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.service.ArticleDetailService; -import oba.backend.server.service.ArticleSummaryService; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/articles") -@RequiredArgsConstructor -public class ArticleController { - - private final ArticleSummaryService summaryService; - private final ArticleDetailService detailService; - - @GetMapping("/latest") - public ResponseEntity getLatest() { - return ResponseEntity.ok(summaryService.getLatestArticles(5)); - } - - @GetMapping("/{id}") - public ResponseEntity getDetail(@PathVariable Long id) { - return ResponseEntity.ok(detailService.getArticleDetail(id)); - } -} diff --git a/server/src/main/java/oba/backend/server/controller/MobileAuthController.java b/server/src/main/java/oba/backend/server/controller/MobileAuthController.java deleted file mode 100644 index 436ed50..0000000 --- a/server/src/main/java/oba/backend/server/controller/MobileAuthController.java +++ /dev/null @@ -1,34 +0,0 @@ -package oba.backend.server.controller; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.dto.LoginRequest; -import oba.backend.server.dto.TokenResponse; -import oba.backend.server.service.MobileAuthService; -import oba.backend.server.domain.user.User; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/auth") -@RequiredArgsConstructor -public class MobileAuthController { - - private final JwtProvider jwtProvider; - private final MobileAuthService mobileAuthService; - - @PostMapping("/mobile/login") - public ResponseEntity login(@RequestBody LoginRequest request) { - - String identifier = "mobile:" + request.getIdToken(); - - User user = mobileAuthService.findOrCreateMobileUser(identifier); - - TokenResponse tokens = jwtProvider.generateTokens( - user.getId(), - user.getIdentifier() - ); - - return ResponseEntity.ok(tokens); - } -} diff --git a/server/src/main/java/oba/backend/server/controller/TokenController.java b/server/src/main/java/oba/backend/server/controller/TokenController.java deleted file mode 100644 index 85ec110..0000000 --- a/server/src/main/java/oba/backend/server/controller/TokenController.java +++ /dev/null @@ -1,36 +0,0 @@ -package oba.backend.server.controller; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.dto.TokenResponse; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/auth") -@RequiredArgsConstructor -public class TokenController { - - private final JwtProvider jwtProvider; - - @PostMapping("/reissue") - public ResponseEntity reissue(@RequestHeader("Authorization") String refreshHeader) { - - if (!refreshHeader.startsWith("Bearer ")) { - return ResponseEntity.badRequest().build(); - } - - String token = refreshHeader.substring(7); - - if (!jwtProvider.validateToken(token)) { - return ResponseEntity.status(401).build(); - } - - Long userId = jwtProvider.getUserId(token); - String identifier = jwtProvider.getIdentifier(token); - - TokenResponse newTokens = jwtProvider.generateTokens(userId, identifier); - - return ResponseEntity.ok(newTokens); - } -} diff --git a/server/src/main/java/oba/backend/server/controller/AiController.java b/server/src/main/java/oba/backend/server/domain/ai/controller/AiController.java similarity index 75% rename from server/src/main/java/oba/backend/server/controller/AiController.java rename to server/src/main/java/oba/backend/server/domain/ai/controller/AiController.java index 45050ee..0f48a3f 100644 --- a/server/src/main/java/oba/backend/server/controller/AiController.java +++ b/server/src/main/java/oba/backend/server/domain/ai/controller/AiController.java @@ -12,15 +12,9 @@ public class AiController { private final AiService aiService; - // 수동 실행 API (Postman/관리자 테스트용) @PostMapping("/generate/daily") public ResponseEntity runDailyAi() { - - System.out.println("▶ /ai/generate/daily 요청 들어옴"); - - // AiService의 실제 메서드 호출 String result = aiService.runDailyGptTask(); - return ResponseEntity.ok(result); } } diff --git a/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java b/server/src/main/java/oba/backend/server/domain/ai/scheduler/AiScheduler.java similarity index 60% rename from server/src/main/java/oba/backend/server/scheduler/AiScheduler.java rename to server/src/main/java/oba/backend/server/domain/ai/scheduler/AiScheduler.java index 4808724..15309fa 100644 --- a/server/src/main/java/oba/backend/server/scheduler/AiScheduler.java +++ b/server/src/main/java/oba/backend/server/domain/ai/scheduler/AiScheduler.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import oba.backend.server.service.AiService; +import org.springframework.cache.CacheManager; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -12,18 +13,28 @@ public class AiScheduler { private final AiService aiService; + private final CacheManager cacheManager; - // 매일 0시 실행 @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") public void runDailyAiTask() { - log.info("[Scheduler] FastAPI GPT 자동 실행 시작"); - try { String result = aiService.runDailyGptTask(); log.info("[Scheduler] FastAPI 응답: {}", result); + + // Invalidate article caches after new content generation + evictArticleCaches(); } catch (Exception e) { log.error("[Scheduler] FastAPI 호출 실패", e); } } + + private void evictArticleCaches() { + if (cacheManager.getCache("latestArticles") != null) { + cacheManager.getCache("latestArticles").clear(); + } + if (cacheManager.getCache("articleDetail") != null) { + cacheManager.getCache("articleDetail").clear(); + } + } } diff --git a/server/src/main/java/oba/backend/server/service/AiService.java b/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java similarity index 53% rename from server/src/main/java/oba/backend/server/service/AiService.java rename to server/src/main/java/oba/backend/server/domain/ai/service/AiService.java index d5d6b72..5f9aa55 100644 --- a/server/src/main/java/oba/backend/server/service/AiService.java +++ b/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java @@ -1,9 +1,10 @@ package oba.backend.server.service; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; -import org.springframework.http.ResponseEntity; @Service @RequiredArgsConstructor @@ -11,18 +12,12 @@ public class AiService { private final RestTemplate restTemplate = new RestTemplate(); - // Docker Compose 내부 FastAPI 주소 - private final String FASTAPI_URL = "http://ai_backend:8000/generate_daily_gpt_results"; + @Value("${ai.server.url:http://ai_backend:8000/generate_daily_gpt_results}") + private String fastApiUrl; public String runDailyGptTask() { - System.out.println("[Spring] FastAPI 호출 시작 → " + FASTAPI_URL); - ResponseEntity response = - restTemplate.postForEntity(FASTAPI_URL, null, String.class); - - System.out.println("[Spring] FastAPI 응답 수신:"); - System.out.println(response.getBody()); - + restTemplate.postForEntity(fastApiUrl, null, String.class); return response.getBody(); } } diff --git a/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java b/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java new file mode 100644 index 0000000..2e72146 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java @@ -0,0 +1,32 @@ +package oba.backend.server.doma.article.controller; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.doma.article.dto.ArticleDetailResponse; +import oba.backend.server.doma.article.dto.ArticleSummaryResponse; +import oba.backend.server.doma.article.service.ArticleDetailService; +import oba.backend.server.doma.article.service.ArticleSummaryService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/articles") +@RequiredArgsConstructor +public class ArticleController { + + private final ArticleSummaryService summaryService; + private final ArticleDetailService detailService; + + @GetMapping("/latest") + public ResponseEntity> getLatest( + @RequestParam(defaultValue = "5") int limit + ) { + return ResponseEntity.ok(summaryService.getLatestArticles(limit)); + } + + @GetMapping("/{id}") + public ResponseEntity getDetail(@PathVariable Long id) { + return ResponseEntity.ok(detailService.getArticleDetail(id)); + } +} diff --git a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java similarity index 69% rename from server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java rename to server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java index 69aa55a..a27c3d4 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleDetailResponse.java +++ b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.dto; +package oba.backend.server.doma.article.dto; import lombok.Builder; import lombok.Getter; @@ -14,8 +14,8 @@ public class ArticleDetailResponse { private String publishTime; private String servingDate; - private Object content; // 원본 배열 구조 유지 - private Object subtitle; // 원본 배열 구조 유지 + private Object content; + private Object subtitle; private String summary; private List keywords; diff --git a/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java similarity index 71% rename from server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java rename to server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java index 9259de5..76d84d0 100644 --- a/server/src/main/java/oba/backend/server/dto/ArticleSummaryResponse.java +++ b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.dto; +package oba.backend.server.doma.article.dto; import lombok.Builder; import lombok.Getter; @@ -11,5 +11,5 @@ public class ArticleSummaryResponse { private Long articleId; private String title; private List summaryBullets; - private String servingDate; // String으로 변경 + private String servingDate; } diff --git a/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java b/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java similarity index 95% rename from server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java rename to server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java index f3ff43e..556332c 100644 --- a/server/src/main/java/oba/backend/server/entity/mongo/GptDocument.java +++ b/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java @@ -1,4 +1,4 @@ -package oba.backend.server.entity.mongo; +package oba.backend.server.doma.article.entity; import lombok.Data; import org.springframework.data.annotation.Id; @@ -34,7 +34,6 @@ public class GptDocument { @Field("gpt_result") private GptResult gptResult; - // --- GPT Result --- @Data public static class GptResult { private String summary; diff --git a/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java b/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java similarity index 77% rename from server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java rename to server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java index 02d2d4b..3718612 100644 --- a/server/src/main/java/oba/backend/server/repository/mongo/GptMongoRepository.java +++ b/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java @@ -1,6 +1,6 @@ -package oba.backend.server.repository.mongo; +package oba.backend.server.doma.article.repository; -import oba.backend.server.entity.mongo.GptDocument; +import oba.backend.server.doma.article.entity.GptDocument; import org.springframework.data.domain.Pageable; import org.springframework.data.mongodb.repository.MongoRepository; diff --git a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java b/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java similarity index 74% rename from server/src/main/java/oba/backend/server/service/ArticleDetailService.java rename to server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java index b3c1822..409ce34 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleDetailService.java +++ b/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java @@ -1,9 +1,10 @@ -package oba.backend.server.service; +package oba.backend.server.doma.article.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.dto.ArticleDetailResponse; -import oba.backend.server.entity.mongo.GptDocument; -import oba.backend.server.repository.mongo.GptMongoRepository; +import oba.backend.server.doma.article.dto.ArticleDetailResponse; +import oba.backend.server.doma.article.entity.GptDocument; +import oba.backend.server.doma.article.repository.GptMongoRepository; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import java.util.List; @@ -15,20 +16,19 @@ public class ArticleDetailService { private final GptMongoRepository gptMongoRepository; + @Cacheable(value = "articleDetail", key = "#articleId", unless = "#result == null") public ArticleDetailResponse getArticleDetail(Long articleId) { GptDocument doc = gptMongoRepository.findByArticleId(articleId) - .orElseThrow(() -> new RuntimeException("Article not found")); + .orElseThrow(() -> new RuntimeException("Article not found: " + articleId)); - // Keyword: List → List List keywordList = null; if (doc.getKeywords() != null) { keywordList = doc.getKeywords().stream() - .map(k -> k.getKeyword()) + .map(GptDocument.GptResult.Keyword::getKeyword) .toList(); } - // Quiz: List → List> List> quizList = null; if (doc.getQuizzes() != null) { quizList = doc.getQuizzes().stream() @@ -46,7 +46,7 @@ public ArticleDetailResponse getArticleDetail(Long articleId) { .title(doc.getTitle()) .publishTime(doc.getPublishTime()) .servingDate(doc.getServingDate()) - .content(doc.getContent()) // 원본 구조 그대로 내려줌 + .content(doc.getContent()) .subtitle(doc.getSubtitle()) .summary(doc.getSummary()) .keywords(keywordList) diff --git a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java b/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java similarity index 75% rename from server/src/main/java/oba/backend/server/service/ArticleSummaryService.java rename to server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java index 8cfe12d..897d3ea 100644 --- a/server/src/main/java/oba/backend/server/service/ArticleSummaryService.java +++ b/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java @@ -1,9 +1,10 @@ -package oba.backend.server.service; +package oba.backend.server.doma.article.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.dto.ArticleSummaryResponse; -import oba.backend.server.entity.mongo.GptDocument; -import oba.backend.server.repository.mongo.GptMongoRepository; +import oba.backend.server.doma.article.dto.ArticleSummaryResponse; +import oba.backend.server.doma.article.entity.GptDocument; +import oba.backend.server.doma.article.repository.GptMongoRepository; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -17,17 +18,16 @@ public class ArticleSummaryService { private final GptMongoRepository gptMongoRepository; + @Cacheable(value = "latestArticles", key = "#limit", unless = "#result == null || #result.isEmpty()") public List getLatestArticles(int limit) { Pageable pageable = PageRequest.of(0, limit); List docs = gptMongoRepository.findByOrderByServingDateDesc(pageable); return docs.stream().map(doc -> { - List bullets = null; if (doc.getSummary() != null) { - // 문장 단위로 요약 분리 → 최대 3개 bullet bullets = Arrays.stream(doc.getSummary().split("[\\.|·|\\n]")) .map(String::trim) .filter(s -> !s.isBlank()) diff --git a/server/src/main/java/oba/backend/server/service/QuizAnswerParser.java b/server/src/main/java/oba/backend/server/domain/article/service/QuizAnswerParser.java similarity index 72% rename from server/src/main/java/oba/backend/server/service/QuizAnswerParser.java rename to server/src/main/java/oba/backend/server/domain/article/service/QuizAnswerParser.java index 32ca333..11f1e78 100644 --- a/server/src/main/java/oba/backend/server/service/QuizAnswerParser.java +++ b/server/src/main/java/oba/backend/server/domain/article/service/QuizAnswerParser.java @@ -1,20 +1,17 @@ -package oba.backend.server.service; +package oba.backend.server.doma.article.service; public class QuizAnswerParser { /** - * GPT가 보낸 answer 문자열(예: "2", "정답: 2", "2번")을 - * 0~3 인덱스로 변환 + * GPT가 보낸 answer 문자열(예: "2", "정답: 2", "2번")을 0~3 인덱스로 변환 */ public static int toIndex(String answer) { - if (answer == null) return 0; - String cleaned = answer.replaceAll("[^0-9]", "").trim(); try { int num = Integer.parseInt(cleaned); - return Math.max(0, Math.min(3, num - 1)); // 1~4 → 0~3 + return Math.max(0, Math.min(3, num - 1)); // 1~4 → 0~3 } catch (Exception e) { return 0; } diff --git a/server/src/main/java/oba/backend/server/controller/MyQuizController.java b/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java similarity index 80% rename from server/src/main/java/oba/backend/server/controller/MyQuizController.java rename to server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java index 297a067..67f6b79 100644 --- a/server/src/main/java/oba/backend/server/controller/MyQuizController.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java @@ -1,9 +1,9 @@ package oba.backend.server.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.dto.SolvedArticleResponse; -import oba.backend.server.dto.WrongArticleResponse; -import oba.backend.server.service.QuizQueryService; +import oba.backend.server.doma.quiz.dto.SolvedArticleResponse; +import oba.backend.server.doma.quiz.dto.WrongArticleResponse; +import oba.backend.server.doma.quiz.service.QuizQueryService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/server/src/main/java/oba/backend/server/controller/QuizController.java b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java similarity index 84% rename from server/src/main/java/oba/backend/server/controller/QuizController.java rename to server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java index 8a64b1e..e90140f 100644 --- a/server/src/main/java/oba/backend/server/controller/QuizController.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java @@ -1,8 +1,8 @@ package oba.backend.server.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.dto.QuizSubmitRequest; -import oba.backend.server.service.QuizService; +import oba.backend.server.doma.quiz.dto.QuizSubmitRequest; +import oba.backend.server.doma.quiz.service.QuizService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/server/src/main/java/oba/backend/server/controller/QuizResultController.java b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizResultController.java similarity index 84% rename from server/src/main/java/oba/backend/server/controller/QuizResultController.java rename to server/src/main/java/oba/backend/server/domain/quiz/controller/QuizResultController.java index e0453b7..b3e33ec 100644 --- a/server/src/main/java/oba/backend/server/controller/QuizResultController.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizResultController.java @@ -1,8 +1,8 @@ package oba.backend.server.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.dto.QuizResultRequest; -import oba.backend.server.service.QuizResultService; +import oba.backend.server.doma.quiz.dto.QuizResultRequest; +import oba.backend.server.doma.quiz.service.QuizResultService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java new file mode 100644 index 0000000..809ebd4 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java @@ -0,0 +1,12 @@ +package oba.backend.server.doma.quiz.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class QuizResultRequest { + private Long articleId; + private boolean correct; + private int selectedOption; +} diff --git a/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java similarity index 66% rename from server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java rename to server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java index fd662bc..5c7b99b 100644 --- a/server/src/main/java/oba/backend/server/dto/QuizSubmitRequest.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java @@ -1,4 +1,4 @@ -package oba.backend.server.dto; +package oba.backend.server.doma.quiz.dto; import lombok.Getter; import lombok.NoArgsConstructor; @@ -8,5 +8,5 @@ @NoArgsConstructor public class QuizSubmitRequest { private Long articleId; - private List answers; // 0 또는 1 값 + private List answers; // 0 or 1 } diff --git a/server/src/main/java/oba/backend/server/dto/SolvedArticleResponse.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java similarity index 88% rename from server/src/main/java/oba/backend/server/dto/SolvedArticleResponse.java rename to server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java index af0c30f..832135d 100644 --- a/server/src/main/java/oba/backend/server/dto/SolvedArticleResponse.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.dto; +package oba.backend.server.doma.quiz.dto; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/server/src/main/java/oba/backend/server/dto/WrongArticleResponse.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java similarity index 89% rename from server/src/main/java/oba/backend/server/dto/WrongArticleResponse.java rename to server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java index a8983c1..c6aab08 100644 --- a/server/src/main/java/oba/backend/server/dto/WrongArticleResponse.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.dto; +package oba.backend.server.doma.quiz.dto; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticles.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticles.java similarity index 91% rename from server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticles.java rename to server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticles.java index 4e7dca1..1c4349f 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticles.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticles.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.quiz; +package oba.backend.server.doma.quiz.entity; import jakarta.persistence.*; import lombok.*; diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java similarity index 93% rename from server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java rename to server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java index 7e84d79..972f2b0 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectArticlesId.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.quiz; +package oba.backend.server.doma.quiz.entity; import lombok.*; import java.io.Serializable; diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuiz.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java similarity index 91% rename from server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuiz.java rename to server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java index 7345fab..8a0373e 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuiz.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.quiz; +package oba.backend.server.doma.quiz.entity; import jakarta.persistence.*; import lombok.*; diff --git a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java similarity index 93% rename from server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java rename to server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java index fc3600e..a90d7d6 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/IncorrectQuizId.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.quiz; +package oba.backend.server.doma.quiz.entity; import lombok.*; import java.io.Serializable; diff --git a/server/src/main/java/oba/backend/server/repository/quiz/IncorrectArticlesRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectArticlesRepository.java similarity index 64% rename from server/src/main/java/oba/backend/server/repository/quiz/IncorrectArticlesRepository.java rename to server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectArticlesRepository.java index 18f3db2..8c89cf7 100644 --- a/server/src/main/java/oba/backend/server/repository/quiz/IncorrectArticlesRepository.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectArticlesRepository.java @@ -1,7 +1,7 @@ -package oba.backend.server.repository.quiz; +package oba.backend.server.doma.quiz.repository; -import oba.backend.server.domain.quiz.IncorrectArticles; -import oba.backend.server.domain.quiz.IncorrectArticlesId; +import oba.backend.server.doma.quiz.entity.IncorrectArticles; +import oba.backend.server.doma.quiz.entity.IncorrectArticlesId; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/server/src/main/java/oba/backend/server/repository/quiz/IncorrectQuizRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java similarity index 64% rename from server/src/main/java/oba/backend/server/repository/quiz/IncorrectQuizRepository.java rename to server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java index 0907919..9e0af93 100644 --- a/server/src/main/java/oba/backend/server/repository/quiz/IncorrectQuizRepository.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java @@ -1,7 +1,7 @@ -package oba.backend.server.repository.quiz; +package oba.backend.server.doma.quiz.repository; -import oba.backend.server.domain.quiz.IncorrectQuiz; -import oba.backend.server.domain.quiz.IncorrectQuizId; +import oba.backend.server.doma.quiz.entity.IncorrectQuiz; +import oba.backend.server.doma.quiz.entity.IncorrectQuizId; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/server/src/main/java/oba/backend/server/service/QuizQueryService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java similarity index 57% rename from server/src/main/java/oba/backend/server/service/QuizQueryService.java rename to server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java index 18993cc..10522fa 100644 --- a/server/src/main/java/oba/backend/server/service/QuizQueryService.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java @@ -1,10 +1,10 @@ -package oba.backend.server.service; +package oba.backend.server.doma.quiz.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.dto.SolvedArticleResponse; -import oba.backend.server.dto.WrongArticleResponse; -import oba.backend.server.repository.quiz.IncorrectArticlesRepository; -import oba.backend.server.repository.quiz.IncorrectQuizRepository; +import oba.backend.server.doma.quiz.dto.SolvedArticleResponse; +import oba.backend.server.doma.quiz.dto.WrongArticleResponse; +import oba.backend.server.doma.quiz.repository.IncorrectArticlesRepository; +import oba.backend.server.doma.quiz.repository.IncorrectQuizRepository; import org.springframework.stereotype.Service; import java.util.List; @@ -17,9 +17,7 @@ public class QuizQueryService { private final IncorrectArticlesRepository incorrectArticlesRepository; private final IncorrectQuizRepository incorrectQuizRepository; - /** 사용자가 맞힌 기사 리스트 */ public List getSolved(Long userId) { - return incorrectArticlesRepository.findByUserId(userId) .stream() .map(a -> SolvedArticleResponse.builder() @@ -29,22 +27,19 @@ public List getSolved(Long userId) { .collect(Collectors.toList()); } - /** 사용자가 틀린 문제 리스트 */ public List getWrong(Long userId) { - return incorrectQuizRepository.findByUserId(userId) .stream() .map(q -> WrongArticleResponse.builder() .articleId(q.getArticleId()) - .incorrectAnswers( - new boolean[]{ - q.isQuiz1(), - q.isQuiz2(), - q.isQuiz3(), - q.isQuiz4(), - q.isQuiz5() - } - ).build()) + .incorrectAnswers(new boolean[]{ + q.isQuiz1(), + q.isQuiz2(), + q.isQuiz3(), + q.isQuiz4(), + q.isQuiz5() + }) + .build()) .collect(Collectors.toList()); } } diff --git a/server/src/main/java/oba/backend/server/service/QuizResultService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java similarity index 69% rename from server/src/main/java/oba/backend/server/service/QuizResultService.java rename to server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java index fd7a0b9..b52b7d4 100644 --- a/server/src/main/java/oba/backend/server/service/QuizResultService.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java @@ -1,11 +1,10 @@ -package oba.backend.server.service; +package oba.backend.server.doma.quiz.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.dto.QuizResultRequest; -import oba.backend.server.domain.quiz.IncorrectArticles; -import oba.backend.server.domain.quiz.IncorrectArticlesId; -import oba.backend.server.repository.quiz.IncorrectArticlesRepository; +import oba.backend.server.global.auth.jwt.JwtProvider; +import oba.backend.server.doma.quiz.dto.QuizResultRequest; +import oba.backend.server.doma.quiz.entity.IncorrectArticles; +import oba.backend.server.doma.quiz.repository.IncorrectArticlesRepository; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -18,7 +17,6 @@ public class QuizResultService { private final JwtProvider jwtProvider; public void saveQuizResult(String jwt, QuizResultRequest request) { - Long userId = jwtProvider.getUserId(jwt); IncorrectArticles incorrectArticles = IncorrectArticles.builder() @@ -30,4 +28,3 @@ public void saveQuizResult(String jwt, QuizResultRequest request) { incorrectArticlesRepository.save(incorrectArticles); } } - diff --git a/server/src/main/java/oba/backend/server/service/QuizService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java similarity index 72% rename from server/src/main/java/oba/backend/server/service/QuizService.java rename to server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java index f847838..d8ac4ac 100644 --- a/server/src/main/java/oba/backend/server/service/QuizService.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java @@ -1,10 +1,10 @@ -package oba.backend.server.service; +package oba.backend.server.doma.quiz.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.domain.quiz.IncorrectQuiz; -import oba.backend.server.dto.QuizSubmitRequest; -import oba.backend.server.repository.quiz.IncorrectQuizRepository; +import oba.backend.server.global.auth.jwt.JwtProvider; +import oba.backend.server.doma.quiz.entity.IncorrectQuiz; +import oba.backend.server.doma.quiz.dto.QuizSubmitRequest; +import oba.backend.server.doma.quiz.repository.IncorrectQuizRepository; import org.springframework.stereotype.Service; @Service @@ -15,11 +15,8 @@ public class QuizService { private final JwtProvider jwtProvider; public void submit(String jwt, QuizSubmitRequest request) { - - // 1) JWT → userId 추출 Long userId = jwtProvider.getUserId(jwt); - // 2) Entity 생성 IncorrectQuiz incorrectQuiz = IncorrectQuiz.builder() .userId(userId) .articleId(request.getArticleId()) @@ -30,7 +27,6 @@ public void submit(String jwt, QuizSubmitRequest request) { .quiz5(request.getAnswers().get(4) == 1) .build(); - // 3) 저장 incorrectQuizRepository.save(incorrectQuiz); } } diff --git a/server/src/main/java/oba/backend/server/domain/user/Role.java b/server/src/main/java/oba/backend/server/domain/user/Role.java deleted file mode 100644 index bc7bba8..0000000 --- a/server/src/main/java/oba/backend/server/domain/user/Role.java +++ /dev/null @@ -1,6 +0,0 @@ -package oba.backend.server.domain.user; - -public enum Role { - USER, - ADMIN -} diff --git a/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java b/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfo.java similarity index 85% rename from server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java rename to server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfo.java index df609a2..152286f 100644 --- a/server/src/main/java/oba/backend/server/domain/user/ProviderInfo.java +++ b/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfo.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.user; +package oba.backend.server.doma.user.entity; public enum ProviderInfo { LOCAL, diff --git a/server/src/main/java/oba/backend/server/domain/user/ProviderInfoConverter.java b/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfoConverter.java similarity index 91% rename from server/src/main/java/oba/backend/server/domain/user/ProviderInfoConverter.java rename to server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfoConverter.java index 7f16827..336c652 100644 --- a/server/src/main/java/oba/backend/server/domain/user/ProviderInfoConverter.java +++ b/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfoConverter.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.user; +package oba.backend.server.doma.user.entity; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/Role.java b/server/src/main/java/oba/backend/server/domain/user/entity/Role.java new file mode 100644 index 0000000..cefa533 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/entity/Role.java @@ -0,0 +1,6 @@ +package oba.backend.server.doma.user.entity; + +public enum Role { + USER, + ADMIN +} diff --git a/server/src/main/java/oba/backend/server/domain/user/User.java b/server/src/main/java/oba/backend/server/domain/user/entity/User.java similarity index 87% rename from server/src/main/java/oba/backend/server/domain/user/User.java rename to server/src/main/java/oba/backend/server/domain/user/entity/User.java index b2d8a3a..53fe304 100644 --- a/server/src/main/java/oba/backend/server/domain/user/User.java +++ b/server/src/main/java/oba/backend/server/domain/user/entity/User.java @@ -1,4 +1,4 @@ -package oba.backend.server.domain.user; +package oba.backend.server.doma.user.entity; import jakarta.persistence.*; import lombok.*; @@ -44,9 +44,9 @@ public class User { private boolean isDeleted = false; public void updateInfo(String email, String name, String picture) { - this.email = email; - this.name = name; - this.picture = picture; + if (email != null) this.email = email; + if (name != null) this.name = name; + if (picture != null) this.picture = picture; } public static User createMobileUser(String identifier) { diff --git a/server/src/main/java/oba/backend/server/repository/user/UserRepository.java b/server/src/main/java/oba/backend/server/domain/user/repository/UserRepository.java similarity index 68% rename from server/src/main/java/oba/backend/server/repository/user/UserRepository.java rename to server/src/main/java/oba/backend/server/domain/user/repository/UserRepository.java index 4543a5f..5607662 100644 --- a/server/src/main/java/oba/backend/server/repository/user/UserRepository.java +++ b/server/src/main/java/oba/backend/server/domain/user/repository/UserRepository.java @@ -1,6 +1,6 @@ -package oba.backend.server.repository.user; +package oba.backend.server.doma.user.repository; -import oba.backend.server.domain.user.User; +import oba.backend.server.doma.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; diff --git a/server/src/main/java/oba/backend/server/domain/user/service/UserService.java b/server/src/main/java/oba/backend/server/domain/user/service/UserService.java new file mode 100644 index 0000000..176e694 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/service/UserService.java @@ -0,0 +1,46 @@ +package oba.backend.server.doma.user.service; + +import lombok.RequiredArgsConstructor; +import oba.backend.server.doma.user.entity.ProviderInfo; +import oba.backend.server.doma.user.entity.Role; +import oba.backend.server.doma.user.entity.User; +import oba.backend.server.doma.user.repository.UserRepository; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + @Cacheable(value = "userByIdentifier", key = "#identifier", unless = "#result == null") + public User findByIdentifier(String identifier) { + return userRepository.findByIdentifier(identifier).orElse(null); + } + + @CacheEvict(value = "userByIdentifier", key = "#identifier") + public User findOrCreateOAuthUser(String identifier, + String email, + String name, + String picture, + ProviderInfo provider, + Role role) { + return userRepository.findByIdentifier(identifier) + .map(existing -> { + existing.updateInfo(email, name, picture); + return userRepository.save(existing); + }) + .orElseGet(() -> userRepository.save( + User.builder() + .identifier(identifier) + .email(email != null ? email : (identifier + "@oauth.user")) + .name(name != null ? name : "OAuthUser") + .picture(picture) + .authProvider(provider) + .role(role) + .build() + )); + } +} diff --git a/server/src/main/java/oba/backend/server/dto/QuizResultRequest.java b/server/src/main/java/oba/backend/server/dto/QuizResultRequest.java deleted file mode 100644 index 2cc393a..0000000 --- a/server/src/main/java/oba/backend/server/dto/QuizResultRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package oba.backend.server.dto; - -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class QuizResultRequest { - private Long articleId; - private boolean correct; // 맞았는지 - private int selectedOption; // 사용자가 고른 선택지 -} diff --git a/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java b/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java new file mode 100644 index 0000000..ff26d24 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java @@ -0,0 +1,4 @@ +package oba.backend.server.global.auth.controller; + +public class AuthController { +} diff --git a/server/src/main/java/oba/backend/server/controller/OAuthBridgeController.java b/server/src/main/java/oba/backend/server/global/auth/controller/OAuthBridgeController.java similarity index 100% rename from server/src/main/java/oba/backend/server/controller/OAuthBridgeController.java rename to server/src/main/java/oba/backend/server/global/auth/controller/OAuthBridgeController.java diff --git a/server/src/main/java/oba/backend/server/dto/LoginRequest.java b/server/src/main/java/oba/backend/server/global/auth/dto/LoginRequest.java similarity index 100% rename from server/src/main/java/oba/backend/server/dto/LoginRequest.java rename to server/src/main/java/oba/backend/server/global/auth/dto/LoginRequest.java diff --git a/server/src/main/java/oba/backend/server/dto/TokenResponse.java b/server/src/main/java/oba/backend/server/global/auth/dto/TokenResponse.java similarity index 100% rename from server/src/main/java/oba/backend/server/dto/TokenResponse.java rename to server/src/main/java/oba/backend/server/global/auth/dto/TokenResponse.java diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtAuthenticationFilter.java similarity index 99% rename from server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java rename to server/src/main/java/oba/backend/server/global/auth/jwt/JwtAuthenticationFilter.java index 7d6f03f..e1c2c26 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtAuthenticationFilter.java +++ b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtAuthenticationFilter.java @@ -29,10 +29,8 @@ protected void doFilterInternal(HttpServletRequest request, String token = jwtProvider.resolveToken(request); if (token != null && jwtProvider.validateToken(token)) { - Claims claims = jwtProvider.getClaims(token); String identifier = claims.getSubject(); - Authentication auth = jwtProvider.getAuthentication(identifier); SecurityContextHolder.getContext().setAuthentication(auth); } diff --git a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtProvider.java similarity index 77% rename from server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java rename to server/src/main/java/oba/backend/server/global/auth/jwt/JwtProvider.java index 81f70cd..946e29f 100644 --- a/server/src/main/java/oba/backend/server/common/jwt/JwtProvider.java +++ b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtProvider.java @@ -15,8 +15,8 @@ public class JwtProvider { private final SecretKey key; - private final long accessTokenValidity; // ms 단위 - private final long refreshTokenValidity; // ms 단위 + private final long accessTokenValidity; // ms + private final long refreshTokenValidity; // ms public JwtProvider( @Value("${jwt.secret}") String secret, @@ -28,19 +28,16 @@ public JwtProvider( this.refreshTokenValidity = refreshTokenValidity; } - // ===================== - // Token 생성 (초 단위 exp/iat) - // ===================== private String createToken(Long userId, String identifier, long validityMs) { - - long nowSec = System.currentTimeMillis() / 1000; // 현재 시간 sec - long expSec = nowSec + (validityMs / 1000); // 만료 sec + long now = System.currentTimeMillis(); + Date issuedAt = new Date(now); + Date expiry = new Date(now + validityMs); return Jwts.builder() .claim("userId", userId) .setSubject(identifier) - .claim("iat", nowSec) // 초 단위 issuedAt - .claim("exp", expSec) // 초 단위 expiration + .setIssuedAt(issuedAt) + .setExpiration(expiry) .signWith(key, SignatureAlgorithm.HS256) .compact(); } @@ -60,14 +57,11 @@ public TokenResponse generateTokens(Long userId, String identifier) { ); } - // ===================== - // Token Parsing - // ===================== public boolean validateToken(String token) { try { parseClaims(token); return true; - } catch (Exception e) { + } catch (JwtException | IllegalArgumentException e) { return false; } } @@ -91,24 +85,17 @@ public String getIdentifier(String token) { return getClaims(token).getSubject(); } - // ===================== - // Resolve Token - // ===================== public String resolveToken(HttpServletRequest request) { String bearer = request.getHeader("Authorization"); if (bearer == null || !bearer.startsWith("Bearer ")) return null; return bearer.substring(7); } - // ===================== - // Authentication 생성 - // ===================== public org.springframework.security.core.Authentication getAuthentication(String identifier) { - org.springframework.security.core.userdetails.UserDetails user = org.springframework.security.core.userdetails.User.builder() .username(identifier) - .password("") // JWT 인증에서는 비밀번호 사용하지 않음 + .password("") // not used in JWT auth .authorities("USER") .build(); diff --git a/server/src/main/java/oba/backend/server/auth/CustomOAuth2User.java b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2User.java similarity index 69% rename from server/src/main/java/oba/backend/server/auth/CustomOAuth2User.java rename to server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2User.java index d77cd9b..f48f8b1 100644 --- a/server/src/main/java/oba/backend/server/auth/CustomOAuth2User.java +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2User.java @@ -11,26 +11,26 @@ @Getter public class CustomOAuth2User implements OAuth2User { - private final OAuth2User oAuth2User; + private final OAuth2User delegate; private final User user; - public CustomOAuth2User(OAuth2User oAuth2User, User user) { - this.oAuth2User = oAuth2User; + public CustomOAuth2User(OAuth2User delegate, User user) { + this.delegate = delegate; this.user = user; } @Override public Map getAttributes() { - return oAuth2User.getAttributes(); + return delegate.getAttributes(); } @Override public Collection getAuthorities() { - return oAuth2User.getAuthorities(); + return delegate.getAuthorities(); } @Override public String getName() { - return oAuth2User.getName(); + return delegate.getName(); } } diff --git a/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java similarity index 58% rename from server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java rename to server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java index 2cfd22c..179bdbd 100644 --- a/server/src/main/java/oba/backend/server/auth/CustomOAuth2UserService.java +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java @@ -4,7 +4,7 @@ import oba.backend.server.domain.user.ProviderInfo; import oba.backend.server.domain.user.Role; import oba.backend.server.domain.user.User; -import oba.backend.server.repository.user.UserRepository; +import oba.backend.server.service.UserService; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.user.OAuth2User; @@ -16,11 +16,10 @@ @RequiredArgsConstructor public class CustomOAuth2UserService extends DefaultOAuth2UserService { - private final UserRepository userRepository; + private final UserService userService; @Override public OAuth2User loadUser(OAuth2UserRequest request) { - OAuth2User oAuth2User = super.loadUser(request); String provider = request.getClientRegistration().getRegistrationId(); // google / kakao / naver @@ -32,51 +31,44 @@ public OAuth2User loadUser(OAuth2UserRequest request) { String picture; switch (provider) { - case "google" -> { identifier = "google:" + attributes.get("sub"); email = (String) attributes.get("email"); name = (String) attributes.get("name"); picture = (String) attributes.get("picture"); } - case "kakao" -> { identifier = "kakao:" + attributes.get("id"); - Map kakaoAccount = (Map) attributes.get("kakao_account"); - Map profile = (Map) kakaoAccount.get("profile"); + Map profile = kakaoAccount == null ? null : (Map) kakaoAccount.get("profile"); - email = (String) kakaoAccount.get("email"); - name = (String) profile.get("nickname"); - picture = (String) profile.get("profile_image_url"); + email = kakaoAccount == null ? null : (String) kakaoAccount.get("email"); + name = profile == null ? null : (String) profile.get("nickname"); + picture = profile == null ? null : (String) profile.get("profile_image_url"); } - case "naver" -> { Map response = (Map) attributes.get("response"); - - identifier = "naver:" + response.get("id"); - email = (String) response.get("email"); - name = (String) response.get("name"); - picture = (String) response.get("profile_image"); + identifier = "naver:" + (response == null ? null : response.get("id")); + email = response == null ? null : (String) response.get("email"); + name = response == null ? null : (String) response.get("name"); + picture = response == null ? null : (String) response.get("profile_image"); } - default -> throw new IllegalArgumentException("Unsupported provider: " + provider); } - // DB 저장 또는 업데이트 - User user = userRepository.findByIdentifier(identifier) - .orElseGet(() -> userRepository.save( - User.builder() - .identifier(identifier) - .email(email) - .name(name) - .picture(picture) - .authProvider(ProviderInfo.valueOf(provider.toUpperCase())) - .role(Role.USER) - .build() - )); + if (identifier == null) { + throw new IllegalStateException("OAuth2 identifier is null for provider: " + provider); + } + + User user = userService.findOrCreateOAuthUser( + identifier, + email, + name, + picture, + ProviderInfo.valueOf(provider.toUpperCase()), + Role.USER + ); - // ★ 반드시 CustomOAuth2User를 반환해야 SuccessHandler와 연동됨 return new CustomOAuth2User(oAuth2User, user); } } diff --git a/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java similarity index 85% rename from server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java rename to server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java index b6017e4..0c36ca2 100644 --- a/server/src/main/java/oba/backend/server/auth/OAuth2LoginSuccessHandler.java +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java @@ -3,22 +3,22 @@ import lombok.RequiredArgsConstructor; import oba.backend.server.common.jwt.JwtProvider; import oba.backend.server.domain.user.User; -import oba.backend.server.repository.user.UserRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; -import org.springframework.stereotype.Component; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; @Component @RequiredArgsConstructor public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private final JwtProvider jwtProvider; - private final UserRepository userRepository; @Value("${app.mobile-redirect}") private String mobileRedirectUri; @@ -33,13 +33,12 @@ public void onAuthenticationSuccess( CustomOAuth2User customUser = (CustomOAuth2User) authentication.getPrincipal(); User user = customUser.getUser(); - // JWT 생성 String access = jwtProvider.createAccessToken(user.getId(), user.getIdentifier()); String refresh = jwtProvider.createRefreshToken(user.getId(), user.getIdentifier()); String redirectUri = mobileRedirectUri - + "?access=" + access - + "&refresh=" + refresh; + + "?access=" + URLEncoder.encode(access, StandardCharsets.UTF_8) + + "&refresh=" + URLEncoder.encode(refresh, StandardCharsets.UTF_8); response.sendRedirect(redirectUri); } diff --git a/server/src/main/java/oba/backend/server/entity/mongo/BaseEntity.java b/server/src/main/java/oba/backend/server/global/common/BaseEntity.java similarity index 95% rename from server/src/main/java/oba/backend/server/entity/mongo/BaseEntity.java rename to server/src/main/java/oba/backend/server/global/common/BaseEntity.java index 8155cf4..8c7a608 100644 --- a/server/src/main/java/oba/backend/server/entity/mongo/BaseEntity.java +++ b/server/src/main/java/oba/backend/server/global/common/BaseEntity.java @@ -25,7 +25,6 @@ public abstract class BaseEntity { @Column(nullable = false) private Boolean isDeleted = false; - /** 소프트 삭제 수행 */ public void softDelete() { this.isDeleted = true; this.deletedAt = LocalDateTime.now(); diff --git a/server/src/main/java/oba/backend/server/global/common/Const.java b/server/src/main/java/oba/backend/server/global/common/Const.java new file mode 100644 index 0000000..fc0dbb3 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/common/Const.java @@ -0,0 +1,8 @@ +package oba.backend.server.global.common; + +public class Const { + public static final String CACHE_USER = "userCache"; + public static final String CACHE_ARTICLE_DETAIL = "articleDetail"; + public static final String CACHE_LATEST_ARTICLES = "latestArticles"; + public static final String BEARER_PREFIX = "Bearer "; +} diff --git a/server/src/main/java/oba/backend/server/global/config/CacheConfig.java b/server/src/main/java/oba/backend/server/global/config/CacheConfig.java new file mode 100644 index 0000000..882cc74 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/config/CacheConfig.java @@ -0,0 +1,35 @@ +package oba.backend.server.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public Caffeine caffeineSpec() { + return Caffeine.newBuilder() + .initialCapacity(100) + .maximumSize(5_000) + .expireAfterWrite(10, TimeUnit.MINUTES) + .recordStats(); + } + + @Bean + public CacheManager cacheManager(Caffeine caffeine) { + CaffeineCacheManager manager = new CaffeineCacheManager( + "articleDetail", + "latestArticles", + "userByIdentifier" + ); + manager.setCaffeine(caffeine); + return manager; + } +} diff --git a/server/src/main/java/oba/backend/server/config/CorsConfig.java b/server/src/main/java/oba/backend/server/global/config/CorsConfig.java similarity index 100% rename from server/src/main/java/oba/backend/server/config/CorsConfig.java rename to server/src/main/java/oba/backend/server/global/config/CorsConfig.java diff --git a/server/src/main/java/oba/backend/server/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java similarity index 98% rename from server/src/main/java/oba/backend/server/config/SecurityConfig.java rename to server/src/main/java/oba/backend/server/global/config/SecurityConfig.java index a85235f..3666a8f 100644 --- a/server/src/main/java/oba/backend/server/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java @@ -4,13 +4,10 @@ import oba.backend.server.auth.CustomOAuth2UserService; import oba.backend.server.auth.OAuth2LoginSuccessHandler; import oba.backend.server.common.jwt.JwtAuthenticationFilter; - import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; - import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; @@ -39,7 +36,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return config; })) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth .requestMatchers( "/auth/**", @@ -47,16 +43,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/login/**", "/login/oauth2/**", "/articles/**", + "/ai/**", "/error" ).permitAll() .anyRequest().authenticated() ) - .oauth2Login(oauth -> oauth .userInfoEndpoint(info -> info.userService(customOAuth2UserService)) .successHandler(oAuth2LoginSuccessHandler) ) - .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/server/src/main/java/oba/backend/server/global/error/CustomException.java b/server/src/main/java/oba/backend/server/global/error/CustomException.java new file mode 100644 index 0000000..971c2ee --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/error/CustomException.java @@ -0,0 +1,4 @@ +package oba.backend.server.global.error; + +public class CustomException { +} diff --git a/server/src/main/java/oba/backend/server/global/error/GlobalExceptionHandler.java b/server/src/main/java/oba/backend/server/global/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..9719d6a --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/error/GlobalExceptionHandler.java @@ -0,0 +1,4 @@ +package oba.backend.server.global.error; + +public class GlobalExceptionHandler { +} diff --git a/server/src/main/java/oba/backend/server/service/MobileAuthService.java b/server/src/main/java/oba/backend/server/service/MobileAuthService.java deleted file mode 100644 index fce61c9..0000000 --- a/server/src/main/java/oba/backend/server/service/MobileAuthService.java +++ /dev/null @@ -1,18 +0,0 @@ -package oba.backend.server.service; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.User; -import oba.backend.server.repository.user.UserRepository; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class MobileAuthService { - - private final UserRepository userRepository; - - public User findOrCreateMobileUser(String identifier) { - return userRepository.findByIdentifier(identifier) - .orElseGet(() -> userRepository.save(User.createMobileUser(identifier))); - } -} diff --git a/server/src/main/resources/META-INF/spring.factories b/server/src/main/resources/META-INF/spring.factories deleted file mode 100644 index 5cbb97b..0000000 --- a/server/src/main/resources/META-INF/spring.factories +++ /dev/null @@ -1,2 +0,0 @@ -org.springframework.boot.env.EnvironmentPostProcessor=\ -oba.backend.server.config.EnvVarPostProcessor \ No newline at end of file diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml deleted file mode 100644 index d90e1de..0000000 --- a/server/src/main/resources/application-prod.yml +++ /dev/null @@ -1,92 +0,0 @@ -server: - port: ${SERVER_PORT:9000} - forward-headers-strategy: framework - -jwt: - secret: ${JWT_SECRET} - access-token-expiration-ms: ${JWT_ACCESS_TOKEN_EXPIRATION_MS:3600000} - refresh-token-expiration-ms: ${JWT_REFRESH_TOKEN_EXPIRATION_MS:604800000} - -ai: - server: - url: ${AI_SERVER_URL} - -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} - - jpa: - hibernate: - ddl-auto: update - show-sql: false - properties: - hibernate: - format_sql: true - - data: - mongodb: - uri: ${MONGODB_URI} - database: OneBitArticle - - security: - oauth2: - client: - registration: - - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/google" - authorization-grant-type: authorization_code - scope: - - email - - profile - - kakao: - client-id: ${KAKAO_CLIENT_ID} - client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/kakao" - client-authentication-method: client_secret_post - authorization-grant-type: authorization_code - scope: - - profile_nickname - - profile_image - - account_email - - naver: - client-id: ${NAVER_CLIENT_ID} - client-secret: ${NAVER_CLIENT_SECRET} - redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/naver" - authorization-grant-type: authorization_code - scope: - - name - - email - - profile_image - - provider: - google: - authorization-uri: https://accounts.google.com/o/oauth2/v2/auth - token-uri: https://oauth2.googleapis.com/token - user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo - user-name-attribute: sub - - kakao: - authorization-uri: https://kauth.kakao.com/oauth/authorize - token-uri: https://kauth.kakao.com/oauth/token - user-info-uri: https://kapi.kakao.com/v2/user/me - user-name-attribute: id - - naver: - authorization-uri: https://nid.naver.com/oauth2.0/authorize - token-uri: https://nid.naver.com/oauth2.0/token - user-info-uri: https://openapi.naver.com/v1/nid/me - user-name-attribute: response - -oauth: - bridge-url: "https://313a0a887091.ngrok-free.app/oauth2/bridge" - -app: - mobile-redirect: "myapp://oauth/naver" diff --git a/server/src/main/resources/templates/oauth-bridge.html b/server/src/main/resources/templates/oauth-bridge.html deleted file mode 100644 index b319d2e..0000000 --- a/server/src/main/resources/templates/oauth-bridge.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - Connecting... - - -

앱으로 연결 중입니다...

- - - - diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml new file mode 100644 index 0000000..e69de29 From 5eee1f56ee436188169c034fa975ba6502750380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:34:30 +0900 Subject: [PATCH 137/198] =?UTF-8?q?ON-79=20BaseEntity=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=20=EB=B0=8F=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/oba/backend/server/global/common/BaseEntity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/global/common/BaseEntity.java b/server/src/main/java/oba/backend/server/global/common/BaseEntity.java index 8c7a608..04cc426 100644 --- a/server/src/main/java/oba/backend/server/global/common/BaseEntity.java +++ b/server/src/main/java/oba/backend/server/global/common/BaseEntity.java @@ -1,4 +1,4 @@ -package oba.backend.server.entity.mongo; +package oba.backend.server.global.common; import jakarta.persistence.*; import lombok.Getter; From b58c3356ed19dab1d6c6f37344f5c0540dddacb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:34:31 +0900 Subject: [PATCH 138/198] =?UTF-8?q?ON-79=20CacheConfig=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/global/config/CacheConfig.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/server/src/main/java/oba/backend/server/global/config/CacheConfig.java b/server/src/main/java/oba/backend/server/global/config/CacheConfig.java index 882cc74..fb12bde 100644 --- a/server/src/main/java/oba/backend/server/global/config/CacheConfig.java +++ b/server/src/main/java/oba/backend/server/global/config/CacheConfig.java @@ -1,6 +1,7 @@ -package oba.backend.server.config; +package oba.backend.server.global.config; import com.github.benmanes.caffeine.cache.Caffeine; +import oba.backend.server.global.common.Const; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.caffeine.CaffeineCacheManager; @@ -14,22 +15,22 @@ public class CacheConfig { @Bean - public Caffeine caffeineSpec() { + public Caffeine caffeineConfig() { return Caffeine.newBuilder() .initialCapacity(100) - .maximumSize(5_000) - .expireAfterWrite(10, TimeUnit.MINUTES) + .maximumSize(5000) + .expireAfterWrite(30, TimeUnit.MINUTES) // 캐시 만료 시간 30분 .recordStats(); } @Bean public CacheManager cacheManager(Caffeine caffeine) { CaffeineCacheManager manager = new CaffeineCacheManager( - "articleDetail", - "latestArticles", - "userByIdentifier" + Const.CACHE_USER, + Const.CACHE_ARTICLE_DETAIL, + Const.CACHE_LATEST_ARTICLES ); manager.setCaffeine(caffeine); return manager; } -} +} \ No newline at end of file From 033538d3d282ab8ebab0193996047184f5e06c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:34:31 +0900 Subject: [PATCH 139/198] =?UTF-8?q?ON-79=20CorsConfig=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/oba/backend/server/global/config/CorsConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/global/config/CorsConfig.java b/server/src/main/java/oba/backend/server/global/config/CorsConfig.java index bbb88cc..66a2db0 100644 --- a/server/src/main/java/oba/backend/server/global/config/CorsConfig.java +++ b/server/src/main/java/oba/backend/server/global/config/CorsConfig.java @@ -1,4 +1,4 @@ -package oba.backend.server.config; +package oba.backend.server.global.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; From 03514caa676130ba467788c3704274818682afdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:38 +0900 Subject: [PATCH 140/198] =?UTF-8?q?ON-79=20SecurityConfig=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/global/config/SecurityConfig.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java index 3666a8f..788ed19 100644 --- a/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java @@ -1,9 +1,9 @@ -package oba.backend.server.config; +package oba.backend.server.global.config; import lombok.RequiredArgsConstructor; -import oba.backend.server.auth.CustomOAuth2UserService; -import oba.backend.server.auth.OAuth2LoginSuccessHandler; -import oba.backend.server.common.jwt.JwtAuthenticationFilter; +import oba.backend.server.global.auth.oauth.CustomOAuth2UserService; +import oba.backend.server.global.auth.oauth.OAuth2LoginSuccessHandler; +import oba.backend.server.global.auth.jwt.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; From 67881c5d74b1409f523fa3a78d86cec8fd217e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:39 +0900 Subject: [PATCH 141/198] =?UTF-8?q?ON-79=20LoginRequest=20DTO=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/global/auth/dto/LoginRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/global/auth/dto/LoginRequest.java b/server/src/main/java/oba/backend/server/global/auth/dto/LoginRequest.java index d79e316..d7441f8 100644 --- a/server/src/main/java/oba/backend/server/global/auth/dto/LoginRequest.java +++ b/server/src/main/java/oba/backend/server/global/auth/dto/LoginRequest.java @@ -1,4 +1,4 @@ -package oba.backend.server.dto; +package oba.backend.server.global.auth.dto; import lombok.Getter; From 9f012449f5da5f42b863bdc53e981ea077ec6ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:39 +0900 Subject: [PATCH 142/198] =?UTF-8?q?ON-79=20TokenResponse=20DTO=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/global/auth/dto/TokenResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/global/auth/dto/TokenResponse.java b/server/src/main/java/oba/backend/server/global/auth/dto/TokenResponse.java index fbd1e7e..183bc00 100644 --- a/server/src/main/java/oba/backend/server/global/auth/dto/TokenResponse.java +++ b/server/src/main/java/oba/backend/server/global/auth/dto/TokenResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.dto; +package oba.backend.server.global.auth.dto; import lombok.AllArgsConstructor; import lombok.Getter; From 4c94fa75ba0f02412e7939f977c59f2e23b8d2a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:39 +0900 Subject: [PATCH 143/198] =?UTF-8?q?ON-79=20JwtProvider=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/global/auth/jwt/JwtProvider.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/global/auth/jwt/JwtProvider.java b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtProvider.java index 946e29f..978bcc0 100644 --- a/server/src/main/java/oba/backend/server/global/auth/jwt/JwtProvider.java +++ b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtProvider.java @@ -1,9 +1,9 @@ -package oba.backend.server.common.jwt; +package oba.backend.server.global.auth.jwt; import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; -import oba.backend.server.dto.TokenResponse; +import oba.backend.server.global.auth.dto.TokenResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; From 1a11696c32f6e242a1b591f4a6a7e838e153d51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:39 +0900 Subject: [PATCH 144/198] =?UTF-8?q?ON-79=20JwtAuthenticationFilter=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/global/auth/jwt/JwtAuthenticationFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/global/auth/jwt/JwtAuthenticationFilter.java b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtAuthenticationFilter.java index e1c2c26..9c86297 100644 --- a/server/src/main/java/oba/backend/server/global/auth/jwt/JwtAuthenticationFilter.java +++ b/server/src/main/java/oba/backend/server/global/auth/jwt/JwtAuthenticationFilter.java @@ -1,4 +1,4 @@ -package oba.backend.server.common.jwt; +package oba.backend.server.global.auth.jwt; import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; From d2abd36e32d31dd18b9cc62a88e0af9235b6d648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:39 +0900 Subject: [PATCH 145/198] =?UTF-8?q?ON-79=20CustomOAuth2User=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/global/auth/oauth/CustomOAuth2User.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2User.java b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2User.java index f48f8b1..d0f383e 100644 --- a/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2User.java +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2User.java @@ -1,7 +1,7 @@ -package oba.backend.server.auth; +package oba.backend.server.global.auth.oauth; import lombok.Getter; -import oba.backend.server.domain.user.User; +import oba.backend.server.domain.user.entity.User; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.user.OAuth2User; From 02eead65a529a87d28d55462f7f24275f113ccee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:39 +0900 Subject: [PATCH 146/198] =?UTF-8?q?ON-79=20CustomOAuth2UserService=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/oauth/CustomOAuth2UserService.java | 110 ++++++++++-------- 1 file changed, 63 insertions(+), 47 deletions(-) diff --git a/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java index 179bdbd..97f5d06 100644 --- a/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java @@ -1,12 +1,13 @@ -package oba.backend.server.auth; +package oba.backend.server.global.auth.oauth; import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.ProviderInfo; -import oba.backend.server.domain.user.Role; -import oba.backend.server.domain.user.User; -import oba.backend.server.service.UserService; +import oba.backend.server.domain.user.entity.ProviderInfo; +import oba.backend.server.domain.user.entity.Role; +import oba.backend.server.domain.user.entity.User; +import oba.backend.server.domain.user.service.UserService; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; @@ -19,56 +20,71 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final UserService userService; @Override - public OAuth2User loadUser(OAuth2UserRequest request) { + public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(request); + String registrationId = request.getClientRegistration().getRegistrationId(); - String provider = request.getClientRegistration().getRegistrationId(); // google / kakao / naver - Map attributes = oAuth2User.getAttributes(); + // 1. 복잡한 파싱 로직을 내부 객체(OAuthAttributes)에게 위임 + OAuthAttributes attributes = OAuthAttributes.of(registrationId, oAuth2User.getAttributes()); - String identifier; - String email; - String name; - String picture; + // 2. 통합된 유저 조회/생성 메서드 호출 + User user = userService.findOrCreateUser( + attributes.identifier, + attributes.email, + attributes.name, + attributes.picture, + ProviderInfo.from(registrationId), + Role.USER + ); + + return new CustomOAuth2User(oAuth2User, user); + } - switch (provider) { - case "google" -> { - identifier = "google:" + attributes.get("sub"); - email = (String) attributes.get("email"); - name = (String) attributes.get("name"); - picture = (String) attributes.get("picture"); - } - case "kakao" -> { - identifier = "kakao:" + attributes.get("id"); - Map kakaoAccount = (Map) attributes.get("kakao_account"); - Map profile = kakaoAccount == null ? null : (Map) kakaoAccount.get("profile"); + /** + * Provider 별로 상이한 속성(Attribute) 정보를 규격화하는 내부 클래스 (Java 17 Record 사용) + */ + private record OAuthAttributes(String identifier, String email, String name, String picture) { - email = kakaoAccount == null ? null : (String) kakaoAccount.get("email"); - name = profile == null ? null : (String) profile.get("nickname"); - picture = profile == null ? null : (String) profile.get("profile_image_url"); - } - case "naver" -> { - Map response = (Map) attributes.get("response"); - identifier = "naver:" + (response == null ? null : response.get("id")); - email = response == null ? null : (String) response.get("email"); - name = response == null ? null : (String) response.get("name"); - picture = response == null ? null : (String) response.get("profile_image"); - } - default -> throw new IllegalArgumentException("Unsupported provider: " + provider); + static OAuthAttributes of(String provider, Map attributes) { + return switch (provider) { + case "google" -> ofGoogle(attributes); + case "kakao" -> ofKakao(attributes); + case "naver" -> ofNaver(attributes); + default -> throw new IllegalArgumentException("Unsupported provider: " + provider); + }; } - if (identifier == null) { - throw new IllegalStateException("OAuth2 identifier is null for provider: " + provider); + private static OAuthAttributes ofGoogle(Map attributes) { + return new OAuthAttributes( + "google:" + attributes.get("sub"), + (String) attributes.get("email"), + (String) attributes.get("name"), + (String) attributes.get("picture") + ); } - User user = userService.findOrCreateOAuthUser( - identifier, - email, - name, - picture, - ProviderInfo.valueOf(provider.toUpperCase()), - Role.USER - ); + @SuppressWarnings("unchecked") + private static OAuthAttributes ofKakao(Map attributes) { + Map account = (Map) attributes.get("kakao_account"); + Map profile = (account != null) ? (Map) account.get("profile") : null; - return new CustomOAuth2User(oAuth2User, user); + return new OAuthAttributes( + "kakao:" + attributes.get("id"), + (account != null) ? (String) account.get("email") : null, + (profile != null) ? (String) profile.get("nickname") : null, + (profile != null) ? (String) profile.get("profile_image_url") : null + ); + } + + @SuppressWarnings("unchecked") + private static OAuthAttributes ofNaver(Map attributes) { + Map response = (Map) attributes.get("response"); + return new OAuthAttributes( + "naver:" + (response != null ? response.get("id") : ""), + (response != null) ? (String) response.get("email") : null, + (response != null) ? (String) response.get("name") : null, + (response != null) ? (String) response.get("profile_image") : null + ); + } } -} +} \ No newline at end of file From e8f6be93c76fb7e225542f224e3fe42efb129947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:39 +0900 Subject: [PATCH 147/198] =?UTF-8?q?ON-79=20OAuth2LoginSuccessHandler=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/global/auth/oauth/OAuth2LoginSuccessHandler.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java index 0c36ca2..b76d469 100644 --- a/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java @@ -1,8 +1,8 @@ -package oba.backend.server.auth; +package oba.backend.server.global.auth.oauth; import lombok.RequiredArgsConstructor; -import oba.backend.server.common.jwt.JwtProvider; -import oba.backend.server.domain.user.User; +import oba.backend.server.global.auth.jwt.JwtProvider; +import oba.backend.server.domain.user.entity.User; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; From f5b8964256f625089339bcf2e41d29a6d69abe73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:39 +0900 Subject: [PATCH 148/198] =?UTF-8?q?ON-79=20AuthController=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java b/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java index ff26d24..b108698 100644 --- a/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java +++ b/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java @@ -1,4 +1,60 @@ package oba.backend.server.global.auth.controller; +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.user.entity.ProviderInfo; +import oba.backend.server.domain.user.entity.Role; +import oba.backend.server.domain.user.entity.User; +import oba.backend.server.global.auth.dto.LoginRequest; +import oba.backend.server.global.auth.dto.TokenResponse; +import oba.backend.server.global.auth.jwt.JwtProvider; +import oba.backend.server.global.common.Const; +import oba.backend.server.domain.user.service.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor public class AuthController { -} + + private final JwtProvider jwtProvider; + private final UserService userService; + + // 모바일 소셜 로그인 (ID Token 검증은 클라이언트가 했다고 가정) + @PostMapping("/mobile/login") + public ResponseEntity mobileLogin(@RequestBody LoginRequest request) { + String identifier = "mobile:" + request.getIdToken(); + + // Mobile 유저는 별도 프로필 정보가 없으므로 기본값 사용 + User user = userService.findOrCreateUser( + identifier, + identifier + "@mobile.user", + "모바일유저", + null, + ProviderInfo.MOBILE, + Role.USER + ); + + return ResponseEntity.ok(jwtProvider.generateTokens(user.getId(), user.getIdentifier())); + } + + // 토큰 재발급 + @PostMapping("/reissue") + public ResponseEntity reissue(@RequestHeader("Authorization") String refreshHeader) { + if (refreshHeader == null || !refreshHeader.startsWith(Const.BEARER_PREFIX)) { + return ResponseEntity.badRequest().build(); + } + + String token = refreshHeader.substring(Const.BEARER_PREFIX.length()); + + if (!jwtProvider.validateToken(token)) { + return ResponseEntity.status(401).build(); + } + + // 토큰에서 정보 추출 후 재발급 + Long userId = jwtProvider.getUserId(token); + String identifier = jwtProvider.getIdentifier(token); + + return ResponseEntity.ok(jwtProvider.generateTokens(userId, identifier)); + } +} \ No newline at end of file From 430863b6937f44e5a02ec3d808969975ce60e6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:40 +0900 Subject: [PATCH 149/198] =?UTF-8?q?ON-79=20OAuthBridgeController=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/global/auth/controller/OAuthBridgeController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/global/auth/controller/OAuthBridgeController.java b/server/src/main/java/oba/backend/server/global/auth/controller/OAuthBridgeController.java index 478ca70..f2f32dc 100644 --- a/server/src/main/java/oba/backend/server/global/auth/controller/OAuthBridgeController.java +++ b/server/src/main/java/oba/backend/server/global/auth/controller/OAuthBridgeController.java @@ -1,4 +1,4 @@ -package oba.backend.server.controller; +package oba.backend.server.global.auth.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; From 3542aa31680273c8af99d9887f8a857e6a904c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:49 +0900 Subject: [PATCH 150/198] =?UTF-8?q?ON-79=20Role=20Enum=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/oba/backend/server/domain/user/entity/Role.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/Role.java b/server/src/main/java/oba/backend/server/domain/user/entity/Role.java index cefa533..d4024bf 100644 --- a/server/src/main/java/oba/backend/server/domain/user/entity/Role.java +++ b/server/src/main/java/oba/backend/server/domain/user/entity/Role.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.user.entity; +package oba.backend.server.domain.user.entity; public enum Role { USER, From e4979a7c08e70ca791280b89776aa84ed5b72856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:49 +0900 Subject: [PATCH 151/198] =?UTF-8?q?ON-79=20ProviderInfo=20Enum=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/domain/user/entity/ProviderInfo.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfo.java b/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfo.java index 152286f..9eba4a2 100644 --- a/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfo.java +++ b/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfo.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.user.entity; +package oba.backend.server.domain.user.entity; public enum ProviderInfo { LOCAL, From f1197bf84ffb1d19911035ef6ed72ce06443869d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:49 +0900 Subject: [PATCH 152/198] =?UTF-8?q?ON-79=20ProviderInfoConverter=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/user/entity/ProviderInfoConverter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfoConverter.java b/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfoConverter.java index 336c652..ae846b1 100644 --- a/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfoConverter.java +++ b/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfoConverter.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.user.entity; +package oba.backend.server.domain.user.entity; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; From d6a3acffe8361f3b977d56ff484e14205db36ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:49 +0900 Subject: [PATCH 153/198] =?UTF-8?q?ON-79=20User=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/user/entity/User.java | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/User.java b/server/src/main/java/oba/backend/server/domain/user/entity/User.java index 53fe304..243eb53 100644 --- a/server/src/main/java/oba/backend/server/domain/user/entity/User.java +++ b/server/src/main/java/oba/backend/server/domain/user/entity/User.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.user.entity; +package oba.backend.server.domain.user.entity; import jakarta.persistence.*; import lombok.*; @@ -25,9 +25,6 @@ public class User { @Column(nullable = false) private String name; - @Column - private String nickname; - @Column(length = 512) private String picture; @@ -39,24 +36,9 @@ public class User { @Column(nullable = false) private Role role; - @Builder.Default - @Column(nullable = false) - private boolean isDeleted = false; - public void updateInfo(String email, String name, String picture) { - if (email != null) this.email = email; - if (name != null) this.name = name; - if (picture != null) this.picture = picture; - } - - public static User createMobileUser(String identifier) { - return User.builder() - .identifier(identifier) - .email(identifier + "@mobile.user") - .name("모바일유저") - .picture(null) - .authProvider(ProviderInfo.MOBILE) - .role(Role.USER) - .build(); + if (email != null && !email.isBlank()) this.email = email; + if (name != null && !name.isBlank()) this.name = name; + if (picture != null && !picture.isBlank()) this.picture = picture; } -} +} \ No newline at end of file From 0320c9f57e401e54cea02b55ea6430c6034b5048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:49 +0900 Subject: [PATCH 154/198] =?UTF-8?q?ON-79=20UserRepository=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/domain/user/repository/UserRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/user/repository/UserRepository.java b/server/src/main/java/oba/backend/server/domain/user/repository/UserRepository.java index 5607662..5087d72 100644 --- a/server/src/main/java/oba/backend/server/domain/user/repository/UserRepository.java +++ b/server/src/main/java/oba/backend/server/domain/user/repository/UserRepository.java @@ -1,6 +1,6 @@ -package oba.backend.server.doma.user.repository; +package oba.backend.server.domain.user.repository; -import oba.backend.server.doma.user.entity.User; +import oba.backend.server.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; From ab6b68f70cfdc7942ac9fb14155d2d6b4ea6262c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:50 +0900 Subject: [PATCH 155/198] =?UTF-8?q?ON-79=20UserService=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/service/UserService.java | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/user/service/UserService.java b/server/src/main/java/oba/backend/server/domain/user/service/UserService.java index 176e694..bf91ea4 100644 --- a/server/src/main/java/oba/backend/server/domain/user/service/UserService.java +++ b/server/src/main/java/oba/backend/server/domain/user/service/UserService.java @@ -1,13 +1,16 @@ -package oba.backend.server.doma.user.service; +package oba.backend.server.domain.user.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.doma.user.entity.ProviderInfo; -import oba.backend.server.doma.user.entity.Role; -import oba.backend.server.doma.user.entity.User; -import oba.backend.server.doma.user.repository.UserRepository; + +import oba.backend.server.domain.user.entity.ProviderInfo; +import oba.backend.server.domain.user.entity.Role; +import oba.backend.server.domain.user.entity.User; +import oba.backend.server.domain.user.repository.UserRepository; +import oba.backend.server.global.common.Const; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -15,32 +18,31 @@ public class UserService { private final UserRepository userRepository; - @Cacheable(value = "userByIdentifier", key = "#identifier", unless = "#result == null") + @Cacheable(value = Const.CACHE_USER, key = "#identifier", unless = "#result == null") + @Transactional(readOnly = true) public User findByIdentifier(String identifier) { return userRepository.findByIdentifier(identifier).orElse(null); } - @CacheEvict(value = "userByIdentifier", key = "#identifier") - public User findOrCreateOAuthUser(String identifier, - String email, - String name, - String picture, - ProviderInfo provider, - Role role) { + /** + * 유저 생성 혹은 정보 업데이트 (로그인 시 호출) + * 정보가 변경될 수 있으므로 해당 유저의 캐시를 삭제(@CacheEvict)합니다. + */ + @Transactional + @CacheEvict(value = Const.CACHE_USER, key = "#identifier") + public User findOrCreateUser(String identifier, String email, String name, String picture, ProviderInfo provider, Role role) { return userRepository.findByIdentifier(identifier) - .map(existing -> { - existing.updateInfo(email, name, picture); - return userRepository.save(existing); + .map(user -> { + user.updateInfo(email, name, picture); + return user; }) - .orElseGet(() -> userRepository.save( - User.builder() - .identifier(identifier) - .email(email != null ? email : (identifier + "@oauth.user")) - .name(name != null ? name : "OAuthUser") - .picture(picture) - .authProvider(provider) - .role(role) - .build() - )); + .orElseGet(() -> userRepository.save(User.builder() + .identifier(identifier) + .email(email != null ? email : identifier + "@unknown") + .name(name != null ? name : "User") + .picture(picture) + .authProvider(provider) + .role(role) + .build())); } -} +} \ No newline at end of file From 3f45be07af7eb7895e65ca75834aa662a51537df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:53 +0900 Subject: [PATCH 156/198] =?UTF-8?q?ON-79=20AiController=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/domain/ai/controller/AiController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/ai/controller/AiController.java b/server/src/main/java/oba/backend/server/domain/ai/controller/AiController.java index 0f48a3f..59133c9 100644 --- a/server/src/main/java/oba/backend/server/domain/ai/controller/AiController.java +++ b/server/src/main/java/oba/backend/server/domain/ai/controller/AiController.java @@ -1,7 +1,7 @@ -package oba.backend.server.controller; +package oba.backend.server.domain.ai.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.service.AiService; +import oba.backend.server.domain.ai.service.AiService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; From 2872d14ea03887f3c1a3e3c3dc6cf11f2997165c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:53 +0900 Subject: [PATCH 157/198] =?UTF-8?q?ON-79=20AiService=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/domain/ai/service/AiService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java b/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java index 5f9aa55..aa3c3ad 100644 --- a/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java +++ b/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java @@ -1,4 +1,4 @@ -package oba.backend.server.service; +package oba.backend.server.domain.ai.service; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; From 840993e4fe9e00d7cc702e8460963ef8b8ac12e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:54 +0900 Subject: [PATCH 158/198] =?UTF-8?q?ON-79=20AiScheduler=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ai/scheduler/AiScheduler.java | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/ai/scheduler/AiScheduler.java b/server/src/main/java/oba/backend/server/domain/ai/scheduler/AiScheduler.java index 15309fa..3e0d3b9 100644 --- a/server/src/main/java/oba/backend/server/domain/ai/scheduler/AiScheduler.java +++ b/server/src/main/java/oba/backend/server/domain/ai/scheduler/AiScheduler.java @@ -1,12 +1,15 @@ -package oba.backend.server.scheduler; +package oba.backend.server.domain.ai.scheduler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import oba.backend.server.service.AiService; +import oba.backend.server.domain.ai.service.AiService; +import oba.backend.server.global.common.Const; import org.springframework.cache.CacheManager; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import java.util.Objects; + @Slf4j @Component @RequiredArgsConstructor @@ -15,26 +18,26 @@ public class AiScheduler { private final AiService aiService; private final CacheManager cacheManager; + // 매일 0시 실행 @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") public void runDailyAiTask() { - log.info("[Scheduler] FastAPI GPT 자동 실행 시작"); + log.info("[Scheduler] Start Daily GPT Task"); try { String result = aiService.runDailyGptTask(); - log.info("[Scheduler] FastAPI 응답: {}", result); + log.info("[Scheduler] Result: {}", result); + + // 데이터가 갱신되었으므로 관련 캐시 초기화 + evictCaches(); - // Invalidate article caches after new content generation - evictArticleCaches(); } catch (Exception e) { - log.error("[Scheduler] FastAPI 호출 실패", e); + log.error("[Scheduler] Error: ", e); } } - private void evictArticleCaches() { - if (cacheManager.getCache("latestArticles") != null) { - cacheManager.getCache("latestArticles").clear(); - } - if (cacheManager.getCache("articleDetail") != null) { - cacheManager.getCache("articleDetail").clear(); - } + private void evictCaches() { + // 모든 리스트 캐시와 상세 캐시를 날림 (단순화 전략) + Objects.requireNonNull(cacheManager.getCache(Const.CACHE_LATEST_ARTICLES)).clear(); + Objects.requireNonNull(cacheManager.getCache(Const.CACHE_ARTICLE_DETAIL)).clear(); + log.info("[Cache] Article caches evicted."); } -} +} \ No newline at end of file From 76217e3354a4bd16f706e879f8f8f883e1e87525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:57 +0900 Subject: [PATCH 159/198] =?UTF-8?q?ON-79=20GptDocument=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/domain/article/entity/GptDocument.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java b/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java index 556332c..d7f311b 100644 --- a/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java +++ b/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.article.entity; +package oba.backend.server.domain.article.entity; import lombok.Data; import org.springframework.data.annotation.Id; From fd0c6a2e6e6a9b3741071c3a25d151121d7abb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:57 +0900 Subject: [PATCH 160/198] =?UTF-8?q?ON-79=20GptMongoRepository=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/article/repository/GptMongoRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java b/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java index 3718612..4e8c023 100644 --- a/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java +++ b/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java @@ -1,6 +1,6 @@ -package oba.backend.server.doma.article.repository; +package oba.backend.server.domain.article.repository; -import oba.backend.server.doma.article.entity.GptDocument; +import oba.backend.server.domain.article.entity.GptDocument; import org.springframework.data.domain.Pageable; import org.springframework.data.mongodb.repository.MongoRepository; From 1c934d78f7fa54da34eb16153239960740fa2842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:57 +0900 Subject: [PATCH 161/198] =?UTF-8?q?ON-79=20ArticleDetailResponse=20DTO=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/article/dto/ArticleDetailResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java index a27c3d4..1997a60 100644 --- a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java +++ b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.article.dto; +package oba.backend.server.domain.article.dto; import lombok.Builder; import lombok.Getter; From ee616d8ec345c3d50e0a4b6e700254958ab14224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:57 +0900 Subject: [PATCH 162/198] =?UTF-8?q?ON-79=20ArticleSummaryResponse=20DTO=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/article/dto/ArticleSummaryResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java index 76d84d0..0aa9e5d 100644 --- a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java +++ b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.article.dto; +package oba.backend.server.domain.article.dto; import lombok.Builder; import lombok.Getter; From 43bc0835491541efc50a674df1585017dffc3448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:57 +0900 Subject: [PATCH 163/198] =?UTF-8?q?ON-79=20QuizAnswerParser=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/domain/article/service/QuizAnswerParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/service/QuizAnswerParser.java b/server/src/main/java/oba/backend/server/domain/article/service/QuizAnswerParser.java index 11f1e78..11cb451 100644 --- a/server/src/main/java/oba/backend/server/domain/article/service/QuizAnswerParser.java +++ b/server/src/main/java/oba/backend/server/domain/article/service/QuizAnswerParser.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.article.service; +package oba.backend.server.domain.article.service; public class QuizAnswerParser { From 90f6f2de75e15fdbaefb17cec592d5f1ebfa0530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:57 +0900 Subject: [PATCH 164/198] =?UTF-8?q?ON-79=20ArticleDetailService=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/article/service/ArticleDetailService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java b/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java index 409ce34..fb42baa 100644 --- a/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java +++ b/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java @@ -1,9 +1,9 @@ -package oba.backend.server.doma.article.service; +package oba.backend.server.domain.article.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.doma.article.dto.ArticleDetailResponse; -import oba.backend.server.doma.article.entity.GptDocument; -import oba.backend.server.doma.article.repository.GptMongoRepository; +import oba.backend.server.domain.article.dto.ArticleDetailResponse; +import oba.backend.server.domain.article.entity.GptDocument; +import oba.backend.server.domain.article.repository.GptMongoRepository; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; From 0e71e27c962e0ed04a27baf436207545c66e5ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:36:57 +0900 Subject: [PATCH 165/198] =?UTF-8?q?ON-79=20ArticleSummaryService=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ArticleSummaryService.java | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java b/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java index 897d3ea..294ea56 100644 --- a/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java +++ b/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java @@ -1,16 +1,19 @@ -package oba.backend.server.doma.article.service; +package oba.backend.server.domain.article.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.doma.article.dto.ArticleSummaryResponse; -import oba.backend.server.doma.article.entity.GptDocument; -import oba.backend.server.doma.article.repository.GptMongoRepository; + +import oba.backend.server.domain.article.dto.ArticleSummaryResponse; +import oba.backend.server.domain.article.entity.GptDocument; +import oba.backend.server.domain.article.repository.GptMongoRepository; +import oba.backend.server.global.common.Const; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.Arrays; import java.util.List; +import java.util.Collections; @Service @RequiredArgsConstructor @@ -18,30 +21,29 @@ public class ArticleSummaryService { private final GptMongoRepository gptMongoRepository; - @Cacheable(value = "latestArticles", key = "#limit", unless = "#result == null || #result.isEmpty()") + @Cacheable(value = Const.CACHE_LATEST_ARTICLES, key = "#limit") + @Transactional(readOnly = true) public List getLatestArticles(int limit) { + List docs = gptMongoRepository.findByOrderByServingDateDesc(PageRequest.of(0, limit)); - Pageable pageable = PageRequest.of(0, limit); - List docs = gptMongoRepository.findByOrderByServingDateDesc(pageable); - - return docs.stream().map(doc -> { - List bullets = null; - - if (doc.getSummary() != null) { - bullets = Arrays.stream(doc.getSummary().split("[\\.|·|\\n]")) - .map(String::trim) - .filter(s -> !s.isBlank()) - .limit(3) - .toList(); - } - - return ArticleSummaryResponse.builder() - .articleId(doc.getArticleId()) - .title(doc.getTitle()) - .summaryBullets(bullets) - .servingDate(doc.getServingDate()) - .build(); + return docs.stream().map(this::mapToSummary).toList(); + } - }).toList(); + private ArticleSummaryResponse mapToSummary(GptDocument doc) { + List bullets = Collections.emptyList(); + if (doc.getSummary() != null && !doc.getSummary().isBlank()) { + bullets = Arrays.stream(doc.getSummary().split("[\\.|·|\\n]")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .limit(3) + .toList(); + } + + return ArticleSummaryResponse.builder() + .articleId(doc.getArticleId()) + .title(doc.getTitle()) + .summaryBullets(bullets) + .servingDate(doc.getServingDate()) + .build(); } -} +} \ No newline at end of file From 83c9c378d425df419644dcf1b209f2215df4b8a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:37:57 +0900 Subject: [PATCH 166/198] =?UTF-8?q?ON-79=20ArticleController=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=B0=8F=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/article/controller/ArticleController.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java b/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java index 2e72146..f2a7ebf 100644 --- a/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java +++ b/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java @@ -1,10 +1,10 @@ -package oba.backend.server.doma.article.controller; +package oba.backend.server.domain.article.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.doma.article.dto.ArticleDetailResponse; -import oba.backend.server.doma.article.dto.ArticleSummaryResponse; -import oba.backend.server.doma.article.service.ArticleDetailService; -import oba.backend.server.doma.article.service.ArticleSummaryService; +import oba.backend.server.domain.article.dto.ArticleDetailResponse; +import oba.backend.server.domain.article.dto.ArticleSummaryResponse; +import oba.backend.server.domain.article.service.ArticleDetailService; +import oba.backend.server.domain.article.service.ArticleSummaryService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; From 6036c1eaec1ad3b6137673afe300651adefac8e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:01 +0900 Subject: [PATCH 167/198] =?UTF-8?q?ON-79=20IncorrectArticles=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/domain/quiz/entity/IncorrectArticles.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticles.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticles.java index 1c4349f..24ce464 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticles.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticles.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.quiz.entity; +package oba.backend.server.domain.quiz.entity; import jakarta.persistence.*; import lombok.*; From 69a7d17f94bca4e7a62622068e48630a3a69144e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:01 +0900 Subject: [PATCH 168/198] =?UTF-8?q?ON-79=20IncorrectArticlesId=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/domain/quiz/entity/IncorrectArticlesId.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java index 972f2b0..8d7bc45 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.quiz.entity; +package oba.backend.server.domain.quiz.entity; import lombok.*; import java.io.Serializable; From c1e7e71b6ea13fcba8de3dfbdda01819a2700579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:01 +0900 Subject: [PATCH 169/198] =?UTF-8?q?ON-79=20IncorrectQuiz=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/domain/quiz/entity/IncorrectQuiz.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java index 8a0373e..3de0e90 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.quiz.entity; +package oba.backend.server.domain.quiz.entity; import jakarta.persistence.*; import lombok.*; From f94e81851caa3dc2163c57ec882322f2e67bb9e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:01 +0900 Subject: [PATCH 170/198] =?UTF-8?q?ON-79=20IncorrectQuizId=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/domain/quiz/entity/IncorrectQuizId.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java index a90d7d6..dab739c 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.quiz.entity; +package oba.backend.server.domain.quiz.entity; import lombok.*; import java.io.Serializable; From df1753f0261944b692e8db242793a6b7b8eb176d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:01 +0900 Subject: [PATCH 171/198] =?UTF-8?q?ON-79=20IncorrectArticlesRepository=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/quiz/repository/IncorrectArticlesRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectArticlesRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectArticlesRepository.java index 8c89cf7..52753fd 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectArticlesRepository.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectArticlesRepository.java @@ -1,7 +1,7 @@ -package oba.backend.server.doma.quiz.repository; +package oba.backend.server.domain.quiz.repository; -import oba.backend.server.doma.quiz.entity.IncorrectArticles; -import oba.backend.server.doma.quiz.entity.IncorrectArticlesId; +import oba.backend.server.domain.quiz.entity.IncorrectArticles; +import oba.backend.server.domain.quiz.entity.IncorrectArticlesId; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; From 81db96a11b46f393c6b4fe6cc2650da3558a5962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:02 +0900 Subject: [PATCH 172/198] =?UTF-8?q?ON-79=20IncorrectQuizRepository=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/quiz/repository/IncorrectQuizRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java index 9e0af93..93e0b0c 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java @@ -1,7 +1,7 @@ -package oba.backend.server.doma.quiz.repository; +package oba.backend.server.domain.quiz.repository; -import oba.backend.server.doma.quiz.entity.IncorrectQuiz; -import oba.backend.server.doma.quiz.entity.IncorrectQuizId; +import oba.backend.server.domain.quiz.entity.IncorrectQuiz; +import oba.backend.server.domain.quiz.entity.IncorrectQuizId; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; From 7031c684a7d3fa45bac874b5d50f54705f5b40cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:02 +0900 Subject: [PATCH 173/198] =?UTF-8?q?ON-79=20QuizResultRequest=20DTO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/domain/quiz/dto/QuizResultRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java index 809ebd4..5b76861 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.quiz.dto; +package oba.backend.server.domain.quiz.dto; import lombok.Getter; import lombok.NoArgsConstructor; From 8cf7455e6e1fcfaa83760d09534c04e9518bc4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:02 +0900 Subject: [PATCH 174/198] =?UTF-8?q?ON-79=20QuizSubmitRequest=20DTO=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java index 5c7b99b..9bf5991 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.quiz.dto; +package oba.backend.server.domain.quiz.dto; import lombok.Getter; import lombok.NoArgsConstructor; From c02632f80a781e99a92a4c5ae55b7ac1098c8349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:02 +0900 Subject: [PATCH 175/198] =?UTF-8?q?ON-79=20SolvedArticleResponse=20DTO=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/domain/quiz/dto/SolvedArticleResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java index 832135d..4f94c4e 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.quiz.dto; +package oba.backend.server.domain.quiz.dto; import lombok.AllArgsConstructor; import lombok.Builder; From 4c2d922939485d94d684a65ee089a6992d228ec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:02 +0900 Subject: [PATCH 176/198] =?UTF-8?q?ON-79=20WrongArticleResponse=20DTO=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/domain/quiz/dto/WrongArticleResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java index c6aab08..25f91c9 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java @@ -1,4 +1,4 @@ -package oba.backend.server.doma.quiz.dto; +package oba.backend.server.domain.quiz.dto; import lombok.AllArgsConstructor; import lombok.Builder; From 0b88735208fef15c433528c14fdb58f0da2d43ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:02 +0900 Subject: [PATCH 177/198] =?UTF-8?q?ON-79=20QuizQueryService=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/quiz/service/QuizQueryService.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java index 10522fa..05e2c3d 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java @@ -1,10 +1,10 @@ -package oba.backend.server.doma.quiz.service; +package oba.backend.server.domain.quiz.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.doma.quiz.dto.SolvedArticleResponse; -import oba.backend.server.doma.quiz.dto.WrongArticleResponse; -import oba.backend.server.doma.quiz.repository.IncorrectArticlesRepository; -import oba.backend.server.doma.quiz.repository.IncorrectQuizRepository; +import oba.backend.server.domain.quiz.dto.SolvedArticleResponse; +import oba.backend.server.domain.quiz.dto.WrongArticleResponse; +import oba.backend.server.domain.quiz.repository.IncorrectArticlesRepository; +import oba.backend.server.domain.quiz.repository.IncorrectQuizRepository; import org.springframework.stereotype.Service; import java.util.List; From 0d437cf7d05ecbe4850774e19e06ce5b1c47e08c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:02 +0900 Subject: [PATCH 178/198] =?UTF-8?q?ON-79=20QuizService=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/quiz/service/QuizResultService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java index b52b7d4..a438710 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java @@ -1,10 +1,10 @@ -package oba.backend.server.doma.quiz.service; +package oba.backend.server.domain.quiz.service; import lombok.RequiredArgsConstructor; import oba.backend.server.global.auth.jwt.JwtProvider; -import oba.backend.server.doma.quiz.dto.QuizResultRequest; -import oba.backend.server.doma.quiz.entity.IncorrectArticles; -import oba.backend.server.doma.quiz.repository.IncorrectArticlesRepository; +import oba.backend.server.domain.quiz.dto.QuizResultRequest; +import oba.backend.server.domain.quiz.entity.IncorrectArticles; +import oba.backend.server.domain.quiz.repository.IncorrectArticlesRepository; import org.springframework.stereotype.Service; import java.time.LocalDateTime; From 98bd70a14a267ff14f5c6a77a01275dba8d8c03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:02 +0900 Subject: [PATCH 179/198] =?UTF-8?q?ON-79=20MyQuizController=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/quiz/controller/MyQuizController.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java b/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java index 67f6b79..b59317e 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java @@ -1,9 +1,9 @@ -package oba.backend.server.controller; +package oba.backend.server.domain.quiz.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.doma.quiz.dto.SolvedArticleResponse; -import oba.backend.server.doma.quiz.dto.WrongArticleResponse; -import oba.backend.server.doma.quiz.service.QuizQueryService; +import oba.backend.server.domain.quiz.dto.SolvedArticleResponse; +import oba.backend.server.domain.quiz.dto.WrongArticleResponse; +import oba.backend.server.domain.quiz.service.QuizQueryService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; From ceff49b6e8db6ebe416ea93441a1a86c702daf5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:02 +0900 Subject: [PATCH 180/198] =?UTF-8?q?ON-79=20QuizController=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/quiz/controller/QuizController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java index e90140f..114296c 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java @@ -1,8 +1,8 @@ -package oba.backend.server.controller; +package oba.backend.server.domain.quiz.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.doma.quiz.dto.QuizSubmitRequest; -import oba.backend.server.doma.quiz.service.QuizService; +import oba.backend.server.domain.quiz.dto.QuizSubmitRequest; +import oba.backend.server.domain.quiz.service.QuizService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; From 98d6ef06424d6a110337939a1465945e3096423f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:03 +0900 Subject: [PATCH 181/198] =?UTF-8?q?ON-79=20QuizResultController=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/quiz/controller/QuizResultController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizResultController.java b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizResultController.java index b3e33ec..1a236db 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizResultController.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizResultController.java @@ -1,8 +1,8 @@ -package oba.backend.server.controller; +package oba.backend.server.domain.quiz.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.doma.quiz.dto.QuizResultRequest; -import oba.backend.server.doma.quiz.service.QuizResultService; +import oba.backend.server.domain.quiz.dto.QuizResultRequest; +import oba.backend.server.domain.quiz.service.QuizResultService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; From b177a59f39f354aaf0c8bfc6e43cfb204323bf8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:07 +0900 Subject: [PATCH 182/198] =?UTF-8?q?ON-79=20ServerApplication=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/java/oba/backend/server/ServerApplication.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/main/java/oba/backend/server/ServerApplication.java b/server/src/main/java/oba/backend/server/ServerApplication.java index bf90e65..4260785 100644 --- a/server/src/main/java/oba/backend/server/ServerApplication.java +++ b/server/src/main/java/oba/backend/server/ServerApplication.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing @EnableScheduling +@EnableCaching public class ServerApplication { public static void main(String[] args) { SpringApplication.run(ServerApplication.class, args); From 49465919c7fa6329f7b9b4346fdc7fe1f72821b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:08 +0900 Subject: [PATCH 183/198] =?UTF-8?q?ON-79=20application.yml=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/resources/application.yml | 37 +++++++++++++++-------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index aa841fe..053f0a9 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -1,64 +1,73 @@ server: port: 9000 + forward-headers-strategy: framework spring: + config: + import: optional:file:./.env[.properties] # .env 파일을 읽도록 추가 + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver url: ${DB_URL} username: ${DB_USERNAME} password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: update # 운영에서는 validate 권장 + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + data: mongodb: uri: ${MONGODB_URI} + database: oba # Atlas URI에 DB 이름이 포함돼 있으면 이 부분 제거 가능 security: oauth2: client: registration: - google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/google" + redirect-uri: "{baseUrl}/login/oauth2/code/google" authorization-grant-type: authorization_code scope: - email - profile - kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/kakao" + redirect-uri: "{baseUrl}/login/oauth2/code/kakao" client-authentication-method: client_secret_post authorization-grant-type: authorization_code scope: - profile_nickname - profile_image - account_email - naver: client-id: ${NAVER_CLIENT_ID} client-secret: ${NAVER_CLIENT_SECRET} - redirect-uri: "https://313a0a887091.ngrok-free.app/login/oauth2/code/naver" + redirect-uri: "{baseUrl}/login/oauth2/code/naver" authorization-grant-type: authorization_code scope: - name - email - profile_image - provider: google: authorization-uri: https://accounts.google.com/o/oauth2/v2/auth token-uri: https://oauth2.googleapis.com/token user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo user-name-attribute: sub - kakao: authorization-uri: https://kauth.kakao.com/oauth/authorize token-uri: https://kauth.kakao.com/oauth/token user-info-uri: https://kapi.kakao.com/v2/user/me user-name-attribute: id - naver: authorization-uri: https://nid.naver.com/oauth2.0/authorize token-uri: https://nid.naver.com/oauth2.0/token @@ -67,11 +76,15 @@ spring: jwt: secret: ${JWT_SECRET} - access-token-expiration-ms: 3600000 - refresh-token-expiration-ms: 604800000 + access-token-expiration-ms: ${JWT_ACCESS_TOKEN_EXPIRATION_MS} + refresh-token-expiration-ms: ${JWT_REFRESH_TOKEN_EXPIRATION_MS} + +ai: + server: + url: ${AI_SERVER_URL} oauth: - bridge-url: "https://313a0a887091.ngrok-free.app/oauth2/bridge" + bridge-url: "http://localhost:9000/oauth2/bridge" app: mobile-redirect: "myapp://oauth/naver" From 1e3b31d9b423c472a378218b2a472e0b2df09396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:08 +0900 Subject: [PATCH 184/198] =?UTF-8?q?ON-79=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20application-test.yml=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/test/resources/application-test.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml index e69de29..1866026 100644 --- a/server/src/test/resources/application-test.yml +++ b/server/src/test/resources/application-test.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect From 8d1ff51ab346b38076f61c4d00fa04ad6c9035be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:08 +0900 Subject: [PATCH 185/198] =?UTF-8?q?ON-79=20build.gradle=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/build.gradle | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/server/build.gradle b/server/build.gradle index 2588b88..835c65c 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -6,10 +6,13 @@ plugins { group = 'oba.backend' version = '0.0.1-SNAPSHOT' + java { - sourceCompatibility = '17' + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } + repositories { mavenCentral() } @@ -26,10 +29,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + /* -------------------- Cache -------------------- */ + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' + /* -------------------- Database -------------------- */ implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' - runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.mysql:mysql-connector-j:8.0.33' /* -------------------- JWT -------------------- */ implementation 'io.jsonwebtoken:jjwt-api:0.11.5' @@ -39,16 +46,15 @@ dependencies { /* -------------------- Scheduler -------------------- */ implementation 'org.springframework.boot:spring-boot-starter-quartz' - /* -------------------- Dotenv (.env 로딩) -------------------- */ + /* -------------------- Dotenv -------------------- */ implementation 'io.github.cdimascio:dotenv-java:3.0.0' - /* -------------------- Lombok -------------------- */ - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' + /* -------------------- Lombok (최신 버전으로 업데이트) -------------------- */ + compileOnly 'org.projectlombok:lombok:1.18.34' + annotationProcessor 'org.projectlombok:lombok:1.18.34' /* -------------------- Test -------------------- */ testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'com.h2database:h2' } From 6f19d3fc9e3eec329b2297a921365652787793f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:08 +0900 Subject: [PATCH 186/198] =?UTF-8?q?ON-79=20gradlew=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/gradlew | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 server/gradlew diff --git a/server/gradlew b/server/gradlew old mode 100644 new mode 100755 From b85e8ce45081e194b4ed66db1f8d0757da814357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Fri, 9 Jan 2026 10:38:08 +0900 Subject: [PATCH 187/198] =?UTF-8?q?ON-79=20ServerApplicationTests=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/oba/backend/server/ServerApplicationTests.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/test/java/oba/backend/server/ServerApplicationTests.java b/server/src/test/java/oba/backend/server/ServerApplicationTests.java index ea0665c..900082f 100644 --- a/server/src/test/java/oba/backend/server/ServerApplicationTests.java +++ b/server/src/test/java/oba/backend/server/ServerApplicationTests.java @@ -2,10 +2,12 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class ServerApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } From 12e566f47abcda78afb912982e5e5c73b8651fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Sun, 11 Jan 2026 06:57:17 +0900 Subject: [PATCH 188/198] =?UTF-8?q?ON-79=20ArticleController=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20Long=EC=97=90=EC=84=9C=20String=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/article/controller/ArticleController.java | 5 +++-- .../oba/backend/server/global/config/MongoCheckRunner.java | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java diff --git a/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java b/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java index f2a7ebf..852aa1b 100644 --- a/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java +++ b/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java @@ -25,8 +25,9 @@ public ResponseEntity> getLatest( return ResponseEntity.ok(summaryService.getLatestArticles(limit)); } + // 🚨 수정됨: @PathVariable Long -> String @GetMapping("/{id}") - public ResponseEntity getDetail(@PathVariable Long id) { + public ResponseEntity getDetail(@PathVariable String id) { return ResponseEntity.ok(detailService.getArticleDetail(id)); } -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java b/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java new file mode 100644 index 0000000..5552c6d --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java @@ -0,0 +1,4 @@ +package oba.backend.server.global.config; + +public class MongoCheckRunner { +} From 137a6c4d2248ee0beaa89ca40a765efb5b3de723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Sun, 11 Jan 2026 06:57:21 +0900 Subject: [PATCH 189/198] =?UTF-8?q?ON-79=20ArticleDetailResponse=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20articleId=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=9D=84=20String=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/dto/ArticleDetailResponse.java | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java index 1997a60..334830e 100644 --- a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java +++ b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java @@ -4,21 +4,32 @@ import lombok.Getter; import java.util.List; -import java.util.Map; @Getter @Builder public class ArticleDetailResponse { - private Long articleId; + + // 🚨 수정됨: Long -> String + private String articleId; + private String title; private String publishTime; private String servingDate; - private Object content; - private Object subtitle; + // 본문은 텍스트와 이미지 태그가 섞여 있으므로 Object 리스트 + private List content; + private List subtitle; private String summary; private List keywords; + private List quizzes; - private List> quizzes; -} + @Getter + @Builder + public static class QuizDto { + private String question; + private List options; + private int answer; // 프론트엔드에서는 인덱스(0, 1, 2, 3)를 기대함 + private String explanation; + } +} \ No newline at end of file From 0d0e24ad27eb6d0f73740c2a5c755141584b0d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Sun, 11 Jan 2026 06:57:21 +0900 Subject: [PATCH 190/198] =?UTF-8?q?ON-79=20ArticleSummaryResponse=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20articleId=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=9D=84=20String=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/article/dto/ArticleSummaryResponse.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java index 0aa9e5d..b994097 100644 --- a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java +++ b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java @@ -8,8 +8,10 @@ @Getter @Builder public class ArticleSummaryResponse { - private Long articleId; + + private String articleId; + private String title; private List summaryBullets; private String servingDate; -} +} \ No newline at end of file From fae7d83403dca9e05573561d37d1383847202a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Sun, 11 Jan 2026 06:57:24 +0900 Subject: [PATCH 191/198] =?UTF-8?q?ON-79=20GptDocument=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=88=98=EC=A0=95=20-=20Selected=5FArticl?= =?UTF-8?q?es=20=EC=BB=AC=EB=A0=89=EC=85=98=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8E=B8=EC=9D=98=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/article/entity/GptDocument.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java b/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java index d7f311b..1aa9cd5 100644 --- a/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java +++ b/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java @@ -7,12 +7,13 @@ import java.util.List; +// 1. AI 서버가 저장하는 컬렉션 이름과 정확히 일치 (대소문자 구분) @Document(collection = "Selected_Articles") @Data public class GptDocument { @Id - private String id; + private String id; // MongoDB ID @Field("article_id") private Long articleId; @@ -26,14 +27,15 @@ public class GptDocument { private String servingDate; @Field("content_col") - private Object content; + private List content; @Field("sub_col") - private Object subtitle; + private List subtitle; @Field("gpt_result") private GptResult gptResult; + // --- Inner Classes --- @Data public static class GptResult { private String summary; @@ -66,4 +68,4 @@ public List getKeywords() { public List getQuizzes() { return gptResult != null ? gptResult.getQuizzes() : null; } -} +} \ No newline at end of file From 2feae09a98dfe127519c54cffe88401d6f51fb73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Sun, 11 Jan 2026 06:57:27 +0900 Subject: [PATCH 192/198] =?UTF-8?q?ON-79=20ArticleDetailService=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20String=20ID=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=8F=20DTO=20=EB=A7=A4=ED=95=91=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/service/ArticleDetailService.java | 57 +++++++++++++------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java b/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java index fb42baa..cd0021a 100644 --- a/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java +++ b/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java @@ -4,11 +4,11 @@ import oba.backend.server.domain.article.dto.ArticleDetailResponse; import oba.backend.server.domain.article.entity.GptDocument; import oba.backend.server.domain.article.repository.GptMongoRepository; -import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; import java.util.List; -import java.util.Map; @Service @RequiredArgsConstructor @@ -16,41 +16,62 @@ public class ArticleDetailService { private final GptMongoRepository gptMongoRepository; - @Cacheable(value = "articleDetail", key = "#articleId", unless = "#result == null") - public ArticleDetailResponse getArticleDetail(Long articleId) { + // 🚨 수정됨: 인자 타입 Long -> String + @Transactional(readOnly = true) + public ArticleDetailResponse getArticleDetail(String id) { - GptDocument doc = gptMongoRepository.findByArticleId(articleId) - .orElseThrow(() -> new RuntimeException("Article not found: " + articleId)); + // MongoDB _id(String)로 조회 + GptDocument doc = gptMongoRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("해당 ID의 기사를 찾을 수 없습니다: " + id)); - List keywordList = null; + // 키워드 리스트 매핑 + List keywordList = Collections.emptyList(); if (doc.getKeywords() != null) { keywordList = doc.getKeywords().stream() .map(GptDocument.GptResult.Keyword::getKeyword) .toList(); } - List> quizList = null; + // 퀴즈 리스트 매핑 + List quizList = Collections.emptyList(); if (doc.getQuizzes() != null) { quizList = doc.getQuizzes().stream() - .map(q -> Map.of( - "question", q.getQuestion(), - "options", q.getOptions(), - "answer", QuizAnswerParser.toIndex(q.getAnswer()), - "explanation", q.getExplanation() - )) + .map(q -> ArticleDetailResponse.QuizDto.builder() + .question(q.getQuestion()) + .options(q.getOptions()) + .answer(parseAnswerIndex(q.getAnswer(), q.getOptions())) // 정답 인덱스 변환 로직 + .explanation(q.getExplanation()) + .build()) .toList(); } return ArticleDetailResponse.builder() - .articleId(doc.getArticleId()) + .articleId(doc.getId()) // String ID 사용 .title(doc.getTitle()) .publishTime(doc.getPublishTime()) .servingDate(doc.getServingDate()) - .content(doc.getContent()) - .subtitle(doc.getSubtitle()) + .content(doc.getContent()) // List + .subtitle(doc.getSubtitle()) // List .summary(doc.getSummary()) .keywords(keywordList) .quizzes(quizList) .build(); } -} + + // GPT가 정답을 "1" 같은 문자열이나 텍스트로 줄 수 있으므로 인덱스(int)로 변환하는 헬퍼 메서드 + private int parseAnswerIndex(String answerStr, List options) { + try { + // 1. 숫자만 있는 경우 ("0", "1" 등) + if (answerStr.matches("\\d+")) { + return Integer.parseInt(answerStr); + } + // 2. 정답 텍스트 자체가 들어있는 경우 -> 보기 리스트에서 찾기 + int idx = options.indexOf(answerStr); + if (idx != -1) return idx; + + return 0; // 기본값 (에러 방지) + } catch (Exception e) { + return 0; + } + } +} \ No newline at end of file From 4a11444c7a0a3e43ac03dc44f85956a970d04217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Sun, 11 Jan 2026 06:57:27 +0900 Subject: [PATCH 193/198] =?UTF-8?q?ON-79=20ArticleSummaryService=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20MongoDB=20=5Fid(String)=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ArticleSummaryService.java | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java b/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java index 294ea56..a39135f 100644 --- a/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java +++ b/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java @@ -1,7 +1,6 @@ package oba.backend.server.domain.article.service; import lombok.RequiredArgsConstructor; - import oba.backend.server.domain.article.dto.ArticleSummaryResponse; import oba.backend.server.domain.article.entity.GptDocument; import oba.backend.server.domain.article.repository.GptMongoRepository; @@ -12,8 +11,8 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Arrays; -import java.util.List; import java.util.Collections; +import java.util.List; @Service @RequiredArgsConstructor @@ -24,23 +23,31 @@ public class ArticleSummaryService { @Cacheable(value = Const.CACHE_LATEST_ARTICLES, key = "#limit") @Transactional(readOnly = true) public List getLatestArticles(int limit) { + // MongoDB에서 servingDate 기준 내림차순 조회 List docs = gptMongoRepository.findByOrderByServingDateDesc(PageRequest.of(0, limit)); - return docs.stream().map(this::mapToSummary).toList(); + return docs.stream() + .map(this::mapToSummary) + .toList(); } private ArticleSummaryResponse mapToSummary(GptDocument doc) { List bullets = Collections.emptyList(); - if (doc.getSummary() != null && !doc.getSummary().isBlank()) { - bullets = Arrays.stream(doc.getSummary().split("[\\.|·|\\n]")) + + // Entity의 getSummary() 편의 메서드 활용 + String summaryText = doc.getSummary(); + + if (summaryText != null && !summaryText.isBlank()) { + // 마침표(.), 가운데점(·), 줄바꿈(\n) 기준으로 문장 분리 + bullets = Arrays.stream(summaryText.split("[.·\\n]")) .map(String::trim) .filter(s -> !s.isBlank()) - .limit(3) + .limit(3) // 최대 3문장만 요약으로 표시 .toList(); } return ArticleSummaryResponse.builder() - .articleId(doc.getArticleId()) + .articleId(doc.getId()) .title(doc.getTitle()) .summaryBullets(bullets) .servingDate(doc.getServingDate()) From a8aae11aa7187140a0b59b255a537a20624dcbc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Sun, 11 Jan 2026 06:57:27 +0900 Subject: [PATCH 194/198] =?UTF-8?q?ON-79=20QuizService=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20=EA=B8=B0=EC=82=AC=20ID=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=98=81=ED=96=A5=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/server/domain/quiz/service/QuizService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java index d8ac4ac..ecc3678 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java @@ -1,10 +1,10 @@ -package oba.backend.server.doma.quiz.service; +package oba.backend.server.domain.quiz.service; import lombok.RequiredArgsConstructor; import oba.backend.server.global.auth.jwt.JwtProvider; -import oba.backend.server.doma.quiz.entity.IncorrectQuiz; -import oba.backend.server.doma.quiz.dto.QuizSubmitRequest; -import oba.backend.server.doma.quiz.repository.IncorrectQuizRepository; +import oba.backend.server.domain.quiz.entity.IncorrectQuiz; +import oba.backend.server.domain.quiz.dto.QuizSubmitRequest; +import oba.backend.server.domain.quiz.repository.IncorrectQuizRepository; import org.springframework.stereotype.Service; @Service From 5746ae6bb686fae783a7958bff0b3c56cf5bb712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Sun, 11 Jan 2026 06:57:33 +0900 Subject: [PATCH 195/198] =?UTF-8?q?ON-79=20MongoCheckRunner=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=84=9C=EB=B2=84=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=8B=9C=20MongoDB=20=EC=97=B0=EA=B2=B0=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/MongoCheckRunner.java | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java b/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java index 5552c6d..64b3bca 100644 --- a/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java +++ b/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java @@ -1,4 +1,46 @@ package oba.backend.server.global.config; -public class MongoCheckRunner { -} +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.article.repository.GptMongoRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MongoCheckRunner implements CommandLineRunner { + + private final MongoTemplate mongoTemplate; + private final GptMongoRepository repository; + + @Override + public void run(String... args) throws Exception { + System.out.println("=========================================="); + System.out.println("[MongoDB 연결 확인]"); + + // 1. 현재 연결된 데이터베이스 이름 출력 + try { + String dbName = mongoTemplate.getDb().getName(); + System.out.println("연결된 DB 이름: " + dbName); + } catch (Exception e) { + System.out.println("DB 연결 실패: " + e.getMessage()); + } + + // 2. Repository를 통해 데이터 개수 조회 + try { + long count = repository.count(); + System.out.println("👉 'Selected_Articles' 컬렉션 데이터 개수: " + count + "개"); + + if (count == 0) { + System.out.println("데이터가 0개입니다. 컬렉션 이름(@Document)이나 DB 주소를 확인하세요!"); + System.out.println("현재 DB에 존재하는 컬렉션 목록: " + mongoTemplate.getCollectionNames()); + } else { + System.out.println("데이터가 존재합니다! API 조회를 다시 시도해보세요."); + } + } catch (Exception e) { + System.out.println("조회 중 에러 발생: " + e.getMessage()); + } + + System.out.println("=========================================="); + } +} \ No newline at end of file From 062aabcf8607c709df74406c2807787542815338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Sun, 11 Jan 2026 06:57:33 +0900 Subject: [PATCH 196/198] =?UTF-8?q?ON-79=20application.yml=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20MongoDB=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=B2=A0?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=9D=B4=EB=A6=84=20=EB=AA=85=EC=8B=9C=20?= =?UTF-8?q?=EB=B0=8F=20AI=20=EC=84=9C=EB=B2=84=20URL=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 053f0a9..2306d81 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -24,7 +24,7 @@ spring: data: mongodb: uri: ${MONGODB_URI} - database: oba # Atlas URI에 DB 이름이 포함돼 있으면 이 부분 제거 가능 + database: OneBitArticle # Atlas URI에 DB 이름이 포함돼 있으면 이 부분 제거 가능 security: oauth2: From 2cdfdad7d4ae4ba91ae6fefd10c4436472d6dec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Mon, 19 Jan 2026 00:05:42 +0900 Subject: [PATCH 197/198] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=98=A4?= =?UTF-8?q?=EB=8B=B5=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F?= =?UTF-8?q?=20=ED=95=B4=EC=84=A4,=20=ED=82=A4=EC=9B=8C=EB=93=9C,=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/article/entity/GptDocument.java | 71 ------------------- .../article/entity/SelectedArticle.java | 4 ++ .../repository/SelectedArticleRepository.java | 4 ++ .../article/service/QuizAnswerParser.java | 19 ----- .../server/domain/log/entity/ArticleLog.java | 4 ++ .../domain/log/entity/ArticleLogId.java | 4 ++ .../log/repository/ArticleLogRepository.java | 4 ++ .../quiz/controller/QuizResultController.java | 24 ------- .../domain/quiz/entity/IncorrectArticles.java | 27 ------- .../IncorrectArticlesRepository.java | 14 ---- .../domain/stats/entity/UserCategoryId.java | 4 ++ .../stats/entity/UserCategoryStats.java | 4 ++ .../server/domain/stats/entity/UserStats.java | 4 ++ .../UserCategoryStatsRepository.java | 4 ++ .../stats/repository/UserStatsRepository.java | 4 ++ .../user/controller/UserController.java | 4 ++ .../server/domain/user/dto/UserResponse.java | 4 ++ .../domain/user/entity/AuthProvider.java | 4 ++ .../domain/user/entity/ProviderInfo.java | 14 ---- .../user/entity/ProviderInfoConverter.java | 18 ----- .../global/auth/oauth/OAuth2UserInfo.java | 4 ++ .../global/config/RestTemplateConfig.java | 4 ++ .../server/global/error/CustomException.java | 4 -- .../global/error/GlobalExceptionHandler.java | 4 -- 24 files changed, 60 insertions(+), 195 deletions(-) delete mode 100644 server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java create mode 100644 server/src/main/java/oba/backend/server/domain/article/entity/SelectedArticle.java create mode 100644 server/src/main/java/oba/backend/server/domain/article/repository/SelectedArticleRepository.java delete mode 100644 server/src/main/java/oba/backend/server/domain/article/service/QuizAnswerParser.java create mode 100644 server/src/main/java/oba/backend/server/domain/log/entity/ArticleLog.java create mode 100644 server/src/main/java/oba/backend/server/domain/log/entity/ArticleLogId.java create mode 100644 server/src/main/java/oba/backend/server/domain/log/repository/ArticleLogRepository.java delete mode 100644 server/src/main/java/oba/backend/server/domain/quiz/controller/QuizResultController.java delete mode 100644 server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticles.java delete mode 100644 server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectArticlesRepository.java create mode 100644 server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryId.java create mode 100644 server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryStats.java create mode 100644 server/src/main/java/oba/backend/server/domain/stats/entity/UserStats.java create mode 100644 server/src/main/java/oba/backend/server/domain/stats/repository/UserCategoryStatsRepository.java create mode 100644 server/src/main/java/oba/backend/server/domain/stats/repository/UserStatsRepository.java create mode 100644 server/src/main/java/oba/backend/server/domain/user/controller/UserController.java create mode 100644 server/src/main/java/oba/backend/server/domain/user/dto/UserResponse.java create mode 100644 server/src/main/java/oba/backend/server/domain/user/entity/AuthProvider.java delete mode 100644 server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfo.java delete mode 100644 server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfoConverter.java create mode 100644 server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2UserInfo.java create mode 100644 server/src/main/java/oba/backend/server/global/config/RestTemplateConfig.java delete mode 100644 server/src/main/java/oba/backend/server/global/error/CustomException.java delete mode 100644 server/src/main/java/oba/backend/server/global/error/GlobalExceptionHandler.java diff --git a/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java b/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java deleted file mode 100644 index 1aa9cd5..0000000 --- a/server/src/main/java/oba/backend/server/domain/article/entity/GptDocument.java +++ /dev/null @@ -1,71 +0,0 @@ -package oba.backend.server.domain.article.entity; - -import lombok.Data; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -import java.util.List; - -// 1. AI 서버가 저장하는 컬렉션 이름과 정확히 일치 (대소문자 구분) -@Document(collection = "Selected_Articles") -@Data -public class GptDocument { - - @Id - private String id; // MongoDB ID - - @Field("article_id") - private Long articleId; - - private String title; - - @Field("publish_time") - private String publishTime; - - @Field("serving_date") - private String servingDate; - - @Field("content_col") - private List content; - - @Field("sub_col") - private List subtitle; - - @Field("gpt_result") - private GptResult gptResult; - - // --- Inner Classes --- - @Data - public static class GptResult { - private String summary; - private List keywords; - private List quizzes; - - @Data - public static class Keyword { - private String keyword; - private String description; - } - - @Data - public static class Quiz { - private String question; - private List options; - private String answer; - private String explanation; - } - } - - public String getSummary() { - return gptResult != null ? gptResult.getSummary() : null; - } - - public List getKeywords() { - return gptResult != null ? gptResult.getKeywords() : null; - } - - public List getQuizzes() { - return gptResult != null ? gptResult.getQuizzes() : null; - } -} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/entity/SelectedArticle.java b/server/src/main/java/oba/backend/server/domain/article/entity/SelectedArticle.java new file mode 100644 index 0000000..2921f72 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/article/entity/SelectedArticle.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.article.entity; + +public class SelectedArticle { +} diff --git a/server/src/main/java/oba/backend/server/domain/article/repository/SelectedArticleRepository.java b/server/src/main/java/oba/backend/server/domain/article/repository/SelectedArticleRepository.java new file mode 100644 index 0000000..8083b6c --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/article/repository/SelectedArticleRepository.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.article.repository; + +public interface SelectedArticleRepository { +} diff --git a/server/src/main/java/oba/backend/server/domain/article/service/QuizAnswerParser.java b/server/src/main/java/oba/backend/server/domain/article/service/QuizAnswerParser.java deleted file mode 100644 index 11cb451..0000000 --- a/server/src/main/java/oba/backend/server/domain/article/service/QuizAnswerParser.java +++ /dev/null @@ -1,19 +0,0 @@ -package oba.backend.server.domain.article.service; - -public class QuizAnswerParser { - - /** - * GPT가 보낸 answer 문자열(예: "2", "정답: 2", "2번")을 0~3 인덱스로 변환 - */ - public static int toIndex(String answer) { - if (answer == null) return 0; - String cleaned = answer.replaceAll("[^0-9]", "").trim(); - - try { - int num = Integer.parseInt(cleaned); - return Math.max(0, Math.min(3, num - 1)); // 1~4 → 0~3 - } catch (Exception e) { - return 0; - } - } -} diff --git a/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLog.java b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLog.java new file mode 100644 index 0000000..1dd363f --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLog.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.log.entity; + +public class ArticleLog { +} diff --git a/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLogId.java b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLogId.java new file mode 100644 index 0000000..cd8c6b2 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLogId.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.log.entity; + +public class ArticleLogId { +} diff --git a/server/src/main/java/oba/backend/server/domain/log/repository/ArticleLogRepository.java b/server/src/main/java/oba/backend/server/domain/log/repository/ArticleLogRepository.java new file mode 100644 index 0000000..9f03980 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/log/repository/ArticleLogRepository.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.log.repository; + +public class ArticleLogRepository { +} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizResultController.java b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizResultController.java deleted file mode 100644 index 1a236db..0000000 --- a/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizResultController.java +++ /dev/null @@ -1,24 +0,0 @@ -package oba.backend.server.domain.quiz.controller; - -import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.quiz.dto.QuizResultRequest; -import oba.backend.server.domain.quiz.service.QuizResultService; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/quiz-result") -@RequiredArgsConstructor -public class QuizResultController { - - private final QuizResultService quizResultService; - - @PostMapping("/save") - public ResponseEntity saveQuizResult( - @RequestHeader("Authorization") String token, - @RequestBody QuizResultRequest request - ) { - quizResultService.saveQuizResult(token.replace("Bearer ", ""), request); - return ResponseEntity.ok().build(); - } -} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticles.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticles.java deleted file mode 100644 index 24ce464..0000000 --- a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticles.java +++ /dev/null @@ -1,27 +0,0 @@ -package oba.backend.server.domain.quiz.entity; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -@Table(name = "incorrect_articles") -@IdClass(IncorrectArticlesId.class) -public class IncorrectArticles { - - @Id - @Column(name = "user_id") - private Long userId; - - @Id - @Column(name = "article_id") - private Long articleId; - - @Column(name = "sol_date", nullable = false) - private LocalDateTime solDate; -} diff --git a/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectArticlesRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectArticlesRepository.java deleted file mode 100644 index 52753fd..0000000 --- a/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectArticlesRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package oba.backend.server.domain.quiz.repository; - -import oba.backend.server.domain.quiz.entity.IncorrectArticles; -import oba.backend.server.domain.quiz.entity.IncorrectArticlesId; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface IncorrectArticlesRepository extends JpaRepository { - - List findByUserId(Long userId); - - void deleteByUserIdAndArticleId(Long userId, Long articleId); -} diff --git a/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryId.java b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryId.java new file mode 100644 index 0000000..5f60d35 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryId.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.stats.entity; + +public class UserCategoryId { +} diff --git a/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryStats.java b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryStats.java new file mode 100644 index 0000000..ef3b303 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryStats.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.stats.entity; + +public class UserCategoryStats { +} diff --git a/server/src/main/java/oba/backend/server/domain/stats/entity/UserStats.java b/server/src/main/java/oba/backend/server/domain/stats/entity/UserStats.java new file mode 100644 index 0000000..dd090dc --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/stats/entity/UserStats.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.stats.entity; + +public class UserStats { +} diff --git a/server/src/main/java/oba/backend/server/domain/stats/repository/UserCategoryStatsRepository.java b/server/src/main/java/oba/backend/server/domain/stats/repository/UserCategoryStatsRepository.java new file mode 100644 index 0000000..a75d4b7 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/stats/repository/UserCategoryStatsRepository.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.stats.repository; + +public interface UserCategoryStatsRepository { +} diff --git a/server/src/main/java/oba/backend/server/domain/stats/repository/UserStatsRepository.java b/server/src/main/java/oba/backend/server/domain/stats/repository/UserStatsRepository.java new file mode 100644 index 0000000..e8cd0e3 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/stats/repository/UserStatsRepository.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.stats.repository; + +public interface UserStatsRepository { +} diff --git a/server/src/main/java/oba/backend/server/domain/user/controller/UserController.java b/server/src/main/java/oba/backend/server/domain/user/controller/UserController.java new file mode 100644 index 0000000..1dd2dff --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/controller/UserController.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.user.controller; + +public class UserController { +} diff --git a/server/src/main/java/oba/backend/server/domain/user/dto/UserResponse.java b/server/src/main/java/oba/backend/server/domain/user/dto/UserResponse.java new file mode 100644 index 0000000..601cd68 --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/dto/UserResponse.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.user.dto; + +public class UserResponse { +} diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/AuthProvider.java b/server/src/main/java/oba/backend/server/domain/user/entity/AuthProvider.java new file mode 100644 index 0000000..9d6a14f --- /dev/null +++ b/server/src/main/java/oba/backend/server/domain/user/entity/AuthProvider.java @@ -0,0 +1,4 @@ +package oba.backend.server.domain.user.entity; + +public class AuthProvider { +} diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfo.java b/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfo.java deleted file mode 100644 index 9eba4a2..0000000 --- a/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfo.java +++ /dev/null @@ -1,14 +0,0 @@ -package oba.backend.server.domain.user.entity; - -public enum ProviderInfo { - LOCAL, - GOOGLE, - KAKAO, - NAVER, - MOBILE; - - public static ProviderInfo from(String providerName) { - if (providerName == null) return LOCAL; - return ProviderInfo.valueOf(providerName.toUpperCase()); - } -} diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfoConverter.java b/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfoConverter.java deleted file mode 100644 index ae846b1..0000000 --- a/server/src/main/java/oba/backend/server/domain/user/entity/ProviderInfoConverter.java +++ /dev/null @@ -1,18 +0,0 @@ -package oba.backend.server.domain.user.entity; - -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; - -@Converter(autoApply = true) -public class ProviderInfoConverter implements AttributeConverter { - - @Override - public String convertToDatabaseColumn(ProviderInfo attribute) { - return attribute == null ? null : attribute.name(); - } - - @Override - public ProviderInfo convertToEntityAttribute(String dbData) { - return dbData == null ? null : ProviderInfo.valueOf(dbData); - } -} diff --git a/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2UserInfo.java b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2UserInfo.java new file mode 100644 index 0000000..42095b7 --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2UserInfo.java @@ -0,0 +1,4 @@ +package oba.backend.server.global.auth.oauth; + +public class OAuth2UserInfo { +} diff --git a/server/src/main/java/oba/backend/server/global/config/RestTemplateConfig.java b/server/src/main/java/oba/backend/server/global/config/RestTemplateConfig.java new file mode 100644 index 0000000..780d37c --- /dev/null +++ b/server/src/main/java/oba/backend/server/global/config/RestTemplateConfig.java @@ -0,0 +1,4 @@ +package oba.backend.server.global.config; + +public class RestTemplateConfig { +} diff --git a/server/src/main/java/oba/backend/server/global/error/CustomException.java b/server/src/main/java/oba/backend/server/global/error/CustomException.java deleted file mode 100644 index 971c2ee..0000000 --- a/server/src/main/java/oba/backend/server/global/error/CustomException.java +++ /dev/null @@ -1,4 +0,0 @@ -package oba.backend.server.global.error; - -public class CustomException { -} diff --git a/server/src/main/java/oba/backend/server/global/error/GlobalExceptionHandler.java b/server/src/main/java/oba/backend/server/global/error/GlobalExceptionHandler.java deleted file mode 100644 index 9719d6a..0000000 --- a/server/src/main/java/oba/backend/server/global/error/GlobalExceptionHandler.java +++ /dev/null @@ -1,4 +0,0 @@ -package oba.backend.server.global.error; - -public class GlobalExceptionHandler { -} From fd3d22754f485157bcb1dfeeac181c34800919dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CByunjihun=E2=80=9D?= <“vkvlditm00@naver.com”> Date: Mon, 19 Jan 2026 00:07:00 +0900 Subject: [PATCH 198/198] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=98=A4?= =?UTF-8?q?=EB=8B=B5=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F?= =?UTF-8?q?=20=ED=95=B4=EC=84=A4,=20=ED=82=A4=EC=9B=8C=EB=93=9C,=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/select | 300 ++++++++++++++++++ .../server/domain/ai/service/AiService.java | 2 +- .../article/controller/ArticleController.java | 27 +- .../article/dto/ArticleDetailResponse.java | 31 +- .../article/dto/ArticleSummaryResponse.java | 2 - .../article/entity/SelectedArticle.java | 103 +++++- .../repository/GptMongoRepository.java | 14 +- .../repository/SelectedArticleRepository.java | 6 +- .../article/service/ArticleDetailService.java | 87 ++--- .../service/ArticleSummaryService.java | 46 +-- .../server/domain/log/entity/ArticleLog.java | 39 ++- .../domain/log/entity/ArticleLogId.java | 14 +- .../log/repository/ArticleLogRepository.java | 11 +- .../quiz/controller/MyQuizController.java | 22 +- .../quiz/controller/QuizController.java | 43 ++- .../domain/quiz/dto/QuizResultRequest.java | 8 +- .../domain/quiz/dto/QuizSubmitRequest.java | 10 +- .../quiz/dto/SolvedArticleResponse.java | 4 +- .../domain/quiz/dto/WrongArticleResponse.java | 13 +- .../quiz/entity/IncorrectArticlesId.java | 4 +- .../domain/quiz/entity/IncorrectQuiz.java | 39 ++- .../domain/quiz/entity/IncorrectQuizId.java | 4 +- .../repository/IncorrectQuizRepository.java | 12 +- .../domain/quiz/service/QuizQueryService.java | 82 +++-- .../quiz/service/QuizResultService.java | 64 +++- .../domain/quiz/service/QuizService.java | 53 +++- .../domain/stats/entity/UserCategoryId.java | 14 +- .../stats/entity/UserCategoryStats.java | 40 ++- .../server/domain/stats/entity/UserStats.java | 54 +++- .../UserCategoryStatsRepository.java | 10 +- .../stats/repository/UserStatsRepository.java | 7 +- .../user/controller/UserController.java | 42 ++- .../server/domain/user/dto/UserResponse.java | 36 ++- .../domain/user/entity/AuthProvider.java | 9 +- .../server/domain/user/entity/User.java | 55 +++- .../domain/user/service/UserService.java | 72 +++-- .../auth/controller/AuthController.java | 28 +- .../auth/oauth/CustomOAuth2UserService.java | 42 ++- .../auth/oauth/OAuth2LoginSuccessHandler.java | 42 +-- .../global/auth/oauth/OAuth2UserInfo.java | 16 +- .../server/global/config/CorsConfig.java | 14 +- .../global/config/MongoCheckRunner.java | 40 ++- .../global/config/RestTemplateConfig.java | 10 + .../server/global/config/SecurityConfig.java | 42 ++- server/src/main/resources/application.yml | 36 +-- server/sudo | 300 ++++++++++++++++++ server/use | 0 47 files changed, 1559 insertions(+), 390 deletions(-) create mode 100644 server/select create mode 100644 server/sudo create mode 100644 server/use diff --git a/server/select b/server/select new file mode 100644 index 0000000..75da021 --- /dev/null +++ b/server/select @@ -0,0 +1,300 @@ +mysql Ver 9.5.0 for macos26.1 on arm64 (Homebrew) +Copyright (c) 2000, 2025, Oracle and/or its affiliates. + +Oracle is a registered trademark of Oracle Corporation and/or its +affiliates. Other names may be trademarks of their respective +owners. + +Usage: mysql [OPTIONS] [database] + -?, --help Display this help and exit. + -I, --help Synonym for -? + --auto-rehash Enable automatic rehashing. One doesn't need to use + 'rehash' to get table and field completion, but startup + and reconnecting may take a longer time. Disable with + --disable-auto-rehash. + (Defaults to on; use --skip-auto-rehash to disable.) + -A, --no-auto-rehash + No automatic rehashing. One has to use 'rehash' to get + table and field completion. This gives a quicker start of + mysql and disables rehashing on reconnect. + --auto-vertical-output + Automatically switch to vertical output mode if the + result is wider than the terminal width. + -B, --batch Don't use history file. Disable interactive behavior. + (Enables --silent.) + --bind-address=name IP address to bind to. + --binary-as-hex Print binary data as hex. Enabled by default for + interactive terminals. + --character-sets-dir=name + Directory for character set files. + --column-type-info Display column type information. + --commands Enable or disable processing of local mysql commands. + -c, --comments Preserve comments. Send comments to the server. The + default is --comments (keep comments), disable with + --skip-comments. + (Defaults to on; use --skip-comments to disable.) + -C, --compress Use compression in server/client protocol. + -#, --debug[=#] This is a non-debug version. Catch this and exit. + --debug-check This is a non-debug version. Catch this and exit. + -T, --debug-info This is a non-debug version. Catch this and exit. + -D, --database=name Database to use. + --default-character-set=name + Set the default character set. + --delimiter=name Delimiter to be used. + --enable-cleartext-plugin + Enable/disable the clear text authentication plugin. + -e, --execute=name Execute command and quit. (Disables --force and history + file.) + -E, --vertical Print the output of a query (rows) vertically. + -f, --force Continue even if we get an SQL error. + --histignore=name A colon-separated list of patterns to keep statements + from getting logged into syslog and mysql history. + -G, --named-commands + Enable named commands. Named commands mean this program's + internal commands; see mysql> help . When enabled, the + named commands can be used from any line of the query, + otherwise only from the first line, before an enter. + Disable with --disable-named-commands. This option is + disabled by default. + -i, --ignore-spaces Ignore space after function names. + --init-command=name Single SQL Command to execute when connecting to MySQL + server. Will automatically be re-executed when + reconnecting. + --init-command-add=name + Add SQL command to the list to execute when connecting to + MySQL server. Will automatically be re-executed when + reconnecting. + --local-infile Enable/disable LOAD DATA LOCAL INFILE. + -b, --no-beep Turn off beep on error. + -h, --host=name Connect to host. + --dns-srv-name=name Connect to a DNS SRV resource + -H, --html Produce HTML output. + -X, --xml Produce XML output. + --line-numbers Write line numbers for errors. + (Defaults to on; use --skip-line-numbers to disable.) + -L, --skip-line-numbers + Don't write line number for errors. + -n, --unbuffered Flush buffer after each query. + --column-names Write column names in results. + (Defaults to on; use --skip-column-names to disable.) + -N, --skip-column-names + Don't write column names in results. + --sigint-ignore Ignore SIGINT (CTRL-C). + -o, --one-database Ignore statements except those that occur while the + default database is the one named at the command line. + --pager[=name] Pager to use to display results. If you don't supply an + option, the default pager is taken from your ENV variable + PAGER. Valid pagers are less, more, cat [> filename], + etc. See interactive help (\h) also. This option does not + work in batch mode. Disable with --disable-pager. This + option is disabled by default. + -p, --password[=name] + Password to use when connecting to server. If password is + not given it's asked from the tty. + --password1[=name] Password for first factor authentication plugin. + --password2[=name] Password for second factor authentication plugin. + --password3[=name] Password for third factor authentication plugin. + -P, --port=# Port number to use for connection or 0 for default to, in + order of preference, my.cnf, $MYSQL_TCP_PORT, + /etc/services, built-in default (3306). + --prompt=name Set the mysql prompt to this value. + --protocol=name The protocol to use for connection (tcp, socket, pipe, + memory). + -q, --quick Don't cache result, print it row by row. This may slow + down the server if the output is suspended. Doesn't use + history file. + -r, --raw Write fields without conversion. Used with --batch. + --reconnect Reconnect if the connection is lost. Disable with + --disable-reconnect. This option is enabled by default. + (Defaults to on; use --skip-reconnect to disable.) + -s, --silent Be more silent. Print results with a tab as separator, + each row on new line. + -S, --socket=name The socket file to use for connection. + --server-public-key-path=name + File path to the server public RSA key in PEM format. + --get-server-public-key + Get server public key + --ssl-mode=name SSL connection mode. + --ssl-ca=name CA file in PEM format. + --ssl-capath=name CA directory. + --ssl-cert=name X509 cert in PEM format. + --ssl-cipher=name SSL cipher to use. + --ssl-key=name X509 key in PEM format. + --ssl-crl=name Certificate revocation list. + --ssl-crlpath=name Certificate revocation list path. + --tls-version=name TLS version to use, permitted values are: TLSv1.2, + TLSv1.3 + --ssl-fips-mode=name + SSL FIPS mode (applies only for OpenSSL); permitted + values are: OFF, ON, STRICT + --tls-ciphersuites=name + TLS v1.3 cipher to use. + --ssl-session-data=name + Session data file to use to enable ssl session reuse + --ssl-session-data-continue-on-failed-reuse + If set to ON, this option will allow connection to + succeed even if session data cannot be reused. + --tls-sni-servername=name + The SNI server name to pass to server + -t, --table Output in table format. + --tee=name Append everything into outfile. See interactive help (\h) + also. Does not work in batch mode. Disable with + --disable-tee. This option is disabled by default. + -u, --user=name User for login if not current user. + -U, --safe-updates Only allow UPDATE and DELETE that uses keys. + -U, --i-am-a-dummy Synonym for option --safe-updates, -U. + -v, --verbose Write more. (-v -v -v gives the table output format). + -V, --version Output version information and exit. + -w, --wait Wait and retry if connection is down. + --connect-timeout=# Number of seconds before connection timeout. + --max-allowed-packet=# + The maximum packet length to send to or receive from + server. + --net-buffer-length=# + The buffer size for TCP/IP and socket communication. + --select-limit=# Automatic limit for SELECT when using --safe-updates. + --max-join-size=# Automatic limit for rows in a join when using + --safe-updates. + --show-warnings Show warnings after every statement. + -j, --syslog Log filtered interactive commands to syslog. Filtering of + commands depends on the patterns supplied via histignore + option besides the default patterns. + --plugin-dir=name Directory for client-side plugins. + --default-auth=name Default authentication client-side plugin to use. + --binary-mode By default, ASCII '\0' is disallowed and '\r\n' is + translated to '\n'. This switch turns off both features, + and also turns off parsing of all clientcommands except + \C and DELIMITER, in non-interactive mode (for input + piped to mysql or loaded using the 'source' command). + This is necessary when processing output from mysqlbinlog + that may contain blobs. + --connect-expired-password + Notify the server that this client is prepared to handle + expired password sandbox mode. + --compression-algorithms=name + Use compression algorithm in server/client protocol. + Valid values are any combination of + 'zstd','zlib','uncompressed'. + --zstd-compression-level=# + Use this compression level in the client/server protocol, + in case --compression-algorithms=zstd. Valid range is + between 1 and 22, inclusive. Default is 3. + --load-data-local-dir=name + Directory path safe for LOAD DATA LOCAL INFILE to read + from. + --authentication-oci-client-config-profile=name + Specifies the configuration profile whose configuration + options are to be read from the OCI configuration file. + Default is DEFAULT. + --oci-config-file=name + Specifies the location of the OCI configuration file. + Default for Linux is ~/.oci/config and %HOME/.oci/config + on Windows. + --authentication-openid-connect-client-id-token-file=name + Specifies the location of the ID token file. + --telemetry-client Load the telemetry_client plugin. + --plugin-authentication-webauthn-client-preserve-privacy + Allows selection of discoverable credential to be used + for signing challenge. default is false - implies + challenge is signed by all credentials for given relying + party. + --plugin-authentication-webauthn-device=# + Specifies what libfido2 device to use. 0 (the first + device) is the default. + --register-factor=name + Specifies factor for which registration needs to be done + for. + --system-command Enable or disable (by default) the 'system' mysql + command. + +Default options are read from the following files in the given order: +/etc/my.cnf /etc/mysql/my.cnf /opt/homebrew/etc/my.cnf ~/.my.cnf +The following groups are read: mysql client +The following options may be given as the first argument: +--print-defaults Print the program argument list and exit. +--no-defaults Don't read default options from any option file, + except for login file. +--defaults-file=# Only read default options from the given file #. +--defaults-extra-file=# Read this file after the global files are read. +--defaults-group-suffix=# + Also read groups with concat(group, suffix) +--login-path=# Read this path from the login file. +--no-login-paths Don't read login paths from the login path file. + +Variables (--variable-name=value) +and boolean options {FALSE|TRUE} Value (after reading options) +------------------------------------------------------ ------------------- +auto-rehash TRUE +auto-vertical-output FALSE +bind-address (No default value) +binary-as-hex FALSE +character-sets-dir (No default value) +column-type-info FALSE +commands FALSE +comments TRUE +compress FALSE +database (No default value) +default-character-set auto +delimiter ; +enable-cleartext-plugin FALSE +vertical FALSE +force FALSE +histignore (No default value) +named-commands FALSE +ignore-spaces FALSE +local-infile FALSE +no-beep FALSE +host (No default value) +dns-srv-name (No default value) +html FALSE +xml FALSE +line-numbers TRUE +unbuffered FALSE +column-names TRUE +sigint-ignore FALSE +port 0 +prompt mysql> +quick FALSE +raw FALSE +reconnect FALSE +socket (No default value) +server-public-key-path (No default value) +get-server-public-key FALSE +ssl-ca (No default value) +ssl-capath (No default value) +ssl-cert (No default value) +ssl-cipher (No default value) +ssl-key (No default value) +ssl-crl (No default value) +ssl-crlpath (No default value) +tls-version (No default value) +tls-ciphersuites (No default value) +ssl-session-data (No default value) +ssl-session-data-continue-on-failed-reuse FALSE +tls-sni-servername (No default value) +table FALSE +user (No default value) +safe-updates FALSE +i-am-a-dummy FALSE +wait FALSE +connect-timeout 0 +max-allowed-packet 16777216 +net-buffer-length 16384 +select-limit 1000 +max-join-size 1000000 +show-warnings FALSE +plugin-dir (No default value) +default-auth (No default value) +binary-mode FALSE +connect-expired-password FALSE +compression-algorithms (No default value) +zstd-compression-level 3 +load-data-local-dir (No default value) +authentication-oci-client-config-profile (No default value) +oci-config-file (No default value) +authentication-openid-connect-client-id-token-file (No default value) +telemetry-client FALSE +plugin-authentication-webauthn-client-preserve-privacy FALSE +plugin-authentication-webauthn-device 0 +register-factor (No default value) +system-command FALSE diff --git a/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java b/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java index aa3c3ad..d582d62 100644 --- a/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java +++ b/server/src/main/java/oba/backend/server/domain/ai/service/AiService.java @@ -10,7 +10,7 @@ @RequiredArgsConstructor public class AiService { - private final RestTemplate restTemplate = new RestTemplate(); + private final RestTemplate restTemplate; @Value("${ai.server.url:http://ai_backend:8000/generate_daily_gpt_results}") private String fastApiUrl; diff --git a/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java b/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java index 852aa1b..0d8c56a 100644 --- a/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java +++ b/server/src/main/java/oba/backend/server/domain/article/controller/ArticleController.java @@ -5,29 +5,40 @@ import oba.backend.server.domain.article.dto.ArticleSummaryResponse; import oba.backend.server.domain.article.service.ArticleDetailService; import oba.backend.server.domain.article.service.ArticleSummaryService; +import oba.backend.server.global.auth.jwt.JwtProvider; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController -@RequestMapping("/articles") +@RequestMapping("/api/articles") @RequiredArgsConstructor public class ArticleController { private final ArticleSummaryService summaryService; private final ArticleDetailService detailService; + private final JwtProvider jwtProvider; - @GetMapping("/latest") - public ResponseEntity> getLatest( - @RequestParam(defaultValue = "5") int limit + @GetMapping("/latest") // 최종주소: /api/articles/latest + public ResponseEntity> getLatestArticles( + @RequestParam(defaultValue = "10") int limit ) { return ResponseEntity.ok(summaryService.getLatestArticles(limit)); } - // 🚨 수정됨: @PathVariable Long -> String - @GetMapping("/{id}") - public ResponseEntity getDetail(@PathVariable String id) { - return ResponseEntity.ok(detailService.getArticleDetail(id)); + @GetMapping("/{id}") // 최종주소: /api/articles/{id} + public ResponseEntity getArticleDetail( + @PathVariable String id, + @RequestHeader(value = "Authorization", required = false) String token + ) { + Long userId = null; + if (token != null && token.startsWith("Bearer ")) { + String jwt = token.substring(7); + if (jwtProvider.validateToken(jwt)) { + userId = jwtProvider.getUserId(jwt); + } + } + return ResponseEntity.ok(detailService.getArticleDetail(id, userId)); } } \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java index 334830e..2bdb71e 100644 --- a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java +++ b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleDetailResponse.java @@ -1,35 +1,34 @@ package oba.backend.server.domain.article.dto; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; +import oba.backend.server.domain.article.entity.SelectedArticle; import java.util.List; @Getter @Builder +@NoArgsConstructor +@AllArgsConstructor public class ArticleDetailResponse { - // 🚨 수정됨: Long -> String private String articleId; - private String title; - private String publishTime; + private List content; + private List summaryBullets; + private List keywords; private String servingDate; - - // 본문은 텍스트와 이미지 태그가 섞여 있으므로 Object 리스트 - private List content; - private List subtitle; - - private String summary; - private List keywords; - private List quizzes; + private List quizzes; + private List myQuizResults; @Getter @Builder - public static class QuizDto { - private String question; - private List options; - private int answer; // 프론트엔드에서는 인덱스(0, 1, 2, 3)를 기대함 - private String explanation; + @NoArgsConstructor + @AllArgsConstructor + public static class KeywordDto { + private String keyword; + private String description; } } \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java index b994097..dd28392 100644 --- a/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java +++ b/server/src/main/java/oba/backend/server/domain/article/dto/ArticleSummaryResponse.java @@ -8,9 +8,7 @@ @Getter @Builder public class ArticleSummaryResponse { - private String articleId; - private String title; private List summaryBullets; private String servingDate; diff --git a/server/src/main/java/oba/backend/server/domain/article/entity/SelectedArticle.java b/server/src/main/java/oba/backend/server/domain/article/entity/SelectedArticle.java index 2921f72..3c328f3 100644 --- a/server/src/main/java/oba/backend/server/domain/article/entity/SelectedArticle.java +++ b/server/src/main/java/oba/backend/server/domain/article/entity/SelectedArticle.java @@ -1,4 +1,105 @@ package oba.backend.server.domain.article.entity; +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Document(collection = "Selected_Articles") public class SelectedArticle { -} + + @Id + private String id; + + @Field("article_id") + private Long articleId; + + private String title; + + @Field("serving_date") + private String servingDate; + + @Field("publish_time") + private String publishTime; + + @Field("content_col") + private List> contentCol; + + @Field("gpt_result") + private GptResult gptResult; + + // --- 편의 메서드 --- + public List getContent() { + if (contentCol == null) return new ArrayList<>(); + return contentCol.stream().flatMap(List::stream).collect(Collectors.toList()); + } + + public List getSummaryBullets() { + if (gptResult != null && gptResult.getSummary() != null) { + String rawSummary = gptResult.getSummary(); + return rawSummary.contains("\n") ? Arrays.asList(rawSummary.split("\n")) : List.of(rawSummary); + } + return new ArrayList<>(); + } + + public List getKeywordItems() { + if (gptResult == null || gptResult.getKeywords() == null) return new ArrayList<>(); + return gptResult.getKeywords(); + } + + public List getQuizzes() { + return (gptResult != null) ? gptResult.quizzes : new ArrayList<>(); + } + + // --- 내부 클래스 --- + @Getter @Setter @NoArgsConstructor @AllArgsConstructor + public static class GptResult { + private String summary; + private List keywords; + private List quizzes; + } + + @Getter @Setter @NoArgsConstructor @AllArgsConstructor + public static class KeywordItem { + private String keyword; + private String description; + } + + @Getter @Setter @NoArgsConstructor @AllArgsConstructor + public static class QuizItem { + private String question; + private List options; + private String answer; + private String explanation; + + public int getAnswerIndex() { + try { + if (answer == null) return -1; + String numericPart = answer.replaceAll("[^0-9]", ""); + if (!numericPart.isEmpty() && numericPart.length() < 3) { + return Integer.parseInt(numericPart) - 1; + } + for (int i = 0; i < options.size(); i++) { + String option = options.get(i).trim(); + String cleanAnswer = answer.trim(); + if (option.equals(cleanAnswer) || option.contains(cleanAnswer) || cleanAnswer.contains(option)) { + return i; + } + } + } catch (Exception e) { + return -1; + } + return -1; + } + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java b/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java index 4e8c023..dd96caf 100644 --- a/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java +++ b/server/src/main/java/oba/backend/server/domain/article/repository/GptMongoRepository.java @@ -1,15 +1,17 @@ package oba.backend.server.domain.article.repository; -import oba.backend.server.domain.article.entity.GptDocument; +import oba.backend.server.domain.article.entity.SelectedArticle; import org.springframework.data.domain.Pageable; import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; -public interface GptMongoRepository extends MongoRepository { +@Repository +public interface GptMongoRepository extends MongoRepository { + List findByOrderByServingDateDesc(Pageable pageable); - Optional findByArticleId(Long articleId); - - List findByOrderByServingDateDesc(Pageable pageable); -} + // 숫자 ID로 기사 찾기 (SQL <-> Mongo 매핑용) + Optional findByArticleId(Long articleId); +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/repository/SelectedArticleRepository.java b/server/src/main/java/oba/backend/server/domain/article/repository/SelectedArticleRepository.java index 8083b6c..630dc58 100644 --- a/server/src/main/java/oba/backend/server/domain/article/repository/SelectedArticleRepository.java +++ b/server/src/main/java/oba/backend/server/domain/article/repository/SelectedArticleRepository.java @@ -1,4 +1,8 @@ package oba.backend.server.domain.article.repository; -public interface SelectedArticleRepository { +import oba.backend.server.domain.article.entity.SelectedArticle; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface SelectedArticleRepository + extends MongoRepository { } diff --git a/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java b/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java index cd0021a..ec12883 100644 --- a/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java +++ b/server/src/main/java/oba/backend/server/domain/article/service/ArticleDetailService.java @@ -2,76 +2,57 @@ import lombok.RequiredArgsConstructor; import oba.backend.server.domain.article.dto.ArticleDetailResponse; -import oba.backend.server.domain.article.entity.GptDocument; +import oba.backend.server.domain.article.entity.SelectedArticle; import oba.backend.server.domain.article.repository.GptMongoRepository; +import oba.backend.server.domain.quiz.entity.IncorrectQuiz; +import oba.backend.server.domain.quiz.repository.IncorrectQuizRepository; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.util.Collections; import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class ArticleDetailService { private final GptMongoRepository gptMongoRepository; - - // 🚨 수정됨: 인자 타입 Long -> String - @Transactional(readOnly = true) - public ArticleDetailResponse getArticleDetail(String id) { - - // MongoDB _id(String)로 조회 - GptDocument doc = gptMongoRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("해당 ID의 기사를 찾을 수 없습니다: " + id)); - - // 키워드 리스트 매핑 - List keywordList = Collections.emptyList(); - if (doc.getKeywords() != null) { - keywordList = doc.getKeywords().stream() - .map(GptDocument.GptResult.Keyword::getKeyword) - .toList(); + private final IncorrectQuizRepository incorrectQuizRepository; + + public ArticleDetailResponse getArticleDetail(String articleId, Long userId) { + // Mongo에서 기사 조회 + SelectedArticle doc = gptMongoRepository.findById(articleId) + .orElseThrow(() -> new IllegalArgumentException("해당 ID의 기사를 찾을 수 없습니다: " + articleId)); + + List myResults = Collections.emptyList(); + + if (userId != null) { + Long numericId = doc.getArticleId(); + if (numericId != null) { + Optional quizRecord = incorrectQuizRepository.findByUserIdAndArticleId(userId, numericId); + if (quizRecord.isPresent()) { + myResults = quizRecord.get().getQuizResults(); + } + } } - // 퀴즈 리스트 매핑 - List quizList = Collections.emptyList(); - if (doc.getQuizzes() != null) { - quizList = doc.getQuizzes().stream() - .map(q -> ArticleDetailResponse.QuizDto.builder() - .question(q.getQuestion()) - .options(q.getOptions()) - .answer(parseAnswerIndex(q.getAnswer(), q.getOptions())) // 정답 인덱스 변환 로직 - .explanation(q.getExplanation()) - .build()) - .toList(); - } + List keywordDtos = doc.getKeywordItems().stream() + .map(item -> ArticleDetailResponse.KeywordDto.builder() + .keyword(item.getKeyword()) + .description(item.getDescription()) // 설명 필드 매핑 + .build()) + .collect(Collectors.toList()); return ArticleDetailResponse.builder() - .articleId(doc.getId()) // String ID 사용 + .articleId(doc.getId()) .title(doc.getTitle()) - .publishTime(doc.getPublishTime()) + .content(doc.getContent()) + .summaryBullets(doc.getSummaryBullets()) + .keywords(keywordDtos) .servingDate(doc.getServingDate()) - .content(doc.getContent()) // List - .subtitle(doc.getSubtitle()) // List - .summary(doc.getSummary()) - .keywords(keywordList) - .quizzes(quizList) + .quizzes(doc.getQuizzes()) + .myQuizResults(myResults) .build(); } - - // GPT가 정답을 "1" 같은 문자열이나 텍스트로 줄 수 있으므로 인덱스(int)로 변환하는 헬퍼 메서드 - private int parseAnswerIndex(String answerStr, List options) { - try { - // 1. 숫자만 있는 경우 ("0", "1" 등) - if (answerStr.matches("\\d+")) { - return Integer.parseInt(answerStr); - } - // 2. 정답 텍스트 자체가 들어있는 경우 -> 보기 리스트에서 찾기 - int idx = options.indexOf(answerStr); - if (idx != -1) return idx; - - return 0; // 기본값 (에러 방지) - } catch (Exception e) { - return 0; - } - } } \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java b/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java index a39135f..a1fa158 100644 --- a/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java +++ b/server/src/main/java/oba/backend/server/domain/article/service/ArticleSummaryService.java @@ -2,17 +2,12 @@ import lombok.RequiredArgsConstructor; import oba.backend.server.domain.article.dto.ArticleSummaryResponse; -import oba.backend.server.domain.article.entity.GptDocument; +import oba.backend.server.domain.article.entity.SelectedArticle; import oba.backend.server.domain.article.repository.GptMongoRepository; -import oba.backend.server.global.common.Const; -import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Arrays; -import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -20,37 +15,16 @@ public class ArticleSummaryService { private final GptMongoRepository gptMongoRepository; - @Cacheable(value = Const.CACHE_LATEST_ARTICLES, key = "#limit") - @Transactional(readOnly = true) public List getLatestArticles(int limit) { - // MongoDB에서 servingDate 기준 내림차순 조회 - List docs = gptMongoRepository.findByOrderByServingDateDesc(PageRequest.of(0, limit)); + List docs = gptMongoRepository.findByOrderByServingDateDesc(PageRequest.of(0, limit)); return docs.stream() - .map(this::mapToSummary) - .toList(); - } - - private ArticleSummaryResponse mapToSummary(GptDocument doc) { - List bullets = Collections.emptyList(); - - // Entity의 getSummary() 편의 메서드 활용 - String summaryText = doc.getSummary(); - - if (summaryText != null && !summaryText.isBlank()) { - // 마침표(.), 가운데점(·), 줄바꿈(\n) 기준으로 문장 분리 - bullets = Arrays.stream(summaryText.split("[.·\\n]")) - .map(String::trim) - .filter(s -> !s.isBlank()) - .limit(3) // 최대 3문장만 요약으로 표시 - .toList(); - } - - return ArticleSummaryResponse.builder() - .articleId(doc.getId()) - .title(doc.getTitle()) - .summaryBullets(bullets) - .servingDate(doc.getServingDate()) - .build(); + .map(doc -> ArticleSummaryResponse.builder() + .articleId(doc.getId()) + .title(doc.getTitle()) + .summaryBullets(doc.getSummaryBullets()) // 엔티티 메서드 사용 + .servingDate(doc.getServingDate()) // 엔티티 Getter 사용 + .build()) + .collect(Collectors.toList()); } } \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLog.java b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLog.java index 1dd363f..727743e 100644 --- a/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLog.java +++ b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLog.java @@ -1,4 +1,41 @@ package oba.backend.server.domain.log.entity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "Article_Logs") +@IdClass(ArticleLogId.class) public class ArticleLog { -} + + @Id + @Column(name = "user_id") + private Long userId; + + @Id + @Column(name = "article_id") + private Long articleId; + + @Column(name = "is_resolved", nullable = false) + @Builder.Default + private boolean isResolved = false; + + @CreationTimestamp + @Column(name = "initial_at", nullable = false, updatable = false) + private LocalDateTime initialAt; + + @Column(name = "resolve_at") + private LocalDateTime resolveAt; + + public void markAsResolved() { + this.isResolved = true; + this.resolveAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLogId.java b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLogId.java index cd8c6b2..68fb888 100644 --- a/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLogId.java +++ b/server/src/main/java/oba/backend/server/domain/log/entity/ArticleLogId.java @@ -1,4 +1,14 @@ package oba.backend.server.domain.log.entity; -public class ArticleLogId { -} +import lombok.*; +import java.io.Serializable; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class ArticleLogId implements Serializable { + private Long userId; + private Long articleId; +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/log/repository/ArticleLogRepository.java b/server/src/main/java/oba/backend/server/domain/log/repository/ArticleLogRepository.java index 9f03980..fb89472 100644 --- a/server/src/main/java/oba/backend/server/domain/log/repository/ArticleLogRepository.java +++ b/server/src/main/java/oba/backend/server/domain/log/repository/ArticleLogRepository.java @@ -1,4 +1,11 @@ package oba.backend.server.domain.log.repository; -public class ArticleLogRepository { -} +import oba.backend.server.domain.log.entity.ArticleLog; +import oba.backend.server.domain.log.entity.ArticleLogId; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface ArticleLogRepository extends JpaRepository { + List findByUserId(Long userId); + List findByUserIdAndIsResolvedFalse(Long userId); +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java b/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java index b59317e..15a3e2a 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/controller/MyQuizController.java @@ -4,25 +4,39 @@ import oba.backend.server.domain.quiz.dto.SolvedArticleResponse; import oba.backend.server.domain.quiz.dto.WrongArticleResponse; import oba.backend.server.domain.quiz.service.QuizQueryService; +import oba.backend.server.global.auth.jwt.JwtProvider; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController -@RequestMapping("/api/my") +@RequestMapping("/api/my") // 혹은 /api/users/me @RequiredArgsConstructor public class MyQuizController { private final QuizQueryService quizQueryService; + private final JwtProvider jwtProvider; + // 내가 푼 문제 목록 @GetMapping("/solved") - public ResponseEntity> getSolved(@RequestParam Long userId) { + public ResponseEntity> getSolved( + @RequestHeader("Authorization") String token) { + Long userId = extractUserId(token); return ResponseEntity.ok(quizQueryService.getSolved(userId)); } + // 나의 오답 노트 @GetMapping("/wrong") - public ResponseEntity> getWrong(@RequestParam Long userId) { + public ResponseEntity> getWrong( + @RequestHeader("Authorization") String token) { + Long userId = extractUserId(token); return ResponseEntity.ok(quizQueryService.getWrong(userId)); } -} + + // 토큰 파싱 헬퍼 메서드 + private Long extractUserId(String token) { + String jwt = token.startsWith("Bearer ") ? token.substring(7) : token; + return jwtProvider.getUserId(jwt); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java index 114296c..76c4a54 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/controller/QuizController.java @@ -1,25 +1,44 @@ package oba.backend.server.domain.quiz.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.quiz.dto.QuizSubmitRequest; -import oba.backend.server.domain.quiz.service.QuizService; +import oba.backend.server.domain.quiz.dto.QuizResultRequest; +import oba.backend.server.domain.quiz.dto.WrongArticleResponse; +import oba.backend.server.domain.quiz.service.QuizQueryService; +import oba.backend.server.domain.quiz.service.QuizResultService; +import oba.backend.server.global.auth.jwt.JwtProvider; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController -@RequestMapping("/quiz") +@RequestMapping("/api/quiz") @RequiredArgsConstructor public class QuizController { - private final QuizService quizService; + private final QuizResultService quizResultService; + private final QuizQueryService quizQueryService; // 조회 서비스 추가 + private final JwtProvider jwtProvider; // 토큰 처리용 - @PostMapping("/submit") - public ResponseEntity submitQuiz( + // 1. 퀴즈 결과 저장 + @PostMapping("/result") + public ResponseEntity saveQuizResult( @RequestHeader("Authorization") String token, - @RequestBody QuizSubmitRequest request - ) { - String jwt = token.replace("Bearer ", ""); - quizService.submit(jwt, request); - return ResponseEntity.ok().build(); + @RequestBody QuizResultRequest request) { + + String accessToken = token.startsWith("Bearer ") ? token.substring(7) : token; + quizResultService.saveQuizResult(accessToken, request); + return ResponseEntity.ok("저장 완료"); + } + + // 2. 오답 노트 조회 + @GetMapping("/wrong") + public ResponseEntity> getWrongArticles( + @RequestHeader("Authorization") String token) { + + String accessToken = token.startsWith("Bearer ") ? token.substring(7) : token; + Long userId = jwtProvider.getUserId(accessToken); + + return ResponseEntity.ok(quizQueryService.getWrong(userId)); } -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java index 5b76861..4278d45 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizResultRequest.java @@ -2,11 +2,11 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.List; @Getter @NoArgsConstructor public class QuizResultRequest { - private Long articleId; - private boolean correct; - private int selectedOption; -} + private String articleId; + private List results; +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java index 9bf5991..4719fc7 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/QuizSubmitRequest.java @@ -1,12 +1,12 @@ package oba.backend.server.domain.quiz.dto; import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.Setter; import java.util.List; @Getter -@NoArgsConstructor +@Setter public class QuizSubmitRequest { - private Long articleId; - private List answers; // 0 or 1 -} + private String articleId; + private List answers; // 사용자가 선택한 보기 인덱스들 +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java index 4f94c4e..1a3ceaf 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/SolvedArticleResponse.java @@ -10,8 +10,8 @@ @NoArgsConstructor @AllArgsConstructor public class SolvedArticleResponse { - private Long articleId; + private String articleId; private String title; private String summary; private String solvedAt; -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java b/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java index 25f91c9..48515eb 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/dto/WrongArticleResponse.java @@ -1,18 +1,15 @@ package oba.backend.server.domain.quiz.dto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Getter @Builder -@NoArgsConstructor @AllArgsConstructor public class WrongArticleResponse { - private Long articleId; + private String articleId; private String title; private String summary; - private boolean[] incorrectAnswers; + private String imageUrl; + private String category; private String solvedAt; -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java index 8d7bc45..6163fb3 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectArticlesId.java @@ -11,7 +11,7 @@ public class IncorrectArticlesId implements Serializable { private Long userId; - private Long articleId; + private String articleId; @Override public boolean equals(Object o) { @@ -26,4 +26,4 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(userId, articleId); } -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java index 3de0e90..4ef9802 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuiz.java @@ -2,14 +2,18 @@ import jakarta.persistence.*; import lombok.*; +import oba.backend.server.domain.log.entity.ArticleLogId; + +import java.util.ArrayList; +import java.util.List; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder -@Table(name = "incorrect_quiz") -@IdClass(IncorrectQuizId.class) +@Table(name = "Incorrect_Quiz") +@IdClass(ArticleLogId.class) public class IncorrectQuiz { @Id @@ -20,9 +24,38 @@ public class IncorrectQuiz { @Column(name = "article_id") private Long articleId; + @Column(nullable = false) private boolean quiz1; + + @Column(nullable = false) private boolean quiz2; + + @Column(nullable = false) private boolean quiz3; + + @Column(nullable = false) private boolean quiz4; + + @Column(nullable = false) private boolean quiz5; -} + + // Helper 메서드 + public void setQuizResults(List results) { + if (results == null || results.size() < 5) return; + this.quiz1 = results.get(0); + this.quiz2 = results.get(1); + this.quiz3 = results.get(2); + this.quiz4 = results.get(3); + this.quiz5 = results.get(4); + } + + public List getQuizResults() { + List results = new ArrayList<>(); + results.add(quiz1); + results.add(quiz2); + results.add(quiz3); + results.add(quiz4); + results.add(quiz5); + return results; + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java index dab739c..abc4d50 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/entity/IncorrectQuizId.java @@ -11,7 +11,7 @@ public class IncorrectQuizId implements Serializable { private Long userId; - private Long articleId; + private String articleId; @Override public boolean equals(Object o) { @@ -26,4 +26,4 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(userId, articleId); } -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java index 93e0b0c..897767f 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/repository/IncorrectQuizRepository.java @@ -1,14 +1,12 @@ package oba.backend.server.domain.quiz.repository; import oba.backend.server.domain.quiz.entity.IncorrectQuiz; -import oba.backend.server.domain.quiz.entity.IncorrectQuizId; +import oba.backend.server.domain.log.entity.ArticleLogId; import org.springframework.data.jpa.repository.JpaRepository; - import java.util.List; +import java.util.Optional; -public interface IncorrectQuizRepository extends JpaRepository { - +public interface IncorrectQuizRepository extends JpaRepository { List findByUserId(Long userId); - - void deleteByUserIdAndArticleId(Long userId, Long articleId); -} + Optional findByUserIdAndArticleId(Long userId, Long articleId); +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java index 05e2c3d..8aeb51a 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizQueryService.java @@ -1,45 +1,83 @@ package oba.backend.server.domain.quiz.service; import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.article.entity.SelectedArticle; +import oba.backend.server.domain.article.repository.GptMongoRepository; import oba.backend.server.domain.quiz.dto.SolvedArticleResponse; import oba.backend.server.domain.quiz.dto.WrongArticleResponse; -import oba.backend.server.domain.quiz.repository.IncorrectArticlesRepository; +import oba.backend.server.domain.quiz.entity.IncorrectQuiz; import oba.backend.server.domain.quiz.repository.IncorrectQuizRepository; +import oba.backend.server.global.auth.jwt.JwtProvider; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class QuizQueryService { - private final IncorrectArticlesRepository incorrectArticlesRepository; private final IncorrectQuizRepository incorrectQuizRepository; + private final GptMongoRepository gptMongoRepository; + private final JwtProvider jwtProvider; + // 1. 내가 푼 문제 (SQL Long ID -> Mongo 조회 -> String ID 반환) public List getSolved(Long userId) { - return incorrectArticlesRepository.findByUserId(userId) - .stream() - .map(a -> SolvedArticleResponse.builder() - .articleId(a.getArticleId()) - .solvedAt(a.getSolDate().toString()) - .build()) + return incorrectQuizRepository.findByUserId(userId).stream() // findAllByUserId -> findByUserId + .map(record -> { + // Long ID로 Mongo 문서 찾기 + SelectedArticle article = gptMongoRepository.findByArticleId(record.getArticleId()) + .orElse(null); + + String title = (article != null) ? article.getTitle() : "삭제된 기사"; + String mongoId = (article != null) ? article.getId() : ""; + + return SolvedArticleResponse.builder() + .articleId(mongoId) // 프론트엔드용 String ID 반환 + .title(title) + .solvedAt(LocalDate.now().toString()) + .build(); + }) .collect(Collectors.toList()); } + // 2. 오답 노트 public List getWrong(Long userId) { - return incorrectQuizRepository.findByUserId(userId) - .stream() - .map(q -> WrongArticleResponse.builder() - .articleId(q.getArticleId()) - .incorrectAnswers(new boolean[]{ - q.isQuiz1(), - q.isQuiz2(), - q.isQuiz3(), - q.isQuiz4(), - q.isQuiz5() - }) - .build()) - .collect(Collectors.toList()); + List records = incorrectQuizRepository.findByUserId(userId); + List responseList = new ArrayList<>(); + + for (IncorrectQuiz record : records) { + // 오답이 하나라도 있으면 + if (record.getQuizResults().contains(false)) { + // Long ID -> Mongo Document + SelectedArticle article = gptMongoRepository.findByArticleId(record.getArticleId()) + .orElse(null); + + if (article != null) { + String summary = (article.getSummaryBullets() != null && !article.getSummaryBullets().isEmpty()) + ? article.getSummaryBullets().get(0) : "요약 없음"; + + responseList.add(WrongArticleResponse.builder() + .articleId(article.getId()) // Mongo ID (String) + .title(article.getTitle()) + .summary(summary) + .category("Tech") + .solvedAt(LocalDate.now().toString()) + .build()); + } + } + } + return responseList; + } + + public List getWeeklyLog(Long userId) { + // 임시 더미 데이터 (UserStats와 연동 필요) + List weeklyLog = new ArrayList<>(); + for (int i = 0; i < 7; i++) weeklyLog.add(false); + return weeklyLog; } -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java index a438710..962e946 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizResultService.java @@ -1,30 +1,64 @@ package oba.backend.server.domain.quiz.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.global.auth.jwt.JwtProvider; +import oba.backend.server.domain.article.entity.SelectedArticle; +import oba.backend.server.domain.article.repository.GptMongoRepository; +import oba.backend.server.domain.log.entity.ArticleLog; +import oba.backend.server.domain.log.repository.ArticleLogRepository; import oba.backend.server.domain.quiz.dto.QuizResultRequest; -import oba.backend.server.domain.quiz.entity.IncorrectArticles; -import oba.backend.server.domain.quiz.repository.IncorrectArticlesRepository; +import oba.backend.server.domain.quiz.entity.IncorrectQuiz; +import oba.backend.server.domain.quiz.repository.IncorrectQuizRepository; +import oba.backend.server.domain.user.entity.User; +import oba.backend.server.domain.user.repository.UserRepository; +import oba.backend.server.global.auth.jwt.JwtProvider; import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class QuizResultService { - private final IncorrectArticlesRepository incorrectArticlesRepository; private final JwtProvider jwtProvider; + private final UserRepository userRepository; + private final IncorrectQuizRepository incorrectQuizRepository; + private final ArticleLogRepository articleLogRepository; + private final GptMongoRepository gptMongoRepository; + + @Transactional + public void saveQuizResult(String token, QuizResultRequest request) { + Long userId = jwtProvider.getUserId(token); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + user.updateStreak(); // UserStats 갱신 + + // Mongo ID -> Numeric ID 변환 + SelectedArticle article = gptMongoRepository.findById(request.getArticleId()) + .orElseThrow(() -> new IllegalArgumentException("기사를 찾을 수 없습니다.")); + Long numericArticleId = article.getArticleId(); + + // 학습 로그 (Article_Logs) 저장/갱신 + ArticleLog log = articleLogRepository.findById(new oba.backend.server.domain.log.entity.ArticleLogId(userId, numericArticleId)) + .orElseGet(() -> ArticleLog.builder() + .userId(userId) + .articleId(numericArticleId) + .build()); - public void saveQuizResult(String jwt, QuizResultRequest request) { - Long userId = jwtProvider.getUserId(jwt); + // 정답 여부 체크 (모두 true일 때 해결 처리) + if (!request.getResults().contains(false)) { + log.markAsResolved(); + } + articleLogRepository.save(log); - IncorrectArticles incorrectArticles = IncorrectArticles.builder() - .userId(userId) - .articleId(request.getArticleId()) - .solDate(LocalDateTime.now()) - .build(); + // 오답 상세 (Incorrect_Quiz) 저장 + IncorrectQuiz quizRecord = incorrectQuizRepository + .findByUserIdAndArticleId(userId, numericArticleId) + .orElseGet(() -> IncorrectQuiz.builder() + .userId(userId) + .articleId(numericArticleId) + .build()); - incorrectArticlesRepository.save(incorrectArticles); + quizRecord.setQuizResults(request.getResults()); + incorrectQuizRepository.save(quizRecord); } -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java index ecc3678..78e9dda 100644 --- a/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java +++ b/server/src/main/java/oba/backend/server/domain/quiz/service/QuizService.java @@ -1,32 +1,67 @@ package oba.backend.server.domain.quiz.service; import lombok.RequiredArgsConstructor; -import oba.backend.server.global.auth.jwt.JwtProvider; -import oba.backend.server.domain.quiz.entity.IncorrectQuiz; +import lombok.extern.slf4j.Slf4j; +import oba.backend.server.domain.article.entity.SelectedArticle; +import oba.backend.server.domain.article.repository.GptMongoRepository; import oba.backend.server.domain.quiz.dto.QuizSubmitRequest; +import oba.backend.server.domain.quiz.entity.IncorrectQuiz; import oba.backend.server.domain.quiz.repository.IncorrectQuizRepository; +import oba.backend.server.global.auth.jwt.JwtProvider; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +@Slf4j @Service @RequiredArgsConstructor public class QuizService { private final IncorrectQuizRepository incorrectQuizRepository; + private final GptMongoRepository gptMongoRepository; private final JwtProvider jwtProvider; + @Transactional public void submit(String jwt, QuizSubmitRequest request) { Long userId = jwtProvider.getUserId(jwt); + // Mongo에서 기사 조회 (String ID 사용) + SelectedArticle article = gptMongoRepository.findById(request.getArticleId()) + .orElseThrow(() -> new IllegalArgumentException("해당 기사 없음")); + + // 숫자 ID 추출 (MySQL 저장용) + Long numericArticleId = article.getArticleId(); + if (numericArticleId == null) { + throw new IllegalArgumentException("기사 ID(숫자)가 존재하지 않습니다."); + } + + // 정답 판별 + List userAnswers = request.getAnswers(); + List quizzes = article.getQuizzes(); + + if (userAnswers.size() != quizzes.size()) { + throw new IllegalArgumentException("답변 수와 문제 수가 일치하지 않음"); + } + + List results = new ArrayList<>(); + for (int i = 0; i < quizzes.size(); i++) { + int userIndex = userAnswers.get(i); + int correctIndex = quizzes.get(i).getAnswerIndex(); + boolean isCorrect = (userIndex == correctIndex); + results.add(isCorrect); + } + + // 저장 (Long ID 사용) IncorrectQuiz incorrectQuiz = IncorrectQuiz.builder() .userId(userId) - .articleId(request.getArticleId()) - .quiz1(request.getAnswers().get(0) == 1) - .quiz2(request.getAnswers().get(1) == 1) - .quiz3(request.getAnswers().get(2) == 1) - .quiz4(request.getAnswers().get(3) == 1) - .quiz5(request.getAnswers().get(4) == 1) + .articleId(numericArticleId) .build(); + // Helper 메서드로 결과 주입 + incorrectQuiz.setQuizResults(results); + incorrectQuizRepository.save(incorrectQuiz); } -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryId.java b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryId.java index 5f60d35..a88e22f 100644 --- a/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryId.java +++ b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryId.java @@ -1,4 +1,14 @@ package oba.backend.server.domain.stats.entity; -public class UserCategoryId { -} +import lombok.*; +import java.io.Serializable; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class UserCategoryId implements Serializable { + private Long userId; + private Integer categoryId; +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryStats.java b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryStats.java index ef3b303..ca7f1b1 100644 --- a/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryStats.java +++ b/server/src/main/java/oba/backend/server/domain/stats/entity/UserCategoryStats.java @@ -1,4 +1,42 @@ package oba.backend.server.domain.stats.entity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "User_Category_Stats") +@IdClass(UserCategoryId.class) public class UserCategoryStats { -} + + @Id + @Column(name = "user_id") + private Long userId; + + @Id + @Column(name = "category_id") + private Integer categoryId; + + @Column(name = "total_quizzes") + @Builder.Default + private int totalQuizzes = 0; + + @Column(name = "correct_quizzes") + @Builder.Default + private int correctQuizzes = 0; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public void addScore(int total, int correct) { + this.totalQuizzes += total; + this.correctQuizzes += correct; + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/stats/entity/UserStats.java b/server/src/main/java/oba/backend/server/domain/stats/entity/UserStats.java index dd090dc..938cbc5 100644 --- a/server/src/main/java/oba/backend/server/domain/stats/entity/UserStats.java +++ b/server/src/main/java/oba/backend/server/domain/stats/entity/UserStats.java @@ -1,4 +1,56 @@ package oba.backend.server.domain.stats.entity; +import jakarta.persistence.*; +import lombok.*; +import oba.backend.server.domain.user.entity.User; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "User_Stats") public class UserStats { -} + + @Id + @Column(name = "user_id") + private Long userId; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "current_streak") + @Builder.Default + private int currentStreak = 0; + + @Column(name = "max_streak") + @Builder.Default + private int maxStreak = 0; + + @Column(name = "total_perfect_days") + @Builder.Default + private int totalPerfectDays = 0; + + @Column(name = "last_learned_at") + private LocalDate lastLearnedAt; + + public void updateStreak() { + LocalDate today = LocalDate.now(); + if (lastLearnedAt != null && lastLearnedAt.equals(today)) return; + + if (lastLearnedAt != null && lastLearnedAt.plusDays(1).equals(today)) { + this.currentStreak++; + } else { + this.currentStreak = 1; + } + + if (this.currentStreak > this.maxStreak) { + this.maxStreak = this.currentStreak; + } + this.lastLearnedAt = today; + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/stats/repository/UserCategoryStatsRepository.java b/server/src/main/java/oba/backend/server/domain/stats/repository/UserCategoryStatsRepository.java index a75d4b7..d87be22 100644 --- a/server/src/main/java/oba/backend/server/domain/stats/repository/UserCategoryStatsRepository.java +++ b/server/src/main/java/oba/backend/server/domain/stats/repository/UserCategoryStatsRepository.java @@ -1,4 +1,10 @@ package oba.backend.server.domain.stats.repository; -public interface UserCategoryStatsRepository { -} +import oba.backend.server.domain.stats.entity.UserCategoryStats; +import oba.backend.server.domain.stats.entity.UserCategoryId; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface UserCategoryStatsRepository extends JpaRepository { + List findByUserId(Long userId); +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/stats/repository/UserStatsRepository.java b/server/src/main/java/oba/backend/server/domain/stats/repository/UserStatsRepository.java index e8cd0e3..8e2cbcf 100644 --- a/server/src/main/java/oba/backend/server/domain/stats/repository/UserStatsRepository.java +++ b/server/src/main/java/oba/backend/server/domain/stats/repository/UserStatsRepository.java @@ -1,4 +1,7 @@ package oba.backend.server.domain.stats.repository; -public interface UserStatsRepository { -} +import oba.backend.server.domain.stats.entity.UserStats; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserStatsRepository extends JpaRepository { +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/user/controller/UserController.java b/server/src/main/java/oba/backend/server/domain/user/controller/UserController.java index 1dd2dff..46a33fc 100644 --- a/server/src/main/java/oba/backend/server/domain/user/controller/UserController.java +++ b/server/src/main/java/oba/backend/server/domain/user/controller/UserController.java @@ -1,4 +1,44 @@ package oba.backend.server.domain.user.controller; +import lombok.RequiredArgsConstructor; +import oba.backend.server.domain.quiz.service.QuizQueryService; +import oba.backend.server.domain.user.dto.UserResponse; +import oba.backend.server.domain.user.entity.User; +import oba.backend.server.domain.user.service.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor public class UserController { -} + + private final UserService userService; + private final QuizQueryService quizQueryService; + + @GetMapping("/me") + public ResponseEntity getMyInfo(@AuthenticationPrincipal UserDetails userDetails) { + if (userDetails == null) { + return ResponseEntity.status(401).build(); + } + + String identifier = userDetails.getUsername(); + User user = userService.findByIdentifier(identifier); + + if (user == null) { + return ResponseEntity.notFound().build(); + } + + // 이번 주 학습 로그 조회 + List weeklyLog = quizQueryService.getWeeklyLog(user.getId()); + + // DTO 생성 (weeklyLog 포함) + return ResponseEntity.ok(UserResponse.from(user, weeklyLog)); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/user/dto/UserResponse.java b/server/src/main/java/oba/backend/server/domain/user/dto/UserResponse.java index 601cd68..a1fb9ec 100644 --- a/server/src/main/java/oba/backend/server/domain/user/dto/UserResponse.java +++ b/server/src/main/java/oba/backend/server/domain/user/dto/UserResponse.java @@ -1,4 +1,38 @@ package oba.backend.server.domain.user.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import oba.backend.server.domain.user.entity.User; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class UserResponse { -} + private Long userId; + private String email; + private String name; + private String picture; + private String authProvider; + private int consecutiveDays; + private List weeklyLog; + + public static UserResponse from(User user, List weeklyLog) { + int streak = (user.getUserStats() != null) ? user.getUserStats().getCurrentStreak() : 0; + + return UserResponse.builder() + .userId(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .picture(user.getPicture()) + .authProvider(user.getAuthProvider() != null ? + user.getAuthProvider().name() : "UNKNOWN") + .consecutiveDays(streak) + .weeklyLog(weeklyLog) + .build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/AuthProvider.java b/server/src/main/java/oba/backend/server/domain/user/entity/AuthProvider.java index 9d6a14f..7e6fd8d 100644 --- a/server/src/main/java/oba/backend/server/domain/user/entity/AuthProvider.java +++ b/server/src/main/java/oba/backend/server/domain/user/entity/AuthProvider.java @@ -1,4 +1,9 @@ package oba.backend.server.domain.user.entity; -public class AuthProvider { -} +public enum AuthProvider { + LOCAL, + GOOGLE, + KAKAO, + NAVER, + MOBILE +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/user/entity/User.java b/server/src/main/java/oba/backend/server/domain/user/entity/User.java index 243eb53..48002a4 100644 --- a/server/src/main/java/oba/backend/server/domain/user/entity/User.java +++ b/server/src/main/java/oba/backend/server/domain/user/entity/User.java @@ -1,15 +1,17 @@ package oba.backend.server.domain.user.entity; import jakarta.persistence.*; -import lombok.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import oba.backend.server.domain.stats.entity.UserStats; +import oba.backend.server.global.common.BaseEntity; -@Entity @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -@Table(name = "users") -public class User { +@NoArgsConstructor +@Entity +@Table(name = "Users") +public class User extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -29,16 +31,41 @@ public class User { private String picture; @Enumerated(EnumType.STRING) - @Column(name = "auth_provider", nullable = false) - private ProviderInfo authProvider; + @Column(length = 50) + private Role role; @Enumerated(EnumType.STRING) - @Column(nullable = false) - private Role role; + @Column(name = "provider", length = 50) + private AuthProvider authProvider; + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private UserStats userStats; + + @Builder + public User(String identifier, String email, String name, String picture, Role role, AuthProvider authProvider) { + this.identifier = identifier; + this.email = email; + this.name = name; + this.picture = picture; + this.role = role; + this.authProvider = authProvider; + } public void updateInfo(String email, String name, String picture) { - if (email != null && !email.isBlank()) this.email = email; - if (name != null && !name.isBlank()) this.name = name; - if (picture != null && !picture.isBlank()) this.picture = picture; + this.email = email; + this.name = name; + this.picture = picture; + } + + public void initStats() { + if (this.userStats == null) { + this.userStats = UserStats.builder().user(this).build(); + } + } + + public void updateStreak() { + if (this.userStats != null) { + this.userStats.updateStreak(); + } } } \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/domain/user/service/UserService.java b/server/src/main/java/oba/backend/server/domain/user/service/UserService.java index bf91ea4..b04abcd 100644 --- a/server/src/main/java/oba/backend/server/domain/user/service/UserService.java +++ b/server/src/main/java/oba/backend/server/domain/user/service/UserService.java @@ -1,48 +1,66 @@ package oba.backend.server.domain.user.service; import lombok.RequiredArgsConstructor; - -import oba.backend.server.domain.user.entity.ProviderInfo; +import lombok.extern.slf4j.Slf4j; import oba.backend.server.domain.user.entity.Role; import oba.backend.server.domain.user.entity.User; +import oba.backend.server.domain.user.entity.AuthProvider; import oba.backend.server.domain.user.repository.UserRepository; -import oba.backend.server.global.common.Const; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; +import oba.backend.server.global.auth.oauth.OAuth2UserInfo; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; - @Cacheable(value = Const.CACHE_USER, key = "#identifier", unless = "#result == null") - @Transactional(readOnly = true) + // 유저 조회 log public User findByIdentifier(String identifier) { - return userRepository.findByIdentifier(identifier).orElse(null); + log.info("[UserService] findByIdentifier 호출됨. 찾는 ID: '{}'", identifier); + + return userRepository.findByIdentifier(identifier) + .orElseThrow(() -> { + log.error(" [UserService] DB 조회 실패! ID: '{}' 인 유저가 테이블에 없습니다.", identifier); + return new IllegalArgumentException("유저를 찾을 수 없습니다."); + }); } - /** - * 유저 생성 혹은 정보 업데이트 (로그인 시 호출) - * 정보가 변경될 수 있으므로 해당 유저의 캐시를 삭제(@CacheEvict)합니다. - */ + // 유저 등록/수정 log @Transactional - @CacheEvict(value = Const.CACHE_USER, key = "#identifier") - public User findOrCreateUser(String identifier, String email, String name, String picture, ProviderInfo provider, Role role) { - return userRepository.findByIdentifier(identifier) - .map(user -> { - user.updateInfo(email, name, picture); - return user; - }) - .orElseGet(() -> userRepository.save(User.builder() - .identifier(identifier) - .email(email != null ? email : identifier + "@unknown") - .name(name != null ? name : "User") - .picture(picture) - .authProvider(provider) - .role(role) - .build())); + public User registerOrUpdateUser(OAuth2UserInfo info) { + log.info(" [UserService] registerOrUpdateUser 호출됨. Provider: {}, ID: {}", info.getProvider(), info.getId()); + + AuthProvider providerEnum = AuthProvider.valueOf(info.getProvider().toUpperCase()); + + User user = userRepository.findByIdentifier(info.getId()) + .orElseGet(() -> { + log.info("[UserService] 신규 유저 생성 시작 ID: {}", info.getId()); + User newUser = User.builder() + .identifier(info.getId()) + .email(info.getEmail()) + .name(info.getName()) + .picture(info.getPicture()) + .role(Role.USER) + .authProvider(providerEnum) + .build(); + newUser.initStats(); // 통계 초기화 + return newUser; + }); + + user.updateInfo(info.getEmail(), info.getName(), info.getPicture()); + User savedUser = userRepository.save(user); + + log.info(" [UserService] 저장 완료. DB PK: {}, Identifier: {}", savedUser.getId(), savedUser.getIdentifier()); + return savedUser; + } + + @Transactional + public void updateStreak(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + user.updateStreak(); } } \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java b/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java index b108698..dbada86 100644 --- a/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java +++ b/server/src/main/java/oba/backend/server/global/auth/controller/AuthController.java @@ -1,14 +1,13 @@ package oba.backend.server.global.auth.controller; import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.entity.ProviderInfo; -import oba.backend.server.domain.user.entity.Role; import oba.backend.server.domain.user.entity.User; +import oba.backend.server.domain.user.service.UserService; import oba.backend.server.global.auth.dto.LoginRequest; import oba.backend.server.global.auth.dto.TokenResponse; import oba.backend.server.global.auth.jwt.JwtProvider; +import oba.backend.server.global.auth.oauth.OAuth2UserInfo; import oba.backend.server.global.common.Const; -import oba.backend.server.domain.user.service.UserService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -20,20 +19,18 @@ public class AuthController { private final JwtProvider jwtProvider; private final UserService userService; - // 모바일 소셜 로그인 (ID Token 검증은 클라이언트가 했다고 가정) + // 모바일 소셜 로그인 @PostMapping("/mobile/login") public ResponseEntity mobileLogin(@RequestBody LoginRequest request) { - String identifier = "mobile:" + request.getIdToken(); - - // Mobile 유저는 별도 프로필 정보가 없으므로 기본값 사용 - User user = userService.findOrCreateUser( - identifier, - identifier + "@mobile.user", - "모바일유저", - null, - ProviderInfo.MOBILE, - Role.USER - ); + // OAuth2UserInfo 가방에 담아서 서비스에 전달 + OAuth2UserInfo userInfo = OAuth2UserInfo.builder() + .id(request.getIdToken()) + .email(request.getIdToken() + "@mobile.user") + .name("모바일유저") + .provider("MOBILE") // AuthProvider.MOBILE로 매핑 + .build(); + + User user = userService.registerOrUpdateUser(userInfo); return ResponseEntity.ok(jwtProvider.generateTokens(user.getId(), user.getIdentifier())); } @@ -51,7 +48,6 @@ public ResponseEntity reissue(@RequestHeader("Authorization") Str return ResponseEntity.status(401).build(); } - // 토큰에서 정보 추출 후 재발급 Long userId = jwtProvider.getUserId(token); String identifier = jwtProvider.getIdentifier(token); diff --git a/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java index 97f5d06..a7e3125 100644 --- a/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/CustomOAuth2UserService.java @@ -1,8 +1,7 @@ package oba.backend.server.global.auth.oauth; import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.user.entity.ProviderInfo; -import oba.backend.server.domain.user.entity.Role; +import lombok.extern.slf4j.Slf4j; import oba.backend.server.domain.user.entity.User; import oba.backend.server.domain.user.service.UserService; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; @@ -13,6 +12,7 @@ import java.util.Map; +@Slf4j @Service @RequiredArgsConstructor public class CustomOAuth2UserService extends DefaultOAuth2UserService { @@ -22,31 +22,30 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { @Override public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(request); - String registrationId = request.getClientRegistration().getRegistrationId(); + String registrationId = request.getClientRegistration().getRegistrationId(); // google, kakao, naver - // 1. 복잡한 파싱 로직을 내부 객체(OAuthAttributes)에게 위임 OAuthAttributes attributes = OAuthAttributes.of(registrationId, oAuth2User.getAttributes()); - // 2. 통합된 유저 조회/생성 메서드 호출 - User user = userService.findOrCreateUser( - attributes.identifier, - attributes.email, - attributes.name, - attributes.picture, - ProviderInfo.from(registrationId), - Role.USER - ); + String uniqueIdentifier = registrationId + "_" + attributes.identifier(); + + OAuth2UserInfo userInfo = OAuth2UserInfo.builder() + .id(uniqueIdentifier) // DB에 "google_12345" 형태로 저장 + .email(attributes.email()) + .name(attributes.name()) + .picture(attributes.picture()) + .provider(registrationId.toUpperCase()) + .build(); + + log.info("OAuth2 User Loaded: Identifier={}", userInfo.getId()); + + User user = userService.registerOrUpdateUser(userInfo); return new CustomOAuth2User(oAuth2User, user); } - /** - * Provider 별로 상이한 속성(Attribute) 정보를 규격화하는 내부 클래스 (Java 17 Record 사용) - */ private record OAuthAttributes(String identifier, String email, String name, String picture) { - static OAuthAttributes of(String provider, Map attributes) { - return switch (provider) { + return switch (provider.toLowerCase()) { case "google" -> ofGoogle(attributes); case "kakao" -> ofKakao(attributes); case "naver" -> ofNaver(attributes); @@ -56,7 +55,7 @@ static OAuthAttributes of(String provider, Map attributes) { private static OAuthAttributes ofGoogle(Map attributes) { return new OAuthAttributes( - "google:" + attributes.get("sub"), + (String) attributes.get("sub"), (String) attributes.get("email"), (String) attributes.get("name"), (String) attributes.get("picture") @@ -67,9 +66,8 @@ private static OAuthAttributes ofGoogle(Map attributes) { private static OAuthAttributes ofKakao(Map attributes) { Map account = (Map) attributes.get("kakao_account"); Map profile = (account != null) ? (Map) account.get("profile") : null; - return new OAuthAttributes( - "kakao:" + attributes.get("id"), + String.valueOf(attributes.get("id")), (account != null) ? (String) account.get("email") : null, (profile != null) ? (String) profile.get("nickname") : null, (profile != null) ? (String) profile.get("profile_image_url") : null @@ -80,7 +78,7 @@ private static OAuthAttributes ofKakao(Map attributes) { private static OAuthAttributes ofNaver(Map attributes) { Map response = (Map) attributes.get("response"); return new OAuthAttributes( - "naver:" + (response != null ? response.get("id") : ""), + (response != null) ? (String) response.get("id") : "", (response != null) ? (String) response.get("email") : null, (response != null) ? (String) response.get("name") : null, (response != null) ? (String) response.get("profile_image") : null diff --git a/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java index b76d469..7eb7cda 100644 --- a/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2LoginSuccessHandler.java @@ -1,22 +1,24 @@ package oba.backend.server.global.auth.oauth; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import oba.backend.server.global.auth.jwt.JwtProvider; +import lombok.extern.slf4j.Slf4j; import oba.backend.server.domain.user.entity.User; +import oba.backend.server.global.auth.jwt.JwtProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +@Slf4j @Component @RequiredArgsConstructor -public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final JwtProvider jwtProvider; @@ -24,22 +26,26 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private String mobileRedirectUri; @Override - public void onAuthenticationSuccess( - HttpServletRequest request, - HttpServletResponse response, - Authentication authentication - ) throws IOException { + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { CustomOAuth2User customUser = (CustomOAuth2User) authentication.getPrincipal(); User user = customUser.getUser(); - String access = jwtProvider.createAccessToken(user.getId(), user.getIdentifier()); - String refresh = jwtProvider.createRefreshToken(user.getId(), user.getIdentifier()); + String identifier = user.getIdentifier(); + + String accessToken = jwtProvider.createAccessToken(user.getId(), identifier); + String refreshToken = jwtProvider.createRefreshToken(user.getId(), identifier); + + log.info("OAuth2 Login Success: User={}, Identifier={}", user.getName(), identifier); - String redirectUri = mobileRedirectUri - + "?access=" + URLEncoder.encode(access, StandardCharsets.UTF_8) - + "&refresh=" + URLEncoder.encode(refresh, StandardCharsets.UTF_8); + String targetUrl = UriComponentsBuilder.fromUriString(mobileRedirectUri) + .queryParam("access_token", accessToken) + .queryParam("refresh_token", refreshToken) + .build() + .encode(StandardCharsets.UTF_8) + .toUriString(); - response.sendRedirect(redirectUri); + clearAuthenticationAttributes(request); + getRedirectStrategy().sendRedirect(request, response, targetUrl); } -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2UserInfo.java b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2UserInfo.java index 42095b7..a1fb77e 100644 --- a/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2UserInfo.java +++ b/server/src/main/java/oba/backend/server/global/auth/oauth/OAuth2UserInfo.java @@ -1,4 +1,18 @@ package oba.backend.server.global.auth.oauth; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class OAuth2UserInfo { -} + private String id; + private String email; + private String name; + private String picture; + private String provider; +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/config/CorsConfig.java b/server/src/main/java/oba/backend/server/global/config/CorsConfig.java index 66a2db0..d1a81dd 100644 --- a/server/src/main/java/oba/backend/server/global/config/CorsConfig.java +++ b/server/src/main/java/oba/backend/server/global/config/CorsConfig.java @@ -1,24 +1,30 @@ package oba.backend.server.global.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.List; + @Configuration public class CorsConfig { + @Value("${cors.allowed-origins}") + private List allowedOrigins; + @Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOriginPatterns("*") - .allowedMethods("*") + .allowedOrigins(allowedOrigins.toArray(new String[0])) // 명시적 도메인 허용 + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") - .allowCredentials(true); + .allowCredentials(true); // 쿠키/인증정보 포함 허용 } }; } -} +} \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java b/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java index 64b3bca..0d7ce0e 100644 --- a/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java +++ b/server/src/main/java/oba/backend/server/global/config/MongoCheckRunner.java @@ -1,46 +1,44 @@ package oba.backend.server.global.config; import lombok.RequiredArgsConstructor; -import oba.backend.server.domain.article.repository.GptMongoRepository; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class MongoCheckRunner implements CommandLineRunner { private final MongoTemplate mongoTemplate; - private final GptMongoRepository repository; @Override - public void run(String... args) throws Exception { - System.out.println("=========================================="); - System.out.println("[MongoDB 연결 확인]"); + public void run(String... args) { + log.info("=========================================="); + log.info("[MongoDB Connection Check]"); - // 1. 현재 연결된 데이터베이스 이름 출력 try { + // DB 연결 확인 String dbName = mongoTemplate.getDb().getName(); - System.out.println("연결된 DB 이름: " + dbName); - } catch (Exception e) { - System.out.println("DB 연결 실패: " + e.getMessage()); - } + log.info("Connected Database: {}", dbName); - // 2. Repository를 통해 데이터 개수 조회 - try { - long count = repository.count(); - System.out.println("👉 'Selected_Articles' 컬렉션 데이터 개수: " + count + "개"); + // 컬렉션 존재 여부 확인 (대소문자 구분 중요!) + String collectionName = "Selected_Articles"; // Entity의 @Document 값과 일치해야 함 + boolean exists = mongoTemplate.collectionExists(collectionName); - if (count == 0) { - System.out.println("데이터가 0개입니다. 컬렉션 이름(@Document)이나 DB 주소를 확인하세요!"); - System.out.println("현재 DB에 존재하는 컬렉션 목록: " + mongoTemplate.getCollectionNames()); + if (exists) { + long count = mongoTemplate.getCollection(collectionName).countDocuments(); + log.info("Collection '{}' FOUND. (Docs: {} count)", collectionName, count); } else { - System.out.println("데이터가 존재합니다! API 조회를 다시 시도해보세요."); + log.error("Collection '{}' NOT FOUND!", collectionName); + log.error(" - Check capitalization (Selected_Articles vs selected_articles)"); + log.error(" - Current Collections: {}", mongoTemplate.getCollectionNames()); } + } catch (Exception e) { - System.out.println("조회 중 에러 발생: " + e.getMessage()); + log.error("MongoDB Connection Failed: ", e); } - - System.out.println("=========================================="); + log.info("=========================================="); } } \ No newline at end of file diff --git a/server/src/main/java/oba/backend/server/global/config/RestTemplateConfig.java b/server/src/main/java/oba/backend/server/global/config/RestTemplateConfig.java index 780d37c..f47e480 100644 --- a/server/src/main/java/oba/backend/server/global/config/RestTemplateConfig.java +++ b/server/src/main/java/oba/backend/server/global/config/RestTemplateConfig.java @@ -1,4 +1,14 @@ package oba.backend.server.global.config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } } diff --git a/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java b/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java index 788ed19..174ce8b 100644 --- a/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java +++ b/server/src/main/java/oba/backend/server/global/config/SecurityConfig.java @@ -6,7 +6,10 @@ import oba.backend.server.global.auth.jwt.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; 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.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -15,6 +18,7 @@ import java.util.List; @Configuration +@EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { @@ -26,34 +30,52 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf.disable()) + // CSRF 비활성화 (JWT 사용 시 필요) + .csrf(AbstractHttpConfigurer::disable) + + // CORS 설정 .cors(cors -> cors.configurationSource(request -> { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOriginPatterns(List.of("*")); + config.setAllowedOriginPatterns(List.of("*")); // 개발용 전체 허용 config.setAllowedHeaders(List.of("*")); - config.setAllowedMethods(List.of("*")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowCredentials(true); return config; })) - .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // 세션 관리 (Stateless) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // API 접근 권한 설정 .authorizeHttpRequests(auth -> auth + // 기사 조회(GET)는 로그인 없이 허용 + .requestMatchers(HttpMethod.GET, "/api/articles/**").permitAll() + + // 로그인/인증 관련 경로는 모두 허용 .requestMatchers( - "/auth/**", "/oauth2/**", "/login/**", - "/login/oauth2/**", - "/articles/**", - "/ai/**", - "/error" + "/auth/**", + "/error", + "/favicon.ico" ).permitAll() + + // 퀴즈 풀기, 마이페이지 등은 인증 필요 + .requestMatchers("/api/quiz/**", "/api/users/me").authenticated() + + // 그 외 모든 요청은 인증 필요 .anyRequest().authenticated() ) + + // OAuth2 로그인 설정 .oauth2Login(oauth -> oauth .userInfoEndpoint(info -> info.userService(customOAuth2UserService)) .successHandler(oAuth2LoginSuccessHandler) ) + + // JWT 필터 .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } -} +} \ No newline at end of file diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 2306d81..ae37d95 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -4,7 +4,7 @@ server: spring: config: - import: optional:file:./.env[.properties] # .env 파일을 읽도록 추가 + import: optional:file:./.env[.properties] datasource: driver-class-name: com.mysql.cj.jdbc.Driver @@ -14,7 +14,9 @@ spring: jpa: hibernate: - ddl-auto: update # 운영에서는 validate 권장 + ddl-auto: update + naming: + physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl show-sql: true properties: hibernate: @@ -24,7 +26,7 @@ spring: data: mongodb: uri: ${MONGODB_URI} - database: OneBitArticle # Atlas URI에 DB 이름이 포함돼 있으면 이 부분 제거 가능 + database: OneBitArticle security: oauth2: @@ -33,30 +35,22 @@ spring: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/google" + redirect-uri: "http://dev.onebitearticle.com:9000/login/oauth2/code/google" authorization-grant-type: authorization_code - scope: - - email - - profile + scope: [email, profile] kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/kakao" + redirect-uri: "http://dev.onebitearticle.com:9000/login/oauth2/code/kakao" client-authentication-method: client_secret_post authorization-grant-type: authorization_code - scope: - - profile_nickname - - profile_image - - account_email + scope: [profile_nickname, profile_image, account_email] naver: client-id: ${NAVER_CLIENT_ID} client-secret: ${NAVER_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/naver" + redirect-uri: "http://dev.onebitearticle.com:9000/login/oauth2/code/naver" authorization-grant-type: authorization_code - scope: - - name - - email - - profile_image + scope: [name, email, profile_image] provider: google: authorization-uri: https://accounts.google.com/o/oauth2/v2/auth @@ -83,8 +77,8 @@ ai: server: url: ${AI_SERVER_URL} -oauth: - bridge-url: "http://localhost:9000/oauth2/bridge" - app: - mobile-redirect: "myapp://oauth/naver" + mobile-redirect: ${MOBILE_REDIRECT_URI} + +cors: + allowed-origins: "http://localhost:3000, http://192.168.219.101:8081, https://onebitearticle.com, http://localhost:8081" \ No newline at end of file diff --git a/server/sudo b/server/sudo new file mode 100644 index 0000000..a17b0f8 --- /dev/null +++ b/server/sudo @@ -0,0 +1,300 @@ +mysql Ver 9.5.0 for macos26.1 on arm64 (Homebrew) +Copyright (c) 2000, 2025, Oracle and/or its affiliates. + +Oracle is a registered trademark of Oracle Corporation and/or its +affiliates. Other names may be trademarks of their respective +owners. + +Usage: mysql [OPTIONS] [database] + -?, --help Display this help and exit. + -I, --help Synonym for -? + --auto-rehash Enable automatic rehashing. One doesn't need to use + 'rehash' to get table and field completion, but startup + and reconnecting may take a longer time. Disable with + --disable-auto-rehash. + (Defaults to on; use --skip-auto-rehash to disable.) + -A, --no-auto-rehash + No automatic rehashing. One has to use 'rehash' to get + table and field completion. This gives a quicker start of + mysql and disables rehashing on reconnect. + --auto-vertical-output + Automatically switch to vertical output mode if the + result is wider than the terminal width. + -B, --batch Don't use history file. Disable interactive behavior. + (Enables --silent.) + --bind-address=name IP address to bind to. + --binary-as-hex Print binary data as hex. Enabled by default for + interactive terminals. + --character-sets-dir=name + Directory for character set files. + --column-type-info Display column type information. + --commands Enable or disable processing of local mysql commands. + -c, --comments Preserve comments. Send comments to the server. The + default is --comments (keep comments), disable with + --skip-comments. + (Defaults to on; use --skip-comments to disable.) + -C, --compress Use compression in server/client protocol. + -#, --debug[=#] This is a non-debug version. Catch this and exit. + --debug-check This is a non-debug version. Catch this and exit. + -T, --debug-info This is a non-debug version. Catch this and exit. + -D, --database=name Database to use. + --default-character-set=name + Set the default character set. + --delimiter=name Delimiter to be used. + --enable-cleartext-plugin + Enable/disable the clear text authentication plugin. + -e, --execute=name Execute command and quit. (Disables --force and history + file.) + -E, --vertical Print the output of a query (rows) vertically. + -f, --force Continue even if we get an SQL error. + --histignore=name A colon-separated list of patterns to keep statements + from getting logged into syslog and mysql history. + -G, --named-commands + Enable named commands. Named commands mean this program's + internal commands; see mysql> help . When enabled, the + named commands can be used from any line of the query, + otherwise only from the first line, before an enter. + Disable with --disable-named-commands. This option is + disabled by default. + -i, --ignore-spaces Ignore space after function names. + --init-command=name Single SQL Command to execute when connecting to MySQL + server. Will automatically be re-executed when + reconnecting. + --init-command-add=name + Add SQL command to the list to execute when connecting to + MySQL server. Will automatically be re-executed when + reconnecting. + --local-infile Enable/disable LOAD DATA LOCAL INFILE. + -b, --no-beep Turn off beep on error. + -h, --host=name Connect to host. + --dns-srv-name=name Connect to a DNS SRV resource + -H, --html Produce HTML output. + -X, --xml Produce XML output. + --line-numbers Write line numbers for errors. + (Defaults to on; use --skip-line-numbers to disable.) + -L, --skip-line-numbers + Don't write line number for errors. + -n, --unbuffered Flush buffer after each query. + --column-names Write column names in results. + (Defaults to on; use --skip-column-names to disable.) + -N, --skip-column-names + Don't write column names in results. + --sigint-ignore Ignore SIGINT (CTRL-C). + -o, --one-database Ignore statements except those that occur while the + default database is the one named at the command line. + --pager[=name] Pager to use to display results. If you don't supply an + option, the default pager is taken from your ENV variable + PAGER. Valid pagers are less, more, cat [> filename], + etc. See interactive help (\h) also. This option does not + work in batch mode. Disable with --disable-pager. This + option is disabled by default. + -p, --password[=name] + Password to use when connecting to server. If password is + not given it's asked from the tty. + --password1[=name] Password for first factor authentication plugin. + --password2[=name] Password for second factor authentication plugin. + --password3[=name] Password for third factor authentication plugin. + -P, --port=# Port number to use for connection or 0 for default to, in + order of preference, my.cnf, $MYSQL_TCP_PORT, + /etc/services, built-in default (3306). + --prompt=name Set the mysql prompt to this value. + --protocol=name The protocol to use for connection (tcp, socket, pipe, + memory). + -q, --quick Don't cache result, print it row by row. This may slow + down the server if the output is suspended. Doesn't use + history file. + -r, --raw Write fields without conversion. Used with --batch. + --reconnect Reconnect if the connection is lost. Disable with + --disable-reconnect. This option is enabled by default. + (Defaults to on; use --skip-reconnect to disable.) + -s, --silent Be more silent. Print results with a tab as separator, + each row on new line. + -S, --socket=name The socket file to use for connection. + --server-public-key-path=name + File path to the server public RSA key in PEM format. + --get-server-public-key + Get server public key + --ssl-mode=name SSL connection mode. + --ssl-ca=name CA file in PEM format. + --ssl-capath=name CA directory. + --ssl-cert=name X509 cert in PEM format. + --ssl-cipher=name SSL cipher to use. + --ssl-key=name X509 key in PEM format. + --ssl-crl=name Certificate revocation list. + --ssl-crlpath=name Certificate revocation list path. + --tls-version=name TLS version to use, permitted values are: TLSv1.2, + TLSv1.3 + --ssl-fips-mode=name + SSL FIPS mode (applies only for OpenSSL); permitted + values are: OFF, ON, STRICT + --tls-ciphersuites=name + TLS v1.3 cipher to use. + --ssl-session-data=name + Session data file to use to enable ssl session reuse + --ssl-session-data-continue-on-failed-reuse + If set to ON, this option will allow connection to + succeed even if session data cannot be reused. + --tls-sni-servername=name + The SNI server name to pass to server + -t, --table Output in table format. + --tee=name Append everything into outfile. See interactive help (\h) + also. Does not work in batch mode. Disable with + --disable-tee. This option is disabled by default. + -u, --user=name User for login if not current user. + -U, --safe-updates Only allow UPDATE and DELETE that uses keys. + -U, --i-am-a-dummy Synonym for option --safe-updates, -U. + -v, --verbose Write more. (-v -v -v gives the table output format). + -V, --version Output version information and exit. + -w, --wait Wait and retry if connection is down. + --connect-timeout=# Number of seconds before connection timeout. + --max-allowed-packet=# + The maximum packet length to send to or receive from + server. + --net-buffer-length=# + The buffer size for TCP/IP and socket communication. + --select-limit=# Automatic limit for SELECT when using --safe-updates. + --max-join-size=# Automatic limit for rows in a join when using + --safe-updates. + --show-warnings Show warnings after every statement. + -j, --syslog Log filtered interactive commands to syslog. Filtering of + commands depends on the patterns supplied via histignore + option besides the default patterns. + --plugin-dir=name Directory for client-side plugins. + --default-auth=name Default authentication client-side plugin to use. + --binary-mode By default, ASCII '\0' is disallowed and '\r\n' is + translated to '\n'. This switch turns off both features, + and also turns off parsing of all clientcommands except + \C and DELIMITER, in non-interactive mode (for input + piped to mysql or loaded using the 'source' command). + This is necessary when processing output from mysqlbinlog + that may contain blobs. + --connect-expired-password + Notify the server that this client is prepared to handle + expired password sandbox mode. + --compression-algorithms=name + Use compression algorithm in server/client protocol. + Valid values are any combination of + 'zstd','zlib','uncompressed'. + --zstd-compression-level=# + Use this compression level in the client/server protocol, + in case --compression-algorithms=zstd. Valid range is + between 1 and 22, inclusive. Default is 3. + --load-data-local-dir=name + Directory path safe for LOAD DATA LOCAL INFILE to read + from. + --authentication-oci-client-config-profile=name + Specifies the configuration profile whose configuration + options are to be read from the OCI configuration file. + Default is DEFAULT. + --oci-config-file=name + Specifies the location of the OCI configuration file. + Default for Linux is ~/.oci/config and %HOME/.oci/config + on Windows. + --authentication-openid-connect-client-id-token-file=name + Specifies the location of the ID token file. + --telemetry-client Load the telemetry_client plugin. + --plugin-authentication-webauthn-client-preserve-privacy + Allows selection of discoverable credential to be used + for signing challenge. default is false - implies + challenge is signed by all credentials for given relying + party. + --plugin-authentication-webauthn-device=# + Specifies what libfido2 device to use. 0 (the first + device) is the default. + --register-factor=name + Specifies factor for which registration needs to be done + for. + --system-command Enable or disable (by default) the 'system' mysql + command. + +Default options are read from the following files in the given order: +/etc/my.cnf /etc/mysql/my.cnf /opt/homebrew/etc/my.cnf ~/.my.cnf +The following groups are read: mysql client +The following options may be given as the first argument: +--print-defaults Print the program argument list and exit. +--no-defaults Don't read default options from any option file, + except for login file. +--defaults-file=# Only read default options from the given file #. +--defaults-extra-file=# Read this file after the global files are read. +--defaults-group-suffix=# + Also read groups with concat(group, suffix) +--login-path=# Read this path from the login file. +--no-login-paths Don't read login paths from the login path file. + +Variables (--variable-name=value) +and boolean options {FALSE|TRUE} Value (after reading options) +------------------------------------------------------ ------------------- +auto-rehash TRUE +auto-vertical-output FALSE +bind-address (No default value) +binary-as-hex FALSE +character-sets-dir (No default value) +column-type-info FALSE +commands FALSE +comments TRUE +compress FALSE +database (No default value) +default-character-set auto +delimiter ; +enable-cleartext-plugin FALSE +vertical FALSE +force FALSE +histignore (No default value) +named-commands FALSE +ignore-spaces TRUE +local-infile FALSE +no-beep FALSE +host (No default value) +dns-srv-name (No default value) +html FALSE +xml FALSE +line-numbers TRUE +unbuffered FALSE +column-names TRUE +sigint-ignore FALSE +port 0 +prompt mysql> +quick FALSE +raw FALSE +reconnect FALSE +socket (No default value) +server-public-key-path (No default value) +get-server-public-key FALSE +ssl-ca (No default value) +ssl-capath (No default value) +ssl-cert (No default value) +ssl-cipher (No default value) +ssl-key (No default value) +ssl-crl (No default value) +ssl-crlpath (No default value) +tls-version (No default value) +tls-ciphersuites (No default value) +ssl-session-data (No default value) +ssl-session-data-continue-on-failed-reuse FALSE +tls-sni-servername (No default value) +table FALSE +user (No default value) +safe-updates FALSE +i-am-a-dummy FALSE +wait FALSE +connect-timeout 0 +max-allowed-packet 16777216 +net-buffer-length 16384 +select-limit 1000 +max-join-size 1000000 +show-warnings FALSE +plugin-dir (No default value) +default-auth (No default value) +binary-mode FALSE +connect-expired-password FALSE +compression-algorithms (No default value) +zstd-compression-level 3 +load-data-local-dir (No default value) +authentication-oci-client-config-profile (No default value) +oci-config-file (No default value) +authentication-openid-connect-client-id-token-file (No default value) +telemetry-client FALSE +plugin-authentication-webauthn-client-preserve-privacy FALSE +plugin-authentication-webauthn-device 0 +register-factor (No default value) +system-command FALSE diff --git a/server/use b/server/use new file mode 100644 index 0000000..e69de29