Skip to content
Merged
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
9 changes: 6 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ POSTGRES_USER=
POSTGRES_PASSWORD=

# RabbitMQ
RABBITMQ_URL=
# 호스트에서 BE/AI를 직접 실행할 때 사용하는 주소
RABBITMQ_URL=amqp://safefam:safefam-local@localhost:5672/
# Compose 컨테이너 전용 주소(비워두면 아래 계정과 rabbitmq:5672로 자동 구성)
RABBITMQ_DOCKER_URL=
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USERNAME=
RABBITMQ_PASSWORD=
RABBITMQ_USERNAME=safefam
RABBITMQ_PASSWORD=safefam-local

# Authentication
JWT_SECRET=
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ cp .env.example .env
| `DB_URL` | Spring에서 직접 사용하는 DB JDBC URL (Docker 외부 실행 시) |
| `DB_USERNAME` / `DB_PASSWORD` | DB 접속 계정 (Docker 외부 실행 시) |
| `RABBITMQ_PASSWORD` | RabbitMQ 비밀번호 |
| `RABBITMQ_HOST` | RabbitMQ 호스트 (Docker 외부 실행 시 `localhost`) |
| `RABBITMQ_URL` | Docker 외부에서 BE/AI를 직접 실행할 때 사용할 RabbitMQ URL (`localhost:5672`) |
| `RABBITMQ_DOCKER_URL` | Compose 컨테이너 전용 RabbitMQ URL. 비워두면 `RABBITMQ_USERNAME`·`RABBITMQ_PASSWORD`와 `rabbitmq:5672`로 자동 구성 |
| `RABBITMQ_HOST` | RabbitMQ 호스트 (Docker 외부 실행 시 `localhost`, Compose 내부는 `rabbitmq`) |
| `JWT_SECRET` | JWT 서명 키 (Base64 인코딩) |
| `JWT_ACCESS_TOKEN_EXPIRATION` | Access 토큰 만료 시간 (ms, 기본 30분) |
| `JWT_REFRESH_TOKEN_EXPIRATION` | Refresh 토큰 만료 시간 (ms, 기본 7일) |
Expand All @@ -56,6 +58,9 @@ docker compose up --build
```

> AI 서버(`SafeFam_AI`)는 `../SafeFam_AI` 경로에 위치해야 합니다.
>
> Compose 내부의 BE와 AI는 호스트용 `RABBITMQ_URL`을 사용하지 않고
> `RABBITMQ_DOCKER_URL` 또는 기본 서비스 주소 `rabbitmq:5672`로 연결합니다.

## 📑 API 문서

Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ services:
DB_USERNAME: ${POSTGRES_USER:?POSTGRES_USER is required}
DB_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}

RABBITMQ_URL: ${RABBITMQ_URL:?RABBITMQ_URL is required}
RABBITMQ_URL: "${RABBITMQ_DOCKER_URL:-amqp://${RABBITMQ_USERNAME}:${RABBITMQ_PASSWORD}@rabbitmq:5672/}"
RABBITMQ_HOST: rabbitmq
RABBITMQ_PORT: 5672
RABBITMQ_USERNAME: ${RABBITMQ_USERNAME}
Expand Down Expand Up @@ -134,7 +134,7 @@ services:
container_name: safefam-ai-server
restart: unless-stopped
environment:
RABBITMQ_URL: ${RABBITMQ_URL:?RABBITMQ_URL is required}
RABBITMQ_URL: "${RABBITMQ_DOCKER_URL:-amqp://${RABBITMQ_USERNAME}:${RABBITMQ_PASSWORD}@rabbitmq:5672/}"

RABBITMQ_ANALYSIS_EXCHANGE: safefam.analysis
RABBITMQ_ANALYSIS_REQUEST_QUEUE: safefam.analysis.requested.q
Expand Down
6 changes: 5 additions & 1 deletion docs/safefam-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ Content-Type: application/json
```json
{
"status": "SUCCESS",
"code": null,
"message": "문자 분석이 완료되었습니다.",
"data": {
"analysisId": 101,
Expand All @@ -222,12 +223,14 @@ Content-Type: application/json
```json
{
"status": "ERROR",
"code": "AN001",
"message": "분석 이력을 찾을 수 없습니다.",
"data": null
}
```

- `status`는 성공 시 `SUCCESS`, 실패 시 `ERROR`를 사용합니다.
- `code`는 실패 원인을 판별하는 안정적인 `ErrorCode` 식별자이며 성공 시 `null`입니다.
- `message`는 사용자가 이해할 수 있는 간결한 한국어 문장으로 작성합니다.
- `data`는 실제 응답 데이터이며, 반환할 데이터가 없으면 `null`을 사용합니다.
- HTTP 상태 코드를 함께 올바르게 사용합니다. 응답 본문의 `status`만으로 성공·실패를 표현하지 않습니다.
Expand Down Expand Up @@ -317,6 +320,7 @@ package com.gold.safefam.global.response;

public record ApiResponse<T>(
String status,
String code,
String message,
T data
) {
Expand Down Expand Up @@ -389,7 +393,7 @@ public enum ErrorCode {
}
```

> 현재 `ApiResponse`는 `status`, `message`, `data` 구조입니다. `ErrorCode.code`를 클라이언트에 전달하려면 팀 합의 후 응답 스키마에 `code` 필드를 추가하고, 백엔드·프론트엔드·API 문서를 한 번에 변경합니다.
> `ApiResponse.code`는 에러 메시지 문구와 무관하게 클라이언트가 실패 원인을 안정적으로 판별하는 계약입니다. 성공 응답에서는 `null`이고 모든 공통 에러 응답에는 해당 `ErrorCode.code`를 포함합니다.

### 5.10 Swagger / OpenAPI

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.gold.safefam.domain.analysis.enums.AnalysisStatus;
import com.gold.safefam.domain.analysis.enums.IndicatorType;
import com.gold.safefam.domain.analysis.entity.Analysis;
import com.gold.safefam.domain.analysis.entity.AnalysisIndicator;
import com.gold.safefam.domain.analysis.model.MessageRiskAnalysisResult;
import com.gold.safefam.domain.analysis.service.AnalysisResultFactory;
import org.springframework.stereotype.Component;
Expand All @@ -25,6 +26,7 @@ public class AnalysisResponseMapper {

private static final String FAILED_TRACK_PREFIX =
"Analysis track unavailable: ";
private static final String MATCHED_RULE_PREFIX = "Matched rule: ";

private final AnalysisResultFactory resultFactory;

Expand All @@ -36,10 +38,9 @@ public AnalysisResponseMapper(AnalysisResultFactory resultFactory) {
public AnalysisResponse toResponse(Analysis analysis) {
List<Indicator> indicators =
analysis.getIndicators().stream()
.map(indicator -> new Indicator(
indicator.getType(),
indicator.getDescription()
))
.filter(indicator -> indicator.getType()
!= IndicatorType.ANALYSIS_TRACK_FAILURE)
.map(this::toPublicIndicator)
.toList();

List<EvidenceCard> evidenceCards =
Expand Down Expand Up @@ -78,11 +79,8 @@ public AnalysisResponse toResponse(Analysis analysis) {
.filter(indicator -> indicator.getType()
== IndicatorType.ANALYSIS_TRACK_FAILURE)
.map(indicator -> indicator.getDescription())
.filter(description -> description != null
&& description.startsWith(FAILED_TRACK_PREFIX))
.map(description -> description.substring(
FAILED_TRACK_PREFIX.length()
))
.filter(description -> description != null)
.map(this::normalizeFailedTrack)
.filter(description -> !description.isBlank())
.distinct()
.toList();
Expand Down Expand Up @@ -143,6 +141,37 @@ private RecommendedAction toRecommendedAction(
);
}

/** 실패 트랙은 전용 필드로만 노출하고, 일반 지표의 기존 영문 표현은 사용자 문구로 정리한다. */
private Indicator toPublicIndicator(AnalysisIndicator indicator) {
return new Indicator(
indicator.getType(),
normalizeIndicatorDescription(indicator.getDescription())
);
}

private String normalizeIndicatorDescription(String description) {
if (description == null) {
return null;
}
if (description.startsWith(MATCHED_RULE_PREFIX)) {
return description.substring(MATCHED_RULE_PREFIX.length()).trim();
}
return switch (description) {
case "Malicious URL detected" -> "위험한 링크가 확인됐습니다.";
case "Shortened URL destination was traced" -> "단축 링크의 최종 목적지를 확인했습니다.";
case "Malicious domain pattern detected" -> "위험한 링크 형식이 확인됐습니다.";
default -> description;
};
}

/** 신규 raw token과 과거 영문 접두사 형식을 모두 failedTracks 계약으로 복원한다. */
private String normalizeFailedTrack(String description) {
String normalized = description.startsWith(FAILED_TRACK_PREFIX)
? description.substring(FAILED_TRACK_PREFIX.length())
: description;
return normalized.trim();
}

private String maskSender(String sender) {
if (sender == null || sender.isBlank()) {
return sender;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ SELECT analysis.riskLevel AS riskLevel, COUNT(analysis) AS count
SELECT analysis.riskLevel AS riskLevel, COUNT(analysis) AS count
FROM Analysis analysis
WHERE analysis.userId = :userId
AND analysis.riskLevel IS NOT NULL
AND analysis.analyzedAt >= :fromAt
GROUP BY analysis.riskLevel
""")
Expand All @@ -93,6 +94,7 @@ SELECT analysis.category AS category, COUNT(analysis) AS count
SELECT analysis.category AS category, COUNT(analysis) AS count
FROM Analysis analysis
WHERE analysis.userId = :userId
AND analysis.category IS NOT NULL
AND analysis.analyzedAt >= :fromAt
GROUP BY analysis.category
""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,7 @@ private void addFailedTrackIndicators(
if (hasText(failedTrack)) {
analysis.addIndicator(new AnalysisIndicator(
IndicatorType.ANALYSIS_TRACK_FAILURE,
truncate(
"Analysis track unavailable: "
+ failedTrack,
500
)
truncate(failedTrack.trim(), 500)
));
}
}
Expand Down Expand Up @@ -288,13 +284,13 @@ private void addUrlResult(
if (Boolean.TRUE.equals(url.malicious())) {
analysis.addIndicator(new AnalysisIndicator(
IndicatorType.MALICIOUS_URL,
"Malicious URL detected"
"위험한 링크가 확인됐습니다."
));
} else if (url.tracedUrl() != null
&& !url.originalUrl().equals(url.tracedUrl())) {
analysis.addIndicator(new AnalysisIndicator(
IndicatorType.SHORTENED_URL,
"Shortened URL destination was traced"
"단축 링크의 최종 목적지를 확인했습니다."
));
}
}
Expand All @@ -320,18 +316,15 @@ private void addRuleIndicators(
if (hasText(matchedRule)) {
analysis.addIndicator(new AnalysisIndicator(
IndicatorType.AI_EVIDENCE,
truncate(
"Matched rule: " + matchedRule,
500
)
truncate(matchedRule.trim(), 500)
));
}
}

if (Boolean.TRUE.equals(ruleAnalysis.maliciousDomainPattern())) {
analysis.addIndicator(new AnalysisIndicator(
IndicatorType.MALICIOUS_URL,
"Malicious domain pattern detected"
"위험한 링크 형식이 확인됐습니다."
));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,41 +20,27 @@ public class GlobalExceptionHandler {
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException exception) {
ErrorCode errorCode = exception.getErrorCode();
return ResponseEntity.status(errorCode.getStatus())
.body(ApiResponse.error(errorCode.getMessage()));
.body(ApiResponse.error(errorCode.getCode(), errorCode.getMessage()));
}

/** JSON 요청 본문의 Bean Validation 실패를 공통 400 입력 오류로 변환한다. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodArgumentNotValidException() {
return ResponseEntity.status(ErrorCode.INVALID_INPUT.getStatus())
.body(ApiResponse.error(ErrorCode.INVALID_INPUT.getMessage()));
}

/** 쿼리·경로 파라미터의 메서드 검증 실패를 공통 400 입력 오류로 변환한다. */
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolationException() {
return ResponseEntity.status(ErrorCode.INVALID_INPUT.getStatus())
.body(ApiResponse.error(ErrorCode.INVALID_INPUT.getMessage()));
}

/** 파싱할 수 없는 JSON 요청 본문을 공통 400 입력 오류로 변환한다. */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleHttpMessageNotReadableException() {
return ResponseEntity.status(ErrorCode.INVALID_INPUT.getStatus())
.body(ApiResponse.error(ErrorCode.INVALID_INPUT.getMessage()));
}

/** 쿼리·경로 파라미터의 타입 변환 실패를 공통 400 입력 오류로 변환한다. */
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch() {
return ResponseEntity.status(ErrorCode.INVALID_INPUT.getStatus())
.body(ApiResponse.error(ErrorCode.INVALID_INPUT.getMessage()));
/** 본문·쿼리·경로 파라미터의 형식 및 Bean Validation 실패를 공통 입력 오류로 변환한다. */
@ExceptionHandler({
MethodArgumentNotValidException.class,
ConstraintViolationException.class,
MethodArgumentTypeMismatchException.class,
HttpMessageNotReadableException.class
})
public ResponseEntity<ApiResponse<Void>> handleInvalidInput() {
ErrorCode errorCode = ErrorCode.INVALID_INPUT;
return ResponseEntity.status(errorCode.getStatus())
.body(ApiResponse.error(errorCode.getCode(), errorCode.getMessage()));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception exception) {
log.error("Unhandled exception", exception);
return ResponseEntity.status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus())
.body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR.getMessage()));
ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
return ResponseEntity.status(errorCode.getStatus())
.body(ApiResponse.error(errorCode.getCode(), errorCode.getMessage()));
}
}
20 changes: 16 additions & 4 deletions src/main/java/com/gold/safefam/global/response/ApiResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,33 @@ public record ApiResponse<T>(
@Schema(example = "SUCCESS")
String status,

@Schema(
description = "실패 원인을 식별하는 안정적인 코드이며 성공 응답에서는 null입니다.",
example = "US002",
nullable = true
)
String code,

@Schema(example = "요청이 성공적으로 처리되었습니다.")
String message,

T data
) {

/** 기존 성공 응답 생성 코드와의 소스 호환성을 유지한다. */
public ApiResponse(String status, String message, T data) {
this(status, null, message, data);
}

public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>("SUCCESS", message, data);
return new ApiResponse<>("SUCCESS", null, message, data);
}

public static ApiResponse<Void> success(String message) {
return new ApiResponse<>("SUCCESS", message, null);
return new ApiResponse<>("SUCCESS", null, message, null);
}

public static ApiResponse<Void> error(String message) {
return new ApiResponse<>("ERROR", message, null);
public static ApiResponse<Void> error(String code, String message) {
return new ApiResponse<>("ERROR", code, message, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public void handle(
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
objectMapper.writeValue(
response.getOutputStream(),
ApiResponse.error(errorCode.getMessage())
ApiResponse.error(errorCode.getCode(), errorCode.getMessage())
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ private void writeErrorResponse(HttpServletResponse response, ErrorCode errorCod
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
objectMapper.writeValue(
response.getOutputStream(),
ApiResponse.error(errorCode.getMessage())
ApiResponse.error(errorCode.getCode(), errorCode.getMessage())
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ private void writeRateLimitResponse(HttpServletResponse response, ErrorCode erro
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"status\":\"ERROR\",\"message\":\"" + errorCode.getMessage() + "\",\"data\":null}"
"{\"status\":\"ERROR\",\"code\":\"" + errorCode.getCode()
+ "\",\"message\":\"" + errorCode.getMessage() + "\",\"data\":null}"
);
}

Expand Down
Loading
Loading