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

import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryCreateRequest;
import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryUpdateRequest;
import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityCategoryResponse;
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.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.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;

@Tag(name = "관리자 - 활동 카테고리 API")
@SecurityRequirement(name = "JWT")
public interface AdminActivityCategoryApi {

@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 = "숨김 포함 전체, display_order 순.")
List<AdminActivityCategoryResponse> getCategories();

@ApiResponses(value = {
@ApiResponse(responseCode = "201"),
@ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))),
@ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "활동 카테고리 생성")
ResponseEntity<AdminActivityCategoryResponse> createCategory(
@RequestBody @Valid ActivityCategoryCreateRequest 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 = "slug는 불변이다.")
AdminActivityCategoryResponse updateCategory(@PathVariable Long id,
@RequestBody @Valid ActivityCategoryUpdateRequest 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))),
@ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "활동 카테고리 삭제", description = "soft delete. 활동이 남아 있으면 409다(AC-3.8).")
ResponseEntity<Void> deleteCategory(@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(@RequestBody @Valid OrderRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.bcsdlab.bcsdinternalapiv2.activity.controller;

import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryCreateRequest;
import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryUpdateRequest;
import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityCategoryResponse;
import com.bcsdlab.bcsdinternalapiv2.activity.service.AdminActivityCategoryService;
import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest;
import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/admin/activity-categories")
@RequiredArgsConstructor
public class AdminActivityCategoryController implements AdminActivityCategoryApi {

private final AdminActivityCategoryService adminActivityCategoryService;

@Override
@GetMapping
public List<AdminActivityCategoryResponse> getCategories() {
return adminActivityCategoryService.getCategories();
}

@Override
@PostMapping
public ResponseEntity<AdminActivityCategoryResponse> createCategory(
@Valid @RequestBody ActivityCategoryCreateRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(adminActivityCategoryService.createCategory(request));
}

@Override
@PutMapping("/{id}")
public AdminActivityCategoryResponse updateCategory(@PathVariable Long id,
@Valid @RequestBody ActivityCategoryUpdateRequest request) {
return adminActivityCategoryService.updateCategory(id, request);
}

@Override
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteCategory(@PathVariable Long id) {
adminActivityCategoryService.deleteCategory(id);
return ResponseEntity.noContent().build();
}

@Override
@PatchMapping("/{id}/publish")
public ResponseEntity<Void> publish(@PathVariable Long id, @Valid @RequestBody PublishRequest request) {
adminActivityCategoryService.publish(id, request);
return ResponseEntity.noContent().build();
}

@Override
@PatchMapping("/order")
public ResponseEntity<Void> reorder(@Valid @RequestBody OrderRequest request) {
adminActivityCategoryService.reorder(request);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record ActivityCategoryCreateRequest(
@NotBlank
@Pattern(regexp = "^[a-z0-9]+(-[a-z0-9]+)*$", message = "slug는 소문자·숫자·하이픈만 사용할 수 있습니다.")
String slug,

@NotBlank
@Size(max = 30)
String name,

@Size(max = 200)
String headline,

String heroImageUrl
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record ActivityCategoryUpdateRequest(
@NotBlank
@Size(max = 30)
String name,

@Size(max = 200)
String headline,

String heroImageUrl
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response;

import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory;

public record ActivityCategoryResponse(
String slug,
String name,
String headline,
String heroImageUrl
) {
public static ActivityCategoryResponse from(ActivityCategory category) {
return new ActivityCategoryResponse(
category.getSlug(), category.getName(), category.getHeadline(), category.getHeroImageUrl());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response;

import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory;

/**
* 활동 건수(activity 잔존 여부 판단에 쓰는 그 수)는 아직 없다 — Activity가 생기는
* T-15에서 이 레코드에 필드를 추가한다.
*/
public record AdminActivityCategoryResponse(
Long id,
String slug,
String name,
String headline,
String heroImageUrl,
boolean isPublished,
int displayOrder
) {
public static AdminActivityCategoryResponse from(ActivityCategory category) {
return new AdminActivityCategoryResponse(
category.getId(), category.getSlug(), category.getName(), category.getHeadline(),
category.getHeroImageUrl(), category.isPublished(), category.getDisplayOrder());
}
}
Loading