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
15 changes: 15 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [AI 하네스](#ai-하네스)
- [자주 사용하는 명령](#자주-사용하는-명령)
- [프로필](#프로필)
- [API 문서](#api-문서)
- [임시 인증](#임시-인증)
- [이미지 저장소](#이미지-저장소)
- [DB 스키마](#db-스키마)
Expand Down Expand Up @@ -159,6 +160,20 @@ SPRING_PROFILES_ACTIVE=local ./gradlew bootRun
> `prod` 프로필은 배포 환경 전용이므로 로컬에서 실행하지 않는다. 원격 DB에 Flyway 마이그레이션이 적용될 수 있다.
> `application-prod.yml`에는 DB 접속 정보의 기본값이 없으므로 필수 환경변수가 누락되면 애플리케이션이 기동되지 않는다.

## API 문서

로컬·개발 환경의 Swagger UI는 `http://localhost:8080/swagger-ui.html`에서 확인한다. 첫 화면은 `user-api` 그룹으로 연다.

| 그룹 | 포함 경로 |
|---|---|
| `user-api` | `/api/v1/**` 중 `/api/v1/admin/**` 제외 |
| `admin-api` | `/api/v1/admin/**` |
| `internal-api` | `/internal/v1/**` |

운영 환경에서는 API 문서 JSON과 Swagger UI를 모두 비활성화한다.

Spring Boot 4 지원과 최신 기능을 위해 `springdoc-openapi` 3.1.0을 유지한다. 다만 이 버전은 Bean Validation 제약이 붙은 숫자 파라미터를 문서화할 때 경고 로그를 출력하는 [알려진 회귀 문제](https://github.com/springdoc/springdoc-openapi/issues/3314)가 있다. 현재 문서 응답과 스키마 생성에는 문제가 없으므로 하위 버전으로 내리지 않고, [수정 PR](https://github.com/springdoc/springdoc-openapi/pull/3315)이 반영된 정식 버전이 나오면 업그레이드한다.

## 임시 인증

로그인 사용자는 `X-User-Id` 헤더로 식별한다.
Expand Down
1 change: 1 addition & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.0")

implementation("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.chalkak.backend.config;

import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@OpenAPIDefinition(
info = @Info(
title = "Chalkak API",
version = "v1",
description = "Chalkak 사용자, 운영자 및 내부 API 문서"
)
)
@SecurityScheme(
name = "userIdHeader",
type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER,
paramName = "X-User-Id",
description = "로컬·개발 환경에서 로그인 사용자를 식별하는 임시 헤더"
)
@Configuration(proxyBeanMethods = false)
@Profile("!prod")
public class OpenApiConfig {

@Bean
public GroupedOpenApi userApi() {
return GroupedOpenApi.builder()
.group("user-api")
.pathsToMatch("/api/v1/**")
.pathsToExclude("/api/v1/admin/**")
.build();
}

@Bean
public GroupedOpenApi adminApi() {
return GroupedOpenApi.builder()
.group("admin-api")
.pathsToMatch("/api/v1/admin/**")
.build();
}

@Bean
public GroupedOpenApi internalApi() {
return GroupedOpenApi.builder()
.group("internal-api")
.pathsToMatch("/internal/v1/**")
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.chalkak.backend.post.api.v1.controller;

import com.chalkak.backend.common.util.CanonicalUuidParser;
import com.chalkak.backend.post.api.v1.docs.PostApiDocs;
import com.chalkak.backend.post.api.v1.dto.request.PostListRequest;
import com.chalkak.backend.post.api.v1.dto.response.PostDetailResponse;
import com.chalkak.backend.post.api.v1.dto.response.PostListResponse;
Expand All @@ -19,10 +20,11 @@
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/posts")
public class PostController {
public class PostController implements PostApiDocs {

private final PostService postService;

@Override
@GetMapping
public ResponseEntity<PostListResponse> getPosts(
@Valid @ModelAttribute PostListRequest request
Expand All @@ -40,6 +42,7 @@ public ResponseEntity<PostListResponse> getPosts(
);
}

@Override
@GetMapping("/{postId}")
public ResponseEntity<PostDetailResponse> getPost(@PathVariable String postId) {
UUID parsedPostId = CanonicalUuidParser.parse(postId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.chalkak.backend.post.api.v1.docs;

import com.chalkak.backend.exception.ErrorResponse;
import com.chalkak.backend.post.api.v1.dto.request.PostListRequest;
import com.chalkak.backend.post.api.v1.dto.response.PostDetailResponse;
import com.chalkak.backend.post.api.v1.dto.response.PostListResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;

@Tag(name = "Posts", description = "게시물 API")
public interface PostApiDocs {

@Operation(
summary = "게시물 목록 조회",
description = "랜덤 정렬의 다음 페이지 요청에는 최초 응답의 randomSeed를 사용합니다."
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "게시물 목록 조회 성공",
useReturnTypeSchema = true
),
@ApiResponse(
responseCode = "400",
description = "잘못된 조회 조건",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
),
@ApiResponse(
responseCode = "404",
description = "해당 날짜의 주제를 찾을 수 없음",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
)
})
ResponseEntity<PostListResponse> getPosts(
@ParameterObject PostListRequest request
);

@Operation(summary = "게시물 상세 조회")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "게시물 상세 조회 성공",
useReturnTypeSchema = true
),
@ApiResponse(
responseCode = "400",
description = "잘못된 게시물 ID",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
),
@ApiResponse(
responseCode = "404",
description = "게시물을 찾을 수 없음",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
)
})
ResponseEntity<PostDetailResponse> getPost(
@Parameter(
description = "게시물 ID",
example = "0198f6c1-62ba-7d30-8b12-0f733b6570d4"
)
String postId
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.chalkak.backend.post.api.v1.dto.request;

import com.chalkak.backend.post.service.PostSort;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
Expand All @@ -9,21 +10,34 @@
import org.springframework.format.annotation.DateTimeFormat;

public record PostListRequest(
@Schema(description = "조회할 주제 날짜", example = "2026-08-12")
@NotNull(message = "조회 조건이 올바르지 않습니다.")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate topicDate,

@Schema(
description = "정렬 방식",
defaultValue = "recent",
allowableValues = {"recent", "random"}
)
PostSort sort,

@Schema(
description = "랜덤 정렬 결과를 유지하는 값으로 다음 페이지 요청에도 동일하게 전달",
example = "f4c3a091",
nullable = true
)
@Pattern(
regexp = "[A-Za-z0-9_-]{1,64}",
message = "조회 조건이 올바르지 않습니다."
)
String randomSeed,

@Schema(description = "페이지 번호", defaultValue = "1", example = "1")
@Min(value = 1, message = "조회 조건이 올바르지 않습니다.")
Integer page,

@Schema(description = "페이지당 게시물 수", defaultValue = "20", example = "20")
@Min(value = 1, message = "조회 조건이 올바르지 않습니다.")
@Max(value = 100, message = "조회 조건이 올바르지 않습니다.")
Integer pageSize
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.chalkak.backend.post.api.v1.dto.response;

import com.chalkak.backend.post.service.PostDetail;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import java.util.UUID;

public record PostDetailResponse(
UUID id,
TopicResponse topic,
String originalImageUrl,
@Schema(description = "변환된 게시물 썸네일 URL", nullable = true)
String thumbnailImageUrl,
String signatureOriginalImageUrl,
String title
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.chalkak.backend.post.api.v1.dto.response;

import com.chalkak.backend.post.service.PostListResult;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
Expand All @@ -9,6 +10,10 @@ public record PostListResponse(
int currentPage,
int pageSize,
boolean hasNext,
@Schema(
description = "랜덤 정렬 결과를 유지하는 값으로 다음 페이지 요청에도 동일하게 전달",
nullable = true
)
String randomSeed,
List<PostResponse> posts
) {
Expand All @@ -28,8 +33,10 @@ public static PostListResponse fromPostListResult(PostListResult result) {
public record PostResponse(
UUID id,
String originalImageUrl,
@Schema(description = "변환된 게시물 썸네일 URL", nullable = true)
String thumbnailImageUrl,
String signatureOriginalImageUrl,
@Schema(description = "변환된 사인 이미지 썸네일 URL", nullable = true)
String signatureThumbnailImageUrl,
String title,
Instant submittedAt
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.chalkak.backend.topic.api.v1.controller;

import com.chalkak.backend.topic.api.v1.docs.TopicApiDocs;
import com.chalkak.backend.topic.api.v1.dto.response.TopicDetailResponse;
import com.chalkak.backend.topic.service.TopicDetail;
import com.chalkak.backend.topic.service.TopicService;
Expand All @@ -15,10 +16,11 @@
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/topics")
public class TopicController {
public class TopicController implements TopicApiDocs {

private final TopicService topicService;

@Override
@GetMapping
public ResponseEntity<TopicDetailResponse> getTopic(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.chalkak.backend.topic.api.v1.docs;

import com.chalkak.backend.exception.ErrorResponse;
import com.chalkak.backend.topic.api.v1.dto.response.TopicDetailResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.LocalDate;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;

@Tag(name = "Topics", description = "주제 API")
public interface TopicApiDocs {

@Operation(summary = "주제 조회")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "주제 조회 성공",
useReturnTypeSchema = true
),
@ApiResponse(
responseCode = "400",
description = "잘못된 날짜 형식",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
),
@ApiResponse(
responseCode = "404",
description = "주제를 찾을 수 없음",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
)
})
ResponseEntity<TopicDetailResponse> getTopic(
@Parameter(description = "조회할 주제 날짜", example = "2026-08-12")
LocalDate date
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.chalkak.backend.user.api.internal.v1.controller;

import com.chalkak.backend.user.api.internal.v1.docs.SignatureProcessingCallbackApiDocs;
import com.chalkak.backend.user.infrastructure.infra.SignatureProcessingCallbackAuthenticator;
import com.chalkak.backend.user.service.UserService;
import java.util.UUID;
Expand All @@ -18,14 +19,16 @@
* 인증 헤더를 {@code required = false}로 받는 이유는, 누락을 400이 아니라 인증 실패인 401로 다루기 위해서다.
* 서명 실패는 secret 불일치나 시계 차를 뜻하는 즉시 알람 대상이므로 일반 요청 오류와 섞이면 안 된다.
*/
public class SignatureProcessingCallbackController {
public class SignatureProcessingCallbackController
implements SignatureProcessingCallbackApiDocs {

private static final String TIMESTAMP_HEADER = "X-Chalkak-Callback-Timestamp";
private static final String SIGNATURE_HEADER = "X-Chalkak-Callback-Signature";

private final UserService userService;
private final SignatureProcessingCallbackAuthenticator authenticator;

@Override
@PostMapping("/{uploadId}/complete")
public ResponseEntity<Void> complete(
@PathVariable UUID uploadId,
Expand All @@ -38,6 +41,7 @@ public ResponseEntity<Void> complete(
return ResponseEntity.noContent().build();
}

@Override
@PostMapping("/{uploadId}/failed")
public ResponseEntity<Void> fail(
@PathVariable UUID uploadId,
Expand Down
Loading
Loading