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,18 @@
package com.bcsdlab.bcsdinternalapiv2.activity.exception;

import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdException;
import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType;

public class ActivityException extends BcsdException {

private final BcsdExceptionType exceptionType;

public ActivityException(BcsdExceptionType exceptionType) {
this.exceptionType = exceptionType;
}

@Override
public BcsdExceptionType getExceptionType() {
return exceptionType;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.bcsdlab.bcsdinternalapiv2.activity.exception;

import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType;
import org.springframework.http.HttpStatus;

public enum ActivityExceptionType implements BcsdExceptionType {

ACTIVITY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 활동입니다."),
;

private final HttpStatus status;
private final String message;

ActivityExceptionType(HttpStatus status, String message) {
this.status = status;
this.message = message;
}

@Override
public HttpStatus getHttpStatus() {
return status;
}

@Override
public String getMessage() {
return message;
}

@Override
public BcsdExceptionType withDetail(String detailMessage) {
return new DetailedActivityExceptionType(this, detailMessage);
}

private record DetailedActivityExceptionType(ActivityExceptionType type, String detailMessage)
implements BcsdExceptionType {

@Override
public HttpStatus getHttpStatus() {
return type.getHttpStatus();
}

@Override
public String getMessage() {
return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip();
}

@Override
public BcsdExceptionType withDetail(String detailMessage) {
return new DetailedActivityExceptionType(type, detailMessage);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public static MemberSummary from(Member member) {
member.getId(),
member.getName(),
member.getStudentNumber(),
member.getTrack().name(),
member.getTrack().getCode(),
member.getGeneration(),
member.getMemberType().name(),
member.getUniversity()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.exception;

import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdException;
import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType;

public class CurriculumException extends BcsdException {

private final BcsdExceptionType exceptionType;

public CurriculumException(BcsdExceptionType exceptionType) {
this.exceptionType = exceptionType;
}

@Override
public BcsdExceptionType getExceptionType() {
return exceptionType;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.exception;

import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType;
import org.springframework.http.HttpStatus;

public enum CurriculumExceptionType implements BcsdExceptionType {

CURRICULUM_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 커리큘럼입니다."),
;

private final HttpStatus status;
private final String message;

CurriculumExceptionType(HttpStatus status, String message) {
this.status = status;
this.message = message;
}

@Override
public HttpStatus getHttpStatus() {
return status;
}

@Override
public String getMessage() {
return message;
}

@Override
public BcsdExceptionType withDetail(String detailMessage) {
return new DetailedCurriculumExceptionType(this, detailMessage);
}

private record DetailedCurriculumExceptionType(CurriculumExceptionType type, String detailMessage)
implements BcsdExceptionType {

@Override
public HttpStatus getHttpStatus() {
return type.getHttpStatus();
}

@Override
public String getMessage() {
return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip();
}

@Override
public BcsdExceptionType withDetail(String detailMessage) {
return new DetailedCurriculumExceptionType(type, detailMessage);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.bcsdlab.bcsdinternalapiv2.global;

import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import java.time.Instant;
import lombok.Getter;

/**
* 최상위 엔티티(트랙 페이지·커리큘럼·활동 카테고리·활동 등)에만 적용하는 soft delete 규약.
* 하위 트리(주차·토픽·세부항목·이미지)는 이 클래스를 쓰지 않고 {@code on delete cascade}로
* 물리 삭제한다.
*
* <p><b>중요</b>: {@code @SQLRestriction("deleted_at is null")}은 이 매핑된 슈퍼클래스가 아니라
* 이 클래스를 상속하는 각 {@code @Entity} 클래스에 직접 붙여야 한다. Hibernate의
* {@code @SQLRestriction}은 매핑된 슈퍼클래스로부터 상속되지 않는다.
*/
@Getter
@MappedSuperclass
public abstract class SoftDeletableEntity extends BaseTimeEntity {

@Column(name = "deleted_at")
private Instant deletedAt;

public boolean isDeleted() {
return deletedAt != null;
}

public void delete(Instant now) {
this.deletedAt = now;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.bcsdlab.bcsdinternalapiv2.global.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

@Configuration
@RequiredArgsConstructor
public class S3Config {

private final S3Properties s3Properties;

@Bean
public S3Client s3Client() {
return S3Client.builder()
.region(Region.of(s3Properties.region()))
.build();
}

@Bean
public S3Presigner s3Presigner() {
return S3Presigner.builder()
.region(Region.of(s3Properties.region()))
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.bcsdlab.bcsdinternalapiv2.global.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app.s3")
public record S3Properties(String region) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/v1/auth/login", "/v1/auth/reissue", "/v1/auth/logout")
.permitAll()
.requestMatchers("/v1/auth/password/**").permitAll()
.requestMatchers("/v1/tracks/**").permitAll()
.requestMatchers("/v1/members/me/initial-setup")
.hasAnyAuthority("SCOPE_PRE_ACTIVATION", "SCOPE_FULL")
.requestMatchers("/v1/admin/**").access(AuthorizationManagers.allOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request;

import jakarta.validation.constraints.NotNull;
import java.util.List;

/**
* 순서 변경 전용 규약: {@code PATCH .../order}. 배열 인덱스가 새 display_order다.
* {@link com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders}와 짝을 이룬다.
*/
public record OrderRequest(@NotNull List<Long> ids) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request;

/**
* 공개/숨김 전용 규약: {@code PATCH .../{id}/publish}. 트랙 페이지·커리큘럼 세트·
* 활동 카테고리·활동이 전부 이 형태를 공유한다(05-api-spec.md 공통 규약).
*/
public record PublishRequest(boolean isPublished) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.bcsdlab.bcsdinternalapiv2.global.exception;

public class GlobalException extends BcsdException {

private final BcsdExceptionType exceptionType;

public GlobalException(BcsdExceptionType exceptionType) {
this.exceptionType = exceptionType;
}

@Override
public BcsdExceptionType getExceptionType() {
return exceptionType;
}
}
Loading