From e50541f9068f5c863fda707f7bc85ed000ad3995 Mon Sep 17 00:00:00 2001 From: overhyeon Date: Mon, 9 Nov 2026 03:44:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20AI=20=EC=84=9C=EB=B2=84=EC=9A=A9?= =?UTF-8?q?=20ai-api=20=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/ai-api/build.gradle.kts | 17 +++++++++++++++++ bootstrap/build.gradle.kts | 1 + settings.gradle.kts | 1 + 3 files changed, 19 insertions(+) create mode 100644 api/ai-api/build.gradle.kts diff --git a/api/ai-api/build.gradle.kts b/api/ai-api/build.gradle.kts new file mode 100644 index 000000000..bba8148d6 --- /dev/null +++ b/api/ai-api/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + java +} + +description = "AI 서버(stream-ai) — 내부 토큰, /v1/ai/**, 조회 전용" + +// 학생에게 보이는 글만 내보내도록 학생 화면이 쓰는 조회 서비스를 그대로 쓴다. 쓰기용 서비스는 부르지 않는다 +dependencies { + implementation(project(":api:common-api")) + implementation(project(":core:common")) + implementation(project(":core:domain:event")) + implementation(project(":core:domain:welfare")) + + implementation(platform(libs.springBootDependenciesBom)) + implementation(libs.springBootStarterWebmvc) + implementation(libs.springdocStarterWebmvcUi) +} diff --git a/bootstrap/build.gradle.kts b/bootstrap/build.gradle.kts index 4737c0daa..90beab13c 100644 --- a/bootstrap/build.gradle.kts +++ b/bootstrap/build.gradle.kts @@ -9,6 +9,7 @@ dependencies { implementation(project(":api:common-api")) implementation(project(":api:admin-api")) implementation(project(":api:app-api")) + implementation(project(":api:ai-api")) implementation(project(":gateway:auth")) implementation(project(":gateway:logging")) implementation(project(":infrastructure:db")) diff --git a/settings.gradle.kts b/settings.gradle.kts index 2abfa400f..ec5d2cd06 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,7 @@ include( "api:common-api", "api:admin-api", "api:app-api", + "api:ai-api", "core:common", "core:domain:event", "core:domain:file", From fd17964950cef228b0812b7541b94b6fc3ee9721 Mon Sep 17 00:00:00 2001 From: overhyeon Date: Mon, 9 Nov 2026 03:44:54 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20/v1/ai/**=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=A0=84=EC=9A=A9=20=EB=B3=B4=EC=95=88=20?= =?UTF-8?q?=EC=B2=B4=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + .../aiserver/AiServerAuthentication.java | 35 +++++++++++ .../security/aiserver/AiServerProperties.java | 21 +++++++ .../aiserver/InternalTokenAuthFilter.java | 60 +++++++++++++++++++ .../config/AiServerSecurityConfig.java | 55 +++++++++++++++++ .../resources/application-gateway-auth.yml | 5 ++ 6 files changed, 177 insertions(+) create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/AiServerAuthentication.java create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/AiServerProperties.java create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/InternalTokenAuthFilter.java create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/AiServerSecurityConfig.java diff --git a/.env.example b/.env.example index fef28f020..404257957 100644 --- a/.env.example +++ b/.env.example @@ -73,4 +73,5 @@ EMAIL_FROM_NAME=소프트웨어융합대학 학생회 STREAM_AI_BASE_URL=http://localhost:8000 STREAM_AI_CHAT_PATH=/chat # AI 서버가 백엔드를 거친 요청인지 확인하는 값. 코드·로그에 남기지 않는다 +# 반대 방향(AI 서버 → 백엔드 /v1/ai/**)도 같은 값으로 확인한다(gateway:auth ai-server.internal-token) STREAM_AI_INTERNAL_TOKEN=replace-with-stream-ai-internal-token diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/AiServerAuthentication.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/AiServerAuthentication.java new file mode 100644 index 000000000..0c6a61ce6 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/AiServerAuthentication.java @@ -0,0 +1,35 @@ +package kr.ac.kookmin.stream.security.aiserver; + +import java.util.List; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +/** + * 내부 토큰을 확인한 AI 서버 요청. 학생도 운영진도 아니라 회원 ID가 없다. + *

+ * 권한 {@link #AUTHORITY}는 {@code Role} enum에 넣지 않는다. 넣으면 JWT·{@code PrincipalProvider}까지 + * AI 서버를 알아야 하는데, AI 서버는 {@code /v1/ai/**}에서만 쓰이는 별도 클라이언트다. + */ +public class AiServerAuthentication extends AbstractAuthenticationToken { + + public static final String AUTHORITY = "AI_SERVER"; + + private AiServerAuthentication() { + super(List.of(new SimpleGrantedAuthority(AUTHORITY))); + setAuthenticated(true); + } + + public static AiServerAuthentication create() { + return new AiServerAuthentication(); + } + + @Override + public Object getPrincipal() { + return AUTHORITY; + } + + @Override + public Object getCredentials() { + return null; + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/AiServerProperties.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/AiServerProperties.java new file mode 100644 index 000000000..7575b4e74 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/AiServerProperties.java @@ -0,0 +1,21 @@ +package kr.ac.kookmin.stream.security.aiserver; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * AI 서버(stream-ai)가 {@code /v1/ai/**}를 부를 때 싣는 내부 토큰. + *

+ * 백엔드 → AI 서버 방향에 쓰는 {@code STREAM_AI_INTERNAL_TOKEN}과 같은 값을 쓴다. 비밀값 하나로 양방향을 맞춰 + * 배포 설정을 단순하게 둔다. 비어 있으면 {@code /v1/ai/**}는 전부 401이다(설정 누락으로 열리지 않게). + * + * @param internalToken {@code X-Internal-Token} 헤더와 비교할 값 + */ +@ConfigurationProperties(prefix = "ai-server") +public record AiServerProperties( + String internalToken +) { + + public boolean hasToken() { + return internalToken != null && !internalToken.isBlank(); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/InternalTokenAuthFilter.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/InternalTokenAuthFilter.java new file mode 100644 index 000000000..ac77f77c6 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/aiserver/InternalTokenAuthFilter.java @@ -0,0 +1,60 @@ +package kr.ac.kookmin.stream.security.aiserver; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * {@code X-Internal-Token}이 설정값과 같으면 AI 서버로 인증한다. 다르면 아무것도 하지 않는다 — + * 인증이 비어 있으면 뒤의 인가 단계가 401로 막는다. + *

+ * 빈으로 등록하지 않는다({@code JwtAuthFilter}와 같은 이유). 빈이 되면 스프링 부트가 모든 요청의 서블릿 필터로도 걸어 버린다. + * {@code AiServerSecurityConfig}가 {@code /v1/ai/**} 체인에만 넣는다. + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class InternalTokenAuthFilter extends OncePerRequestFilter { + + private static final String HEADER = "X-Internal-Token"; + + private final AiServerProperties properties; + + public static InternalTokenAuthFilter of(AiServerProperties properties) { + return new InternalTokenAuthFilter(properties); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + if (matches(request.getHeader(HEADER))) { + SecurityContextHolder.getContext().setAuthentication(AiServerAuthentication.create()); + } + filterChain.doFilter(request, response); + } + + /** + * 설정값이 비어 있으면 무엇이 와도 거절한다. + *

+ * {@code equals} 대신 {@link MessageDigest#isEqual}을 쓴다. {@code equals}는 앞에서부터 비교하다 다르면 바로 멈춰서, + * 응답 시간 차이로 토큰을 한 글자씩 알아낼 수 있다. {@code isEqual}은 길이가 같으면 항상 끝까지 비교한다. + */ + private boolean matches(String token) { + if (!properties.hasToken() || token == null) { + return false; + } + return MessageDigest.isEqual( + token.getBytes(StandardCharsets.UTF_8), + properties.internalToken().getBytes(StandardCharsets.UTF_8) + ); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/AiServerSecurityConfig.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/AiServerSecurityConfig.java new file mode 100644 index 000000000..1717c33dc --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/AiServerSecurityConfig.java @@ -0,0 +1,55 @@ +package kr.ac.kookmin.stream.security.config; + +import kr.ac.kookmin.stream.security.aiserver.AiServerAuthentication; +import kr.ac.kookmin.stream.security.aiserver.AiServerProperties; +import kr.ac.kookmin.stream.security.aiserver.InternalTokenAuthFilter; +import kr.ac.kookmin.stream.security.handler.RestAccessDeniedHandler; +import kr.ac.kookmin.stream.security.handler.RestAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +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.access.intercept.AuthorizationFilter; + +/** + * AI 서버 전용 경로 {@code /v1/ai/**}의 보안 체인. 학생·운영진 체인({@link SecurityConfig})은 건드리지 않고 옆에 둔다. + *

+ * {@code @Order(1)}로 기존 체인보다 먼저 검사한다. {@code securityMatcher}에 걸린 요청은 이 체인만 타고, + * 나머지는 기존 체인으로 넘어간다. 그래서 학생 JWT로는 {@code /v1/ai/**}에 들어올 수 없고, + * 내부 토큰으로는 {@code /v1/app/**}에 들어갈 수 없다. + *

+ * CORS는 열지 않는다. 브라우저가 아니라 같은 네트워크의 AI 서버만 부른다. + */ +@Configuration +@RequiredArgsConstructor +public class AiServerSecurityConfig { + + private static final String AI_SERVER_PATH = "/v1/ai/**"; + + private final AiServerProperties aiServerProperties; + private final RestAuthenticationEntryPoint authenticationEntryPoint; + private final RestAccessDeniedHandler accessDeniedHandler; + + @Bean + @Order(1) + public SecurityFilterChain aiServerSecurityFilterChain(HttpSecurity http) throws Exception { + return http + .securityMatcher(AI_SERVER_PATH) + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(request -> request + .anyRequest().hasAuthority(AiServerAuthentication.AUTHORITY)) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + // ExceptionTranslationFilter 뒤에 두어야 인증 실패가 EntryPoint(401)로 넘어간다 — SecurityConfig와 같은 위치 + .addFilterBefore(InternalTokenAuthFilter.of(aiServerProperties), AuthorizationFilter.class) + .build(); + } +} diff --git a/gateway/auth/src/main/resources/application-gateway-auth.yml b/gateway/auth/src/main/resources/application-gateway-auth.yml index 0de731c07..503060047 100644 --- a/gateway/auth/src/main/resources/application-gateway-auth.yml +++ b/gateway/auth/src/main/resources/application-gateway-auth.yml @@ -5,3 +5,8 @@ jwt: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS} + +# AI 서버가 /v1/ai/**를 부를 때 싣는 X-Internal-Token. 백엔드 → AI 서버 방향과 같은 값을 쓴다. +# 비어 있으면 /v1/ai/**는 전부 401이다. +ai-server: + internal-token: ${STREAM_AI_INTERNAL_TOKEN:} From 4643493f5d040b581a8e398beffead5a4d05efaa Mon Sep 17 00:00:00 2001 From: overhyeon Date: Mon, 9 Nov 2026 03:44:54 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20AI=20=EC=84=9C=EB=B2=84=EC=9A=A9=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=C2=B7=ED=96=89=EC=82=AC=C2=B7=EC=95=84?= =?UTF-8?q?=EC=B9=B4=EC=9D=B4=EB=B9=99=C2=B7=EC=97=B4=EB=A6=B0=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/ai/event/archive/AiArchiveApi.java | 22 ++++++++++ .../ai/event/archive/AiArchiveController.java | 35 +++++++++++++++ .../archive/response/AiArchiveResponse.java | 30 +++++++++++++ .../stream/api/ai/event/event/AiEventApi.java | 22 ++++++++++ .../api/ai/event/event/AiEventController.java | 43 +++++++++++++++++++ .../event/event/response/AiEventResponse.java | 36 ++++++++++++++++ .../ai/welfare/feedback/AiFeedbackApi.java | 22 ++++++++++ .../feedback/AiFeedbackController.java | 38 ++++++++++++++++ .../feedback/response/AiFeedbackResponse.java | 27 ++++++++++++ .../api/ai/welfare/notice/AiNoticeApi.java | 22 ++++++++++ .../ai/welfare/notice/AiNoticeController.java | 43 +++++++++++++++++++ .../notice/response/AiNoticeResponse.java | 29 +++++++++++++ 12 files changed, 369 insertions(+) create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/AiArchiveApi.java create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/AiArchiveController.java create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/response/AiArchiveResponse.java create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/AiEventApi.java create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/AiEventController.java create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/response/AiEventResponse.java create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/AiFeedbackApi.java create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/AiFeedbackController.java create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/response/AiFeedbackResponse.java create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/AiNoticeApi.java create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/AiNoticeController.java create mode 100644 api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/response/AiNoticeResponse.java diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/AiArchiveApi.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/AiArchiveApi.java new file mode 100644 index 000000000..b96b8717b --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/AiArchiveApi.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.api.ai.event.archive; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import kr.ac.kookmin.stream.api.ai.event.archive.response.AiArchiveResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; + +/** + * AI 서버용 아카이빙 API의 문서 명세. 구현은 {@link AiArchiveController}가 맡는다. + */ +@Tag(name = "AI 서버 — 아카이빙", description = "챗봇 인덱싱용 조회 (X-Internal-Token)") +public interface AiArchiveApi { + + @Operation(summary = "아카이빙 전체 조회", + description = "학생 앱 아카이빙 목록에 보이는 기록 전체를 본문까지 한 번에 내려준다. 챗봇이 검색 인덱스를 만들 때 쓴다. " + + "이미지는 내려주지 않는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"UNAUTHORIZED"}) + ApiResponse> getArchives(); +} diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/AiArchiveController.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/AiArchiveController.java new file mode 100644 index 000000000..5a3d317c5 --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/AiArchiveController.java @@ -0,0 +1,35 @@ +package kr.ac.kookmin.stream.api.ai.event.archive; + +import java.util.List; +import kr.ac.kookmin.stream.api.ai.event.archive.response.AiArchiveResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveSummary; +import kr.ac.kookmin.stream.event.domain.archive.service.ArchiveService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/ai/archives") +@RequiredArgsConstructor +public class AiArchiveController implements AiArchiveApi { + + private final ArchiveService archiveService; + + /** + * 학생 목록(연도·검색어 없이 = 전체)으로 대상을 정하고, 본문은 한 건씩 상세로 가져온다. + * 목록 요약에는 본문이 없다. 아카이빙은 학기에 수십 건 수준이라 한 건씩 읽어도 부담이 없다. + */ + @Override + @GetMapping + public ApiResponse> getArchives() { + List archives = archiveService.getArchives(null, null).stream() + .map(ArchiveSummary::archiveId) + .map(archiveService::getArchive) + .map(AiArchiveResponse::from) + .toList(); + + return ApiResponse.success(archives); + } +} diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/response/AiArchiveResponse.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/response/AiArchiveResponse.java new file mode 100644 index 000000000..ab60213c0 --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/archive/response/AiArchiveResponse.java @@ -0,0 +1,30 @@ +package kr.ac.kookmin.stream.api.ai.event.archive.response; + +import java.time.LocalDate; +import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveDetail; + +/** + * 챗봇 인덱싱용 아카이빙. 지난 활동이 "언제·어디서·무엇을" 했는지 답할 글만 담는다 — 이미지는 넣지 않는다. + */ +public record AiArchiveResponse( + Long archiveId, + String title, + String content, + String location, + String departmentName, + LocalDate startDate, + LocalDate endDate +) { + + public static AiArchiveResponse from(ArchiveDetail archive) { + return new AiArchiveResponse( + archive.archiveId(), + archive.title(), + archive.content(), + archive.location(), + archive.departmentName(), + archive.startDate(), + archive.endDate() + ); + } +} diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/AiEventApi.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/AiEventApi.java new file mode 100644 index 000000000..675603aac --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/AiEventApi.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.api.ai.event.event; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import kr.ac.kookmin.stream.api.ai.event.event.response.AiEventResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; + +/** + * AI 서버용 행사 API의 문서 명세. 구현은 {@link AiEventController}가 맡는다. + */ +@Tag(name = "AI 서버 — 행사", description = "챗봇 인덱싱용 조회 (X-Internal-Token)") +public interface AiEventApi { + + @Operation(summary = "행사 전체 조회", + description = "학생 앱에 보이는(게시된) 행사 전체를 소개글까지 한 번에 내려준다. 챗봇이 검색 인덱스를 만들 때 쓴다. " + + "모집 인원·신청 현황처럼 계속 바뀌는 값은 넣지 않는다 — 챗봇이 질문할 때 학생 API로 따로 조회한다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"UNAUTHORIZED"}) + ApiResponse> getEvents(); +} diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/AiEventController.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/AiEventController.java new file mode 100644 index 000000000..f7e54ce39 --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/AiEventController.java @@ -0,0 +1,43 @@ +package kr.ac.kookmin.stream.api.ai.event.event; + +import java.util.ArrayList; +import java.util.List; +import kr.ac.kookmin.stream.api.ai.event.event.response.AiEventResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; +import kr.ac.kookmin.stream.event.domain.event.service.EventService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/ai/events") +@RequiredArgsConstructor +public class AiEventController implements AiEventApi { + + private static final int PAGE_SIZE = 100; + + private final EventService eventService; + + /** + * 학생 목록과 같은 서비스로 게시된 행사 ID를 모은 뒤, 소개글·장소가 있는 행사 본체를 한 번에 가져온다. + * 목록 요약에는 소개글이 없어서 두 단계로 나눴다. 둘 다 게시된 행사만 돌려준다. + */ + @Override + @GetMapping + public ApiResponse> getEvents() { + List eventIds = new ArrayList<>(); + EventCursor cursor = null; + do { + CursorSliceResult page = eventService.getPublishedEvents(null, null, cursor, PAGE_SIZE); + page.content().forEach(summary -> eventIds.add(summary.eventId())); + cursor = page.hasNext() ? EventCursor.from(page.nextCursor()) : null; + } while (cursor != null); + + return ApiResponse.success( + eventService.getPublishedByIds(eventIds).stream().map(AiEventResponse::from).toList()); + } +} diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/response/AiEventResponse.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/response/AiEventResponse.java new file mode 100644 index 000000000..e04ef190c --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/event/event/response/AiEventResponse.java @@ -0,0 +1,36 @@ +package kr.ac.kookmin.stream.api.ai.event.event.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; + +/** + * 챗봇 인덱싱용 행사. "언제·어디서·무엇을"에 답할 글만 담는다. + * 모집 인원·신청자 수처럼 계속 바뀌는 값은 인덱싱하면 틀린 답이 나가므로 넣지 않는다. + * {@code applyEndAt}은 챗봇이 마감된 행사를 걸러내는 유효기간으로 쓴다. + */ +public record AiEventResponse( + Long eventId, + String title, + String description, + String place, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime updatedAt +) { + + public static AiEventResponse from(Event event) { + return new AiEventResponse( + event.getId(), + event.getTitle(), + event.getDescription(), + event.getPlace(), + event.getApplyStartAt(), + event.getApplyEndAt(), + event.getEventStartAt(), + event.getEventEndAt(), + event.getUpdatedAt() + ); + } +} diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/AiFeedbackApi.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/AiFeedbackApi.java new file mode 100644 index 000000000..1d110e349 --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/AiFeedbackApi.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.api.ai.welfare.feedback; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import kr.ac.kookmin.stream.api.ai.welfare.feedback.response.AiFeedbackResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; + +/** + * AI 서버용 열린피드백 API의 문서 명세. 구현은 {@link AiFeedbackController}가 맡는다. + */ +@Tag(name = "AI 서버 — 열린피드백", description = "챗봇 인덱싱용 조회 (X-Internal-Token)") +public interface AiFeedbackApi { + + @Operation(summary = "공개된 열린피드백 답변 전체 조회", + description = "답변이 공개된 회차의 질문·답변 전체를 한 번에 내려준다. 챗봇이 검색 인덱스를 만들 때 쓴다. " + + "질문한 학생·답변한 운영진은 내려주지 않는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"UNAUTHORIZED"}) + ApiResponse> getFeedbacks(); +} diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/AiFeedbackController.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/AiFeedbackController.java new file mode 100644 index 000000000..e7a3f7123 --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/AiFeedbackController.java @@ -0,0 +1,38 @@ +package kr.ac.kookmin.stream.api.ai.welfare.feedback; + +import java.util.List; +import kr.ac.kookmin.stream.api.ai.welfare.feedback.response.AiFeedbackResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackPeriod; +import kr.ac.kookmin.stream.welfare.domain.feedback.service.FeedbackService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/ai/feedbacks") +@RequiredArgsConstructor +public class AiFeedbackController implements AiFeedbackApi { + + private static final int PAGE_SIZE = 100; + + // 연도·회차를 정하지 않으면 공개된 모든 회차다 + private static final FeedbackPeriod ALL_PERIODS = FeedbackPeriod.of(null, null); + + private final FeedbackService feedbackService; + + /** + * 학생 열린피드백 목록과 같은 서비스다. 답변이 공개된 회차의, 답변된 질문만 나온다. + */ + @Override + @GetMapping + public ApiResponse> getFeedbacks() { + List feedbacks = PageResult.fetchAll( + PAGE_SIZE, offset -> feedbackService.search(ALL_PERIODS, null, offset)); + + return ApiResponse.success(feedbacks.stream().map(AiFeedbackResponse::from).toList()); + } +} diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/response/AiFeedbackResponse.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/response/AiFeedbackResponse.java new file mode 100644 index 000000000..a58480f13 --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/feedback/response/AiFeedbackResponse.java @@ -0,0 +1,27 @@ +package kr.ac.kookmin.stream.api.ai.welfare.feedback.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; + +/** + * 챗봇 인덱싱용 열린피드백. 질문과 답변 글만 담는다. + *

+ * 질문한 학생({@code createdBy})과 답변한 운영진({@code answeredBy})은 절대 넣지 않는다. + * 열린피드백은 익명으로 받는다. 인덱스에 들어가면 챗봇 답변으로 새어 나갈 수 있다. + */ +public record AiFeedbackResponse( + Long feedbackId, + String question, + String answer, + LocalDateTime answeredAt +) { + + public static AiFeedbackResponse from(Feedback feedback) { + return new AiFeedbackResponse( + feedback.getId(), + feedback.getQuestion(), + feedback.getAnswer(), + feedback.getAnsweredAt() + ); + } +} diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/AiNoticeApi.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/AiNoticeApi.java new file mode 100644 index 000000000..58bbe7554 --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/AiNoticeApi.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.api.ai.welfare.notice; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import kr.ac.kookmin.stream.api.ai.welfare.notice.response.AiNoticeResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; + +/** + * AI 서버용 공지 API의 문서 명세. 구현은 {@link AiNoticeController}가 맡는다. + */ +@Tag(name = "AI 서버 — 공지", description = "챗봇 인덱싱용 조회 (X-Internal-Token)") +public interface AiNoticeApi { + + @Operation(summary = "공지 전체 조회", + description = "학생 앱에 보이는 공지 전체를 본문까지 한 번에 내려준다. 챗봇이 검색 인덱스를 만들 때 쓴다. " + + "작성자·이미지·첨부 파일은 내려주지 않는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"UNAUTHORIZED"}) + ApiResponse> getNotices(); +} diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/AiNoticeController.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/AiNoticeController.java new file mode 100644 index 000000000..facd47860 --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/AiNoticeController.java @@ -0,0 +1,43 @@ +package kr.ac.kookmin.stream.api.ai.welfare.notice; + +import java.util.ArrayList; +import java.util.List; +import kr.ac.kookmin.stream.api.ai.welfare.notice.response.AiNoticeResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor; +import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/ai/notices") +@RequiredArgsConstructor +public class AiNoticeController implements AiNoticeApi { + + // 학생 목록 API의 최대 크기와 같다. 공지가 수백 건 수준이라 몇 번이면 끝난다 + private static final int PAGE_SIZE = 100; + + private final NoticeService noticeService; + + /** + * 학생 목록과 같은 서비스로 끝까지 넘겨 모은다. 삭제된 공지가 빠지는 규칙이 학생 화면과 자동으로 같다. + * 챗봇은 매번 전체를 받아 인덱스를 다시 만들므로, 응답에 없는 공지는 인덱스에서도 빠진다. + */ + @Override + @GetMapping + public ApiResponse> getNotices() { + List notices = new ArrayList<>(); + NoticeCursor cursor = null; + do { + CursorSliceResult page = noticeService.getNotices(null, null, cursor, PAGE_SIZE); + notices.addAll(page.content()); + cursor = page.hasNext() ? NoticeCursor.from(page.nextCursor()) : null; + } while (cursor != null); + + return ApiResponse.success(notices.stream().map(AiNoticeResponse::from).toList()); + } +} diff --git a/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/response/AiNoticeResponse.java b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/response/AiNoticeResponse.java new file mode 100644 index 000000000..1d239b2a6 --- /dev/null +++ b/api/ai-api/src/main/java/kr/ac/kookmin/stream/api/ai/welfare/notice/response/AiNoticeResponse.java @@ -0,0 +1,29 @@ +package kr.ac.kookmin.stream.api.ai.welfare.notice.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice; +import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory; + +/** + * 챗봇 인덱싱용 공지. 검색과 출처 표시에 필요한 글만 담는다 — 작성자, 이미지·첨부 파일은 넣지 않는다. + */ +public record AiNoticeResponse( + Long noticeId, + NoticeCategory category, + String title, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + + public static AiNoticeResponse from(Notice notice) { + return new AiNoticeResponse( + notice.getId(), + notice.getCategory(), + notice.getTitle(), + notice.getContent(), + notice.getCreatedAt(), + notice.getUpdatedAt() + ); + } +}