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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
- **뮤지션 인맥지도** — 함께 공연한 협연자와 협연 횟수를 그래프로 시각화
- **카카오 소셜 로그인 + JWT 인증** — GENERAL / MUSICIAN / ADMIN 3가지 유저 역할
- **뮤지션 인증 플로우** — 일반 유저의 뮤지션 인증 신청 → 어드민 승인/거부
- **어드민 관리** — 뮤지션·공연장·공연 등록/수정, 뮤지션 인증 대기 목록 관리
- **어드민 관리** — 뮤지션·공연장·공연 등록/수정(출연 라인업 지정 포함), 뮤지션 인증 대기 목록 관리
- **공연장 좌표 크롤링** — Kakao Local API로 기존 공연장 주소를 위도/경도로 변환해 저장

---
Expand Down Expand Up @@ -73,8 +73,8 @@
| GET | `/api/performances/search?type=genre&keyword=` | 장르 검색 | 불필요 | - |
| GET | `/api/performances/musician/{id}` | 특정 뮤지션의 공연 목록 | 불필요 | ✓ |
| POST | `/api/performances` | 공연 이력 추가 | 필요 (MUSICIAN) | ✓ |
| POST | `/api/admin/performances` | 공연 등록 | 필요 (ADMIN) | ✓ |
| PUT | `/api/admin/performances/{id}` | 공연 수정 (취소/복구) | 필요 (ADMIN) | ✓ |
| POST | `/api/admin/performances` | 공연 등록 (라인업 포함 가능) | 필요 (ADMIN) | ✓ |
| PUT | `/api/admin/performances/{id}` | 공연 수정 (취소/복구, 라인업 교체) | 필요 (ADMIN) | ✓ |

### 어드민
| Method | URL | 설명 | 인증 | 프론트 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,36 +157,50 @@ public ResponseEntity<?> updateVenue(@PathVariable Long id, @RequestBody VenueRe

@Operation(
summary = "공연 등록",
description = "새 공연을 등록합니다. ADMIN만 호출 가능.",
description = """
새 공연을 등록합니다. ADMIN만 호출 가능.

**musicianIds (라인업)**
- 출연 뮤지션 id 목록을 함께 보내면 공연 저장 후 라인업(performance_lineup)에도 등록됩니다.
- 생략(null)하면 라인업 없이 공연만 등록됩니다.
- 존재하지 않는 뮤지션 id가 섞여 있으면 공연 등록까지 전부 취소(롤백)됩니다.
""",
security = @SecurityRequirement(name = "bearerAuth")
)
@ApiResponse(responseCode = "200", description = "등록 성공")
@ApiResponse(responseCode = "400", description = "존재하지 않는 공연장 id")
@ApiResponse(responseCode = "400", description = "존재하지 않는 공연장 또는 뮤지션 id")
@PostMapping("/performances")
public ResponseEntity<?> createPerformance(@RequestBody PerformanceRequest request) {
// 요청 바디의 JSON을 PerformanceRequest 객체로 받아 PerformanceService에 등록 요청
try {
return ResponseEntity.ok(performanceService.create(request));
} catch (NoSuchElementException e) {
// 요청의 venueId에 해당하는 공연장이 없으면 400 반환
// 요청의 venueId에 해당하는 공연장이 없거나 musicianIds에 없는 뮤지션이 있으면 400 반환
return ResponseEntity.badRequest().body(e.getMessage());
}
}

@Operation(
summary = "공연 수정",
description = "기존 공연 정보를 수정합니다. ADMIN만 호출 가능.",
description = """
기존 공연 정보를 수정합니다. ADMIN만 호출 가능.

**musicianIds (라인업)**
- 목록을 보내면 기존 라인업을 전부 지우고 보낸 목록으로 교체합니다.
- 빈 배열([])을 보내면 라인업이 전부 삭제됩니다.
- 생략(null)하면 라인업은 건드리지 않고 공연 정보만 수정됩니다.
""",
security = @SecurityRequirement(name = "bearerAuth")
)
@ApiResponse(responseCode = "200", description = "수정 성공")
@ApiResponse(responseCode = "400", description = "존재하지 않는 공연 또는 공연장 id")
@ApiResponse(responseCode = "400", description = "존재하지 않는 공연, 공연장 또는 뮤지션 id")
@PutMapping("/performances/{id}")
public ResponseEntity<?> updatePerformance(@PathVariable Long id, @RequestBody PerformanceRequest request) {
// {id}로 수정할 공연을 특정하고, 요청 바디로 새 값을 받아 PerformanceService에 수정 요청
try {
return ResponseEntity.ok(performanceService.update(id, request));
} catch (NoSuchElementException e) {
// 존재하지 않는 공연 또는 공연장 id면 400 반환
// 존재하지 않는 공연, 공연장 또는 뮤지션 id면 400 반환
return ResponseEntity.badRequest().body(e.getMessage());
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,15 @@ WHERE pl.performance IN (
ORDER BY COUNT(pl.performance) DESC
""")
List<GraphEdgeResponse> findCoPerformers(@Param("musicianId") Long musicianId);

// 특정 공연의 라인업을 전부 삭제하는 메서드 (공연 수정 시 라인업 교체용)
//
// 메서드 이름 규칙(쿼리 메서드)으로 Spring Data JPA가 쿼리를 자동 생성함:
// deleteBy + Id(복합키 필드명) + _(언더스코어: 복합키 안으로 한 단계 들어감) + PerformanceId
// → "복합키(id) 안의 performanceId가 일치하는 행을 모두 DELETE"
// 실행되는 SQL: DELETE FROM performance_lineup WHERE performance_id = ?
//
// 삭제는 DB를 변경하는 작업이라 트랜잭션 안에서만 실행 가능
// → 호출하는 쪽(PerformanceService의 메서드)에 @Transactional이 붙어 있어야 함
void deleteById_PerformanceId(Long performanceId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import lombok.NoArgsConstructor; // 파라미터 없는 기본 생성자 자동 생성 — Spring이 JSON을 이 객체로 변환할 때 필요

import java.time.LocalDateTime; // 공연 일시를 담기 위한 날짜+시각 타입
import java.util.List; // 출연 뮤지션 id를 여러 개 담기 위한 컬렉션 타입

// @Entity 없음 — DB 테이블이 아니라 요청 데이터를 잠시 담는 그릇(DTO)
@Getter
Expand All @@ -19,4 +20,5 @@ public class PerformanceRequest {
private String setList; // 셋리스트 (선택, 곡명 콤마 구분)
private boolean cancelled; // 취소 여부 (필수, 기본값 false)
private String sourceUrl; // 출처 URL (선택)
private List<Long> musicianIds; // 출연 뮤지션 id 목록 (선택) — null이면 라인업을 건드리지 않고, 값이 있으면 라인업을 이 목록으로 통째로 교체
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,12 @@ public Performance create(PerformanceRequest request) {
.sourceUrl(request.getSourceUrl()) // 출처 URL (null 가능)
.build(); // 객체 생성 완료

return performanceRepository.save(performance); // INSERT INTO performance ... — 새 객체라 save() 필요
performanceRepository.save(performance); // INSERT INTO performance ... — 여기서 performance.id가 채워짐

// 요청에 musicianIds가 있으면 출연 뮤지션들을 라인업에 등록 (null이면 라인업 없이 공연만 저장)
saveLineup(performance, request.getMusicianIds());

return performance; // 저장된 공연 정보 반환
}

// ADMIN 전용 — 공연 수정 — id로 기존 공연을 찾아 update() 호출
Expand All @@ -128,6 +133,38 @@ public Performance update(Long id, PerformanceRequest request) {
request.isCancelled(), // 취소 여부
request.getSourceUrl() // 출처 URL
); // save() 없음 — @Transactional + 더티 체킹으로 트랜잭션 종료 시 UPDATE 자동 실행

// 요청에 musicianIds가 있으면 기존 라인업을 전부 지우고 새 목록으로 교체
// (null이면 라인업은 손대지 않음 — 공연 정보만 수정하고 싶을 때를 위해)
if (request.getMusicianIds() != null) {
performanceLineupRepository.deleteById_PerformanceId(performance.getId()); // 기존 라인업 전체 삭제
performanceLineupRepository.flush(); // DELETE를 즉시 DB에 반영 — JPA는 기본적으로 INSERT를 DELETE보다 먼저 내보내서,
// flush 없이 같은 뮤지션을 다시 넣으면 "이미 존재하는 PK" 충돌이 날 수 있음
saveLineup(performance, request.getMusicianIds()); // 새 목록으로 다시 등록
}

return performance; // 수정된 엔티티 반환
}

// 공통 로직 — 뮤지션 id 목록을 라인업(performance_lineup)에 저장
// create()와 update() 양쪽에서 호출하므로 별도 메서드로 분리
private void saveLineup(Performance performance, List<Long> musicianIds) {
if (musicianIds == null) {
return; // musicianIds를 아예 안 보낸 경우 — 라인업 저장 생략
}

for (Long musicianId : musicianIds) {
// 존재하지 않는 뮤지션 id가 섞여 있으면 예외 발생
// → @Transactional 덕분에 이미 저장된 공연/라인업까지 전부 롤백됨 (전체 성공 아니면 전체 취소)
Musician musician = musicianRepository.findById(musicianId)
.orElseThrow(() -> new NoSuchElementException("존재하지 않는 뮤지션입니다. id=" + musicianId));

PerformanceLineup lineup = PerformanceLineup.builder()
.id(new PerformanceLineupId(performance.getId(), musician.getId())) // 복합키(공연id, 뮤지션id) 생성
.performance(performance) // 공연 객체
.musician(musician) // 뮤지션 객체
.build();
performanceLineupRepository.save(lineup); // performance_lineup 테이블에 INSERT
}
}
}
12 changes: 7 additions & 5 deletions backend/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@ spring:
password: ${DB_PASSWORD} # 비밀번호는 환경변수로 관리 (코드에 직접 쓰면 위험)
driver-class-name: org.postgresql.Driver # PostgreSQL 드라이버 사용

server:
# ${PORT:8080} : Render 같은 배포 환경은 PORT 환경변수로 "이 포트를 써라"라고 알려줌
# 로컬에선 PORT를 안 정해놨으니 지금까지처럼 8080번 그대로 사용
port: ${PORT:8080}

jpa:
hibernate:
ddl-auto: update # 서버 시작 시 @Entity 클래스 보고 테이블 자동 생성/변경 (데이터는 유지)
Expand Down Expand Up @@ -45,6 +40,13 @@ server:
user-info-uri: https://kapi.kakao.com/v2/user/me # 카카오에서 유저 정보 받아오는 URL
user-name-attribute: id # 카카오 응답에서 유저 고유값 키 이름

# 서버 설정 — spring: 블록과 같은 최상위 레벨에 있어야 함
# (spring: 블록 안에 넣으면 jpa/security 설정의 소속이 바뀌어 서버가 못 뜸)
server:
# ${PORT:8080} : Render 같은 배포 환경은 PORT 환경변수로 "이 포트를 써라"라고 알려줌
# 로컬에선 PORT를 안 정해놨으니 지금까지처럼 8080번 그대로 사용
port: ${PORT:8080}

# JWT 설정
jwt:
secret: ${JWT_SECRET} # JWT 서명용 비밀 키 (64자 이상 권장, HS512 자동 적용)
Expand Down
Loading