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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
- **어드민 관리** — 뮤지션·공연장·공연 등록/수정(출연 라인업 지정 포함), 뮤지션 인증 대기 목록 관리
- **공연장 좌표 크롤링** — Kakao Local API로 기존 공연장 주소를 위도/경도로 변환해 저장
- **모바일 반응형 UI** — 768px 이하에서 햄버거 메뉴 + 슬라이드 드로어로 전환, 그리드 1열 재배치
- **요청 데이터 검증** — URL 형식을 프론트 폼 + 백엔드 Bean Validation(`@Pattern`/`@Valid`)으로 이중 검사, 전역 예외 핸들러가 필드별 메시지로 400 응답

---

Expand Down
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-validation' // Bean Validation — DTO 필드에 @Pattern 등 검증 어노테이션 사용 가능하게 함
runtimeOnly 'com.h2database:h2' // 테스트용 인메모리 DB (테스트 코드에서만 사용)
runtimeOnly 'org.postgresql:postgresql' // 실제 PostgreSQL 연결 드라이버
implementation 'me.paulschwarz:spring-dotenv:4.0.0' // .env 파일을 읽어서 환경변수로 등록해주는 라이브러리
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.dazz.again.domain.venue.Venue; // 공연장 엔티티 — 등록/수정 응답에 사용
import com.dazz.again.domain.venue.VenueRequest; // 공연장 등록/수정 요청 DTO
import com.dazz.again.domain.venue.VenueService; // 공연장 등록/수정 비즈니스 로직
import jakarta.validation.Valid; // @RequestBody DTO에 붙은 검증 어노테이션(@Pattern 등)을 실제로 실행시키는 스위치
import io.swagger.v3.oas.annotations.Operation; // Swagger에서 각 API의 요약 설명을 표시하는 어노테이션
import io.swagger.v3.oas.annotations.responses.ApiResponse; // Swagger에서 HTTP 응답 코드별 설명을 표시하는 어노테이션
import io.swagger.v3.oas.annotations.security.SecurityRequirement; // Swagger에서 "이 API는 JWT 인증 필요"를 자물쇠 아이콘으로 표시
Expand Down Expand Up @@ -98,7 +99,7 @@ public ResponseEntity<?> reject(@PathVariable Long id) {
)
@ApiResponse(responseCode = "200", description = "등록 성공")
@PostMapping("/musicians")
public ResponseEntity<Musician> createMusician(@RequestBody MusicianRequest request) {
public ResponseEntity<Musician> createMusician(@Valid @RequestBody MusicianRequest request) {
// 요청 바디의 JSON을 MusicianRequest 객체로 받아 MusicianService에 등록 요청
return ResponseEntity.ok(musicianService.createByAdmin(request));
}
Expand All @@ -111,7 +112,7 @@ public ResponseEntity<Musician> createMusician(@RequestBody MusicianRequest requ
@ApiResponse(responseCode = "200", description = "수정 성공")
@ApiResponse(responseCode = "400", description = "존재하지 않는 뮤지션")
@PutMapping("/musicians/{id}")
public ResponseEntity<?> updateMusician(@PathVariable Long id, @RequestBody MusicianRequest request) {
public ResponseEntity<?> updateMusician(@PathVariable Long id, @Valid @RequestBody MusicianRequest request) {
// {id}로 수정할 뮤지션을 특정하고, 요청 바디로 새 값을 받아 MusicianService에 수정 요청
try {
return ResponseEntity.ok(musicianService.updateByAdmin(id, request));
Expand All @@ -130,7 +131,7 @@ public ResponseEntity<?> updateMusician(@PathVariable Long id, @RequestBody Musi
)
@ApiResponse(responseCode = "200", description = "등록 성공")
@PostMapping("/venues")
public ResponseEntity<Venue> createVenue(@RequestBody VenueRequest request) {
public ResponseEntity<Venue> createVenue(@Valid @RequestBody VenueRequest request) {
// 요청 바디의 JSON을 VenueRequest 객체로 받아 VenueService에 등록 요청
return ResponseEntity.ok(venueService.create(request));
}
Expand All @@ -143,7 +144,7 @@ public ResponseEntity<Venue> createVenue(@RequestBody VenueRequest request) {
@ApiResponse(responseCode = "200", description = "수정 성공")
@ApiResponse(responseCode = "400", description = "존재하지 않는 공연장")
@PutMapping("/venues/{id}")
public ResponseEntity<?> updateVenue(@PathVariable Long id, @RequestBody VenueRequest request) {
public ResponseEntity<?> updateVenue(@PathVariable Long id, @Valid @RequestBody VenueRequest request) {
// {id}로 수정할 공연장을 특정하고, 요청 바디로 새 값을 받아 VenueService에 수정 요청
try {
return ResponseEntity.ok(venueService.update(id, request));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping; // HTTP PUT 메서드 매핑 어노테이션
import jakarta.validation.Valid; // @RequestBody DTO에 붙은 검증 어노테이션(@Pattern 등)을 실제로 실행시키는 스위치
import org.springframework.web.bind.annotation.RequestBody; // 요청 body의 JSON을 Java 객체로 변환하는 어노테이션
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
Expand All @@ -38,7 +39,7 @@ public class MusicianController {
@ApiResponse(responseCode = "401", description = "토큰 없음 또는 만료")
@ApiResponse(responseCode = "403", description = "MUSICIAN 역할이 아님")
@PutMapping("/me")
public ResponseEntity<Musician> updateMyProfile(@RequestBody MusicianUpdateRequest request) {
public ResponseEntity<Musician> updateMyProfile(@Valid @RequestBody MusicianUpdateRequest request) {
// JwtFilter가 SecurityContextHolder에 저장한 userId를 꺼냄
Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ResponseEntity.ok(musicianService.updateMyProfile(userId, request));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// ADMIN이 뮤지션을 등록하거나 수정할 때 클라이언트에서 받는 요청 데이터를 담는 파일
package com.dazz.again.domain.musician;

import jakarta.validation.constraints.Pattern; // Bean Validation — 필드값이 정규식과 일치하는지 검사
import lombok.Getter; // 모든 필드에 getter 메서드를 자동 생성
import lombok.NoArgsConstructor; // 파라미터 없는 기본 생성자 자동 생성 — Spring이 JSON을 이 객체로 변환할 때 필요

Expand All @@ -9,12 +10,26 @@
@NoArgsConstructor
public class MusicianRequest {

// URL 형식 검사용 정규식:
// ^$ → 빈 문자열 허용 (선택 입력 필드이므로)
// (https?://)? → http:// 또는 https:// 는 있어도 되고 없어도 됨
// [^\s]+\.[^\s]+ → 공백 없이 점(.)이 포함된 주소 (예: instagram.com/dazz)
// ※ @Pattern은 값이 null이면 검사를 건너뜀 → 빈 문자열("")까지 허용하려고 ^$ 를 추가
static final String URL_PATTERN = "^$|^(https?://)?[^\\s]+\\.[^\\s]+$";
static final String URL_MESSAGE = "URL 형식이 아닙니다 (예: https://instagram.com/dazz)";

private String stageName; // 활동명 (필수)
private String realName; // 본명 (선택)
private String position; // 악기 (필수)
private String bio; // 소개 (선택)

@Pattern(regexp = URL_PATTERN, message = URL_MESSAGE) // 형식이 틀리면 컨트롤러 진입 전에 400 에러
private String snsUrl; // 인스타그램 URL (선택)

@Pattern(regexp = URL_PATTERN, message = URL_MESSAGE)
private String profileImageUrl; // 프로필 사진 URL (선택)

@Pattern(regexp = URL_PATTERN, message = URL_MESSAGE)
private String sourceUrl; // 출처 URL (선택)

// sourceType, userId는 요청에 포함하지 않음
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// DTO: 클라이언트가 보낸 JSON 데이터를 Java 객체로 변환해서 받는 그릇
package com.dazz.again.domain.musician;

import jakarta.validation.constraints.Pattern; // Bean Validation — 필드값이 정규식과 일치하는지 검사
import lombok.Getter; // 모든 필드의 getter 메서드를 자동 생성
import lombok.NoArgsConstructor; // 파라미터 없는 기본 생성자 자동 생성 (JSON 역직렬화에 필요)

Expand All @@ -13,6 +14,11 @@ public class MusicianUpdateRequest {
private String realName; // 본명 — null이면 기존 값 유지
private String position; // 악기 — null이면 기존 값 유지
private String bio; // 소개 — null이면 기존 값 유지

// URL 정규식/메시지는 같은 패키지의 MusicianRequest에 정의된 상수를 재사용
@Pattern(regexp = MusicianRequest.URL_PATTERN, message = MusicianRequest.URL_MESSAGE)
private String snsUrl; // 인스타그램 URL — null이면 기존 값 유지

@Pattern(regexp = MusicianRequest.URL_PATTERN, message = MusicianRequest.URL_MESSAGE)
private String profileImageUrl; // 프로필 사진 URL — null이면 기존 값 유지
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// ADMIN이 공연장을 등록하거나 수정할 때 클라이언트에서 받는 요청 데이터를 담는 파일
package com.dazz.again.domain.venue;

import jakarta.validation.constraints.Pattern; // Bean Validation — 필드값이 정규식과 일치하는지 검사
import lombok.Getter; // 모든 필드에 getter 메서드를 자동 생성
import lombok.NoArgsConstructor; // 파라미터 없는 기본 생성자 자동 생성 — Spring이 JSON을 이 객체로 변환할 때 필요

Expand All @@ -9,9 +10,20 @@
@NoArgsConstructor
public class VenueRequest {

// URL 형식 검사용 정규식 — musician 패키지의 것과 같지만,
// venue 도메인이 musician 도메인 클래스에 의존하지 않도록 따로 정의 (도메인 간 결합 방지)
// ^$: 빈 문자열 허용 / (https?://)?: 스킴 생략 가능 / [^\s]+\.[^\s]+: 공백 없이 점 포함
static final String URL_PATTERN = "^$|^(https?://)?[^\\s]+\\.[^\\s]+$";
static final String URL_MESSAGE = "URL 형식이 아닙니다 (예: https://example.com)";

private String name; // 공연장 이름 (필수)
private String location; // 공연장 위치 (필수)

@Pattern(regexp = URL_PATTERN, message = URL_MESSAGE) // 형식이 틀리면 컨트롤러 진입 전에 400 에러
private String instagramUrl; // 인스타그램 URL (선택)

@Pattern(regexp = URL_PATTERN, message = URL_MESSAGE)
private String homepageUrl; // 홈페이지 URL (선택)

private String description; // 공연장 설명 (선택)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 모든 컨트롤러에서 발생하는 예외를 한 곳에서 받아 일관된 형식의 에러 응답으로 변환하는 파일
package com.dazz.again.global.error;

import org.springframework.http.ResponseEntity; // HTTP 상태코드 + 응답 바디를 함께 반환하는 클래스
import org.springframework.web.bind.MethodArgumentNotValidException; // @Valid 검증 실패 시 스프링이 던지는 예외
import org.springframework.web.bind.annotation.ExceptionHandler; // "이 예외가 발생하면 이 메서드가 처리한다"고 지정하는 어노테이션
import org.springframework.web.bind.annotation.RestControllerAdvice; // 모든 @RestController의 예외를 가로채는 전역 처리기임을 선언

import java.util.LinkedHashMap; // 필드 순서가 유지되는 Map (에러 메시지를 필드 선언 순서대로 보여주기 위함)
import java.util.Map;

// @RestControllerAdvice: 프로젝트의 모든 컨트롤러에 공통으로 적용되는 "예외 처리 전담반"
// 컨트롤러마다 try-catch를 쓰는 대신, 여기 한 곳에서 예외 → 에러 응답 변환을 담당한다
@RestControllerAdvice
public class GlobalExceptionHandler {

// @Valid 검증 실패 처리 — DTO의 @Pattern 등 검증에 걸리면 스프링이
// MethodArgumentNotValidException을 던지는데, 그걸 여기서 받아서
// { "필드명": "에러 메시지" } 형태의 400 응답으로 바꿔준다
// (이 핸들러가 없으면 클라이언트는 알아보기 힘든 기본 에러 응답을 받게 됨)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationError(MethodArgumentNotValidException e) {
Map<String, String> errors = new LinkedHashMap<>();

// 검증에 실패한 필드가 여러 개일 수 있으므로 전부 모아서 담음
e.getBindingResult().getFieldErrors().forEach(
fieldError -> errors.put(fieldError.getField(), fieldError.getDefaultMessage())
);

// 400 Bad Request + 예: { "snsUrl": "URL 형식이 아닙니다 (예: https://instagram.com/dazz)" }
return ResponseEntity.badRequest().body(errors);
}
}
2 changes: 1 addition & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export default function App() {
case 'become-musician': return <ScreenBecomeMusician navigate={navigate} auth={auth} />;
case 'claim-existing': return <ScreenClaimExisting navigate={navigate} onSubmitRequest={handleVerifyRequest} />;
case 'onboarding': return <ScreenOnboarding navigate={navigate} onSubmitRequest={handleVerifyRequest} />;
case 'pending': return <ScreenPending navigate={navigate} auth={auth} />;
case 'pending': return <ScreenPending navigate={navigate} />;

case 'dashboard': return <ScreenDashboard navigate={navigate} me={me} />;
case 'profile-edit': return <ScreenProfileEdit me={me} navigate={navigate} onUpdate={(f) => setMe((p) => ({ ...p, ...f }))} onToast={showToast} />;
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/pages/auth/ScreenPending.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; // useEffect: 내 신청 정보 로
import Icon from '../../components/Icon'; // 아이콘
import { api } from '../../api/client'; // 백엔드 API 호출 함수 모음

export default function ScreenPending({ navigate, auth }) {
export default function ScreenPending({ navigate }) { // auth prop 제거 — user_id 표시를 없애면서 더 이상 필요 없음
// req: 내 인증 신청 단건 정보 — GET /api/verify/musician/me
const [req, setReq] = useState(null);

Expand Down Expand Up @@ -33,8 +33,8 @@ export default function ScreenPending({ navigate, auth }) {
<div className="rr"><span>신청 일시</span><b className="mono">{req.requestedAt?.slice(0, 10)}</b></div>
</>
)}
{/* userId는 auth에서 바로 가져옴 */}
<div className="rr"><span>user_id</span><b className="mono">{auth.userId}</b></div>
{/* user_id는 내부 DB 식별자라 사용자에게 보여줄 필요 없음 — 동료 피드백으로 제거.
문의 대응용 식별자는 위의 "요청 ID"로 충분 */}
<div className="rr"><span>상태</span><b style={{ color: 'var(--wine)' }}>PENDING · 승인 대기</b></div>
</div>
<div className="row" style={{ gap: 10, marginTop: 22 }}>
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/pages/dashboard/ScreenProfileEdit.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react';
import Icon from '../../components/Icon';
import PresetPicker from './PresetPicker';
import { isValidUrl, isValidHandle } from '../../utils/validators'; // URL/핸들 형식 검사

export default function ScreenProfileEdit({ me, navigate, onUpdate, onToast }) {
const [form, setForm] = useState({
Expand All @@ -17,6 +18,14 @@ export default function ScreenProfileEdit({ me, navigate, onUpdate, onToast }) {

const set = (k, v) => setForm((p) => ({ ...p, [k]: v }));

// 링크 필드 형식 검사 — 틀린 필드에만 오류 메시지를 담음 (통과하면 null)
const linkErrors = {
instagram: isValidHandle(form.instagram) ? null : '@ 뒤의 아이디만 입력해주세요 (예: dazz_pianist)',
youtube: isValidHandle(form.youtube) ? null : '@ 뒤의 채널명만 입력해주세요 (예: dazzjazz)',
website: isValidUrl(form.website) ? null : 'URL 형식이 아니에요 (예: example.com 또는 https://example.com)',
};
const linksOk = !linkErrors.instagram && !linkErrors.youtube && !linkErrors.website;

const save = () => {
onUpdate && onUpdate(form);
onToast && onToast('프로필이 저장됐습니다');
Expand Down Expand Up @@ -61,14 +70,18 @@ export default function ScreenProfileEdit({ me, navigate, onUpdate, onToast }) {
<div className="field">
<label className="label">Instagram</label>
<div className="prefix"><span>@</span><input type="text" value={form.instagram} onChange={(e) => set('instagram', e.target.value)} /></div>
{/* 형식이 틀렸을 때만 오류 안내 표시 */}
{linkErrors.instagram && <span className="err">{linkErrors.instagram}</span>}
</div>
<div className="field">
<label className="label">YouTube</label>
<div className="prefix"><span>@</span><input type="text" value={form.youtube} onChange={(e) => set('youtube', e.target.value)} /></div>
{linkErrors.youtube && <span className="err">{linkErrors.youtube}</span>}
</div>
<div className="field">
<label className="label">웹사이트</label>
<div className="prefix"><span><Icon name="globe" size={13} /></span><input type="text" value={form.website} onChange={(e) => set('website', e.target.value)} /></div>
{linkErrors.website && <span className="err">{linkErrors.website}</span>}
</div>
</div>
</div>
Expand All @@ -80,7 +93,8 @@ export default function ScreenProfileEdit({ me, navigate, onUpdate, onToast }) {

<div className="row" style={{ justifyContent: 'flex-end', gap: 10, marginTop: 24 }}>
<button className="btn ghost" onClick={() => navigate('dashboard')}>취소</button>
<button className="btn primary" onClick={save}><Icon name="check" size={16} stroke={2.5} /> 저장</button>
{/* 링크 형식 오류가 있으면 저장 버튼 비활성화 */}
<button className="btn primary" onClick={save} disabled={!linksOk}><Icon name="check" size={16} stroke={2.5} /> 저장</button>
</div>
</div>
</div>
Expand Down
Loading
Loading