diff --git a/.env.example b/.env.example index 34a9703..d6d604f 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,9 @@ MINIO_READ_TIMEOUT=30s # 예) ALLOWED_ORIGINS=https://youthpick.samchon.cloud ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:5174 +# sitemap.xml의 절대 URL을 만들 때 쓰는 프론트엔드 배포 도메인. 로컬은 기본값(운영 도메인)을 그대로 둬도 무방하다. +FRONTEND_URL=https://youthpick.samchon.cloud + # ===== 온통청년 정책 API (배치 수집용 — 비우면 배치만 실패하고 앱은 뜬다) ===== # 발급: https://www.youthcenter.go.kr 회원가입 후 open api 인증키 신청 YOUTH_API_KEY= diff --git a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java index 2386f4f..196b87d 100644 --- a/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java +++ b/src/main/java/com/bop/youthpick/global/config/SecurityConfig.java @@ -76,6 +76,9 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/api/v1/policies/**", "/api/v1/health") .permitAll() + // 검색엔진 크롤러용 sitemap — 인증 없이 항상 열려 있어야 한다. + .requestMatchers("/sitemap.xml") + .permitAll() // 게시글 조회는 공개, 생성·수정·삭제는 회원 전용 .requestMatchers(HttpMethod.GET, "/api/v1/posts/**") .permitAll() diff --git a/src/main/java/com/bop/youthpick/global/config/SiteProperties.java b/src/main/java/com/bop/youthpick/global/config/SiteProperties.java new file mode 100644 index 0000000..4ffc00e --- /dev/null +++ b/src/main/java/com/bop/youthpick/global/config/SiteProperties.java @@ -0,0 +1,7 @@ +package com.bop.youthpick.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** 프론트엔드 배포 도메인. sitemap.xml처럼 절대 URL을 만들어야 하는 곳에서 쓴다. */ +@ConfigurationProperties(prefix = "youthpick.site") +public record SiteProperties(String frontendUrl) {} diff --git a/src/main/java/com/bop/youthpick/post/repository/PostRepository.java b/src/main/java/com/bop/youthpick/post/repository/PostRepository.java index 9f914ce..4430beb 100644 --- a/src/main/java/com/bop/youthpick/post/repository/PostRepository.java +++ b/src/main/java/com/bop/youthpick/post/repository/PostRepository.java @@ -1,6 +1,8 @@ package com.bop.youthpick.post.repository; import com.bop.youthpick.post.entity.Post; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -17,6 +19,15 @@ public interface PostRepository extends JpaRepository, JpaSpecificat @EntityGraph(attributePaths = {"user", "policy"}) // jpql을 써서 구현하는 방법도 있음 Optional findByIdAndDeletedAtIsNull(Long id); // 쿼리메서드 + // sitemap.xml 생성 전용 — 목록/상세 API처럼 user/policy까지 끌고 올 필요가 없어 id/updatedAt만 projection한다. + List findAllByDeletedAtIsNullOrderByUpdatedAtDesc(); + + interface PostSitemapView { + Long getId(); + + LocalDateTime getUpdatedAt(); + } + // JpaSpecificationExecutor의 기본 findAll(Specification, Pageable)을 재선언해 // @EntityGraph로 목록 조회 시 user/policy N+1을 막는다(PostSummaryResponse가 둘 다 참조). @Override diff --git a/src/main/java/com/bop/youthpick/sitemap/controller/SitemapController.java b/src/main/java/com/bop/youthpick/sitemap/controller/SitemapController.java new file mode 100644 index 0000000..ca27437 --- /dev/null +++ b/src/main/java/com/bop/youthpick/sitemap/controller/SitemapController.java @@ -0,0 +1,30 @@ +package com.bop.youthpick.sitemap.controller; + +import com.bop.youthpick.sitemap.service.SitemapService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * sitemap.xml은 URL 프로토콜 특성상 사이트 루트("/sitemap.xml")에서 서빙해야 하므로 다른 컨트롤러처럼 {@code /api/v1} 아래 두지 않는다. + * nginx가 이 경로를 백엔드로 프록시한다(front-end/nginx.conf 참고). + */ +@Tag(name = "SEO") +@RestController +@RequiredArgsConstructor +public class SitemapController { + + private final SitemapService sitemapService; + + @Operation( + summary = "sitemap.xml 조회", + description = "정적 페이지 + 공개 커뮤니티 게시글 URL을 담은 sitemap을 반환한다.") + @GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE) + public ResponseEntity sitemap() { + return ResponseEntity.ok(sitemapService.generate()); + } +} diff --git a/src/main/java/com/bop/youthpick/sitemap/service/SitemapService.java b/src/main/java/com/bop/youthpick/sitemap/service/SitemapService.java new file mode 100644 index 0000000..426554e --- /dev/null +++ b/src/main/java/com/bop/youthpick/sitemap/service/SitemapService.java @@ -0,0 +1,55 @@ +package com.bop.youthpick.sitemap.service; + +import com.bop.youthpick.global.config.SiteProperties; +import com.bop.youthpick.post.repository.PostRepository; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 검색엔진(Search Console)에 등록할 sitemap.xml을 요청마다 생성한다. 프론트는 SSR 없는 Vite SPA라 빌드 시점에는 게시글처럼 동적인 URL을 알 + * 수 없으므로, 백엔드가 DB를 조회해 매 요청 최신 목록으로 만들어 낸다. + */ +@Service +@RequiredArgsConstructor +public class SitemapService { + + private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE; + + private final PostRepository postRepository; + private final SiteProperties siteProperties; + + @Transactional(readOnly = true) + public String generate() { + String baseUrl = siteProperties.frontendUrl(); + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append("\n"); + + appendUrl(xml, baseUrl + "/", null, "daily", "1.0"); + appendUrl(xml, baseUrl + "/search", null, "daily", "0.8"); + appendUrl(xml, baseUrl + "/community", null, "hourly", "0.8"); + + for (PostRepository.PostSitemapView post : + postRepository.findAllByDeletedAtIsNullOrderByUpdatedAtDesc()) { + String lastmod = LASTMOD_FORMATTER.format(post.getUpdatedAt()); + appendUrl(xml, baseUrl + "/community/" + post.getId(), lastmod, "weekly", "0.6"); + } + + xml.append("\n"); + return xml.toString(); + } + + private void appendUrl( + StringBuilder xml, String loc, String lastmod, String changefreq, String priority) { + xml.append(" \n"); + xml.append(" ").append(loc).append("\n"); + if (lastmod != null) { + xml.append(" ").append(lastmod).append("\n"); + } + xml.append(" ").append(changefreq).append("\n"); + xml.append(" ").append(priority).append("\n"); + xml.append(" \n"); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f80e298..311bedf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,6 +33,9 @@ spring: youthpick: cors: allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173,http://localhost:5174} + site: + # sitemap.xml에 절대 URL()을 만들 때 쓰는 프론트엔드 배포 도메인. + frontend-url: ${FRONTEND_URL:https://youthpick.samchon.cloud} file: max-size: ${FILE_MAX_SIZE:10MB} minio: