Skip to content
Closed
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
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# BCSD Internal API V2

BCSD 동아리 인터널(내부 관리) 서비스 백엔드. Spring Boot 4 / Java 21 / PostgreSQL 16.

## 로컬 실행

### 요구 사항
- JDK 21
- Docker (로컬 Postgres 컨테이너용)

### 절차

1. `.env` 생성
```
cp .env.example .env
```
`.env`를 열어 `JWT_SECRET`을 32바이트 이상 임의 문자열로 채운다.
```
openssl rand -base64 32
```
`.env`는 `.gitignore`에 포함되어 있다.

2. 로컬 Postgres 기동
```
docker compose up -d
```
이미 로컬에 5432 포트를 쓰는 Postgres가 있다면(Postgres.app, `brew services` 등) 충돌한다.
`docker compose ps`로 컨테이너가 healthy인지 확인하고, 충돌 시:
```
COMPOSE_POSTGRES_PORT=5433 docker compose up -d
```
와 함께 `.env`에 `DB_PORT=5433`을 추가한다.

3. 애플리케이션 실행
```
./gradlew bootRun
```
`developmentOnly` 의존성인 `springboot4-dotenv`가 `.env`를 자동으로 읽는다.
별도 `--spring.profiles.active` 지정은 필요 없다 — `application.yml`의 프로퍼티 기본값이
이미 로컬 개발을 기준으로 되어 있다(`DB_HOST:localhost`, `DB_PORT:5432` 등).

4. 확인
```
curl http://localhost:8080/health
```
`OK`(200)가 반환되면 정상이다. 부팅 로그에서 Flyway 마이그레이션이 전부 적용됐는지도 확인한다.

### 테스트 실행

```
./gradlew test
```

Testcontainers가 테스트 실행 시 Postgres 컨테이너를 자동으로 띄우므로 Docker가 실행 중이어야 한다.
로컬 Postgres(2번 단계)와는 별개다 — 테스트는 매번 격리된 컨테이너를 쓴다.

### 흔히 겪는 문제

- **`Cannot find a Java installation on your machine ... {languageVersion=21}`**
이 프로젝트는 JDK 21 툴체인을 요구한다. JDK 21을 설치한 뒤 `~/.gradle/gradle.properties`
(저장소가 아니라 사용자 홈, 커밋하지 않는다)에 다음을 추가한다.
```
org.gradle.java.installations.paths=<JDK 21 설치 경로>
```

- **`FATAL: role "postgres" does not exist`로 Flyway 마이그레이션이 실패한다**
로컬에 이미 떠 있는 다른 Postgres(홈브루 서비스 등)가 5432를 선점하고 있다는 신호다.
위 2번 단계의 `COMPOSE_POSTGRES_PORT` 대안을 쓴다.

- **`JWT_SECRET`이 비어 있으면 부팅이 즉시 실패한다.**
`.env`에 32바이트 이상 값을 채웠는지 확인한다(`app.jwt.secret: ${JWT_SECRET}`에 기본값이 없다).

## 배포

`main` push → GitHub Actions → ghcr.io 이미지 빌드 → 서버 SSH 접속 후 기존 컨테이너 종료·재시작(순차 재시작, 수초 다운타임). 서버가 KONECT 운영 서버를 공유하고 API 컨테이너 메모리 한도가 256MiB로 빠듯하므로, 배포 후 `docker stats`와 `free -h`로 확인한다.
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ repositories {
dependencies {
implementation(platform("software.amazon.awssdk:bom:2.53.2"))
implementation("software.amazon.awssdk:ses")
// software.amazon.awssdk:s3에 S3Presigner가 포함되어 있어 별도 s3-presigner 아티팩트가 없다
// (AWS SDK가 이 버전대에서 통합함 — Maven Central에 s3-presigner 좌표 자체가 존재하지 않는다).
implementation("software.amazon.awssdk:s3")
implementation("org.jsoup:jsoup:1.18.3")
implementation("org.springframework.retry:spring-retry:2.0.13")
implementation("org.aspectj:aspectjweaver:1.9.25")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
Expand Down
23 changes: 23 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
services:
postgres:
image: postgres:16-alpine
container_name: bcsd-internal-api-v2-postgres
restart: unless-stopped
ports:
# 로컬에 이미 5432를 쓰는 Postgres가 있다면(Postgres.app, brew services 등)
# COMPOSE_POSTGRES_PORT=5433 docker compose up -d 로 띄우고 .env의 DB_PORT를 맞춘다.
- "${COMPOSE_POSTGRES_PORT:-5432}:5432"
environment:
POSTGRES_DB: bcsd_internal_api_v2
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- bcsd-internal-api-v2-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d bcsd_internal_api_v2"]
interval: 5s
timeout: 5s
retries: 10

volumes:
bcsd-internal-api-v2-pgdata:
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.bcsdlab.bcsdinternalapiv2.activity.controller;

import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityDetailResponse;
import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityTimelineResponse;
import io.swagger.v3.oas.annotations.Operation;
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.util.List;

@Tag(name = "활동 API (홈페이지 공개)")
public interface ActivityApi {

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "404", description = "존재하지 않거나 숨겨진 카테고리"),
})
@Operation(summary = "연도 그룹 활동 타임라인",
description = "연도 내림차순 → 월 내림차순 → display_order 순으로, 공개된 활동만 반환합니다.")
List<ActivityTimelineResponse> getTimeline(String category);

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "404", description = "존재하지 않거나 숨겨진 활동"),
})
@Operation(summary = "활동 상세")
ActivityDetailResponse getActivity(Long id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.bcsdlab.bcsdinternalapiv2.activity.controller;

import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityCategoryResponse;
import io.swagger.v3.oas.annotations.Operation;
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.util.List;

@Tag(name = "활동 API (홈페이지 공개)")
public interface ActivityCategoryApi {

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
})
@Operation(summary = "공개 활동 카테고리 목록", description = "탭 노출 순서(display_order)대로 공개된 카테고리만 반환합니다.")
List<ActivityCategoryResponse> getCategories();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.bcsdlab.bcsdinternalapiv2.activity.controller;

import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityCategoryResponse;
import com.bcsdlab.bcsdinternalapiv2.activity.service.ActivityCategoryService;
import java.util.List;
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/activity-categories")
@RequiredArgsConstructor
public class ActivityCategoryController implements ActivityCategoryApi {

private final ActivityCategoryService activityCategoryService;

@Override
@GetMapping
public List<ActivityCategoryResponse> getCategories() {
return activityCategoryService.getCategories();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.bcsdlab.bcsdinternalapiv2.activity.controller;

import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityDetailResponse;
import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityTimelineResponse;
import com.bcsdlab.bcsdinternalapiv2.activity.service.ActivityService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/activities")
@RequiredArgsConstructor
public class ActivityController implements ActivityApi {

private final ActivityService activityService;

@Override
@GetMapping
public List<ActivityTimelineResponse> getTimeline(@RequestParam String category) {
return activityService.getTimeline(category);
}

@Override
@GetMapping("/{id}")
public ActivityDetailResponse getActivity(@PathVariable Long id) {
return activityService.getActivity(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.bcsdlab.bcsdinternalapiv2.activity.controller;

import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCreateRequest;
import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityImagesReplaceRequest;
import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityUpdateRequest;
import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityDetailResponse;
import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivitySummaryResponse;
import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest;
import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest;
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.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;

@Tag(name = "관리자 - 활동 API")
@SecurityRequirement(name = "JWT")
public interface AdminActivityApi {

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "활동 목록", description = "숨김 포함. categoryId/year/published로 선택적으로 필터링한다.")
Page<AdminActivitySummaryResponse> getActivities(@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) Integer year,
@RequestParam(required = false) Boolean published,
Pageable pageable);

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "활동 상세 조회")
AdminActivityDetailResponse getActivity(@PathVariable Long id);

@ApiResponses(value = {
@ApiResponse(responseCode = "201"),
@ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "활동 생성", description = "본문은 저장 전 정제된다(ADR-008). 생성 직후 공개 상태다.")
ResponseEntity<AdminActivityDetailResponse> createActivity(@RequestBody @Valid ActivityCreateRequest request);

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "활동 수정")
AdminActivityDetailResponse updateActivity(@PathVariable Long id,
@RequestBody @Valid ActivityUpdateRequest request);

@ApiResponses(value = {
@ApiResponse(responseCode = "204"),
@ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "활동 삭제", description = "soft delete — 데이터는 보존된다.")
ResponseEntity<Void> deleteActivity(@PathVariable Long id);

@ApiResponses(value = {
@ApiResponse(responseCode = "204"),
@ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "공개/숨김")
ResponseEntity<Void> publish(@PathVariable Long id, @RequestBody @Valid PublishRequest request);

@ApiResponses(value = {
@ApiResponse(responseCode = "204"),
@ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "같은 (카테고리, 연, 월) 안에서 순서 변경")
ResponseEntity<Void> reorder(@Parameter(required = true) @RequestParam Long categoryId,
@Parameter(required = true) @RequestParam int year,
@Parameter(required = true) @RequestParam int month,
@RequestBody @Valid OrderRequest request);

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "활동 사진 전체 교체", description = "첫 번째 항목이 목록 썸네일이다(INV-12).")
List<String> replaceImages(@PathVariable Long id, @RequestBody @Valid ActivityImagesReplaceRequest request);
}
Loading