diff --git a/README.md b/README.md index 8425513..c5f3401 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ - **어드민 관리** — 뮤지션·공연장·공연 등록/수정(출연 라인업 지정 포함), 뮤지션 인증 대기 목록 관리 - **공연장 좌표 크롤링** — Kakao Local API로 기존 공연장 주소를 위도/경도로 변환해 저장 - **모바일 반응형 UI** — 768px 이하에서 햄버거 메뉴 + 슬라이드 드로어로 전환, 그리드 1열 재배치 +- **요청 데이터 검증** — URL 형식을 프론트 폼 + 백엔드 Bean Validation(`@Pattern`/`@Valid`)으로 이중 검사, 전역 예외 핸들러가 필드별 메시지로 400 응답 --- diff --git a/backend/build.gradle b/backend/build.gradle index 48e90c6..06564f3 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -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 파일을 읽어서 환경변수로 등록해주는 라이브러리 diff --git a/backend/src/main/java/com/dazz/again/domain/admin/AdminController.java b/backend/src/main/java/com/dazz/again/domain/admin/AdminController.java index 2a3c0cc..a356b39 100644 --- a/backend/src/main/java/com/dazz/again/domain/admin/AdminController.java +++ b/backend/src/main/java/com/dazz/again/domain/admin/AdminController.java @@ -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 인증 필요"를 자물쇠 아이콘으로 표시 @@ -98,7 +99,7 @@ public ResponseEntity reject(@PathVariable Long id) { ) @ApiResponse(responseCode = "200", description = "등록 성공") @PostMapping("/musicians") - public ResponseEntity createMusician(@RequestBody MusicianRequest request) { + public ResponseEntity createMusician(@Valid @RequestBody MusicianRequest request) { // 요청 바디의 JSON을 MusicianRequest 객체로 받아 MusicianService에 등록 요청 return ResponseEntity.ok(musicianService.createByAdmin(request)); } @@ -111,7 +112,7 @@ public ResponseEntity 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)); @@ -130,7 +131,7 @@ public ResponseEntity updateMusician(@PathVariable Long id, @RequestBody Musi ) @ApiResponse(responseCode = "200", description = "등록 성공") @PostMapping("/venues") - public ResponseEntity createVenue(@RequestBody VenueRequest request) { + public ResponseEntity createVenue(@Valid @RequestBody VenueRequest request) { // 요청 바디의 JSON을 VenueRequest 객체로 받아 VenueService에 등록 요청 return ResponseEntity.ok(venueService.create(request)); } @@ -143,7 +144,7 @@ public ResponseEntity 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)); diff --git a/backend/src/main/java/com/dazz/again/domain/musician/MusicianController.java b/backend/src/main/java/com/dazz/again/domain/musician/MusicianController.java index 7a3520c..ec4e548 100644 --- a/backend/src/main/java/com/dazz/again/domain/musician/MusicianController.java +++ b/backend/src/main/java/com/dazz/again/domain/musician/MusicianController.java @@ -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; @@ -38,7 +39,7 @@ public class MusicianController { @ApiResponse(responseCode = "401", description = "토큰 없음 또는 만료") @ApiResponse(responseCode = "403", description = "MUSICIAN 역할이 아님") @PutMapping("/me") - public ResponseEntity updateMyProfile(@RequestBody MusicianUpdateRequest request) { + public ResponseEntity updateMyProfile(@Valid @RequestBody MusicianUpdateRequest request) { // JwtFilter가 SecurityContextHolder에 저장한 userId를 꺼냄 Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return ResponseEntity.ok(musicianService.updateMyProfile(userId, request)); diff --git a/backend/src/main/java/com/dazz/again/domain/musician/MusicianRequest.java b/backend/src/main/java/com/dazz/again/domain/musician/MusicianRequest.java index 0865dea..794cd42 100644 --- a/backend/src/main/java/com/dazz/again/domain/musician/MusicianRequest.java +++ b/backend/src/main/java/com/dazz/again/domain/musician/MusicianRequest.java @@ -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을 이 객체로 변환할 때 필요 @@ -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는 요청에 포함하지 않음 diff --git a/backend/src/main/java/com/dazz/again/domain/musician/MusicianUpdateRequest.java b/backend/src/main/java/com/dazz/again/domain/musician/MusicianUpdateRequest.java index adbf39c..a84f7bd 100644 --- a/backend/src/main/java/com/dazz/again/domain/musician/MusicianUpdateRequest.java +++ b/backend/src/main/java/com/dazz/again/domain/musician/MusicianUpdateRequest.java @@ -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 역직렬화에 필요) @@ -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이면 기존 값 유지 } \ No newline at end of file diff --git a/backend/src/main/java/com/dazz/again/domain/venue/VenueRequest.java b/backend/src/main/java/com/dazz/again/domain/venue/VenueRequest.java index 00c542b..1de45cc 100644 --- a/backend/src/main/java/com/dazz/again/domain/venue/VenueRequest.java +++ b/backend/src/main/java/com/dazz/again/domain/venue/VenueRequest.java @@ -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을 이 객체로 변환할 때 필요 @@ -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; // 공연장 설명 (선택) } \ No newline at end of file diff --git a/backend/src/main/java/com/dazz/again/global/error/GlobalExceptionHandler.java b/backend/src/main/java/com/dazz/again/global/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..4e7c1bf --- /dev/null +++ b/backend/src/main/java/com/dazz/again/global/error/GlobalExceptionHandler.java @@ -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> handleValidationError(MethodArgumentNotValidException e) { + Map 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); + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 93e977c..b317082 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -207,7 +207,7 @@ export default function App() { case 'become-musician': return ; case 'claim-existing': return ; case 'onboarding': return ; - case 'pending': return ; + case 'pending': return ; case 'dashboard': return ; case 'profile-edit': return setMe((p) => ({ ...p, ...f }))} onToast={showToast} />; diff --git a/frontend/src/pages/auth/ScreenPending.jsx b/frontend/src/pages/auth/ScreenPending.jsx index ba827aa..84c5de8 100644 --- a/frontend/src/pages/auth/ScreenPending.jsx +++ b/frontend/src/pages/auth/ScreenPending.jsx @@ -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); @@ -33,8 +33,8 @@ export default function ScreenPending({ navigate, auth }) {
신청 일시{req.requestedAt?.slice(0, 10)}
)} - {/* userId는 auth에서 바로 가져옴 */} -
user_id{auth.userId}
+ {/* user_id는 내부 DB 식별자라 사용자에게 보여줄 필요 없음 — 동료 피드백으로 제거. + 문의 대응용 식별자는 위의 "요청 ID"로 충분 */}
상태PENDING · 승인 대기
diff --git a/frontend/src/pages/dashboard/ScreenProfileEdit.jsx b/frontend/src/pages/dashboard/ScreenProfileEdit.jsx index acbb5ad..3e782ff 100644 --- a/frontend/src/pages/dashboard/ScreenProfileEdit.jsx +++ b/frontend/src/pages/dashboard/ScreenProfileEdit.jsx @@ -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({ @@ -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('프로필이 저장됐습니다'); @@ -61,14 +70,18 @@ export default function ScreenProfileEdit({ me, navigate, onUpdate, onToast }) {
@ set('instagram', e.target.value)} />
+ {/* 형식이 틀렸을 때만 오류 안내 표시 */} + {linkErrors.instagram && {linkErrors.instagram}}
@ set('youtube', e.target.value)} />
+ {linkErrors.youtube && {linkErrors.youtube}}
set('website', e.target.value)} />
+ {linkErrors.website && {linkErrors.website}}
@@ -80,7 +93,8 @@ export default function ScreenProfileEdit({ me, navigate, onUpdate, onToast }) {
- + {/* 링크 형식 오류가 있으면 저장 버튼 비활성화 */} +
diff --git a/frontend/src/pages/onboarding/ScreenOnboarding.jsx b/frontend/src/pages/onboarding/ScreenOnboarding.jsx index 88d6a60..34efc0a 100644 --- a/frontend/src/pages/onboarding/ScreenOnboarding.jsx +++ b/frontend/src/pages/onboarding/ScreenOnboarding.jsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import Icon from '../../components/Icon'; +import { isValidUrl, isValidHandle } from '../../utils/validators'; // URL/핸들 형식 검사 const INSTRUMENTS = ['Piano', 'Bass', 'Drums', 'Guitar', 'Sax', 'Vocal', 'Trumpet', 'Trombone', 'Violin', 'Vibraphone', 'Percussion', 'Other']; @@ -20,6 +21,15 @@ export default function ScreenOnboarding({ navigate, onSubmitRequest }) { const prev = () => setStep((s) => Math.max(s - 1, 0)); const submit = () => onSubmitRequest({ kind: 'new', ...form }); + // 링크 단계(step 3) 형식 검사 — 틀린 필드에만 오류 메시지를 담음 (통과하면 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; + return (
@@ -71,14 +81,18 @@ export default function ScreenOnboarding({ navigate, onSubmitRequest }) {
@ set('instagram', e.target.value)} />
+ {/* 형식이 틀렸을 때만 오류 안내 표시 */} + {linkErrors.instagram && {linkErrors.instagram}}
@ set('youtube', e.target.value)} />
+ {linkErrors.youtube && {linkErrors.youtube}}
set('website', e.target.value)} />
+ {linkErrors.website && {linkErrors.website}}
)} @@ -98,8 +112,9 @@ export default function ScreenOnboarding({ navigate, onSubmitRequest }) {
+ {/* 다음 버튼: 악기 미선택(step 0) 또는 링크 형식 오류(step 3)면 비활성화 */} {step < STEPS.length - 1 ? ( - + ) : (