From 73d6d031bf85b9a3dcfb8872cb0565120559f96e Mon Sep 17 00:00:00 2001 From: Doi Kim <154426332+d0ikim@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:04:17 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B0=80=EB=8F=85=EC=84=B1,=20=ED=84=B0=EC=B9=98=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EA=B3=B5=EC=97=B0?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EA=B9=A8=EC=A7=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/styles/global.css | 48 +++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index fbe1fbf..9ac7f9f 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -519,14 +519,6 @@ button.pill:hover { opacity: 0.85; } .cc-body { padding: 16px 18px; flex: 1; display: flex; flex-direction: column; } .cc-lineup { margin-top: auto; display: flex; align-items: center; gap: 10px; padding-top: 12px; border-top: 1px solid var(--line); } -.concert-hero { display: flex; gap: 22px; align-items: center; padding: 26px 30px; } -.concert-hero .ch-date { - flex: 0 0 auto; width: 92px; text-align: center; - background: var(--wine-soft); border: 1px solid var(--wine-line); border-radius: 12px; padding: 14px 8px; -} -.concert-hero .ch-date b { display: block; font-family: 'Noto Serif KR', serif; font-size: 40px; font-weight: 900; color: var(--wine); line-height: 1; margin: 4px 0; } -.concert-hero .ch-date span { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--wine); display: block; } - /* ─── Admin ───────────────────────────────────────────── */ .sidebar.admin a .count { font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 600; @@ -901,11 +893,47 @@ button.pill:hover { opacity: 0.85; } background: rgba(20, 14, 12, 0.45); z-index: 55; } - /* ── 본문 여백·타이포 축소 ── */ + /* ── 본문 여백·타이포 ── */ .main .pad { padding: 20px 16px; } .h1 { font-size: 28px; } .h2 { font-size: 22px; } - .lead { font-size: 15px; } + /* 긴 영문 단어(밴드명 등)가 포함된 제목이 화면 밖으로 삐져나가지 않게 강제 줄바꿈 */ + .h1, .h2, .h3 { overflow-wrap: anywhere; } + .lead { font-size: 16px; } + /* 데스크톱 기준(14px)은 폰에서 너무 작음 — 모바일 기본 글자를 16px로 키움 */ + body { font-size: 16px; } + + /* ── 터치 크기 확대 ── + iOS 휴먼 인터페이스 가이드라인의 최소 터치 영역은 44px. + 데스크톱용 28~36px 버튼은 폰에서 누르기 어려우므로 전부 키운다 */ + .btn { height: 46px; padding: 0 18px; font-size: 15px; border-radius: 8px; } + .btn.lg { height: 50px; font-size: 16px; } + .btn.sm { height: 40px; padding: 0 14px; font-size: 14px; } + .btn.icon { width: 44px; height: 44px; } + .chip { height: 40px; padding: 0 16px; font-size: 15px; } + .chip.sm { height: 34px; font-size: 13px; } + .tabs button { font-size: 15px; padding: 14px 0; } + .pill { height: 26px; font-size: 13px; } + /* .pill.lg는 뮤지션 목록의 악기 필터 등 주요 터치 대상이라 더 크게 */ + .pill.lg { height: 38px; font-size: 14px; padding: 0 16px; } + + /* ── 입력창 확대 ── + 폰트가 16px 미만인 input에 포커스하면 iOS Safari가 화면을 강제 확대하므로 + 반드시 16px 이상으로 키운다 */ + .field input[type=text], .field input[type=date], .field input[type=email], + .field input[type=url], .field textarea, .field select { + font-size: 16px; padding: 12px 14px; + } + .field label { font-size: 14px; } + .field .help { font-size: 13px; } + .field .prefix span { font-size: 14px; } + + /* ── 드로어 메뉴 링크: 손가락으로 누르기 좋게 ── */ + .sidebar a { font-size: 16px; padding: 13px 16px; } + .sidebar .section { font-size: 12px; } + + /* ── 표·기타 텍스트 ── */ + .admin-table { font-size: 14px; } /* ── 아직 1열로 안 바뀐 그리드들 전환 ── */ .form-grid { grid-template-columns: 1fr; } From 108a02fd6e78b950d5b30883f6ef10dedc837404 Mon Sep 17 00:00:00 2001 From: Doi Kim <154426332+d0ikim@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:10:06 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=EC=A7=80=EB=8F=84=20=EB=A7=90?= =?UTF-8?q?=ED=92=8D=EC=84=A0=20=EC=A3=BC=EC=86=8C=EC=82=90=EC=A0=B8?= =?UTF-8?q?=EB=82=98=EC=98=B4=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=99=88?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A7=81=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/venues/VenueMap.jsx | 44 +++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/venues/VenueMap.jsx b/frontend/src/pages/venues/VenueMap.jsx index ea5c215..2544463 100644 --- a/frontend/src/pages/venues/VenueMap.jsx +++ b/frontend/src/pages/venues/VenueMap.jsx @@ -11,12 +11,29 @@ function escapeHtml(text) { return div.innerHTML; } -// 마커 위에 표시할 인포윈도우(말풍선) HTML 내용을 만듦 -function buildInfoWindowContent(venue) { +// URL을 href="..." 속성 안에 넣기 전에 따옴표를 이스케이프 (속성이 중간에 끊기는 것 방지) +function escapeAttr(url) { + return String(url ?? '').replace(/"/g, '"'); +} + +// 마커 위에 표시할 인포윈도우(말풍선) HTML 내용을 만듦 (export: 테스트에서 단독 확인 가능하도록) +export function buildInfoWindowContent(venue) { + // 홈페이지 URL이 등록된 공연장만 말풍선에 링크 표시 (클릭 시 새 탭으로 열림) + const homepageLink = venue.homepageUrl + ? ` + 홈페이지 방문 ↗ + ` + : ''; + + // width를 고정(200px)하고 줄바꿈을 허용해야 긴 주소가 말풍선 밖으로 삐져나가지 않음 + // (max-width만 주면 카카오맵이 말풍선 크기를 잘못 계산해 텍스트가 넘칠 수 있음) return ` -
+
${escapeHtml(venue.name)}
${escapeHtml(venue.location)}
+ ${homepageLink}
`; } @@ -25,6 +42,7 @@ export default function VenueMap({ venues = [], selected, onSelect }) { const boxRef = useRef(null); // 카카오맵이 실제로 그려질 div DOM 요소를 가리킴 const mapRef = useRef(null); // 생성된 카카오 지도 객체 (한 번만 만들고 계속 재사용) const markersRef = useRef([]); // 지도 위에 그려둔 마커 목록 (venues가 바뀔 때 지우고 다시 그리기 위해 보관) + const pinnedRef = useRef(null); // 클릭으로 "고정"된 말풍선 — 고정된 동안엔 마우스가 떠나도 닫지 않음 (안의 링크를 클릭할 수 있도록) const [ready, setReady] = useState(false); // 카카오맵 SDK 로드 + 지도 생성이 끝났는지 여부 // 1) 컴포넌트가 처음 화면에 나타날 때 딱 한 번 — 카카오맵 SDK로 지도를 생성 @@ -42,6 +60,15 @@ export default function VenueMap({ venues = [], selected, onSelect }) { center: new window.kakao.maps.LatLng(SEOUL_CENTER.lat, SEOUL_CENTER.lng), level: 7, // 지도 확대 레벨 — 숫자가 작을수록 확대됨 (1~14) }); + + // 마커가 아닌 지도 빈 곳을 클릭하면, 고정해둔 말풍선을 닫음 + window.kakao.maps.event.addListener(mapRef.current, 'click', () => { + if (pinnedRef.current) { + pinnedRef.current.close(); + pinnedRef.current = null; + } + }); + setReady(true); // 아래 useEffect들이 이제 지도를 다뤄도 된다는 신호 }); }, []); @@ -56,6 +83,7 @@ export default function VenueMap({ venues = [], selected, onSelect }) { marker.setMap(null); }); markersRef.current = []; + pinnedRef.current = null; // 마커를 다시 그리므로 고정 상태도 초기화 // 좌표값이 있는 공연장만 필터링 (아직 좌표를 못 구한 곳은 지도에 표시할 수 없음) const withCoords = venues.filter((v) => v.latitude != null && v.longitude != null); @@ -72,11 +100,17 @@ export default function VenueMap({ venues = [], selected, onSelect }) { }); window.kakao.maps.event.addListener(marker, 'mouseover', () => infowindow.open(mapRef.current, marker)); - window.kakao.maps.event.addListener(marker, 'mouseout', () => infowindow.close()); + // 마우스가 마커를 떠나면 닫되, 클릭으로 고정된 말풍선은 그대로 둠 (링크 클릭 기회 보장) + window.kakao.maps.event.addListener(marker, 'mouseout', () => { + if (pinnedRef.current !== infowindow) infowindow.close(); + }); - // 마커 클릭 시 말풍선도 띄우고, 부모 컴포넌트(ScreenVenues)에게 선택된 공연장 id를 알려줌 + // 마커 클릭(모바일에선 탭) 시: + // ① 이전에 고정된 말풍선이 있으면 닫고 ② 이 말풍선을 열어서 고정 ③ 부모에게 선택된 공연장 id 전달 window.kakao.maps.event.addListener(marker, 'click', () => { + if (pinnedRef.current && pinnedRef.current !== infowindow) pinnedRef.current.close(); infowindow.open(mapRef.current, marker); + pinnedRef.current = infowindow; onSelect(v.id); }); From d9c8f9776371c6325764c80b852b5716c5b2ffe6 Mon Sep 17 00:00:00 2001 From: Doi Kim <154426332+d0ikim@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:14:25 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=95=85=EA=B8=B0=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D=EC=8B=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EA=B3=A0=EC=A0=95=20=EB=B0=8F=20=EB=AA=A8?= =?UTF-8?q?=EB=B0=94=EC=9D=BC=20=ED=9E=88=EC=96=B4=EB=A1=9C=20=EC=B6=95?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/visitor/ScreenDirectory.jsx | 26 ++++++++++++++++--- frontend/src/styles/global.css | 4 +++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/visitor/ScreenDirectory.jsx b/frontend/src/pages/visitor/ScreenDirectory.jsx index a8e38e5..4bf0a31 100644 --- a/frontend/src/pages/visitor/ScreenDirectory.jsx +++ b/frontend/src/pages/visitor/ScreenDirectory.jsx @@ -1,5 +1,5 @@ // 뮤지션 디렉토리 페이지 — 서비스 메인 화면이자 뮤지션 목록을 보여주는 화면 -import { useState, useEffect } from 'react'; // useState: 상태 관리 / useEffect: 마운트 시 API 호출 +import { useState, useEffect, useRef } from 'react'; // useState: 상태 관리 / useEffect: 마운트 시 API 호출 / useRef: 스크롤 기준점·마운트 여부 기억 import Icon from '../../components/Icon'; // 아이콘 컴포넌트 import { api } from '../../api/client'; // 백엔드 API 호출 함수 모음 @@ -19,6 +19,23 @@ export default function ScreenDirectory({ navigate, auth, onLoginClick }) { // filter: 선택된 악기 필터 ('all' 또는 'Piano' 등) const [filter, setFilter] = useState('all'); + // 필터 변경 시 스크롤을 맞출 기준점 (디렉토리 헤더 div를 가리킴) + const listTopRef = useRef(null); + // 첫 렌더인지 여부 — 페이지에 처음 들어왔을 때는 스크롤을 건드리면 안 되므로 구분 + const didMountRef = useRef(false); + + // 악기 필터를 바꾸면 목록 길이가 확 줄면서 페이지 높이가 짧아지고, + // 브라우저가 스크롤 위치를 잃어 화면이 최상단(히어로)으로 튀는 문제가 있었음. + // → 필터가 바뀔 때마다 "디렉토리 헤더"가 화면 상단에 오도록 스크롤을 고정해서 + // 히어로를 다시 지나칠 필요 없이 바로 목록을 볼 수 있게 함 + useEffect(() => { + if (!didMountRef.current) { + didMountRef.current = true; // 첫 렌더는 건너뜀 (페이지 진입 시에는 맨 위부터 보여야 함) + return; + } + listTopRef.current?.scrollIntoView({ block: 'start' }); + }, [filter]); // filter가 바뀔 때만 실행 (검색어 입력 중에는 스크롤하지 않음) + // 마운트 시 전체 뮤지션 목록을 백엔드에서 불러옴 useEffect(() => { api.getMusicians() // GET /api/musicians @@ -55,7 +72,8 @@ export default function ScreenDirectory({ navigate, auth, onLoginClick }) {
K-JAZZ INSIGHT NAVIGATOR · MVP
-

+ {/* 제목 크기는 인라인이 아니라 CSS(.hero-title)로 지정 — 모바일 미디어 쿼리로 줄일 수 있게 */} +

한국재즈 뮤지션을
뮤지션이 직접 정리합니다.

@@ -93,8 +111,8 @@ export default function ScreenDirectory({ navigate, auth, onLoginClick }) {

- {/* 디렉토리 헤더 — 뮤지션 수 + 검색창 */} -
+ {/* 디렉토리 헤더 — 뮤지션 수 + 검색창 (ref: 필터 변경 시 이 위치로 스크롤) */} +
뮤지션 디렉토리

활동중인 뮤지션 {list.length}명

diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 9ac7f9f..bee519d 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -314,6 +314,7 @@ button.pill:hover { opacity: 0.85; } position: relative; overflow: hidden; } +.hero-title { font-size: 44px; } /* 모바일에서는 아래 768px 미디어 쿼리가 줄임 */ .hero .stamp { position: absolute; right: -40px; top: -40px; width: 200px; height: 200px; border-radius: 999px; @@ -899,6 +900,9 @@ button.pill:hover { opacity: 0.85; } .h2 { font-size: 22px; } /* 긴 영문 단어(밴드명 등)가 포함된 제목이 화면 밖으로 삐져나가지 않게 강제 줄바꿈 */ .h1, .h2, .h3 { overflow-wrap: anywhere; } + /* 히어로 제목·여백 축소 — 히어로가 화면을 덜 차지해 목록까지 스크롤이 짧아짐 */ + .hero { padding: 32px 24px; } + .hero-title { font-size: 30px; } .lead { font-size: 16px; } /* 데스크톱 기준(14px)은 폰에서 너무 작음 — 모바일 기본 글자를 16px로 키움 */ body { font-size: 16px; } From 6ad8298cccece365c67696c673b83d5e1efe43e5 Mon Sep 17 00:00:00 2001 From: Doi Kim <154426332+d0ikim@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:05:44 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20SNS,=EC=9B=B9=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20URL=ED=98=95=EC=8B=9D=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(=ED=94=84=EB=A1=A0=ED=8A=B8=20=ED=8F=BC+?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20Bean=20Validation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + backend/build.gradle | 1 + .../again/domain/admin/AdminController.java | 9 ++--- .../domain/musician/MusicianController.java | 3 +- .../domain/musician/MusicianRequest.java | 15 +++++++++ .../musician/MusicianUpdateRequest.java | 6 ++++ .../dazz/again/domain/venue/VenueRequest.java | 12 +++++++ .../global/error/GlobalExceptionHandler.java | 33 +++++++++++++++++++ .../src/pages/dashboard/ScreenProfileEdit.jsx | 16 ++++++++- .../src/pages/onboarding/ScreenOnboarding.jsx | 17 +++++++++- frontend/src/styles/global.css | 1 + frontend/src/utils/validators.js | 22 +++++++++++++ 12 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/com/dazz/again/global/error/GlobalExceptionHandler.java create mode 100644 frontend/src/utils/validators.js 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/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 ? ( - + ) : (