Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions api/ai-api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<List<AiArchiveResponse>> getArchives();
}
Original file line number Diff line number Diff line change
@@ -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<List<AiArchiveResponse>> getArchives() {
List<AiArchiveResponse> archives = archiveService.getArchives(null, null).stream()
.map(ArchiveSummary::archiveId)
.map(archiveService::getArchive)
.map(AiArchiveResponse::from)
.toList();

return ApiResponse.success(archives);
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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<List<AiEventResponse>> getEvents();
}
Original file line number Diff line number Diff line change
@@ -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<List<AiEventResponse>> getEvents() {
List<Long> eventIds = new ArrayList<>();
EventCursor cursor = null;
do {
CursorSliceResult<EventSummary> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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<List<AiFeedbackResponse>> getFeedbacks();
}
Original file line number Diff line number Diff line change
@@ -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<List<AiFeedbackResponse>> getFeedbacks() {
List<Feedback> feedbacks = PageResult.fetchAll(
PAGE_SIZE, offset -> feedbackService.search(ALL_PERIODS, null, offset));

return ApiResponse.success(feedbacks.stream().map(AiFeedbackResponse::from).toList());
}
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 챗봇 인덱싱용 열린피드백. 질문과 답변 글만 담는다.
* <p>
* <b>질문한 학생({@code createdBy})과 답변한 운영진({@code answeredBy})은 절대 넣지 않는다.</b>
* 열린피드백은 익명으로 받는다. 인덱스에 들어가면 챗봇 답변으로 새어 나갈 수 있다.
*/
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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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<List<AiNoticeResponse>> getNotices();
}
Original file line number Diff line number Diff line change
@@ -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<List<AiNoticeResponse>> getNotices() {
List<Notice> notices = new ArrayList<>();
NoticeCursor cursor = null;
do {
CursorSliceResult<Notice> 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());
}
}
Loading
Loading