재난 대응 매뉴얼 기반 RAG + LLM 파이프라인 API.
Guard → Analyzer → Executor → LLM 구조. 기상상황보고서 자동 생성 포함. Spring WebFlux 코드 예제를 바로 복사해서 쓰세요.
모든 /api/* 엔드포인트는 X-API-KEY 헤더가 필요합니다. /health, /docs는 인증 불필요.
X-API-KEY: {발급받은_키}
베이스 URL: https://sw-kim.com
서버와 LangGraph 파이프라인 준비 여부를 반환합니다.
curl https://sw-kim.com/health
응답 예시:
{"status": "ok", "graph_ready": true}
서버에 등록된 재난 매뉴얼 PDF 목록을 반환합니다. selected_pdf 파라미터에 사용할 filename을 확인하세요.
curl https://sw-kim.com/api/documents \
-H "X-API-KEY: {발급받은_키}"
응답 예시:
{
"documents": [
{"id": 0, "name": "풍수해재난현장조치행동매뉴얼", "filename": "풍수해 재난 현장조치 행동매뉴얼.pdf", "total_pages": 120},
{"id": 1, "name": "군산지방해양수산청현장조치매뉴얼", "filename": "현장조치 행동매뉴얼(해양수산부 군산지방해양수산청).pdf", "total_pages": 85}
]
}
특정 PDF의 페이지를 base64 PNG로 반환합니다.
| 파라미터 | 타입 | 위치 | 설명 |
|---|---|---|---|
doc_id 필수 | integer | path | 문서 ID (/api/documents 에서 확인) |
page 필수 | integer | path | 페이지 번호 (0-based) |
curl https://sw-kim.com/api/documents/0/page/0 \
-H "X-API-KEY: {발급받은_키}"
질문을 보내고 완성된 답변을 한 번에 받습니다. 간단한 테스트나 배치 처리에 적합합니다.
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
question 필수 | string | — | 사용자 질문 |
history 선택 | array | [] | 이전 대화 기록 [{role, content}] |
selected_pdf 선택 | string | "all" | 특정 PDF 파일명 또는 "all" |
curl -X POST https://sw-kim.com/api/chat \
-H "Content-Type: application/json" \
-H "X-API-KEY: {발급받은_키}" \
-d '{
"question": "호우 특보 시 대피 요령은?",
"history": [],
"selected_pdf": "all"
}'
POST /api/chat HTTP/1.1
Host: sw-kim.com
Content-Type: application/json
{
"question": "호우 특보 시 대피 요령은?",
"history": [
{"role": "user", "content": "이전 질문"},
{"role": "assistant", "content": "이전 답변"}
],
"selected_pdf": "풍수해 재난 현장조치 행동매뉴얼.pdf"
}
응답 (ChatResponse):
{
"answer": "호우 특보 발령 시 즉시 저지대 침수 취약지역을 확인하고...",
"reference_docs": "풍수해 재난 현장조치 행동매뉴얼.pdf",
"thinking_content": ""
}
Spring에서 사용하는 메인 엔드포인트입니다.
요청 필드는 /api/chat과 동일하며, 응답을 Server-Sent Events(SSE)로 스트리밍합니다. 이벤트 타입은 아래 SSE 이벤트 섹션을 참조하세요.
curl -X POST https://sw-kim.com/api/chat/stream \
-H "Content-Type: application/json" \
-H "X-API-KEY: {발급받은_키}" \
-d '{"question": "화재 대피 방법은?"}' \
--no-buffer
기상청 API를 직접 호출해 DOCX/PDF 보고서를 자동 생성합니다. LangGraph 파이프라인을 우회하는 독립 파이프라인입니다.
기상특보·기온·조석·적설·기상전망 데이터를 수집하고 DOCX/PDF 보고서를 생성합니다. 진행 상황을 SSE로 스트리밍합니다.
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
base_datetime 필수 | string | — | 보고서 기준 일시 "YYYY-MM-DD HH:MM" |
template_id 선택 | string | "기상상황보고" | 보고서 템플릿 ID |
airforce_forecast 선택 | string | "" | 공군 기상대 전망 텍스트 (API 없음, UI 직접 입력) |
curl -X POST https://sw-kim.com/api/report/generate \
-H "Content-Type: application/json" \
-H "X-API-KEY: {발급받은_키}" \
-d '{
"base_datetime": "2026-03-30 06:00",
"airforce_forecast": "전라북도는 내일 오전까지 흐리겠습니다."
}' --no-buffer
SSE 이벤트 흐름:
data: {"type":"step","step":"기상특보 수집","status":"running"}
data: {"type":"step","step":"기상특보 수집","status":"done","summary":"군산 기상특보 없음"}
data: {"type":"step","step":"조치사항 생성","status":"done","summary":"1. 저지대 침수...","rag_sources":[...]}
data: {"type":"done","report_id":"abc12345","report_json":{...},"docx_url":"/api/report/download/abc12345?format=docx","pdf_url":"/api/report/download/abc12345?format=pdf","review_needed":true}
data: [DONE]
보고서 조치사항 텍스트를 LLM으로 직접 편집합니다. LangGraph 파이프라인을 우회하고 LLM을 직접 호출합니다.
이벤트: chunk (누적 텍스트) → [DONE]
| 필드 | 타입 | 설명 |
|---|---|---|
current_plan 필수 | string | 현재 조치사항 텍스트 |
weather_context 선택 | string | 기상 컨텍스트 (보고서 데이터 요약) |
instruction 필수 | string | 편집 지시사항 (예: "더 간결하게 다듬어줘") |
curl -X POST https://sw-kim.com/api/report/edit-stream \
-H "Content-Type: application/json" \
-H "X-API-KEY: {발급받은_키}" \
-d '{
"current_plan": "1. 저지대 주민 대피 조치\n2. 배수펌프 가동 준비",
"weather_context": "기상특보: 호우주의보\n기온: 최고 12도",
"instruction": "더 구체적인 시간 기준을 추가해줘"
}' --no-buffer
생성된 보고서를 DOCX 또는 PDF로 다운로드합니다. report_id는 /api/report/generate의 done 이벤트에서 반환됩니다.
| 파라미터 | 타입 | 위치 | 설명 |
|---|---|---|---|
report_id 필수 | string | path | 보고서 ID (done 이벤트에서 수신) |
format 선택 | string | query | docx 또는 pdf (기본: docx) |
# DOCX 다운로드
curl "https://sw-kim.com/api/report/download/abc12345?format=docx" \
-H "X-API-KEY: {발급받은_키}" -O
# PDF 다운로드
curl "https://sw-kim.com/api/report/download/abc12345?format=pdf" \
-H "X-API-KEY: {발급받은_키}" -O
/api/chat/stream)/api/report/generate)복사 후 바로 사용 가능한 Spring WebFlux 코드입니다.
package com.disastergpt.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
public class ChatRequest {
private String question;
private List<Map<String, String>> history;
@JsonProperty("selected_pdf")
private String selectedPdf = "all";
public ChatRequest() {}
public String getQuestion() { return question; }
public void setQuestion(String question) { this.question = question; }
public List<Map<String, String>> getHistory() { return history; }
public void setHistory(List<Map<String, String>> history) { this.history = history; }
public String getSelectedPdf() { return selectedPdf; }
public void setSelectedPdf(String selectedPdf) { this.selectedPdf = selectedPdf; }
}
package com.disastergpt.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class StreamChunk {
// chat: "status" | "chunk" | "refs" | "tool_status" | "error"
// report: "step" | "done" | "error"
private String type;
// ── 채팅 필드 ──────────────────────────────────────
private String content; // status·chunk·error
private String node; // chunk 일 때 "final"
private List<Map<String, Object>> refs; // refs: [{docId, name, page}]
private List<Map<String, Object>> tools; // tool_status: [{tool, data_source}]
// ── 보고서 step 이벤트 ──────────────────────────────
private String step;
private String status; // "running" | "done" | "error"
private String summary;
// ── 보고서 done 이벤트 ─────────────────────────────
@JsonProperty("review_needed")
private Boolean reviewNeeded;
@JsonProperty("report_id")
private String reportId;
@JsonProperty("report_json")
private Map<String, Object> reportJson;
@JsonProperty("docx_url")
private String docxUrl;
@JsonProperty("pdf_url")
private String pdfUrl;
// ── getters / setters ──────────────────────────────
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getNode() { return node; }
public void setNode(String node) { this.node = node; }
public List<Map<String, Object>> getRefs() { return refs; }
public void setRefs(List<Map<String, Object>> refs) { this.refs = refs; }
public List<Map<String, Object>> getTools() { return tools; }
public void setTools(List<Map<String, Object>> tools) { this.tools = tools; }
public String getStep() { return step; }
public void setStep(String step) { this.step = step; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; }
@JsonProperty("review_needed")
public Boolean getReviewNeeded() { return reviewNeeded; }
public void setReviewNeeded(Boolean reviewNeeded) { this.reviewNeeded = reviewNeeded; }
@JsonProperty("report_id")
public String getReportId() { return reportId; }
public void setReportId(String reportId) { this.reportId = reportId; }
@JsonProperty("report_json")
public Map<String, Object> getReportJson() { return reportJson; }
public void setReportJson(Map<String, Object> reportJson) { this.reportJson = reportJson; }
@JsonProperty("docx_url")
public String getDocxUrl() { return docxUrl; }
public void setDocxUrl(String docxUrl) { this.docxUrl = docxUrl; }
@JsonProperty("pdf_url")
public String getPdfUrl() { return pdfUrl; }
public void setPdfUrl(String pdfUrl) { this.pdfUrl = pdfUrl; }
}
package com.disastergpt.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public class ReportRequest {
private String templateId = "기상상황보고";
private String baseDatetime; // "2026-03-29 22:00"
private String airforceForecast = "";
@JsonProperty("template_id") @JsonAlias("templateId")
public String getTemplateId() { return templateId; }
public void setTemplateId(String v) { this.templateId = v; }
@JsonProperty("base_datetime") @JsonAlias("baseDatetime")
public String getBaseDatetime() { return baseDatetime; }
public void setBaseDatetime(String v) { this.baseDatetime = v; }
@JsonProperty("airforce_forecast") @JsonAlias("airforceForecast")
public String getAirforceForecast() { return airforceForecast; }
public void setAirforceForecast(String v) { this.airforceForecast = v; }
}
package com.disastergpt.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
public class EditRequest {
private String currentPlan = "";
private String weatherContext = "";
private String instruction = "";
@JsonProperty("current_plan") @JsonAlias("currentPlan")
public String getCurrentPlan() { return currentPlan; }
public void setCurrentPlan(String v) { this.currentPlan = v; }
@JsonProperty("weather_context") @JsonAlias("weatherContext")
public String getWeatherContext() { return weatherContext; }
public void setWeatherContext(String v) { this.weatherContext = v; }
@JsonProperty("instruction")
public String getInstruction() { return instruction; }
public void setInstruction(String v) { this.instruction = v; }
}
server:
port: 8080
disastergpt:
api:
base-url: https://sw-kim.com
key: ${DISASTER_API_KEY:} # 환경변수로 주입
package com.disastergpt.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
@Configuration
public class WebClientConfig {
@Value("${disastergpt.api.base-url}")
private String baseUrl;
@Value("${disastergpt.api.key}")
private String apiKey;
@Bean
public WebClient disasterGptWebClient(WebClient.Builder builder) {
return builder
.baseUrl(baseUrl)
.defaultHeader("X-API-KEY", apiKey)
// X-Trace-ID는 ChatService에서 요청 시 직접 주입 (Reactor Context 기반)
.codecs(cfg -> cfg.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) // 10MB
.build();
}
}
package com.disastergpt.service;
import com.disastergpt.dto.ChatRequest;
import com.disastergpt.dto.ChatResponse;
import com.disastergpt.dto.StreamChunk;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class ChatService {
private static final Logger log = LoggerFactory.getLogger(ChatService.class);
private final WebClient webClient;
private final ObjectMapper objectMapper;
public ChatService(WebClient disasterGptWebClient, ObjectMapper objectMapper) {
this.webClient = disasterGptWebClient;
this.objectMapper = objectMapper;
}
/** 단일 응답 (테스트·배치용) */
public Mono<ChatResponse> chat(ChatRequest request, String traceId) {
log.info("[TRACE {}][FASTAPI ][CALL ] POST /api/chat", traceId);
long start = System.currentTimeMillis();
return webClient.post()
.uri("/api/chat")
.header("X-Trace-ID", traceId)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToMono(ChatResponse.class)
.doOnSuccess(r -> log.info("[TRACE {}][FASTAPI ][DONE ] elapsed={}ms answer={}ch",
traceId, System.currentTimeMillis() - start,
r != null && r.getAnswer() != null ? r.getAnswer().length() : 0));
}
/** SSE 스트리밍 — StreamChunk Flux 반환 */
public Flux<StreamChunk> chatStream(ChatRequest request, String traceId) {
log.info("[TRACE {}][FASTAPI ][STREAM ] POST /api/chat/stream", traceId);
AtomicInteger chunkNo = new AtomicInteger(0);
AtomicLong start = new AtomicLong(System.currentTimeMillis());
return webClient.post()
.uri("/api/chat/stream")
.header("X-Trace-ID", traceId)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {})
.filter(event -> event.data() != null && !event.data().isEmpty())
.map(ServerSentEvent::data)
.takeUntil("[DONE]"::equals)
.filter(data -> !"[DONE]".equals(data))
.flatMap(data -> {
try {
StreamChunk chunk = objectMapper.readValue(data, StreamChunk.class);
int no = chunkNo.incrementAndGet();
if ("refs".equals(chunk.getType())) {
log.info("[TRACE {}][SSE ][REFS ] refs={}docs",
traceId, chunk.getRefs() != null ? chunk.getRefs().size() : 0);
} else if ("status".equals(chunk.getType())) {
log.info("[TRACE {}][SSE ][STATUS ] {}", traceId, chunk.getContent());
} else if ("chunk".equals(chunk.getType()) && (no == 1 || no % 20 == 0)) {
log.info("[TRACE {}][SSE ][CHUNK ] #{} content={}ch",
traceId, no,
chunk.getContent() != null ? chunk.getContent().length() : 0);
} else if ("error".equals(chunk.getType())) {
log.error("[TRACE {}][SSE ][ERROR ] {}", traceId, chunk.getContent());
}
return Flux.just(chunk);
} catch (Exception e) {
return Flux.empty();
}
})
.doOnComplete(() -> log.info("[TRACE {}][SSE ][DONE ] total_chunks={} elapsed={}ms",
traceId, chunkNo.get(), System.currentTimeMillis() - start.get()));
}
}
package com.disastergpt.service;
import com.disastergpt.dto.EditRequest;
import com.disastergpt.dto.ReportRequest;
import com.disastergpt.dto.StreamChunk;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
public class ReportService {
private static final Logger log = LoggerFactory.getLogger(ReportService.class);
private final WebClient webClient;
private final ObjectMapper objectMapper;
public ReportService(WebClient disasterGptWebClient, ObjectMapper objectMapper) {
this.webClient = disasterGptWebClient;
this.objectMapper = objectMapper;
}
/** FastAPI /api/report/generate SSE 스트림 프록시 */
public Flux<StreamChunk> generateStream(ReportRequest request, String traceId) {
log.info("[TRACE {}][REPORT ][GENERATE] base_datetime={}",
traceId, request.getBaseDatetime());
return webClient.post()
.uri("/api/report/generate")
.header("X-Trace-ID", traceId)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {})
.filter(e -> e.data() != null && !e.data().isEmpty())
.map(ServerSentEvent::data)
.takeUntil("[DONE]"::equals)
.filter(data -> !"[DONE]".equals(data))
.flatMap(data -> {
try {
return Flux.just(objectMapper.readValue(data, StreamChunk.class));
} catch (Exception e) {
return Flux.empty();
}
});
}
/** FastAPI /api/report/edit-stream 프록시 — LangGraph 우회 직접 LLM 편집 */
public Flux<StreamChunk> editStream(EditRequest request, String traceId) {
log.info("[TRACE {}][REPORT ][EDIT ] POST /api/report/edit-stream", traceId);
return webClient.post()
.uri("/api/report/edit-stream")
.header("X-Trace-ID", traceId)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<String>>() {})
.filter(e -> e.data() != null && !e.data().isEmpty())
.map(ServerSentEvent::data)
.takeUntil("[DONE]"::equals)
.filter(data -> !"[DONE]".equals(data))
.flatMap(data -> {
try {
return Flux.just(objectMapper.readValue(data, StreamChunk.class));
} catch (Exception e) {
return Flux.empty();
}
});
}
/** FastAPI /api/report/download/{reportId}?format=docx|pdf 프록시 */
public Mono<byte[]> downloadFile(String reportId, String format, String traceId) {
log.info("[TRACE {}][REPORT ][DOWNLOAD] id={} format={}", traceId, reportId, format);
return webClient.get()
.uri(u -> u.path("/api/report/download/{id}").queryParam("format", format).build(reportId))
.header("X-Trace-ID", traceId)
.retrieve()
.bodyToMono(byte[].class);
}
}
package com.disastergpt.controller;
import com.disastergpt.dto.ChatRequest;
import com.disastergpt.dto.ChatResponse;
import com.disastergpt.dto.StreamChunk;
import com.disastergpt.filter.TraceFilter;
import com.disastergpt.service.ChatService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/chat")
public class ChatController {
private static final Logger log = LoggerFactory.getLogger(ChatController.class);
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
/** POST /chat — 단일 응답 */
@PostMapping
public Mono<ChatResponse> chat(@RequestBody ChatRequest request,
ServerWebExchange exchange) {
return Mono.deferContextual(ctx -> {
String tid = ctx.getOrDefault(TraceFilter.TRACE_ID_KEY, "--------");
log.info("[TRACE {}][REQUEST ][CHAT ] question=\"{}\"", tid,
request.getQuestion() != null ? request.getQuestion() : "");
return chatService.chat(request, tid);
});
}
/**
* POST /chat/stream — SSE 스트리밍
*
* curl -X POST http://localhost:8080/chat/stream \
* -H "Content-Type: application/json" \
* -H "Accept: text/event-stream" \
* -d '{"question":"화재 시 대피 요령은?"}'
*/
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<StreamChunk>> chatStream(@RequestBody ChatRequest request,
ServerWebExchange exchange) {
return Flux.deferContextual(ctx -> {
String tid = ctx.getOrDefault(TraceFilter.TRACE_ID_KEY, "--------");
log.info("[TRACE {}][REQUEST ][STREAM ] question=\"{}\" pdf={}",
tid, request.getQuestion(), request.getSelectedPdf());
return chatService.chatStream(request, tid)
.map(chunk -> ServerSentEvent.<StreamChunk>builder()
.data(chunk)
.build());
});
}
}
package com.disastergpt.controller;
import com.disastergpt.dto.EditRequest;
import com.disastergpt.dto.ReportRequest;
import com.disastergpt.dto.StreamChunk;
import com.disastergpt.filter.TraceFilter;
import com.disastergpt.service.ReportService;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/report")
public class ReportController {
private final ReportService reportService;
public ReportController(ReportService reportService) {
this.reportService = reportService;
}
/** POST /report/generate — SSE 스트리밍 프록시 */
@PostMapping(value = "/generate", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<StreamChunk>> generate(
@RequestBody ReportRequest request,
ServerWebExchange exchange) {
return Flux.deferContextual(ctx -> {
String tid = ctx.getOrDefault(TraceFilter.TRACE_ID_KEY, "--------");
return reportService.generateStream(request, tid)
.map(chunk -> ServerSentEvent.<StreamChunk>builder()
.data(chunk)
.build());
});
}
/** POST /report/edit-stream — 조치사항 AI 편집 SSE (LangGraph 우회) */
@PostMapping(value = "/edit-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<StreamChunk>> editStream(
@RequestBody EditRequest request,
ServerWebExchange exchange) {
return Flux.deferContextual(ctx -> {
String tid = ctx.getOrDefault(TraceFilter.TRACE_ID_KEY, "--------");
return reportService.editStream(request, tid)
.map(chunk -> ServerSentEvent.<StreamChunk>builder()
.data(chunk)
.build());
});
}
/** GET /report/download/{reportId}?format=docx|pdf — 파일 다운로드 */
@GetMapping("/download/{reportId}")
public Mono<ResponseEntity<byte[]>> download(
@PathVariable String reportId,
@RequestParam(defaultValue = "docx") String format,
ServerWebExchange exchange) {
return Flux.deferContextual(ctx -> {
String tid = ctx.getOrDefault(TraceFilter.TRACE_ID_KEY, "--------");
return reportService.downloadFile(reportId, format, tid).flux();
}).next().map(bytes -> {
String ext = format.toLowerCase();
MediaType mediaType = "pdf".equals(ext) ? MediaType.APPLICATION_PDF
: MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
String filename = "기상상황보고_" + reportId + "." + ext;
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(
ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build());
return ResponseEntity.ok()
.headers(headers)
.contentType(mediaType)
.body(bytes);
});
}
}
package com.disastergpt.controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/documents")
public class DocumentController {
private final WebClient webClient;
public DocumentController(WebClient disasterGptWebClient) {
this.webClient = disasterGptWebClient;
}
/**
* GET /documents
* 응답: {"documents":[{"id":0,"name":"...","filename":"...","total_pages":42},...]}
*/
@GetMapping
public Mono<String> listDocuments() {
return webClient.get()
.uri("/api/documents")
.retrieve()
.bodyToMono(String.class);
}
/**
* GET /documents/{docId}/page/{page}
* 응답: {"image":"base64_png","page":0,"total_pages":42}
* - docId: /documents 응답의 id 값 (0-based)
* - page: 0-based 페이지 번호, 범위 초과 시 404
*/
@GetMapping("/{docId}/page/{page}")
public Mono<String> getPage(@PathVariable int docId, @PathVariable int page) {
return webClient.get()
.uri("/api/documents/{docId}/page/{page}", docId, page)
.retrieve()
.bodyToMono(String.class);
}
}
package com.disastergpt.filter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import java.util.UUID;
/**
* 모든 요청에 trace_id를 부여하고 응답 헤더(X-Trace-ID)에 추가.
* - 인바운드 X-Trace-ID 헤더가 있으면 재사용
* - 없으면 UUID 앞 8자리 신규 생성
* traceId는 Reactor Context에 저장 → Service에서 꺼내 FastAPI로 전달
*/
@Component
public class TraceFilter implements WebFilter {
private static final Logger log = LoggerFactory.getLogger(TraceFilter.class);
public static final String TRACE_ID_KEY = "traceId";
public static final String TRACE_HEADER = "X-Trace-ID";
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String traceId = exchange.getRequest().getHeaders().getFirst(TRACE_HEADER);
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
final String tid = traceId;
exchange.getResponse().getHeaders().add(TRACE_HEADER, tid);
log.info("[TRACE {}][FILTER ][ASSIGN ] method={} uri={}",
tid, exchange.getRequest().getMethod(),
exchange.getRequest().getURI().getPath());
return chain.filter(exchange)
.contextWrite(ctx -> ctx.put(TRACE_ID_KEY, tid));
}
}