From 0a8fde9e7559b1c9e4ad6802e496a7f1b97635c4 Mon Sep 17 00:00:00 2001 From: BlackNoir5 <103233073+BlackNoir5@users.noreply.github.com> Date: Sat, 4 Jan 2025 05:38:28 +0900 Subject: [PATCH 01/23] =?UTF-8?q?feat:=20crossroad=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit map api front / 경로찾기 / 마커 찍기 / 신호등 상태 반환하기 기능 추가 --- keystore.p12 | Bin 0 -> 2780 bytes .../domain/basetime/BaseTimeEntity.java | 1 + .../controller/CrossroadController.java | 38 +- .../crossroad/controller/WebController.java | 17 + .../crossroad/dto/CrossroadApiResponse.java | 12 +- .../dto/CrossroadStateApiResponse.java | 70 ++++ .../domain/crossroad/dto/SignalState.java | 32 ++ .../domain/crossroad/entity/Crossroad.java | 16 +- .../exception/CrossroadErrorCode.java | 2 +- .../crossroad/service/CrossroadProvider.java | 30 +- .../crossroad/service/CrossroadService.java | 23 +- .../global/config/SecurityConfig.java | 1 + .../resources/static/css/crossroad/signal.css | 25 ++ .../resources/static/js/crossroad/tMap.js | 360 ++++++++++++++++++ .../resources/templates/crossroad/main.html | 124 ++++++ 15 files changed, 724 insertions(+), 27 deletions(-) create mode 100644 keystore.p12 create mode 100644 src/main/java/org/programmers/signalbuddy/domain/crossroad/controller/WebController.java create mode 100644 src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadStateApiResponse.java create mode 100644 src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/SignalState.java create mode 100644 src/main/resources/static/css/crossroad/signal.css create mode 100644 src/main/resources/static/js/crossroad/tMap.js create mode 100644 src/main/resources/templates/crossroad/main.html diff --git a/keystore.p12 b/keystore.p12 new file mode 100644 index 0000000000000000000000000000000000000000..c70b283aa4d1e596fc4d9616f9df3fd23eff5597 GIT binary patch literal 2780 zcma)8c{CJ^7M~e2#*8J)2qC+XrDiN+EQRQ^XK9AS7h*7E8%rr-#uCL?$`rzwu~YWt zBVNFA60sXC%7j-rH<`#&lkI1ogqKn#yaeU#TP1%wyn zgu?#a@}N|}h>o$03Duem^81tKj=5!0GDjll%muFgF3iz#|V=6Ye_*h2$R4DII&^8{aUus5z?ONYK zP@0N}zGKTOWWh?uX%aK_gyvd7755~QuluP~T}so78MQ+*i^yb$mDWahpR#LX+;H@~ z+U;=~k2GFK+Q@HW_re4v1@bIplj4fQ45n#+E+~??98xp&#W~}~pJWAc07 z%*xlZHxJ2?t_HlH$+S_vOM4)Q2@QH8J)R+pqP9{vYpi(LBTXMhbBk z_P(~*vyGfwAnVhnYsLP3ZG|8`<|lH|D7OI!dIAcjYc48R3z>$W+9M@`rWca|q4ZnIMr8 zjyR)!MyIx%IE#Oz+c7AhX=`L}!o*+tu_jW*11SGG|7WCU!(G0jOl*&UZ4nW%>nqMZnaWkhh(CIiVJrVEP-T~HvS>D-xzuy_b_NRT2)IEpDQc;a2t1DdmwxG^Tw?^5rTLb2KSAzVr z1Ievtx-1#mMrPv52M!n|9}8fUX>c^FrRKK{%V*8RvPWr8FEXs~;$byJx%0MfOm(_B zP0z9O`{OhsR_q^ri1S(?9A*otom*;G&Ql+W0BablKJFPsx8gT>x_@bxK(+z zbyT+%)`D2!Zyh=MDnOJLu!BcuMJp|QtWhj)qZZb__+q$JQ*p(`w#NulbkaZGcP@=8 zn!2~0ea)cUDri}UvOcu>-9+{IZryntC)8l&KAg^9%8{d&q+b#7QMtk%rnYjsAteS= zYc2-i_Ee+z`s z2(TcI=uePFYinp>QQBGMFAK5T7q)YDd3DFiV6Y(j?&$~C&2$@-H+P_)S+Ol zput1qw*MdNZlcU;hRJJ-H`qisijs?+Vklt#*MoN#sejT%urIT-ea)Uv z@@<029u?0$77Zw~iefKkg7ci%{f4?1IrcR?3O-LmyTY~%&NHitlKJU0Z_QUR%ptlp z%xg-)7w@_}PxuYm;vsOp{UX{o7LbKmiQn+~R37W)`$uCj8(HPPmM1Ew?D$i^jeVv| zB~FwzK|SlP_SP^ff<_!;%~yRL+>alsmO5!;B>N{Xsmeg6q7^^86sENp)u-Wq)q<_7 zuDyWq1E<0--B{uB5&t~-nc#ydGYh*uG^We_3cjVwwX2}w@OW*>Ond^^r?1#|r+v%L zsDuzL4|~|Z({@2?_;IGk&x2SXp>(wA-fL%zdJ$W?t;2*OJCk=N_E4gmC&*50>r$Yb zMK34yTJ4;Rarlyp5f)yp9zxYk)y2JD$~W50J@)j5>Y6&DRWjuhtx!=eWu*YwJnDQJ z5~BAp^O-uGi%6!rNxCx=cN1ObgikFm{$OLu4%e)e=z~n<$pmzH8Trk8#rSyE>hQ>` zRV?R&!@d{qOB*DAq^kI}Axolr%`bKqGnkODvV^(T>%|`L%?RHc4`k$fp!uhCI41bg zgad6@Nub^iF+o&(Vd?wO&Rkxlg#M8ixb!=(^Gbc0H|2xBh1ZzYfsrh(i?}S+tT8!U z3?5IrhDvpg$?%2wFvTsKrS(~0Ny520k;qGRKOKDOadx8+ZumJHeoMfS6 zkA>aQf?_;4#Zy@#4`=7=E>pmP&7I;}8BLpHc@Zjw*W%%@{@bDtU%{ZEeM?nm|8C{%OcG^?l;#dU4mfu-oeKA zUB2?u=i5muF?f=FA0hGH&7vO;0+(rC+1)a>hkjtJQ^^|>1nccafe;y2Qhi70mj9D` z-7c)(T)BR zs!K+f3~-w7J(N!oz^-CLxXV5sXtr5hzGrD!7D_ZsZa#e1!(bqc3q4NUjH%03Yi#pL zs`};|s<|ar5*C saveCrossroadDates(@Min(1) @RequestParam("page") int crossroadService.saveCrossroadDates(page, pageSize); return ResponseEntity.ok().build(); } + + + @GetMapping("/marker") // 저장된 DB 데이터를 기반으로 map에 찍을 marker의 데이터를 point로 가져오기 + public ResponseEntity> pointToMarker(){ + List markers = crossroadService.getAllMarkers(); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(markers); + } + + + @GetMapping("/state/{id}") // id를 기반으로 신호등 데이터 상태 검색 + public ResponseEntity> markerToState(@PathVariable("id") Long id) { + HttpHeaders headers = new HttpHeaders(); + headers.setCacheControl(CacheControl.noCache().getHeaderValue()); + + List stateRes = crossroadService.checkSignalState(id); + + return ResponseEntity.ok() + .headers(headers) + .contentType(MediaType.APPLICATION_JSON) + .body(stateRes); + } + } diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/controller/WebController.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/controller/WebController.java new file mode 100644 index 00000000..9b990a7d --- /dev/null +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/controller/WebController.java @@ -0,0 +1,17 @@ +package org.programmers.signalbuddy.domain.crossroad.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.ModelAndView; + +@Controller +@RequestMapping("/crossroad") +public class WebController { + + @GetMapping("/main") + public ModelAndView index(ModelAndView mv) { + mv.setViewName("/crossroad/main"); + return mv; + } +} diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadApiResponse.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadApiResponse.java index 20146e4d..a9dc3e8f 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadApiResponse.java +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadApiResponse.java @@ -1,7 +1,6 @@ package org.programmers.signalbuddy.domain.crossroad.dto; -import static org.programmers.signalbuddy.domain.crossroad.service.PointUtil.toPoint; - +import org.programmers.signalbuddy.domain.crossroad.entity.Crossroad; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AccessLevel; @@ -10,6 +9,8 @@ import lombok.Getter; import org.locationtech.jts.geom.Point; +import static org.programmers.signalbuddy.domain.crossroad.service.PointUtil.toPoint; + @Getter @Builder @JsonIgnoreProperties(ignoreUnknown = true) @@ -31,4 +32,11 @@ public class CrossroadApiResponse { public Point getPoint() { return toPoint(this.lat, this.lng); } + + public CrossroadApiResponse(Crossroad crossroad) { + this.crossroadApiId = crossroad.getCrossroadApiId(); + this.name = crossroad.getName(); + this.lat = crossroad.getCoordinate().getX(); + this.lng = crossroad.getCoordinate().getY(); + } } diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadStateApiResponse.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadStateApiResponse.java new file mode 100644 index 00000000..03274dab --- /dev/null +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadStateApiResponse.java @@ -0,0 +1,70 @@ +package org.programmers.signalbuddy.domain.crossroad.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class CrossroadStateApiResponse { + + @JsonProperty("itstId") + private String crossroadApiId; + + // e,w,s,n,ne,nw,se,sw 8방위 + // p : 사람, rs: remain second (남은 시간), sn: state name (상태 이름) + + @JsonProperty("ntPdsgRmdrCs") + private int nprs; + + @JsonProperty("etPdsgRmdrCs") + private int eprs; + + @JsonProperty("stPdsgRmdrCs") + private int sprs; + + @JsonProperty("wtPdsgRmdrCs") + private int wprs; + + @JsonProperty("nePdsgRmdrCs") + private int neprs; + + @JsonProperty("nwPdsgRmdrCs") + private int nwprs; + + @JsonProperty("swPdsgRmdrCs") + private int swprs; + + @JsonProperty("sePdsgRmdrCs") + private int seprs; + + @JsonProperty("ntPdsgStatNm") + private SignalState npsn; + + @JsonProperty("etPdsgStatNm") + private SignalState epsn; + + @JsonProperty("wtPdsgStatNm") + private SignalState wpsn; + + @JsonProperty("stPdsgStatNm") + private SignalState spsn; + + @JsonProperty("nePdsgStatNm") + private SignalState nepsn; + + @JsonProperty("nwPdsgStatNm") + private SignalState nwpsn; + + @JsonProperty("sePdsgStatNm") + private SignalState sepsn; + + @JsonProperty("swPdsgStatNm") + private SignalState swpsn; + +} diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/SignalState.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/SignalState.java new file mode 100644 index 00000000..bc6d7013 --- /dev/null +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/SignalState.java @@ -0,0 +1,32 @@ +package org.programmers.signalbuddy.domain.crossroad.dto; + + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum SignalState { + GREEN("protected-Movement-Allowed",true), // 신호등이 녹색, 이동 보장 상태 + YELLOW("permissive-Movement-Allowed",true), // 신호등이 황생, 이동 가능 상태 + RED("stop-And-Remain",false); // 신호등이 적색, 정지 상태 + + private String state; + private boolean can_cross; + + @JsonValue // 데이터 직렬화 java -> JSON + public String getState() { + return name().toLowerCase(); + } + + @JsonCreator // 데이터 역직렬화 JSON -> java + public static SignalState fromState(String state) { + for(SignalState signalState : SignalState.values()) { + if(signalState.state.equals(state)) { + return signalState; + } + } + throw new IllegalArgumentException("Unknown name: " + state); + } +} diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/entity/Crossroad.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/entity/Crossroad.java index 9858c7c0..7c85475d 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/crossroad/entity/Crossroad.java +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/entity/Crossroad.java @@ -1,19 +1,10 @@ package org.programmers.signalbuddy.domain.crossroad.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; -import org.locationtech.jts.geom.Point; import org.programmers.signalbuddy.domain.basetime.BaseTimeEntity; import org.programmers.signalbuddy.domain.crossroad.dto.CrossroadApiResponse; +import jakarta.persistence.*; +import lombok.*; +import org.locationtech.jts.geom.Point; @Entity(name = "crossroads") @Getter @@ -41,4 +32,5 @@ public Crossroad(CrossroadApiResponse response) { this.name = response.getName(); this.coordinate = response.getPoint(); } + } \ No newline at end of file diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/exception/CrossroadErrorCode.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/exception/CrossroadErrorCode.java index 07af81b2..f72bf4bf 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/crossroad/exception/CrossroadErrorCode.java +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/exception/CrossroadErrorCode.java @@ -1,9 +1,9 @@ package org.programmers.signalbuddy.domain.crossroad.exception; +import org.programmers.signalbuddy.global.exception.ErrorCode; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; -import org.programmers.signalbuddy.global.exception.ErrorCode; import org.springframework.http.HttpStatus; @Getter diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/service/CrossroadProvider.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/service/CrossroadProvider.java index 0c7663f1..e6b2f88e 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/crossroad/service/CrossroadProvider.java +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/service/CrossroadProvider.java @@ -1,16 +1,18 @@ package org.programmers.signalbuddy.domain.crossroad.service; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.programmers.signalbuddy.domain.crossroad.dto.CrossroadApiResponse; +import org.programmers.signalbuddy.domain.crossroad.dto.CrossroadStateApiResponse; import org.programmers.signalbuddy.domain.crossroad.exception.CrossroadErrorCode; import org.programmers.signalbuddy.global.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; +import java.util.List; + @Slf4j @Component @RequiredArgsConstructor @@ -20,6 +22,8 @@ public class CrossroadProvider { private String API_KEY; @Value("${t-data-api.crossroad-api}") private String CROSSROAD_API_URL; + @Value("${t-data-api.traffic-light-api}") + private String SIGNAL_STATE_URL; private final WebClient webClient; @@ -34,9 +38,27 @@ public List requestCrossroadApi(int page, int pageSize) { .retrieve() .bodyToMono(new ParameterizedTypeReference>() {}) .onErrorMap(e -> { - log.error("{}\n{}", e.getMessage(), e.getCause()); + log.error("{}\n", e.getMessage(), e.getCause()); throw new BusinessException(CrossroadErrorCode.CROSSROAD_API_REQUEST_FAILED); }) .block(); } + + public List requestCrossroadStateApi(Long id) { + return webClient.get() + .uri(SIGNAL_STATE_URL, + uriBuilder -> uriBuilder + .queryParam("apiKey",API_KEY) + .queryParam("itstId",id) + .queryParam("pageNo",1) + .queryParam("numOfRows",1) + .build()) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .onErrorMap(e->{ + log.error("{}\n", e.getMessage(), e.getCause()); + throw new BusinessException(CrossroadErrorCode.CROSSROAD_API_REQUEST_FAILED); + }) + .block(); + } } diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/service/CrossroadService.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/service/CrossroadService.java index 99ad07e7..5b7211d4 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/crossroad/service/CrossroadService.java +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/service/CrossroadService.java @@ -1,17 +1,19 @@ package org.programmers.signalbuddy.domain.crossroad.service; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; import org.programmers.signalbuddy.domain.crossroad.dto.CrossroadApiResponse; +import org.programmers.signalbuddy.domain.crossroad.dto.CrossroadStateApiResponse; import org.programmers.signalbuddy.domain.crossroad.entity.Crossroad; import org.programmers.signalbuddy.domain.crossroad.exception.CrossroadErrorCode; import org.programmers.signalbuddy.domain.crossroad.repository.CrossroadRepository; import org.programmers.signalbuddy.global.exception.BusinessException; +import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -38,4 +40,19 @@ public void saveCrossroadDates(int page, int pageSize) { throw new BusinessException(CrossroadErrorCode.ALREADY_EXIST_CROSSROAD); } } + + public List checkSignalState(Long id) { // id값으로 신호등의 상태를 검색 + return crossroadProvider.requestCrossroadStateApi(id); + } + + public List getAllMarkers(){ + List crossroads = crossroadRepository.findAll(); + List responseList = new ArrayList<>(); + + for(Crossroad crossroad : crossroads){ + responseList.add(new CrossroadApiResponse(crossroad)); + } + + return responseList; + } } diff --git a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java index a2213b84..18044669 100644 --- a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java +++ b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java @@ -50,6 +50,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.GET, "/api/comments").permitAll() // 교차로 .requestMatchers("/api/crossroads/save").hasRole("ADMIN") + .requestMatchers("/crossroad/**").permitAll() // 피드백 .requestMatchers(HttpMethod.GET, "/api/feedbacks", "/feedbacks/**").permitAll() // 회원 diff --git a/src/main/resources/static/css/crossroad/signal.css b/src/main/resources/static/css/crossroad/signal.css new file mode 100644 index 00000000..7088dc05 --- /dev/null +++ b/src/main/resources/static/css/crossroad/signal.css @@ -0,0 +1,25 @@ +.circle{ + width: 50px; + height: 50px; + border-radius: 50%; + background-color: rgb(252, 83, 83); + text-align: center; + line-height: 3; + margin: 10px; +} + +.suggestions-list { + position: absolute; /* 부모 요소 기준으로 위치 설정 */ + width: 20%; + max-height: 200px; /* 최대 높이 제한 */ + overflow-y: auto; /* 스크롤바 표시 */ + border: 1px solid #ccc; /* 테두리 */ + background-color: white; + z-index: 1000; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + visibility: hidden; /* 초기에는 숨김 */ +} + +.suggestions-list.show { + visibility: visible; /* 보이도록 설정 */ +} \ No newline at end of file diff --git a/src/main/resources/static/js/crossroad/tMap.js b/src/main/resources/static/js/crossroad/tMap.js new file mode 100644 index 00000000..04f1568d --- /dev/null +++ b/src/main/resources/static/js/crossroad/tMap.js @@ -0,0 +1,360 @@ + +let marker = null;// 마커 변수 +let abortController = new AbortController(); // API 요청 중 signal로 상태 변경 +let startMarker=null; +let endMarker=null; +let input = null; // 데이터 +let routeLayer=null; + +// 데이터 요청 상태 관리 +let isFetchingData = false; +let marker_id; +let minTime; + +const markers = {}; + + +// 지도 초기화 +var map = new Tmapv2.Map("map", { + center: new Tmapv2.LatLng(37.5665, 126.9780), // 지도 중심 좌표 (위도, 경도) + width: "100%", + height: "900px", + zoom: 12 // 줌 레벨 (값이 높을수록 확대) +}); + +fetch('api/crossroads/marker') + .then(response=> response.json()) + .then(data=>{ + // 데이터 순회하며 마커 생성 + data.forEach(crossroad => { + const { lat, lng } = crossroad.coordinate; + + const marker = new Tmapv2.Marker({ + position: new Tmapv2.LatLng(lat,lng), + map, + title: crossroad.name + }); + + markers[crossroad.crossroadApiId]=marker; + + marker.addListener("click",()=> { + marker_id=crossroad.crossroadApiId + console.log("click"); + disableFetching(); + enableFetching(); + callSignalStatus(marker_id); + }); + }); + }); /** TODO: + * PROBLEM: marker 찍기는 map 객체에 별도로 상태를 저장해두기가 불가 + * SOLUTION: 서버에서 데이터의 상태를 유지하기 위한 기능 고민 + * WHY: marker 갯수가 많아지면 랜더링 시간이 과하게 길어질 수 있음 + */ + +// 데이터 갱신 비활성화 +function disableFetching() { + isFetchingData = false; + handleCountdown(); + console.log("Data fetching disabled."); +} + +// 데이터 갱신 활성화 +function enableFetching() { + isFetchingData = true; + console.log("Data fetching enabled."); +} + +// 데이터 저장 및 UI 반영 +function callSignalStatus(api_id){ + console.log(api_id); + fetch(`api/crossroads/state/${api_id}`,{ cache: "no-store" }) + .then(response => response.json()) + .then( data => { + updateUI(data); + }); +} + +// UI 업데이트 함수 +function updateUI(data) { + minTime=10000; + const directions = ["NW", "NT", "NE", "WT", "ET", "SW", "ST", "SE"]; + + console.log(data); + + directions.forEach((dir) => { + const stateKey = `${dir.toLowerCase()}PdsgStatNm`; + const timeKey = `${dir.toLowerCase()}PdsgRmdrCs`; + + if (!(stateKey in data[0]) || !(timeKey in data[0])) { + console.warn(`Missing data for direction: ${dir}`); + return; + } + + const state = data[0][stateKey]; + const time = data[0][timeKey]; + const circle = document.getElementById(dir); + + const statusColors = { + "stop-And-Remain": "red", + "protected-Movement-Allowed": "green", + "permissive-Movement-Allowed": "yellow", + default: "gray" + }; + + if (!state) { + circle.style.display = "none"; // null 상태는 숨김 + } else if(state){ + circle.style.display = "block"; + circle.style.backgroundColor = statusColors[state]; // 상태 색상 + minTime = Math.min(minTime, time)+10; // 최소 남은 시간 계산 + } + }); + + time(); +} + +// 1초마다 카운트다운 실행 +function handleCountdown() { + if (!isFetchingData) return; + + if (minTime > 0) { + minTime -= 10; // 남은 시간 감소 + } + + // 시간이 0이면 데이터 갱신 + if (minTime <= 0) { + console.log("Time expired during countdown. Fetching new data..."); + callSignalStatus(marker_id); + } + + time(); // UI 업데이트 +} + +function time(){ + // 최소 남은 시간 표시 + const timeSpan = document.querySelector(".text-body-secondary"); + timeSpan.textContent = `${minTime/10} s Left`; +} + +// 1초마다 카운트다운 실행 +setInterval(handleCountdown, 1000); + +// 검색 버튼 클릭 시 동작 +document.getElementById("search").addEventListener("click", async function() { + var address = document.getElementById("searchInput").value; // 입력된 주소 가져오기 + + if(address) { + // 기존 요청을 취소 + if (abortController) { + abortController.abort(); // 이전 요청 취소 + } + + //새로운 컨트롤러 생성 + abortController = new AbortController(); + + await searchAddress(address, abortController.signal); + } else { + alert("주소를 입력하세요!"); + } +}); + +// 주소 검색 함수 +async function searchAddress(address, signal) { + input = document.getElementById("searchInput").value; + // 검색할 주소 + const url = `https://apis.openapi.sk.com/tmap/pois?version=1&searchType=all&page=1&multiPoint=Y&searchtypCd=A&searchKeyword=${input}`; + + try { + const response = await fetch(url, { + method: "GET", + headers: { + "Accept": "application/json", + "appKey": "1bxEMLzGUg68a4EeRA5F14J5Vbgh6GWI3zLXabl9" + }, + signal: signal + }); + + if (!response.ok) { + throw new Error(`HTTP Error: ${response.status}`); + } + + const data = await response.json(); + const pois = data.searchPoiInfo.pois.poi; + + if (pois && pois.length > 0) {// POI 이름만 추출 + updateSuggestions(pois); + } else { + updateSuggestions([]); + } + } catch (error) { + if (error.name === 'AbortError') { + // AbortError는 요청 취소이므로 그냥 무시하고 정상 처리 + console.log("검색이 취소되었습니다."); + } else { + console.error("Error during address search:", error); + } + updateSuggestions([]); + } +} + +//이벤트 핸들러 변수 저장 +const handleClick = (poi) => { + document.getElementById("searchInput").value = poi.name; // 클릭 시 입력 필드에 채우기 + clearMarker(); // 기존 마커 제거 + findRoute(poi.frontLat, poi.frontLon); // 경로 찾기 +}; + +function updateSuggestions(pois) { + const suggestionsElement = document.getElementById("suggestions"); + suggestionsElement.innerHTML = ""; // 기존 추천어 목록 초기화 + + + if (pois.length === 0) { + suggestionsElement.style.display = "none"; + return; + } + + pois.forEach(poi => { + const div = document.createElement("div"); + div.textContent = poi.name; + + // 클릭 이벤트 리스너를 추가하기 전에 기존 이벤트 리스너 제거 + div.removeEventListener("click", handleClick); // 기존 이벤트 리스너 제거 (있다면) + suggestionsElement.style.visibility = "visible"; + + // 새로운 클릭 이벤트 리스너 추가 + div.addEventListener("click", () => handleClick(poi)); + suggestionsElement.appendChild(div); + + }); + + suggestionsElement.classList.add("show"); // 추천어 목록 표시 +} + +// 마커를 제거하는 함수 +function clearMarker() { + + if (marker) { + marker.setMap(null); // 지도에서 마커 제거 + marker = null; // 마커 변수 초기화 + } +} + +document.getElementById("searchInput").addEventListener("input", function () { + const inputValue = searchInput.value.trim(); + if (inputValue === "") { + // 검색박스가 비었을 때 + clearMarker(); // 마커 제거 + } +}); + +// 경로 검색 +async function findRoute(endLat,endLon) { + + document.getElementById("suggestions").style.visibility = "hidden";// 추천어 목록 숨김 + + const startLat = 37.5665; + const startLon = 126.9780; + + if (!endLat || !endLon) { + alert("도착지를 선택하세요!"); + return; + } + + const url = `https://apis.openapi.sk.com/tmap/routes?version=1&callback=result`; + const headers = { + "Accept": "application/json", + "appKey": "1bxEMLzGUg68a4EeRA5F14J5Vbgh6GWI3zLXabl9" + }; + const body = JSON.stringify({ + startX: startLon, + startY: startLat, + endX: endLon, + endY: endLat, + reqCoordType: "WGS84GEO", + resCoordType: "EPSG3857", + startName: "출발지", + endName: "도착지" + }); + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body + }); + const data = await response.json(); + const routeData = data.features; + + if (routeLayer) { + routeLayer.setMap(null); + } + + console.log(routeData); + + drawRoute(routeData, startLat, startLon, endLat, endLon); + } catch (error) { + console.error("길찾기 중 오류:", error); + } +} + +function convertToWGS84(x, y) { + var lon = (x / 20037508.34) * 180; + var lat = (y / 20037508.34) * 180; + lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180)) - Math.PI / 2); + + return { lat: lat, lon: lon }; +} // 더러운거 보지 마세요 -> 좌표계 변경 + +// 경로 그리기 +function drawRoute(routeData, startLat, startLon, endLat, endLon) { + + const path = routeData + .filter(item => item.geometry.type === "LineString") + .flatMap(item => item.geometry.coordinates.map(coord => { + // 좌표 변환 + const converted = convertToWGS84(coord[0], coord[1]); + + // 변환된 좌표 유효성 검사 + if (converted.lat === 0 && converted.lon === 0) { + console.warn("Invalid coordinates detected:", coord); + return null; // 잘못된 좌표는 제외 + } + + // 유효한 좌표를 LatLng 객체로 반환 + return new Tmapv2.LatLng(converted.lat, converted.lon); + }) + ) + .filter(Boolean); + + routeLayer = new Tmapv2.Polyline({ + path, + strokeColor: "#ff0000", + strokeWeight: 6, + map: map + }); + + addMarkers(startLat, startLon, endLat, endLon); +} + +// 마커 추가 +function addMarkers(startLat, startLon, endLat, endLon) { + + if (startMarker) startMarker.setMap(null); + if (endMarker) endMarker.setMap(null); + + startMarker = new Tmapv2.Marker({ + position: new Tmapv2.LatLng(startLat, startLon), + map, + title: "출발지" + }); + + endMarker = new Tmapv2.Marker({ + position: new Tmapv2.LatLng(endLat, endLon), + map, + title: "도착지" + }); + + map.setCenter(new Tmapv2.LatLng(startLat, startLon)); + map.setZoom(14); +} diff --git a/src/main/resources/templates/crossroad/main.html b/src/main/resources/templates/crossroad/main.html new file mode 100644 index 00000000..77dc8da2 --- /dev/null +++ b/src/main/resources/templates/crossroad/main.html @@ -0,0 +1,124 @@ + + + + + + SignalBuddy + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+
+
+ + + + \ No newline at end of file From efe8bf1866d04c099612476752daed034daa5677 Mon Sep 17 00:00:00 2001 From: BlackNoir5 <103233073+BlackNoir5@users.noreply.github.com> Date: Sat, 4 Jan 2025 05:54:55 +0900 Subject: [PATCH 02/23] Update submodule --- submodule | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodule b/submodule index 2614115c..4975a988 160000 --- a/submodule +++ b/submodule @@ -1 +1 @@ -Subproject commit 2614115c0562f96d2bb62180542f3094c542ca31 +Subproject commit 4975a988b6498504d74c9c8806e0204aede5d09a From c050cdc88e7d3af88b58b342d7068fc10d376cce Mon Sep 17 00:00:00 2001 From: BlackNoir5 <103233073+BlackNoir5@users.noreply.github.com> Date: Sun, 5 Jan 2025 18:31:21 +0900 Subject: [PATCH 03/23] =?UTF-8?q?feat:=20crossroad=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20fix:=20cr?= =?UTF-8?q?ossroad=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat - map 클릭시 마커 생성 및 도착 위치 설정 fix - 1.4 순환참조 문제 (point) 변경 --- .../crossroad/controller/WebController.java | 6 +- .../crossroad/dto/CrossroadApiResponse.java | 11 ++- .../domain/crossroad/dto/SignalState.java | 2 +- .../domain/crossroad/entity/Crossroad.java | 2 +- .../global/config/SecurityConfig.java | 2 +- .../resources/static/js/crossroad/tMap.js | 71 +++++++++++-------- .../templates/{crossroad => }/main.html | 0 7 files changed, 54 insertions(+), 40 deletions(-) rename src/main/resources/templates/{crossroad => }/main.html (100%) diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/controller/WebController.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/controller/WebController.java index 9b990a7d..95b3a971 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/crossroad/controller/WebController.java +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/controller/WebController.java @@ -6,12 +6,12 @@ import org.springframework.web.servlet.ModelAndView; @Controller -@RequestMapping("/crossroad") +@RequestMapping("/") public class WebController { - @GetMapping("/main") + @GetMapping public ModelAndView index(ModelAndView mv) { - mv.setViewName("/crossroad/main"); + mv.setViewName("main"); return mv; } } diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadApiResponse.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadApiResponse.java index a9dc3e8f..a2eb5401 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadApiResponse.java +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/CrossroadApiResponse.java @@ -8,8 +8,7 @@ import lombok.Builder; import lombok.Getter; import org.locationtech.jts.geom.Point; - -import static org.programmers.signalbuddy.domain.crossroad.service.PointUtil.toPoint; +import org.programmers.signalbuddy.domain.crossroad.service.PointUtil; @Getter @Builder @@ -29,14 +28,14 @@ public class CrossroadApiResponse { @JsonProperty("mapCtptIntLot") private Double lng; // 경도 - public Point getPoint() { - return toPoint(this.lat, this.lng); + public Point toPoint() { + return PointUtil.toPoint(this.lat, this.lng); } public CrossroadApiResponse(Crossroad crossroad) { this.crossroadApiId = crossroad.getCrossroadApiId(); this.name = crossroad.getName(); - this.lat = crossroad.getCoordinate().getX(); - this.lng = crossroad.getCoordinate().getY(); + this.lat = crossroad.getCoordinate().getY(); + this.lng = crossroad.getCoordinate().getX(); } } diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/SignalState.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/SignalState.java index bc6d7013..6ba27b65 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/SignalState.java +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/dto/SignalState.java @@ -9,7 +9,7 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public enum SignalState { GREEN("protected-Movement-Allowed",true), // 신호등이 녹색, 이동 보장 상태 - YELLOW("permissive-Movement-Allowed",true), // 신호등이 황생, 이동 가능 상태 + GRAY("permissive-Movement-Allowed",true), // 신호등이 황생, 이동 가능 상태 RED("stop-And-Remain",false); // 신호등이 적색, 정지 상태 private String state; diff --git a/src/main/java/org/programmers/signalbuddy/domain/crossroad/entity/Crossroad.java b/src/main/java/org/programmers/signalbuddy/domain/crossroad/entity/Crossroad.java index 7c85475d..efd4e65c 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/crossroad/entity/Crossroad.java +++ b/src/main/java/org/programmers/signalbuddy/domain/crossroad/entity/Crossroad.java @@ -30,7 +30,7 @@ public class Crossroad extends BaseTimeEntity { public Crossroad(CrossroadApiResponse response) { this.crossroadApiId = response.getCrossroadApiId(); this.name = response.getName(); - this.coordinate = response.getPoint(); + this.coordinate = response.toPoint(); } } \ No newline at end of file diff --git a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java index 18044669..22075c95 100644 --- a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java +++ b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java @@ -50,7 +50,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.GET, "/api/comments").permitAll() // 교차로 .requestMatchers("/api/crossroads/save").hasRole("ADMIN") - .requestMatchers("/crossroad/**").permitAll() + .requestMatchers(HttpMethod.GET,"/api/crossroads/**").permitAll() // 피드백 .requestMatchers(HttpMethod.GET, "/api/feedbacks", "/feedbacks/**").permitAll() // 회원 diff --git a/src/main/resources/static/js/crossroad/tMap.js b/src/main/resources/static/js/crossroad/tMap.js index 04f1568d..ce5991c4 100644 --- a/src/main/resources/static/js/crossroad/tMap.js +++ b/src/main/resources/static/js/crossroad/tMap.js @@ -1,10 +1,11 @@ let marker = null;// 마커 변수 let abortController = new AbortController(); // API 요청 중 signal로 상태 변경 -let startMarker=null; -let endMarker=null; +let startMarker= null; +let endMarker= null; let input = null; // 데이터 -let routeLayer=null; +let routeLayer= null; +let startLatLng=null; // 데이터 요청 상태 관리 let isFetchingData = false; @@ -22,23 +23,43 @@ var map = new Tmapv2.Map("map", { zoom: 12 // 줌 레벨 (값이 높을수록 확대) }); +map.addListener("click", function (event) { + latLng = null; // 값 초기화 + latLng = event.latLng; // 클릭한 위치의 위도, 경도 정보 + + console.log(latLng); + + if (startMarker && latLng) { + startMarker.setMap(null); // 기존 마커 제거 + } + + if(latLng){ + startMarker = new Tmapv2.Marker({ + position: latLng, + map: map + }); + } + +}); + fetch('api/crossroads/marker') .then(response=> response.json()) - .then(data=>{ + .then(data=> { // 데이터 순회하며 마커 생성 data.forEach(crossroad => { - const { lat, lng } = crossroad.coordinate; + const lat = crossroad.mapCtptIntLat; + const lng = crossroad.mapCtptIntLot; const marker = new Tmapv2.Marker({ position: new Tmapv2.LatLng(lat,lng), map, - title: crossroad.name + title: crossroad.itstNm }); markers[crossroad.crossroadApiId]=marker; marker.addListener("click",()=> { - marker_id=crossroad.crossroadApiId + marker_id=crossroad.itstId console.log("click"); disableFetching(); enableFetching(); @@ -94,18 +115,17 @@ function updateUI(data) { const time = data[0][timeKey]; const circle = document.getElementById(dir); - const statusColors = { + /* const statusColors = { "stop-And-Remain": "red", "protected-Movement-Allowed": "green", - "permissive-Movement-Allowed": "yellow", - default: "gray" - }; + "permissive-Movement-Allowed": "gray" + };*/ if (!state) { circle.style.display = "none"; // null 상태는 숨김 } else if(state){ circle.style.display = "block"; - circle.style.backgroundColor = statusColors[state]; // 상태 색상 + circle.style.backgroundColor = state; // 상태 색상 minTime = Math.min(minTime, time)+10; // 최소 남은 시간 계산 } }); @@ -200,7 +220,6 @@ async function searchAddress(address, signal) { //이벤트 핸들러 변수 저장 const handleClick = (poi) => { document.getElementById("searchInput").value = poi.name; // 클릭 시 입력 필드에 채우기 - clearMarker(); // 기존 마커 제거 findRoute(poi.frontLat, poi.frontLon); // 경로 찾기 }; @@ -234,10 +253,13 @@ function updateSuggestions(pois) { // 마커를 제거하는 함수 function clearMarker() { - if (marker) { - marker.setMap(null); // 지도에서 마커 제거 - marker = null; // 마커 변수 초기화 + if (startMarker) { + startMarker.setMap(null); // 지도에서 마커 제거 + startMarker = null; // 마커 변수 초기화 + endMarker.setMap(null); + endMarker = null; } + } document.getElementById("searchInput").addEventListener("input", function () { @@ -251,10 +273,12 @@ document.getElementById("searchInput").addEventListener("input", function () { // 경로 검색 async function findRoute(endLat,endLon) { - document.getElementById("suggestions").style.visibility = "hidden";// 추천어 목록 숨김 + console.log(latLng); + + let startLat = latLng._lat; + let startLon = latLng._lng; - const startLat = 37.5665; - const startLon = 126.9780; + document.getElementById("suggestions").style.visibility = "hidden";// 추천어 목록 숨김 if (!endLat || !endLon) { alert("도착지를 선택하세요!"); @@ -340,21 +364,12 @@ function drawRoute(routeData, startLat, startLon, endLat, endLon) { // 마커 추가 function addMarkers(startLat, startLon, endLat, endLon) { - if (startMarker) startMarker.setMap(null); if (endMarker) endMarker.setMap(null); - startMarker = new Tmapv2.Marker({ - position: new Tmapv2.LatLng(startLat, startLon), - map, - title: "출발지" - }); - endMarker = new Tmapv2.Marker({ position: new Tmapv2.LatLng(endLat, endLon), map, title: "도착지" }); - map.setCenter(new Tmapv2.LatLng(startLat, startLon)); - map.setZoom(14); } diff --git a/src/main/resources/templates/crossroad/main.html b/src/main/resources/templates/main.html similarity index 100% rename from src/main/resources/templates/crossroad/main.html rename to src/main/resources/templates/main.html From b7165f883837814933557fd5eb9edb077685d6e2 Mon Sep 17 00:00:00 2001 From: BlackNoir5 <103233073+BlackNoir5@users.noreply.github.com> Date: Sun, 5 Jan 2025 19:59:29 +0900 Subject: [PATCH 04/23] =?UTF-8?q?feat:=20crossroad=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20fix:=20cr?= =?UTF-8?q?ossroad=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat - map 클릭시 마커 생성 및 도착 위치 설정 fix - 1.4 순환참조 문제 (point) 변경 --- src/main/resources/static/js/crossroad/tMap.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/resources/static/js/crossroad/tMap.js b/src/main/resources/static/js/crossroad/tMap.js index ce5991c4..17d27432 100644 --- a/src/main/resources/static/js/crossroad/tMap.js +++ b/src/main/resources/static/js/crossroad/tMap.js @@ -285,7 +285,7 @@ async function findRoute(endLat,endLon) { return; } - const url = `https://apis.openapi.sk.com/tmap/routes?version=1&callback=result`; + const url = `https://apis.openapi.sk.com/tmap/routes/pedestrian`; const headers = { "Accept": "application/json", "appKey": "1bxEMLzGUg68a4EeRA5F14J5Vbgh6GWI3zLXabl9" @@ -310,6 +310,8 @@ async function findRoute(endLat,endLon) { const data = await response.json(); const routeData = data.features; + console.log(data); + if (routeLayer) { routeLayer.setMap(null); } From aa29b1b4ce72a4fe2d83bd684b4607893fc02919 Mon Sep 17 00:00:00 2001 From: BlackNoir5 <103233073+BlackNoir5@users.noreply.github.com> Date: Sun, 5 Jan 2025 22:40:24 +0900 Subject: [PATCH 05/23] submodule commit --- submodule | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodule b/submodule index 4975a988..5dfa1f19 160000 --- a/submodule +++ b/submodule @@ -1 +1 @@ -Subproject commit 4975a988b6498504d74c9c8806e0204aede5d09a +Subproject commit 5dfa1f19fa19ad6a875af369d60348c920444b15 From 484b82f3cebca04bc9c26ad66e580640c38831fd Mon Sep 17 00:00:00 2001 From: zzuharchive Date: Fri, 3 Jan 2025 03:34:10 +0900 Subject: [PATCH 06/23] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 ++ .../global/dto/CustomUser2Member.java | 12 ++- .../{ => basic}/CustomUserDetails.java | 2 +- .../{ => basic}/CustomUserDetailsService.java | 2 +- .../filter/UserAuthenticationFilter.java | 29 ++++--- .../CustomAuthenticationSuccessHandler.java | 10 ++- .../security/oauth/CustomOAuth2User.java | 50 ++++++++++++ .../oauth/CustomOAuth2UserService.java | 79 +++++++++++++++++++ .../oauth/response/GoogleResponse.java | 33 ++++++++ .../oauth/response/KakaoResponse.java | 36 +++++++++ .../oauth/response/NaverResponse.java | 34 ++++++++ .../oauth/response/OAuth2Response.java | 14 ++++ .../resources/templates/member/loginform.html | 5 +- .../comment/service/CommentServiceTest.java | 2 +- .../feedback/service/FeedbackServiceTest.java | 2 +- .../domain/like/service/LikeServiceTest.java | 2 +- 16 files changed, 297 insertions(+), 20 deletions(-) rename src/main/java/org/programmers/signalbuddy/global/security/{ => basic}/CustomUserDetails.java (96%) rename src/main/java/org/programmers/signalbuddy/global/security/{ => basic}/CustomUserDetailsService.java (95%) create mode 100644 src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2User.java create mode 100644 src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java create mode 100644 src/main/java/org/programmers/signalbuddy/global/security/oauth/response/GoogleResponse.java create mode 100644 src/main/java/org/programmers/signalbuddy/global/security/oauth/response/KakaoResponse.java create mode 100644 src/main/java/org/programmers/signalbuddy/global/security/oauth/response/NaverResponse.java create mode 100644 src/main/java/org/programmers/signalbuddy/global/security/oauth/response/OAuth2Response.java diff --git a/build.gradle b/build.gradle index 5199aa45..fa8b3640 100644 --- a/build.gradle +++ b/build.gradle @@ -76,6 +76,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + // oAuth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // Session implementation 'org.springframework.session:spring-session-data-redis' diff --git a/src/main/java/org/programmers/signalbuddy/global/dto/CustomUser2Member.java b/src/main/java/org/programmers/signalbuddy/global/dto/CustomUser2Member.java index abe02507..cbca2a1a 100644 --- a/src/main/java/org/programmers/signalbuddy/global/dto/CustomUser2Member.java +++ b/src/main/java/org/programmers/signalbuddy/global/dto/CustomUser2Member.java @@ -3,7 +3,8 @@ import lombok.Getter; import org.programmers.signalbuddy.domain.member.entity.enums.MemberRole; import org.programmers.signalbuddy.domain.member.entity.enums.MemberStatus; -import org.programmers.signalbuddy.global.security.CustomUserDetails; +import org.programmers.signalbuddy.global.security.basic.CustomUserDetails; +import org.programmers.signalbuddy.global.security.oauth.CustomOAuth2User; @Getter public class CustomUser2Member { @@ -24,5 +25,14 @@ public CustomUser2Member(CustomUserDetails customUserDetails) { this.status = customUserDetails.getStatus(); } + public CustomUser2Member(CustomOAuth2User customOAuth2User) { + this.memberId = customOAuth2User.getMemberId(); + this.email = customOAuth2User.getEmail(); + this.profileImageUrl = customOAuth2User.getProfileImageUrl(); + this.nickname = customOAuth2User.getNickname(); + this.role = customOAuth2User.getRole(); + this.status = customOAuth2User.getStatus(); + } + public CustomUser2Member(String arg) {} } diff --git a/src/main/java/org/programmers/signalbuddy/global/security/CustomUserDetails.java b/src/main/java/org/programmers/signalbuddy/global/security/basic/CustomUserDetails.java similarity index 96% rename from src/main/java/org/programmers/signalbuddy/global/security/CustomUserDetails.java rename to src/main/java/org/programmers/signalbuddy/global/security/basic/CustomUserDetails.java index 002e7e37..21bd895e 100644 --- a/src/main/java/org/programmers/signalbuddy/global/security/CustomUserDetails.java +++ b/src/main/java/org/programmers/signalbuddy/global/security/basic/CustomUserDetails.java @@ -1,4 +1,4 @@ -package org.programmers.signalbuddy.global.security; +package org.programmers.signalbuddy.global.security.basic; import java.util.ArrayList; import java.util.Collection; diff --git a/src/main/java/org/programmers/signalbuddy/global/security/CustomUserDetailsService.java b/src/main/java/org/programmers/signalbuddy/global/security/basic/CustomUserDetailsService.java similarity index 95% rename from src/main/java/org/programmers/signalbuddy/global/security/CustomUserDetailsService.java rename to src/main/java/org/programmers/signalbuddy/global/security/basic/CustomUserDetailsService.java index 169e793a..4dc6dc96 100644 --- a/src/main/java/org/programmers/signalbuddy/global/security/CustomUserDetailsService.java +++ b/src/main/java/org/programmers/signalbuddy/global/security/basic/CustomUserDetailsService.java @@ -1,4 +1,4 @@ -package org.programmers.signalbuddy.global.security; +package org.programmers.signalbuddy.global.security.basic; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/org/programmers/signalbuddy/global/security/filter/UserAuthenticationFilter.java b/src/main/java/org/programmers/signalbuddy/global/security/filter/UserAuthenticationFilter.java index eac3388f..ad2fbef8 100644 --- a/src/main/java/org/programmers/signalbuddy/global/security/filter/UserAuthenticationFilter.java +++ b/src/main/java/org/programmers/signalbuddy/global/security/filter/UserAuthenticationFilter.java @@ -5,12 +5,12 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.Collections; -import org.programmers.signalbuddy.global.security.CustomUserDetails; +import java.util.Collection; +import org.programmers.signalbuddy.global.security.basic.CustomUserDetails; +import org.programmers.signalbuddy.global.security.oauth.CustomOAuth2User; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; @@ -20,15 +20,24 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - CustomUserDetails userDetails = (CustomUserDetails) request.getSession() - .getAttribute("user"); - if (userDetails != null) { + Object user = request.getSession().getAttribute("user"); - Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, - null, - userDetails.getAuthorities()); - SecurityContextHolder.getContext().setAuthentication(authentication); + if (user instanceof CustomUserDetails) { + CustomUserDetails customUserDetails = (CustomUserDetails) user; + setAuthentication(customUserDetails, customUserDetails.getAuthorities()); + + } else if (user instanceof CustomOAuth2User) { + CustomOAuth2User customOAuth2User = (CustomOAuth2User) user; + setAuthentication(customOAuth2User, customOAuth2User.getAuthorities()); } + filterChain.doFilter(request, response); } + + private void setAuthentication(Object principal, + Collection authorities) { + Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, + authorities); + SecurityContextHolder.getContext().setAuthentication(authentication); + } } diff --git a/src/main/java/org/programmers/signalbuddy/global/security/handler/CustomAuthenticationSuccessHandler.java b/src/main/java/org/programmers/signalbuddy/global/security/handler/CustomAuthenticationSuccessHandler.java index dfa5ece0..df6ef896 100644 --- a/src/main/java/org/programmers/signalbuddy/global/security/handler/CustomAuthenticationSuccessHandler.java +++ b/src/main/java/org/programmers/signalbuddy/global/security/handler/CustomAuthenticationSuccessHandler.java @@ -4,7 +4,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import org.programmers.signalbuddy.global.security.CustomUserDetails; +import org.programmers.signalbuddy.global.security.basic.CustomUserDetails; +import org.programmers.signalbuddy.global.security.oauth.CustomOAuth2User; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; @@ -18,14 +19,17 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo // 임시 경로 String redirectUrl = "/feedbacks"; - if(principal instanceof CustomUserDetails) { + if (principal instanceof CustomUserDetails) { CustomUserDetails customUserDetails = (CustomUserDetails) principal; - if(customUserDetails.getRole().name().contains("ADMIN")){ + if (customUserDetails.getRole().name().contains("ADMIN")) { // 임시 경로 redirectUrl = "/admins/members/list"; } request.getSession().setAttribute("user", customUserDetails); + } else if (principal instanceof CustomOAuth2User) { + CustomOAuth2User customOAuth2User = (CustomOAuth2User) principal; + request.getSession().setAttribute("user", customOAuth2User); } request.getSession().setMaxInactiveInterval(3600); diff --git a/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2User.java b/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2User.java new file mode 100644 index 00000000..72904f93 --- /dev/null +++ b/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2User.java @@ -0,0 +1,50 @@ +package org.programmers.signalbuddy.global.security.oauth; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.programmers.signalbuddy.domain.member.entity.enums.MemberRole; +import org.programmers.signalbuddy.domain.member.entity.enums.MemberStatus; +import org.programmers.signalbuddy.global.security.oauth.response.OAuth2Response; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@Getter +@AllArgsConstructor +public class CustomOAuth2User implements OAuth2User, Serializable { + + private OAuth2Response oAuth2Response; + private Long memberId; + private String email; + private String password; + private String profileImageUrl; + private String nickname; + private MemberRole role; + private MemberStatus status; + + @Override + public String getName() { + return oAuth2Response.getName(); + } + + @Override + public Map getAttributes() { + return null; + } + + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + authorities.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return role.toString(); + } + }); + + return authorities; + } +} diff --git a/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java b/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java new file mode 100644 index 00000000..f8ae7cf1 --- /dev/null +++ b/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java @@ -0,0 +1,79 @@ +package org.programmers.signalbuddy.global.security.oauth; + +import lombok.RequiredArgsConstructor; +import org.programmers.signalbuddy.domain.member.entity.Member; +import org.programmers.signalbuddy.domain.member.entity.enums.MemberRole; +import org.programmers.signalbuddy.domain.member.entity.enums.MemberStatus; +import org.programmers.signalbuddy.domain.member.repository.MemberRepository; +import org.programmers.signalbuddy.domain.social.entity.SocialProvider; +import org.programmers.signalbuddy.domain.social.repository.SocialProviderRepository; +import org.programmers.signalbuddy.global.security.oauth.response.GoogleResponse; +import org.programmers.signalbuddy.global.security.oauth.response.KakaoResponse; +import org.programmers.signalbuddy.global.security.oauth.response.NaverResponse; +import org.programmers.signalbuddy.global.security.oauth.response.OAuth2Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private static final Logger log = LoggerFactory.getLogger(CustomOAuth2UserService.class); + private final MemberRepository memberRepository; + private final SocialProviderRepository socialProviderRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + OAuth2User oAuth2User = super.loadUser(userRequest); + System.out.println(oAuth2User.getAttributes()); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + log.info("registrationId: {}", registrationId); + OAuth2Response oAuth2Response; + + if (registrationId.equals("naver")) { + oAuth2Response = new NaverResponse(oAuth2User.getAttributes()); + } else if (registrationId.equals("google")) { + oAuth2Response = new GoogleResponse(oAuth2User.getAttributes()); + } else if (registrationId.equals("kakao")) { + oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); + } else { + oAuth2Response = null; + return null; + } + + // 기존 사용자 조회, 없으면 소셜의 이메일과 닉네임을 기반으로 새로운 사용자 생성 + String email = oAuth2Response.getEmail(); + Member saveMember = memberRepository.findByEmail(email) + .orElseGet(() -> { + Member newMember = Member.builder() + .email(email) + .nickname(oAuth2Response.getName()) + .profileImageUrl("/image") + .role(MemberRole.USER) + .memberStatus(MemberStatus.ACTIVITY) + .build(); + memberRepository.save(newMember); + return newMember; + }); + + // SocialProvider 생성 및 저장 + SocialProvider socialProvider = SocialProvider.builder() + .socialId(oAuth2Response.getProviderId()) + .oauthProvider(oAuth2Response.getProvider()) + .member(saveMember) + .build(); + + socialProviderRepository.save(socialProvider); + + return new CustomOAuth2User(oAuth2Response, saveMember.getMemberId(), email, + saveMember.getPassword(), saveMember.getProfileImageUrl(), saveMember.getNickname(), + saveMember.getRole(), saveMember.getMemberStatus()); + } +} diff --git a/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/GoogleResponse.java b/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/GoogleResponse.java new file mode 100644 index 00000000..b1d6b333 --- /dev/null +++ b/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/GoogleResponse.java @@ -0,0 +1,33 @@ +package org.programmers.signalbuddy.global.security.oauth.response; + +import java.util.Map; + +public class GoogleResponse implements OAuth2Response { + + private final Map attribute; + + public GoogleResponse(Map attribute) { + this.attribute = attribute; + } + + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getProviderId() { + return attribute.get("sub").toString(); + } + + @Override + public String getEmail() { + return attribute.get("email").toString(); + } + + @Override + public String getName() { + return attribute.get("name").toString(); + } +} diff --git a/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/KakaoResponse.java b/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/KakaoResponse.java new file mode 100644 index 00000000..66981455 --- /dev/null +++ b/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/KakaoResponse.java @@ -0,0 +1,36 @@ +package org.programmers.signalbuddy.global.security.oauth.response; + +import java.util.Map; + +public class KakaoResponse implements OAuth2Response { + + private Map attribute; + private Map kakaoAccountAttributes; + private Map profileAttributes; + + public KakaoResponse(Map attributes) { + this.attribute = attributes; + this.kakaoAccountAttributes = (Map) attribute.get("kakao_account"); + this.profileAttributes = (Map) kakaoAccountAttributes.get("profile"); + } + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attribute.get("id").toString(); + } + + @Override + public String getEmail() { + return kakaoAccountAttributes.get("email").toString(); + } + + @Override + public String getName() { + return profileAttributes.get("nickname").toString(); + } +} diff --git a/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/NaverResponse.java b/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/NaverResponse.java new file mode 100644 index 00000000..4ba889de --- /dev/null +++ b/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/NaverResponse.java @@ -0,0 +1,34 @@ +package org.programmers.signalbuddy.global.security.oauth.response; + + +import java.util.Map; + +public class NaverResponse implements OAuth2Response { + + private final Map attribute; + + public NaverResponse(Map attribute) { + this.attribute = (Map) attribute.get("response"); + } + + + @Override + public String getProvider() { + return "naver"; + } + + @Override + public String getProviderId() { + return attribute.get("id").toString(); + } + + @Override + public String getEmail() { + return attribute.get("email").toString(); + } + + @Override + public String getName() { + return attribute.get("nickname").toString(); + } +} diff --git a/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/OAuth2Response.java b/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/OAuth2Response.java new file mode 100644 index 00000000..7d51adf4 --- /dev/null +++ b/src/main/java/org/programmers/signalbuddy/global/security/oauth/response/OAuth2Response.java @@ -0,0 +1,14 @@ +package org.programmers.signalbuddy.global.security.oauth.response; + +import java.io.Serializable; + +public interface OAuth2Response extends Serializable { + + String getProvider(); + + String getProviderId(); + + String getEmail(); + + String getName(); +} diff --git a/src/main/resources/templates/member/loginform.html b/src/main/resources/templates/member/loginform.html index 6b70531f..6a9d86bc 100644 --- a/src/main/resources/templates/member/loginform.html +++ b/src/main/resources/templates/member/loginform.html @@ -4,11 +4,14 @@

로그인 페이지

-

+
+네이버 간편 로그인 +구글 간편 로그인 +카카오 간편 로그인 \ No newline at end of file diff --git a/src/test/java/org/programmers/signalbuddy/domain/comment/service/CommentServiceTest.java b/src/test/java/org/programmers/signalbuddy/domain/comment/service/CommentServiceTest.java index be7d3aa5..8bce2dee 100644 --- a/src/test/java/org/programmers/signalbuddy/domain/comment/service/CommentServiceTest.java +++ b/src/test/java/org/programmers/signalbuddy/domain/comment/service/CommentServiceTest.java @@ -25,7 +25,7 @@ import org.programmers.signalbuddy.domain.member.repository.MemberRepository; import org.programmers.signalbuddy.global.dto.CustomUser2Member; import org.programmers.signalbuddy.global.exception.BusinessException; -import org.programmers.signalbuddy.global.security.CustomUserDetails; +import org.programmers.signalbuddy.global.security.basic.CustomUserDetails; import org.programmers.signalbuddy.global.support.ServiceTest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; diff --git a/src/test/java/org/programmers/signalbuddy/domain/feedback/service/FeedbackServiceTest.java b/src/test/java/org/programmers/signalbuddy/domain/feedback/service/FeedbackServiceTest.java index 9a3f9ec1..44434886 100644 --- a/src/test/java/org/programmers/signalbuddy/domain/feedback/service/FeedbackServiceTest.java +++ b/src/test/java/org/programmers/signalbuddy/domain/feedback/service/FeedbackServiceTest.java @@ -19,7 +19,7 @@ import org.programmers.signalbuddy.domain.member.repository.MemberRepository; import org.programmers.signalbuddy.global.dto.CustomUser2Member; import org.programmers.signalbuddy.global.exception.BusinessException; -import org.programmers.signalbuddy.global.security.CustomUserDetails; +import org.programmers.signalbuddy.global.security.basic.CustomUserDetails; import org.programmers.signalbuddy.global.support.ServiceTest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; diff --git a/src/test/java/org/programmers/signalbuddy/domain/like/service/LikeServiceTest.java b/src/test/java/org/programmers/signalbuddy/domain/like/service/LikeServiceTest.java index fa80bf31..b83481ca 100644 --- a/src/test/java/org/programmers/signalbuddy/domain/like/service/LikeServiceTest.java +++ b/src/test/java/org/programmers/signalbuddy/domain/like/service/LikeServiceTest.java @@ -19,7 +19,7 @@ import org.programmers.signalbuddy.domain.member.entity.enums.MemberStatus; import org.programmers.signalbuddy.domain.member.repository.MemberRepository; import org.programmers.signalbuddy.global.dto.CustomUser2Member; -import org.programmers.signalbuddy.global.security.CustomUserDetails; +import org.programmers.signalbuddy.global.security.basic.CustomUserDetails; import org.programmers.signalbuddy.global.support.ServiceTest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; From 590e592ebefde341e0f0d5c5e8aa324c262d95fc Mon Sep 17 00:00:00 2001 From: zzuharchive Date: Fri, 3 Jan 2025 04:07:45 +0900 Subject: [PATCH 07/23] =?UTF-8?q?feat:=20securityCofig=EC=97=90=20outh2?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/SecurityConfig.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java index 22075c95..36d4136d 100644 --- a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java +++ b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java @@ -1,7 +1,10 @@ package org.programmers.signalbuddy.global.config; +import lombok.RequiredArgsConstructor; import org.programmers.signalbuddy.global.security.filter.UserAuthenticationFilter; import org.programmers.signalbuddy.global.security.handler.CustomAuthenticationSuccessHandler; + +import org.programmers.signalbuddy.global.security.oauth.CustomOAuth2UserService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -13,6 +16,7 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +@RequiredArgsConstructor @Configuration @EnableWebSecurity public class SecurityConfig { @@ -27,6 +31,8 @@ CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler() { return new CustomAuthenticationSuccessHandler(); } + private final CustomOAuth2UserService customOAuth2UserService; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -59,17 +65,26 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .anyRequest().authenticated() ); - // 로그인 관련 설정 + // 기본 로그인 관련 설정 http .formLogin((auth) -> auth .loginPage("/members/login") .loginProcessingUrl("/login") - // 메인으로 이동하도록 설정 - //.defaultSuccessUrl("/home", true) .successHandler(customAuthenticationSuccessHandler()) .permitAll() ); + // 소셜 로그인 관련 설정 + http + .oauth2Login((oauth) -> oauth + .loginPage("/login") + .userInfoEndpoint(userInfoEndpointConfig -> + userInfoEndpointConfig.userService(customOAuth2UserService)) + .authorizationEndpoint(authorization -> authorization + .baseUri("/oauth2/authorization")) + .successHandler(customAuthenticationSuccessHandler()) + .permitAll()); + // 로그아웃 관련 설정 http .logout((auth) -> auth From 1b39a98d3355c7b8357cf625a67e8c4a915a3391 Mon Sep 17 00:00:00 2001 From: zzuharchive Date: Fri, 3 Jan 2025 13:02:04 +0900 Subject: [PATCH 08/23] =?UTF-8?q?fix:=20=EC=86=8C=EC=85=9CDB=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B6=94=EA=B0=80=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/SocialProviderRepository.java | 1 + .../global/config/SecurityConfig.java | 5 ++--- .../oauth/CustomOAuth2UserService.java | 20 +++++++++++-------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/programmers/signalbuddy/domain/social/repository/SocialProviderRepository.java b/src/main/java/org/programmers/signalbuddy/domain/social/repository/SocialProviderRepository.java index 30e231fd..d5e6b111 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/social/repository/SocialProviderRepository.java +++ b/src/main/java/org/programmers/signalbuddy/domain/social/repository/SocialProviderRepository.java @@ -7,4 +7,5 @@ @Repository public interface SocialProviderRepository extends JpaRepository { + boolean existsByOauthProviderAndSocialId(String provider, String providerId); } diff --git a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java index 36d4136d..e7a0a08d 100644 --- a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java +++ b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java @@ -49,7 +49,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/images/**", "/webjars/**").permitAll() // 로그인 - .requestMatchers("/members/login", "admins/login", "/api/members/join", "/api/admins/join").permitAll() + .requestMatchers("/members/login", "admins/login", "/api/members/join", + "/api/admins/join").permitAll() // 북마크 .requestMatchers("/api/bookmarks/**", "/bookmarks/**").hasRole("USER") // 댓글 @@ -80,8 +81,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .loginPage("/login") .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOAuth2UserService)) - .authorizationEndpoint(authorization -> authorization - .baseUri("/oauth2/authorization")) .successHandler(customAuthenticationSuccessHandler()) .permitAll()); diff --git a/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java b/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java index f8ae7cf1..64027412 100644 --- a/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java +++ b/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java @@ -13,6 +13,7 @@ import org.programmers.signalbuddy.global.security.oauth.response.OAuth2Response; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; @@ -55,7 +56,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic Member newMember = Member.builder() .email(email) .nickname(oAuth2Response.getName()) - .profileImageUrl("/image") + .profileImageUrl("static/images/member/profile-icon.png") .role(MemberRole.USER) .memberStatus(MemberStatus.ACTIVITY) .build(); @@ -63,14 +64,17 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic return newMember; }); - // SocialProvider 생성 및 저장 - SocialProvider socialProvider = SocialProvider.builder() - .socialId(oAuth2Response.getProviderId()) - .oauthProvider(oAuth2Response.getProvider()) - .member(saveMember) - .build(); + // 이미 소셜이 저장되어 있는 경우, 중복 저장하지 않음. + if(!socialProviderRepository.existsByOauthProviderAndSocialId(oAuth2Response.getProvider(), oAuth2Response.getProviderId())){ - socialProviderRepository.save(socialProvider); + SocialProvider socialProvider = SocialProvider.builder() + .oauthProvider(oAuth2Response.getProvider()) + .socialId(oAuth2Response.getProviderId()) + .member(saveMember) + .build(); + + socialProviderRepository.save(socialProvider); + } return new CustomOAuth2User(oAuth2Response, saveMember.getMemberId(), email, saveMember.getPassword(), saveMember.getProfileImageUrl(), saveMember.getNickname(), From dacbed6e1ebc326cabc2deb079d55fc294ee6657 Mon Sep 17 00:00:00 2001 From: zzuharchive Date: Sat, 4 Jan 2025 13:54:24 +0900 Subject: [PATCH 09/23] =?UTF-8?q?fix:=20OAuth2UserService=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A0=80=EC=9E=A5=ED=95=9C=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../programmers/signalbuddy/global/config/SecurityConfig.java | 2 +- .../global/security/oauth/CustomOAuth2UserService.java | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java index e7a0a08d..28dc6226 100644 --- a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java +++ b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java @@ -88,7 +88,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .logout((auth) -> auth .logoutUrl("/logout") - .logoutSuccessUrl("/login") + .logoutSuccessUrl("/members/login") .deleteCookies("JSESSIONID") .invalidateHttpSession(true) .clearAuthentication(true)); diff --git a/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java b/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java index 64027412..2d8345c6 100644 --- a/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java +++ b/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2UserService.java @@ -60,8 +60,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic .role(MemberRole.USER) .memberStatus(MemberStatus.ACTIVITY) .build(); - memberRepository.save(newMember); - return newMember; + return memberRepository.save(newMember); }); // 이미 소셜이 저장되어 있는 경우, 중복 저장하지 않음. From 80edc024875c98048cdcbb34823b5d699e4d1471 Mon Sep 17 00:00:00 2001 From: dongJ Date: Sat, 4 Jan 2025 17:36:41 +0900 Subject: [PATCH 10/23] =?UTF-8?q?refactor:=20User=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BookmarkController.java | 9 +++--- .../BookmarkRepositoryCustomImpl.java | 4 +-- .../bookmark/service/BookmarkService.java | 13 +++++---- .../CustomFeedbackRepositoryImpl.java | 3 +- .../controller/MemberWebController.java | 14 +++++++++ .../domain/member/entity/Member.java | 29 +++++++++---------- .../domain/member/service/MemberService.java | 12 ++++---- .../bookmark/service/BookmarkServiceTest.java | 20 +++++++++---- 8 files changed, 64 insertions(+), 40 deletions(-) diff --git a/src/main/java/org/programmers/signalbuddy/domain/bookmark/controller/BookmarkController.java b/src/main/java/org/programmers/signalbuddy/domain/bookmark/controller/BookmarkController.java index 1649acbf..de5403ca 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/bookmark/controller/BookmarkController.java +++ b/src/main/java/org/programmers/signalbuddy/domain/bookmark/controller/BookmarkController.java @@ -8,6 +8,8 @@ import org.programmers.signalbuddy.domain.bookmark.dto.BookmarkRequest; import org.programmers.signalbuddy.domain.bookmark.dto.BookmarkResponse; import org.programmers.signalbuddy.domain.bookmark.service.BookmarkService; +import org.programmers.signalbuddy.global.annotation.CurrentUser; +import org.programmers.signalbuddy.global.dto.CustomUser2Member; import org.springframework.boot.autoconfigure.security.SecurityProperties.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -46,7 +48,7 @@ public ResponseEntity> getBookmarks( @PostMapping @ApiResponse(responseCode = "201", description = "즐겨찾기 등록 성공") public ResponseEntity addBookmark( - @RequestBody @Validated BookmarkRequest createRequest, User user) { + @RequestBody @Validated BookmarkRequest createRequest, @CurrentUser CustomUser2Member user) { log.info("createRequest: {}", createRequest); final BookmarkResponse created = bookmarkService.createBookmark(createRequest, user); return ResponseEntity.status(HttpStatus.CREATED).body(created); @@ -56,7 +58,7 @@ public ResponseEntity addBookmark( @PatchMapping("{id}") @ApiResponse(responseCode = "200", description = "즐겨찾기 수정 성공") public ResponseEntity updateBookmark( - @RequestBody @Validated BookmarkRequest updateRequest, @PathVariable Long id, User user) { + @RequestBody @Validated BookmarkRequest updateRequest, @PathVariable Long id, @CurrentUser CustomUser2Member user) { log.info("updateRequest: {}", updateRequest); final BookmarkResponse updated = bookmarkService.updateBookmark(updateRequest, id, user); return ResponseEntity.ok().body(updated); @@ -65,8 +67,7 @@ public ResponseEntity updateBookmark( @Operation(summary = "즐겨찾기 삭제", description = "즐겨찾기 삭제 기능") @DeleteMapping("{id}") @ApiResponse(responseCode = "200", description = "즐겨찾기 삭제 성공") - public ResponseEntity deleteBookmark(@PathVariable Long id, User user) { - user.setName("1"); + public ResponseEntity deleteBookmark(@PathVariable Long id, @CurrentUser CustomUser2Member user) { log.info("delete bookmark id: {}", id); bookmarkService.deleteBookmark(id, user); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); diff --git a/src/main/java/org/programmers/signalbuddy/domain/bookmark/repository/BookmarkRepositoryCustomImpl.java b/src/main/java/org/programmers/signalbuddy/domain/bookmark/repository/BookmarkRepositoryCustomImpl.java index bf1a5005..0bc7020c 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/bookmark/repository/BookmarkRepositoryCustomImpl.java +++ b/src/main/java/org/programmers/signalbuddy/domain/bookmark/repository/BookmarkRepositoryCustomImpl.java @@ -37,12 +37,12 @@ public class BookmarkRepositoryCustomImpl implements BookmarkRepositoryCustom { public Page findPagedByMember(Pageable pageable, Long memberId) { final List responses = queryFactory.select(pageBookmarkDto).from(bookmark) .join(member) - .on(bookmark.member.eq(member).and(member.memberId.eq(1L))) // TODO : 1L -> memberId + .on(bookmark.member.eq(member).and(member.memberId.eq(memberId))) .offset(pageable.getOffset()).limit(pageable.getPageSize()) .orderBy(new OrderSpecifier<>(Order.ASC, bookmark.bookmarkId)).fetch(); final Long count = queryFactory.select(bookmark.count()).from(bookmark).join(member) - .on(member.memberId.eq(1L)) // TODO : 1L -> memberId + .on(bookmark.member.eq(member).and(member.memberId.eq(memberId))) .fetchOne(); return new PageImpl<>(responses, pageable, count != null ? count : 0); } diff --git a/src/main/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkService.java b/src/main/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkService.java index 44728a92..bc7a99c8 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkService.java +++ b/src/main/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkService.java @@ -14,8 +14,8 @@ import org.programmers.signalbuddy.domain.member.entity.Member; import org.programmers.signalbuddy.domain.member.exception.MemberErrorCode; import org.programmers.signalbuddy.domain.member.repository.MemberRepository; +import org.programmers.signalbuddy.global.dto.CustomUser2Member; import org.programmers.signalbuddy.global.exception.BusinessException; -import org.springframework.boot.autoconfigure.security.SecurityProperties.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -34,7 +34,7 @@ public Page findPagedBookmarks(Pageable pageable, Long memberI return bookmarkRepository.findPagedByMember(pageable, memberId); } - public BookmarkResponse createBookmark(BookmarkRequest createRequest, User user) { + public BookmarkResponse createBookmark(BookmarkRequest createRequest, CustomUser2Member user) { final Member member = getMember(user); final Point point = toPoint(createRequest.getLng(), createRequest.getLat()); @@ -45,7 +45,8 @@ public BookmarkResponse createBookmark(BookmarkRequest createRequest, User user) } @Transactional - public BookmarkResponse updateBookmark(BookmarkRequest updateRequest, Long id, User user) { + public BookmarkResponse updateBookmark(BookmarkRequest updateRequest, Long id, + CustomUser2Member user) { final Member member = getMember(user); final Bookmark bookmark = bookmarkRepository.findById(id) @@ -58,15 +59,15 @@ public BookmarkResponse updateBookmark(BookmarkRequest updateRequest, Long id, U } @Transactional - public void deleteBookmark(Long id, User user) { + public void deleteBookmark(Long id, CustomUser2Member user) { final Member member = getMember(user); final Bookmark bookmark = bookmarkRepository.findById(id) .orElseThrow(() -> new BusinessException(BookmarkErrorCode.NOT_FOUND_BOOKMARK)); bookmarkRepository.delete(bookmark); } - private Member getMember(User user) { - return memberRepository.findById(Long.parseLong(user.getName())) // TODO : User 수정 필요 + private Member getMember(CustomUser2Member user) { + return memberRepository.findById(user.getMemberId()) .orElseThrow(() -> new BusinessException(MemberErrorCode.NOT_FOUND_MEMBER)); } diff --git a/src/main/java/org/programmers/signalbuddy/domain/feedback/repository/CustomFeedbackRepositoryImpl.java b/src/main/java/org/programmers/signalbuddy/domain/feedback/repository/CustomFeedbackRepositoryImpl.java index 2efa7490..5fe479f2 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/feedback/repository/CustomFeedbackRepositoryImpl.java +++ b/src/main/java/org/programmers/signalbuddy/domain/feedback/repository/CustomFeedbackRepositoryImpl.java @@ -72,7 +72,8 @@ public Page findPagedByMember(Long memberId, Pageable pageable .offset(pageable.getOffset()).limit(pageable.getPageSize()) .orderBy(new OrderSpecifier<>(Order.DESC, feedback.createdAt)).fetch(); final Long count = jpaQueryFactory.select(feedback.count()).from(feedback).join(member) - .on(member.memberId.eq(memberId)).fetchOne(); + .on(feedback.member.eq(member).and(member.memberId.eq(memberId))) + .fetchOne(); return new PageImpl<>(responses, pageable, count != null ? count : 0); } diff --git a/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java b/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java index 8b513127..4a3cd4b6 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java +++ b/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java @@ -1,21 +1,26 @@ package org.programmers.signalbuddy.domain.member.controller; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.programmers.signalbuddy.domain.bookmark.dto.BookmarkResponse; import org.programmers.signalbuddy.domain.bookmark.service.BookmarkService; import org.programmers.signalbuddy.domain.feedback.dto.FeedbackResponse; import org.programmers.signalbuddy.domain.feedback.service.FeedbackService; import org.programmers.signalbuddy.domain.member.dto.MemberResponse; import org.programmers.signalbuddy.domain.member.service.MemberService; +import org.programmers.signalbuddy.global.annotation.CurrentUser; +import org.programmers.signalbuddy.global.dto.CustomUser2Member; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.servlet.ModelAndView; +@Slf4j @Controller @RequestMapping("members") @RequiredArgsConstructor @@ -25,11 +30,17 @@ public class MemberWebController { private final BookmarkService bookmarkService; private final FeedbackService feedbackService; + @ModelAttribute("user") + public CustomUser2Member currentUser(@CurrentUser CustomUser2Member user) { + return user; + } + @GetMapping("{id}") public ModelAndView getMemberView(ModelAndView mv, @PathVariable Long id) { mv.setViewName("member/info"); final MemberResponse member = memberService.getMember(id); mv.addObject("member", member); + mv.addObject("memberId", id); return mv; } @@ -38,6 +49,7 @@ public ModelAndView editMemberView(ModelAndView mv, @PathVariable Long id) { mv.setViewName("member/edit"); final MemberResponse member = memberService.getMember(id); mv.addObject("member", member); + mv.addObject("memberId", id); return mv; } @@ -53,6 +65,7 @@ public ModelAndView findPagedBookmarks(@PageableDefault(page = 0, size = 5) Page final Page pagedBookmarks = bookmarkService.findPagedBookmarks(pageable, id); mv.addObject("pagination", pagedBookmarks); + mv.addObject("memberId", id); mv.setViewName("/member/bookmark/list"); return mv; } @@ -63,6 +76,7 @@ public ModelAndView findPagedFeedbacks(@PageableDefault(page = 0, size = 5) Page final Page pagedFeedbacks = feedbackService.findPagedFeedbacksByMember(id, pageable); mv.addObject("pagination", pagedFeedbacks); + mv.addObject("memberId", id); mv.setViewName("member/feedback/list"); return mv; } diff --git a/src/main/java/org/programmers/signalbuddy/domain/member/entity/Member.java b/src/main/java/org/programmers/signalbuddy/domain/member/entity/Member.java index 74affa7e..eea0443f 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/member/entity/Member.java +++ b/src/main/java/org/programmers/signalbuddy/domain/member/entity/Member.java @@ -18,6 +18,7 @@ import org.programmers.signalbuddy.domain.member.entity.enums.MemberRole; import org.programmers.signalbuddy.domain.member.entity.enums.MemberStatus; import org.programmers.signalbuddy.global.dto.CustomUser2Member; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @Entity(name = "members") @Getter @@ -49,12 +50,22 @@ public class Member extends BaseTimeEntity { @Enumerated(EnumType.STRING) private MemberStatus memberStatus; - public void updateMember(MemberUpdateRequest request) { + // 관리자인지 확인 + public static boolean isAdmin(Member member) { + return MemberRole.ADMIN.equals(member.getRole()); + } + + // 요청자와 작성자가 다른 경우 + public static boolean isNotSameMember(CustomUser2Member user, Member member) { + return !user.getMemberId().equals(member.getMemberId()); + } + + public void updateMember(MemberUpdateRequest request, String encodedPassword) { if (request.getEmail() != null) { this.email = request.getEmail(); } if (request.getPassword() != null) { - this.password = request.getPassword(); // TODO: 암호화 적용 + this.password = encodedPassword; } if (request.getNickname() != null) { this.nickname = request.getNickname(); @@ -67,18 +78,4 @@ public void updateMember(MemberUpdateRequest request) { public void softDelete() { this.memberStatus = MemberStatus.WITHDRAWAL; } - - // 관리자인지 확인 - public static boolean isAdmin(Member member) { - return MemberRole.ADMIN.equals(member.getRole()); - } - - // 요청자와 작성자가 다른 경우 - public static boolean isNotSameMember(CustomUser2Member user, Member member) { - return !user.getMemberId().equals(member.getMemberId()); - } - - public void setPassword(String password) { - this.password = password; - } } diff --git a/src/main/java/org/programmers/signalbuddy/domain/member/service/MemberService.java b/src/main/java/org/programmers/signalbuddy/domain/member/service/MemberService.java index 46b03bd7..a42ebdc2 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/member/service/MemberService.java +++ b/src/main/java/org/programmers/signalbuddy/domain/member/service/MemberService.java @@ -16,6 +16,7 @@ import org.programmers.signalbuddy.domain.member.mapper.MemberMapper; import org.programmers.signalbuddy.domain.member.repository.MemberRepository; import org.programmers.signalbuddy.global.exception.BusinessException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; @@ -28,9 +29,9 @@ @RequiredArgsConstructor public class MemberService { - private final String FILE_PATH = "images/"; // TODO : 프로퍼티로 이동 - private final MemberRepository memberRepository; + @Value("${file-path}") + private String filePath; private BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); public MemberResponse getMember(Long id) { @@ -44,14 +45,15 @@ public MemberResponse getMember(Long id) { public MemberResponse updateMember(Long id, MemberUpdateRequest memberUpdateRequest) { final Member member = memberRepository.findById(id) .orElseThrow(() -> new BusinessException(MemberErrorCode.NOT_FOUND_MEMBER)); - member.updateMember(memberUpdateRequest); + final String encodedPassword = bCryptPasswordEncoder.encode( + memberUpdateRequest.getPassword()); + member.updateMember(memberUpdateRequest, encodedPassword); log.info("Member updated: {}", member); return MemberMapper.INSTANCE.toDto(member); } @Transactional public MemberResponse deleteMember(Long id) { - // TODO : id 검증 로직 추가 final Member member = memberRepository.findById(id) .orElseThrow(() -> new BusinessException(MemberErrorCode.NOT_FOUND_MEMBER)); member.softDelete(); @@ -79,7 +81,7 @@ public MemberResponse joinMember(MemberJoinRequest memberJoinRequest) { public Resource getProfileImage(String filename) { try { - final Path path = Paths.get(FILE_PATH).resolve(filename); + final Path path = Paths.get(filePath).resolve(filename); if (Files.notExists(path)) { return new ClassPathResource("static/images/member/profile-icon.png"); // 프로필 이미지가 없을 경우 기본 이미지 diff --git a/src/test/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkServiceTest.java b/src/test/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkServiceTest.java index 2824b0db..200426d3 100644 --- a/src/test/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkServiceTest.java +++ b/src/test/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkServiceTest.java @@ -17,6 +17,8 @@ import org.programmers.signalbuddy.domain.member.entity.Member; import org.programmers.signalbuddy.domain.member.entity.enums.MemberStatus; import org.programmers.signalbuddy.domain.member.repository.MemberRepository; +import org.programmers.signalbuddy.global.dto.CustomUser2Member; +import org.programmers.signalbuddy.global.security.CustomUserDetails; import org.programmers.signalbuddy.global.support.ServiceTest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.security.SecurityProperties.User; @@ -57,8 +59,10 @@ void setup() { @Test @DisplayName("즐겨찾기 등록 테스트") void createBookmark() { - User user = new User(); - user.setName(member.getMemberId().toString()); + CustomUser2Member user = new CustomUser2Member( + new CustomUserDetails(member.getMemberId(), "", "", + "", "", MemberRole.USER, MemberStatus.ACTIVITY)); + final BookmarkRequest request = BookmarkRequest.builder().lat(37.12345).lng(127.12345) .address("test").build(); final BookmarkResponse response = bookmarkService.createBookmark(request, user); @@ -74,8 +78,10 @@ void createBookmark() { @Test @DisplayName("즐겨찾기 수정 테스트") void updateBookmark() { - User user = new User(); - user.setName(member.getMemberId().toString()); + CustomUser2Member user = new CustomUser2Member( + new CustomUserDetails(member.getMemberId(), "", "", + "", "", MemberRole.USER, MemberStatus.ACTIVITY)); + final BookmarkRequest request = BookmarkRequest.builder().lat(37.12345).lng(127.12345) .address("test").build(); @@ -94,8 +100,10 @@ void updateBookmark() { @Test @DisplayName("즐겨찾기 삭제 테스트") void deleteBookmark() { - User user = new User(); - user.setName(member.getMemberId().toString()); + CustomUser2Member user = new CustomUser2Member( + new CustomUserDetails(member.getMemberId(), "", "", + "", "", MemberRole.USER, MemberStatus.ACTIVITY)); + bookmarkService.deleteBookmark(bookmark.getBookmarkId(), user); final Optional found = bookmarkRepository.findById(bookmark.getBookmarkId()); From 1bdd640763fe1b1472f490f01fb7cc4f784047be Mon Sep 17 00:00:00 2001 From: dongJ Date: Sat, 4 Jan 2025 18:38:21 +0900 Subject: [PATCH 11/23] =?UTF-8?q?refactor:=20Session=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=9D=B4=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BookmarkWebController.java | 12 ++++-- .../controller/MemberWebController.java | 19 ++------- .../member/dto/MemberUpdateRequest.java | 2 +- .../member/exception/MemberErrorCode.java | 3 +- .../basic/CustomUserDetailsService.java | 6 +++ .../resources/templates/fragments/header.html | 29 +++++++++----- .../templates/fragments/sidebar.html | 9 ++--- .../templates/member/bookmark/edit.html | 5 +-- .../templates/member/bookmark/list.html | 21 +++++----- src/main/resources/templates/member/edit.html | 38 ++++++++++-------- .../templates/member/feedback/list.html | 6 +-- src/main/resources/templates/member/info.html | 40 ++++++++++++------- 12 files changed, 106 insertions(+), 84 deletions(-) diff --git a/src/main/java/org/programmers/signalbuddy/domain/bookmark/controller/BookmarkWebController.java b/src/main/java/org/programmers/signalbuddy/domain/bookmark/controller/BookmarkWebController.java index 47ad6c72..82bc78fc 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/bookmark/controller/BookmarkWebController.java +++ b/src/main/java/org/programmers/signalbuddy/domain/bookmark/controller/BookmarkWebController.java @@ -3,12 +3,11 @@ import lombok.RequiredArgsConstructor; import org.programmers.signalbuddy.domain.bookmark.dto.BookmarkResponse; import org.programmers.signalbuddy.domain.bookmark.service.BookmarkService; -import org.springframework.boot.autoconfigure.security.SecurityProperties.User; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; +import org.programmers.signalbuddy.global.annotation.CurrentUser; +import org.programmers.signalbuddy.global.dto.CustomUser2Member; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.servlet.ModelAndView; @@ -20,6 +19,11 @@ public class BookmarkWebController { private final BookmarkService bookmarkService; + @ModelAttribute("user") + public CustomUser2Member currentUser(@CurrentUser CustomUser2Member user) { + return user; + } + @GetMapping("{id}") public ModelAndView getBookmark(@PathVariable Long id, ModelAndView mv) { final BookmarkResponse bookmark = bookmarkService.getBookmark(id); diff --git a/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java b/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java index 4a3cd4b6..a8ed15f0 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java +++ b/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java @@ -6,8 +6,6 @@ import org.programmers.signalbuddy.domain.bookmark.service.BookmarkService; import org.programmers.signalbuddy.domain.feedback.dto.FeedbackResponse; import org.programmers.signalbuddy.domain.feedback.service.FeedbackService; -import org.programmers.signalbuddy.domain.member.dto.MemberResponse; -import org.programmers.signalbuddy.domain.member.service.MemberService; import org.programmers.signalbuddy.global.annotation.CurrentUser; import org.programmers.signalbuddy.global.dto.CustomUser2Member; import org.springframework.data.domain.Page; @@ -26,7 +24,6 @@ @RequiredArgsConstructor public class MemberWebController { - private final MemberService memberService; private final BookmarkService bookmarkService; private final FeedbackService feedbackService; @@ -35,21 +32,15 @@ public CustomUser2Member currentUser(@CurrentUser CustomUser2Member user) { return user; } - @GetMapping("{id}") - public ModelAndView getMemberView(ModelAndView mv, @PathVariable Long id) { + @GetMapping + public ModelAndView getMemberView(ModelAndView mv) { mv.setViewName("member/info"); - final MemberResponse member = memberService.getMember(id); - mv.addObject("member", member); - mv.addObject("memberId", id); return mv; } - @GetMapping("{id}/edit") - public ModelAndView editMemberView(ModelAndView mv, @PathVariable Long id) { + @GetMapping("edit") + public ModelAndView editMemberView(ModelAndView mv) { mv.setViewName("member/edit"); - final MemberResponse member = memberService.getMember(id); - mv.addObject("member", member); - mv.addObject("memberId", id); return mv; } @@ -65,7 +56,6 @@ public ModelAndView findPagedBookmarks(@PageableDefault(page = 0, size = 5) Page final Page pagedBookmarks = bookmarkService.findPagedBookmarks(pageable, id); mv.addObject("pagination", pagedBookmarks); - mv.addObject("memberId", id); mv.setViewName("/member/bookmark/list"); return mv; } @@ -76,7 +66,6 @@ public ModelAndView findPagedFeedbacks(@PageableDefault(page = 0, size = 5) Page final Page pagedFeedbacks = feedbackService.findPagedFeedbacksByMember(id, pageable); mv.addObject("pagination", pagedFeedbacks); - mv.addObject("memberId", id); mv.setViewName("member/feedback/list"); return mv; } diff --git a/src/main/java/org/programmers/signalbuddy/domain/member/dto/MemberUpdateRequest.java b/src/main/java/org/programmers/signalbuddy/domain/member/dto/MemberUpdateRequest.java index 5919e453..de90783b 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/member/dto/MemberUpdateRequest.java +++ b/src/main/java/org/programmers/signalbuddy/domain/member/dto/MemberUpdateRequest.java @@ -27,6 +27,6 @@ public class MemberUpdateRequest { @Schema(description = "닉네임", requiredMode = RequiredMode.NOT_REQUIRED, defaultValue = "Nickname") private String nickname; - @Schema(description = "프로필 사진", requiredMode = RequiredMode.NOT_REQUIRED, defaultValue = "/images/test.png") + @Schema(description = "프로필 사진", requiredMode = RequiredMode.NOT_REQUIRED, defaultValue = "test.png") private String profileImageUrl; } diff --git a/src/main/java/org/programmers/signalbuddy/domain/member/exception/MemberErrorCode.java b/src/main/java/org/programmers/signalbuddy/domain/member/exception/MemberErrorCode.java index 3d788793..9454664d 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/member/exception/MemberErrorCode.java +++ b/src/main/java/org/programmers/signalbuddy/domain/member/exception/MemberErrorCode.java @@ -12,7 +12,8 @@ public enum MemberErrorCode implements ErrorCode { NOT_FOUND_MEMBER(HttpStatus.NOT_FOUND, 60000, "해당 사용자를 찾을 수 없습니다."), ALREADY_EXIST_EMAIL(HttpStatus.CONFLICT, 60001, "이미 존재하는 이메일 입니다."), - PROFILE_IMAGE_LOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 60002, "프로필 이미지 로드 중 오류가 발생했습니다."); + PROFILE_IMAGE_LOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 60002, "프로필 이미지 로드 중 오류가 발생했습니다."), + WITHDRAWN_MEMBER(HttpStatus.FORBIDDEN, 60003, "탈퇴한 회원입니다."); private HttpStatus httpStatus; private int code; diff --git a/src/main/java/org/programmers/signalbuddy/global/security/basic/CustomUserDetailsService.java b/src/main/java/org/programmers/signalbuddy/global/security/basic/CustomUserDetailsService.java index 4dc6dc96..8b070704 100644 --- a/src/main/java/org/programmers/signalbuddy/global/security/basic/CustomUserDetailsService.java +++ b/src/main/java/org/programmers/signalbuddy/global/security/basic/CustomUserDetailsService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.programmers.signalbuddy.domain.member.entity.Member; +import org.programmers.signalbuddy.domain.member.entity.enums.MemberStatus; import org.programmers.signalbuddy.domain.member.exception.MemberErrorCode; import org.programmers.signalbuddy.domain.member.repository.MemberRepository; import org.programmers.signalbuddy.global.exception.BusinessException; @@ -26,6 +27,11 @@ public CustomUserDetails loadUserByUsername(String email) throws UsernameNotFoun return new BusinessException(MemberErrorCode.NOT_FOUND_MEMBER); }); + final MemberStatus memberStatus = findMember.getMemberStatus(); + if (memberStatus == MemberStatus.WITHDRAWAL) { + throw new BusinessException(MemberErrorCode.WITHDRAWN_MEMBER); + } + return new CustomUserDetails(findMember.getMemberId(), findMember.getEmail(), findMember.getPassword(), findMember.getProfileImageUrl(), findMember.getNickname(), findMember.getRole(), findMember.getMemberStatus()); diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index f6e59dca..ee7d00b5 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -8,22 +8,31 @@
- +
Signal Buddy
diff --git a/src/main/resources/templates/fragments/sidebar.html b/src/main/resources/templates/fragments/sidebar.html index 687c5421..7ea63a22 100644 --- a/src/main/resources/templates/fragments/sidebar.html +++ b/src/main/resources/templates/fragments/sidebar.html @@ -6,18 +6,15 @@ diff --git a/src/main/resources/templates/member/bookmark/edit.html b/src/main/resources/templates/member/bookmark/edit.html index 9c295fcb..3316fd74 100644 --- a/src/main/resources/templates/member/bookmark/edit.html +++ b/src/main/resources/templates/member/bookmark/edit.html @@ -44,9 +44,8 @@

자주 가는 곳 - 수정<
- 취소 + th:href="@{/members/{memberId}/bookmarks(memberId=${user.memberId})}">취소
@@ -69,7 +68,7 @@

자주 가는 곳 - 수정< }, }) .then(response => { - window.location.href = '/members/1/bookmarks' + window.location.href = `/members/${id}/bookmarks` }) .catch(error => { console.error('삭제 실패:', error); diff --git a/src/main/resources/templates/member/bookmark/list.html b/src/main/resources/templates/member/bookmark/list.html index 90477ca6..7fb74eaa 100644 --- a/src/main/resources/templates/member/bookmark/list.html +++ b/src/main/resources/templates/member/bookmark/list.html @@ -17,7 +17,8 @@
-

자주가는 곳 (1/20)

+

자주가는 곳 (1/20)

@@ -30,13 +31,15 @@

+ edit

-
+
add_circle
@@ -47,11 +50,11 @@

- - - + +

diff --git a/src/main/resources/templates/member/edit.html b/src/main/resources/templates/member/edit.html index 0eb779d4..8b928690 100644 --- a/src/main/resources/templates/member/edit.html +++ b/src/main/resources/templates/member/edit.html @@ -20,29 +20,34 @@ class="font-title text-neutral-950 text-xl mb-8">계정 관리

Profile + th:src="${'/api/members/files/' + user.profileImageUrl}">
- - +
- - +
- - +
- - 취소 - + 취소 +
@@ -60,11 +65,11 @@ email, nickname } - if(password) { + if (password) { params.password = password } fetch(`/api/members/${id}`, { - method: "patch", + method: "PATCH", headers: { "Content-Type": "application/json", }, @@ -76,8 +81,7 @@ .then(data => { console.log('업데이트 성공:', data); alert("수정이 완료되었습니다.") - // TODO : Path Variable 수정 필요 - window.location.href = '/members/1' + window.location.href = `/members` }) .catch(error => { console.error('업데이트 실패:', error); diff --git a/src/main/resources/templates/member/feedback/list.html b/src/main/resources/templates/member/feedback/list.html index d7d2c17c..e94ec008 100644 --- a/src/main/resources/templates/member/feedback/list.html +++ b/src/main/resources/templates/member/feedback/list.html @@ -49,10 +49,10 @@

작성한 피드백

- - +
diff --git a/src/main/resources/templates/member/info.html b/src/main/resources/templates/member/info.html index ea126a98..67fe611c 100644 --- a/src/main/resources/templates/member/info.html +++ b/src/main/resources/templates/member/info.html @@ -20,27 +20,30 @@

계정 관리

Profile + th:src="${'/api/members/files/' + user.profileImageUrl}">
- - +
- - +
- 수정 + th:href="@{/members/edit}">수정
@@ -56,23 +59,30 @@

계정 관리

return; } fetch(`/api/members/${id}`, { - method: "delete", + method: "DELETE", headers: { "Content-Type": "application/json", } }) - .then(response => { - return response.json(); - }) .then(data => { console.log('회원 탈퇴 성공:', data); alert("탈퇴 성공했습니다.") - window.location.href = '/' + sendLogoutRequest(); }) .catch(error => { console.error('회원 탈퇴 실패:', error); }); } + const sendLogoutRequest = () => { + // HTML Form을 동적으로 생성해 POST 요청으로 로그아웃 처리 + const form = document.createElement("form"); + form.method = "POST"; + form.action = "/logout"; // Spring Security의 기본 로그아웃 경로 + + form.appendChild(csrfInput); + document.body.appendChild(form); + form.submit(); + } \ No newline at end of file From 73f2333a76e99a524c780b9ac79e97123c4a525c Mon Sep 17 00:00:00 2001 From: dongJ Date: Sat, 4 Jan 2025 18:48:53 +0900 Subject: [PATCH 12/23] =?UTF-8?q?fix:=20import=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookmark/service/BookmarkServiceTest.java | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/test/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkServiceTest.java b/src/test/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkServiceTest.java index 200426d3..fc64c40d 100644 --- a/src/test/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkServiceTest.java +++ b/src/test/java/org/programmers/signalbuddy/domain/bookmark/service/BookmarkServiceTest.java @@ -13,15 +13,14 @@ import org.programmers.signalbuddy.domain.bookmark.dto.BookmarkResponse; import org.programmers.signalbuddy.domain.bookmark.entity.Bookmark; import org.programmers.signalbuddy.domain.bookmark.repository.BookmarkRepository; -import org.programmers.signalbuddy.domain.member.entity.enums.MemberRole; import org.programmers.signalbuddy.domain.member.entity.Member; +import org.programmers.signalbuddy.domain.member.entity.enums.MemberRole; import org.programmers.signalbuddy.domain.member.entity.enums.MemberStatus; import org.programmers.signalbuddy.domain.member.repository.MemberRepository; import org.programmers.signalbuddy.global.dto.CustomUser2Member; -import org.programmers.signalbuddy.global.security.CustomUserDetails; +import org.programmers.signalbuddy.global.security.basic.CustomUserDetails; import org.programmers.signalbuddy.global.support.ServiceTest; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.security.SecurityProperties.User; import org.springframework.transaction.annotation.Transactional; @Transactional @@ -44,8 +43,8 @@ class BookmarkServiceTest extends ServiceTest { @BeforeEach void setup() { - member = Member.builder().email("bookmark@bookmark.com").password("123456").role(MemberRole.USER) - .nickname("bookmarkTest").memberStatus(MemberStatus.ACTIVITY) + member = Member.builder().email("bookmark@bookmark.com").password("123456") + .role(MemberRole.USER).nickname("bookmarkTest").memberStatus(MemberStatus.ACTIVITY) .profileImageUrl("https://book-test-image.com/test-123131").build(); member = memberRepository.save(member); @@ -60,8 +59,8 @@ void setup() { @DisplayName("즐겨찾기 등록 테스트") void createBookmark() { CustomUser2Member user = new CustomUser2Member( - new CustomUserDetails(member.getMemberId(), "", "", - "", "", MemberRole.USER, MemberStatus.ACTIVITY)); + new CustomUserDetails(member.getMemberId(), "", "", "", "", MemberRole.USER, + MemberStatus.ACTIVITY)); final BookmarkRequest request = BookmarkRequest.builder().lat(37.12345).lng(127.12345) .address("test").build(); @@ -79,8 +78,8 @@ void createBookmark() { @DisplayName("즐겨찾기 수정 테스트") void updateBookmark() { CustomUser2Member user = new CustomUser2Member( - new CustomUserDetails(member.getMemberId(), "", "", - "", "", MemberRole.USER, MemberStatus.ACTIVITY)); + new CustomUserDetails(member.getMemberId(), "", "", "", "", MemberRole.USER, + MemberStatus.ACTIVITY)); final BookmarkRequest request = BookmarkRequest.builder().lat(37.12345).lng(127.12345) .address("test").build(); @@ -101,8 +100,8 @@ void updateBookmark() { @DisplayName("즐겨찾기 삭제 테스트") void deleteBookmark() { CustomUser2Member user = new CustomUser2Member( - new CustomUserDetails(member.getMemberId(), "", "", - "", "", MemberRole.USER, MemberStatus.ACTIVITY)); + new CustomUserDetails(member.getMemberId(), "", "", "", "", MemberRole.USER, + MemberStatus.ACTIVITY)); bookmarkService.deleteBookmark(bookmark.getBookmarkId(), user); final Optional found = bookmarkRepository.findById(bookmark.getBookmarkId()); From f5381e41407d8e39bafde5d95a60820e44176111 Mon Sep 17 00:00:00 2001 From: DongminL Date: Sat, 4 Jan 2025 19:58:53 +0900 Subject: [PATCH 13/23] =?UTF-8?q?fix:=20error=20code=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../signalbuddy/global/exception/GlobalErrorCode.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/programmers/signalbuddy/global/exception/GlobalErrorCode.java b/src/main/java/org/programmers/signalbuddy/global/exception/GlobalErrorCode.java index b358938a..edaadffb 100644 --- a/src/main/java/org/programmers/signalbuddy/global/exception/GlobalErrorCode.java +++ b/src/main/java/org/programmers/signalbuddy/global/exception/GlobalErrorCode.java @@ -9,8 +9,8 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public enum GlobalErrorCode implements ErrorCode { - BAD_REQUEST(HttpStatus.BAD_REQUEST, 80000, "잘못된 요청입니다."), - SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 90000, "알 수 없는 에러가 발생했습니다. 관리자에게 문의하세요."); + BAD_REQUEST(HttpStatus.BAD_REQUEST, 90000, "잘못된 요청입니다."), + SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 90001, "알 수 없는 에러가 발생했습니다. 관리자에게 문의하세요."); private HttpStatus httpStatus; private int code; From 651e3fc083a3b9def89b3f138281e1e523084b92 Mon Sep 17 00:00:00 2001 From: DongminL Date: Sat, 4 Jan 2025 19:59:45 +0900 Subject: [PATCH 14/23] =?UTF-8?q?refactor:=20=EA=B2=BD=EB=A1=9C=EB=AA=85?= =?UTF-8?q?=EC=9D=98=20=EC=9D=BC=EA=B4=80=EC=84=B1=20=EB=86=92=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/like/controller/LikeController.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/programmers/signalbuddy/domain/like/controller/LikeController.java b/src/main/java/org/programmers/signalbuddy/domain/like/controller/LikeController.java index 5e254971..2a6e2f38 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/like/controller/LikeController.java +++ b/src/main/java/org/programmers/signalbuddy/domain/like/controller/LikeController.java @@ -18,14 +18,14 @@ @Tag(name = "좋아요 API") @RestController -@RequestMapping("/api/likes") +@RequestMapping("/api/feedbacks") @RequiredArgsConstructor public class LikeController { private final LikeService likeService; @Operation(summary = "좋아요 추가") - @PostMapping("/{feedbackId}") + @PostMapping("/{feedbackId}/like") public ResponseEntity addLike(@PathVariable("feedbackId") Long feedbackId, @CurrentUser CustomUser2Member user) { likeService.addLike(feedbackId, user); @@ -33,14 +33,14 @@ public ResponseEntity addLike(@PathVariable("feedbackId") Long feedbackId, } @Operation(summary = "좋아요 유무 확인") - @GetMapping("/exist/{feedbackId}") + @GetMapping("/{feedbackId}/exist") public ResponseEntity existsLike(@PathVariable("feedbackId") Long feedbackId, @CurrentUser CustomUser2Member user) { return ResponseEntity.ok(likeService.existsLike(feedbackId, user)); } @Operation(summary = "좋아요 취소") - @DeleteMapping("/{feedbackId}") + @DeleteMapping("/{feedbackId}/like") public ResponseEntity deleteLike(@PathVariable("feedbackId") Long feedbackId, @CurrentUser CustomUser2Member user) { likeService.deleteLike(feedbackId, user); From bde2837a92f233511fccae14676283d219f2e491 Mon Sep 17 00:00:00 2001 From: DongminL Date: Sat, 4 Jan 2025 20:03:37 +0900 Subject: [PATCH 15/23] =?UTF-8?q?pref:=20=ED=95=98=EB=82=98=EC=9D=98=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=EB=A7=8C=EC=9C=BC=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/like/repository/LikeRepository.java | 8 +++++++- .../signalbuddy/domain/like/service/LikeService.java | 7 +------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/programmers/signalbuddy/domain/like/repository/LikeRepository.java b/src/main/java/org/programmers/signalbuddy/domain/like/repository/LikeRepository.java index 0fa74564..9a7eff8b 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/like/repository/LikeRepository.java +++ b/src/main/java/org/programmers/signalbuddy/domain/like/repository/LikeRepository.java @@ -4,6 +4,8 @@ import org.programmers.signalbuddy.domain.like.entity.Like; import org.programmers.signalbuddy.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository @@ -11,5 +13,9 @@ public interface LikeRepository extends JpaRepository { void deleteByMemberAndFeedback(Member member, Feedback feedback); - boolean existsByMemberAndFeedback(Member member, Feedback feedback); + @Query("SELECT CASE WHEN count(*)> 0 THEN true ELSE false END " + + "FROM likes l " + + "WHERE l.member.memberId = :memberId AND l.feedback.feedbackId = :feedbackId") + boolean existsByMemberAndFeedback(@Param("memberId") Long memberId, + @Param("feedbackId") Long feedbackId); } diff --git a/src/main/java/org/programmers/signalbuddy/domain/like/service/LikeService.java b/src/main/java/org/programmers/signalbuddy/domain/like/service/LikeService.java index 3e134e9f..804cc40a 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/like/service/LikeService.java +++ b/src/main/java/org/programmers/signalbuddy/domain/like/service/LikeService.java @@ -36,12 +36,7 @@ public void addLike(Long feedbackId, CustomUser2Member user) { } public LikeExistResponse existsLike(Long feedbackId, CustomUser2Member user) { - Feedback feedback = feedbackRepository.findById(feedbackId) - .orElseThrow(() -> new BusinessException(FeedbackErrorCode.NOT_FOUND_FEEDBACK)); - Member member = memberRepository.findById(user.getMemberId()) - .orElseThrow(() -> new BusinessException(MemberErrorCode.NOT_FOUND_MEMBER)); - - boolean isExisted = likeRepository.existsByMemberAndFeedback(member, feedback); + boolean isExisted = likeRepository.existsByMemberAndFeedback(user.getMemberId(), feedbackId); return new LikeExistResponse(isExisted); } From 599512066ae6d1f5d41c02e25468322665ed1a6e Mon Sep 17 00:00:00 2001 From: DongminL Date: Sat, 4 Jan 2025 20:14:42 +0900 Subject: [PATCH 16/23] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EC=BD=94=EB=93=9C=20=ED=95=98=EB=82=98=EC=9D=98=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20=EC=B6=95=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feedback/repository/FeedbackRepository.java | 6 ++++++ .../domain/like/service/LikeService.java | 15 ++++----------- .../member/repository/MemberRepository.java | 7 +++++++ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/programmers/signalbuddy/domain/feedback/repository/FeedbackRepository.java b/src/main/java/org/programmers/signalbuddy/domain/feedback/repository/FeedbackRepository.java index ccba45c6..f0389f91 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/feedback/repository/FeedbackRepository.java +++ b/src/main/java/org/programmers/signalbuddy/domain/feedback/repository/FeedbackRepository.java @@ -1,6 +1,8 @@ package org.programmers.signalbuddy.domain.feedback.repository; import org.programmers.signalbuddy.domain.feedback.entity.Feedback; +import org.programmers.signalbuddy.domain.feedback.exception.FeedbackErrorCode; +import org.programmers.signalbuddy.global.exception.BusinessException; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -8,4 +10,8 @@ public interface FeedbackRepository extends JpaRepository, CustomFeedbackRepository { + default Feedback findByIdOrThrow(Long id) { + return findById(id) + .orElseThrow(() -> new BusinessException(FeedbackErrorCode.NOT_FOUND_FEEDBACK)); + } } diff --git a/src/main/java/org/programmers/signalbuddy/domain/like/service/LikeService.java b/src/main/java/org/programmers/signalbuddy/domain/like/service/LikeService.java index 804cc40a..b8582cd5 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/like/service/LikeService.java +++ b/src/main/java/org/programmers/signalbuddy/domain/like/service/LikeService.java @@ -2,16 +2,13 @@ import lombok.RequiredArgsConstructor; import org.programmers.signalbuddy.domain.feedback.entity.Feedback; -import org.programmers.signalbuddy.domain.feedback.exception.FeedbackErrorCode; import org.programmers.signalbuddy.domain.feedback.repository.FeedbackRepository; import org.programmers.signalbuddy.domain.like.dto.LikeExistResponse; import org.programmers.signalbuddy.domain.like.entity.Like; import org.programmers.signalbuddy.domain.like.repository.LikeRepository; import org.programmers.signalbuddy.domain.member.entity.Member; -import org.programmers.signalbuddy.domain.member.exception.MemberErrorCode; import org.programmers.signalbuddy.domain.member.repository.MemberRepository; import org.programmers.signalbuddy.global.dto.CustomUser2Member; -import org.programmers.signalbuddy.global.exception.BusinessException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,10 +23,8 @@ public class LikeService { @Transactional public void addLike(Long feedbackId, CustomUser2Member user) { - Feedback feedback = feedbackRepository.findById(feedbackId) - .orElseThrow(() -> new BusinessException(FeedbackErrorCode.NOT_FOUND_FEEDBACK)); - Member member = memberRepository.findById(user.getMemberId()) - .orElseThrow(() -> new BusinessException(MemberErrorCode.NOT_FOUND_MEMBER)); + Feedback feedback = feedbackRepository.findByIdOrThrow(feedbackId); + Member member = memberRepository.findByIdOrThrow(user.getMemberId()); likeRepository.save(Like.create(member, feedback)); feedback.increaseLike(); @@ -42,10 +37,8 @@ public LikeExistResponse existsLike(Long feedbackId, CustomUser2Member user) { @Transactional public void deleteLike(Long feedbackId, CustomUser2Member user) { - Feedback feedback = feedbackRepository.findById(feedbackId) - .orElseThrow(() -> new BusinessException(FeedbackErrorCode.NOT_FOUND_FEEDBACK)); - Member member = memberRepository.findById(user.getMemberId()) - .orElseThrow(() -> new BusinessException(MemberErrorCode.NOT_FOUND_MEMBER)); + Feedback feedback = feedbackRepository.findByIdOrThrow(feedbackId); + Member member = memberRepository.findByIdOrThrow(user.getMemberId()); likeRepository.deleteByMemberAndFeedback(member, feedback); feedback.decreaseLike(); diff --git a/src/main/java/org/programmers/signalbuddy/domain/member/repository/MemberRepository.java b/src/main/java/org/programmers/signalbuddy/domain/member/repository/MemberRepository.java index 0d9b22f4..8f602753 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/member/repository/MemberRepository.java +++ b/src/main/java/org/programmers/signalbuddy/domain/member/repository/MemberRepository.java @@ -2,6 +2,8 @@ import java.util.Optional; import org.programmers.signalbuddy.domain.member.entity.Member; +import org.programmers.signalbuddy.domain.member.exception.MemberErrorCode; +import org.programmers.signalbuddy.global.exception.BusinessException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -19,4 +21,9 @@ public interface MemberRepository extends JpaRepository, CustomMem Member save(Member member); void delete(Member member); + + default Member findByIdOrThrow(Long id) { + return findById(id) + .orElseThrow(() -> new BusinessException(MemberErrorCode.NOT_FOUND_MEMBER)); + } } From e45f6233c726aec2c9e766a4ee5548a376ced4a1 Mon Sep 17 00:00:00 2001 From: zzuharchive Date: Sat, 4 Jan 2025 22:38:16 +0900 Subject: [PATCH 17/23] =?UTF-8?q?fix:=20=EC=86=8C=EC=85=9C=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=9D=B8=EA=B0=80=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../signalbuddy/global/security/oauth/CustomOAuth2User.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2User.java b/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2User.java index 72904f93..d19f6bf6 100644 --- a/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2User.java +++ b/src/main/java/org/programmers/signalbuddy/global/security/oauth/CustomOAuth2User.java @@ -41,7 +41,7 @@ public Collection getAuthorities() { authorities.add(new GrantedAuthority() { @Override public String getAuthority() { - return role.toString(); + return "ROLE_" + role.name(); } }); From a245107ff5763a6ec8f6c2e0de51120596fab43d Mon Sep 17 00:00:00 2001 From: limseohyeon <104908845+limseohyeon@users.noreply.github.com> Date: Sun, 5 Jan 2025 12:04:31 +0900 Subject: [PATCH 18/23] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminWebController.java | 5 + src/main/resources/static/css/admin/main.css | 485 ++++++++++++++++++ src/main/resources/templates/admin/list.html | 33 -- src/main/resources/templates/admin/main.html | 54 ++ 4 files changed, 544 insertions(+), 33 deletions(-) create mode 100644 src/main/resources/static/css/admin/main.css create mode 100644 src/main/resources/templates/admin/main.html diff --git a/src/main/java/org/programmers/signalbuddy/domain/admin/controller/AdminWebController.java b/src/main/java/org/programmers/signalbuddy/domain/admin/controller/AdminWebController.java index 3cd5d409..4b4e82af 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/admin/controller/AdminWebController.java +++ b/src/main/java/org/programmers/signalbuddy/domain/admin/controller/AdminWebController.java @@ -20,6 +20,11 @@ public class AdminWebController { private final AdminService adminService; + @GetMapping() + public ModelAndView adminsMain() { + return new ModelAndView("admin/main"); + } + @GetMapping("members/list") public ModelAndView getAllMembers(@PageableDefault(page = 0, size = 10, sort = "email") Pageable pageable, ModelAndView mv) { Page members = adminService.getAllMembers(pageable); diff --git a/src/main/resources/static/css/admin/main.css b/src/main/resources/static/css/admin/main.css new file mode 100644 index 00000000..46054f68 --- /dev/null +++ b/src/main/resources/static/css/admin/main.css @@ -0,0 +1,485 @@ +@import url(https://fonts.googleapis.com/css2?family=Lato&display=swap); + +@import url(https://fonts.googleapis.com/css2?family=Open+Sans&display=swap); + +/*! tailwindcss v3.4.11 | MIT License | https://tailwindcss.com*/ +*, +:after, +:before { + border: 0 solid #e5e7eb; + box-sizing: border-box; +} +:after, +:before { + --tw-content: ""; +} +:host, +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + font-family: + Open Sans, + ui-sans-serif, + system-ui, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; + font-feature-settings: normal; + font-variation-settings: normal; + -moz-tab-size: 4; + tab-size: 4; + -webkit-tap-highlight-color: transparent; +} +body { + line-height: inherit; + margin: 0; +} +hr { + border-top-width: 1px; + color: inherit; + height: 0; +} +abbr:where([title]) { + text-decoration: underline dotted; +} +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} +a { + color: inherit; + text-decoration: inherit; +} +b, +strong { + font-weight: bolder; +} +code, +kbd, +pre, +samp { + font-family: + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + Liberation Mono, + Courier New, + monospace; + font-feature-settings: normal; + font-size: 1em; + font-variation-settings: normal; +} +small { + font-size: 80%; +} +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} +sub { + bottom: -0.25em; +} +sup { + top: -0.5em; +} +table { + border-collapse: collapse; + border-color: inherit; + text-indent: 0; +} +button, +input, +optgroup, +select, +textarea { + color: inherit; + font-family: inherit; + font-feature-settings: inherit; + font-size: 100%; + font-variation-settings: inherit; + font-weight: inherit; + letter-spacing: inherit; + line-height: inherit; + margin: 0; + padding: 0; +} +button, +select { + text-transform: none; +} +button, +input:where([type="button"]), +input:where([type="reset"]), +input:where([type="submit"]) { + -webkit-appearance: button; + background-color: transparent; + background-image: none; +} +:-moz-focusring { + outline: auto; +} +:-moz-ui-invalid { + box-shadow: none; +} +progress { + vertical-align: baseline; +} +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} +[type="search"] { + -webkit-appearance: textfield; + outline-offset: -2px; +} +::-webkit-search-decoration { + -webkit-appearance: none; +} +::-webkit-file-upload-button { + -webkit-appearance: button; + font: inherit; +} +summary { + display: list-item; +} +blockquote, +dd, +dl, +figure, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +p, +pre { + margin: 0; +} +fieldset { + margin: 0; +} +fieldset, +legend { + padding: 0; +} +menu, +ol, +ul { + list-style: none; + margin: 0; + padding: 0; +} +dialog { + padding: 0; +} +textarea { + resize: vertical; +} +input::placeholder, +textarea::placeholder { + color: #9ca3af; + opacity: 1; +} +[role="button"], +button { + cursor: pointer; +} +:disabled { + cursor: default; +} +audio, +canvas, +embed, +iframe, +img, +object, +svg, +video { + display: block; + vertical-align: middle; +} +img, +video { + height: auto; + max-width: 100%; +} +[hidden] { + display: none; +} +*, +:after, +:before { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgba(59, 130, 246, 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgba(59, 130, 246, 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} +#webcrumbs .mx-auto { + margin-left: auto; + margin-right: auto; +} +#webcrumbs .mb-6 { + margin-bottom: 24px; +} +#webcrumbs .flex { + display: flex; +} +#webcrumbs .h-\[40px\] { + height: 40px; +} +#webcrumbs .h-\[50px\] { + height: 50px; +} +#webcrumbs .min-h-\[600px\] { + min-height: 1000px; +} +#webcrumbs .w-\[100px\] { + width: 100px; +} +#webcrumbs .w-\[1200px\] { + width: 1200px; +} +#webcrumbs .w-\[330px\] { + width: 330px; +} +#webcrumbs .w-full { + width: 100%; +} +#webcrumbs .w-max { + width:100vw +} +#webcrumbs .flex-row { + flex-direction: row; +} +#webcrumbs .items-start { + align-items: flex-start; +} +#webcrumbs .items-center { + align-items: center; +} +#webcrumbs .justify-center { + justify-content: center; +} +#webcrumbs .justify-between { + justify-content: space-between; +} +#webcrumbs .gap-4 { + gap: 16px; +} +#webcrumbs :is(.space-x-4 > :not([hidden]) ~ :not([hidden])) { + --tw-space-x-reverse: 0; + margin-left: calc(16px * (1 - var(--tw-space-x-reverse))); + margin-right: calc(16px * var(--tw-space-x-reverse)); +} +#webcrumbs :is(.space-y-4 > :not([hidden]) ~ :not([hidden])) { + --tw-space-y-reverse: 0; + margin-bottom: calc(16px * var(--tw-space-y-reverse)); + margin-top: calc(16px * (1 - var(--tw-space-y-reverse))); +} +#webcrumbs .rounded-lg { + border-radius: 24px; +} +#webcrumbs .rounded-md { + border-radius: 18px; +} +#webcrumbs .border-2 { + border-width: 2px; +} +#webcrumbs .border-b { + border-bottom-width: 1px; +} +#webcrumbs .border-neutral-700 { + --tw-border-opacity: 1; + border-color: rgb(103 103 103 / var(--tw-border-opacity)); +} +#webcrumbs .border-red-500 { + --tw-border-opacity: 1; + border-color: rgb(239 68 68 / var(--tw-border-opacity)); +} +#webcrumbs .bg-black { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); +} +#webcrumbs .bg-neutral-200 { + --tw-bg-opacity: 1; + background-color: rgb(224 224 224 / var(--tw-bg-opacity)); +} +#webcrumbs .bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} +#webcrumbs .p-6 { + padding: 24px; +} +#webcrumbs .px-6 { + padding-left: 24px; + padding-right: 24px; +} +#webcrumbs .py-4 { + padding-bottom: 16px; + padding-top: 16px; +} +#webcrumbs .pt-8 { + padding-top: 32px; +} +#webcrumbs .font-title { + font-family: + Lato, + ui-sans-serif, + system-ui, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; +} +#webcrumbs .text-2xl { + font-size: 24px; + line-height: 31.200000000000003px; +} +#webcrumbs .text-xl { + font-size: 20px; + line-height: 28px; +} +#webcrumbs .text-black { + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} +#webcrumbs .text-neutral-50 { + --tw-text-opacity: 1; + color: rgb(247 247 247 / var(--tw-text-opacity)); +} +#webcrumbs .text-neutral-900 { + --tw-text-opacity: 1; + color: rgb(70 70 70 / var(--tw-text-opacity)); +} +#webcrumbs .text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} +#webcrumbs { + font-family: Open Sans !important; + font-size: 16px !important; +} +#webcrumbs :is(.bg-neutral-200) { + color: rgba(0, 0, 0, 0.9) !important; +} + +#webcrumbs .hover\:bg-neutral-100:hover { + --tw-bg-opacity: -800; + background-color: rgb(245 245 245 / var(--tw-bg-opacity)); +} \ No newline at end of file diff --git a/src/main/resources/templates/admin/list.html b/src/main/resources/templates/admin/list.html index 0ee7f78b..1e97d9ec 100644 --- a/src/main/resources/templates/admin/list.html +++ b/src/main/resources/templates/admin/list.html @@ -11,42 +11,9 @@ - - - - - -
- - - - - - - - - - -
-
- - - - - - - -
프로필이름회원 지역즐겨찾기 개수
- -
-
diff --git a/src/main/resources/templates/admin/main.html b/src/main/resources/templates/admin/main.html new file mode 100644 index 00000000..90640fa4 --- /dev/null +++ b/src/main/resources/templates/admin/main.html @@ -0,0 +1,54 @@ + + + + + + My Webcrumbs Plugin + + + + +
+ +
+ +
+
+
+ + +
Signal Buddy
+
+
+
+

회원 정보 관리

+
+ + + +
+
+
+
+
+ + \ No newline at end of file From a2a8bbc05f186474bfa4285640d1923d46416fad Mon Sep 17 00:00:00 2001 From: zzuharchive Date: Sun, 5 Jan 2025 14:52:33 +0900 Subject: [PATCH 19/23] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8-?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83-=ED=9A=8C=EC=9B=8C?= =?UTF-8?q?=EA=B0=80=EC=9E=85-=ED=94=84=EB=A1=A0=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/MemberWebController.java | 22 +++ .../domain/member/dto/MemberJoinRequest.java | 13 +- .../member/exception/MemberErrorCode.java | 3 +- .../domain/member/service/MemberService.java | 36 +++- .../global/config/SecurityConfig.java | 4 +- src/main/resources/static/css/admin/login.css | 63 +++++++ src/main/resources/static/css/member/info.css | 13 ++ .../resources/static/css/member/login.css | 166 ++++++++++++++++++ .../resources/static/css/member/signup.css | 120 +++++++++++++ .../resources/static/images/camera-icon.png | Bin 0 -> 9891 bytes .../static/images/socialLogo/google.png | Bin 0 -> 4462 bytes .../static/images/socialLogo/kakaotalk.png | Bin 0 -> 59168 bytes .../static/images/socialLogo/naver.png | Bin 0 -> 9611 bytes .../resources/templates/admin/loginform.html | 32 +++- .../resources/templates/fragments/header.html | 12 +- .../resources/templates/member/loginform.html | 61 +++++-- .../resources/templates/member/signup.html | 61 +++++++ 17 files changed, 573 insertions(+), 33 deletions(-) create mode 100644 src/main/resources/static/css/admin/login.css create mode 100644 src/main/resources/static/css/member/login.css create mode 100644 src/main/resources/static/css/member/signup.css create mode 100644 src/main/resources/static/images/camera-icon.png create mode 100644 src/main/resources/static/images/socialLogo/google.png create mode 100644 src/main/resources/static/images/socialLogo/kakaotalk.png create mode 100644 src/main/resources/static/images/socialLogo/naver.png create mode 100644 src/main/resources/templates/member/signup.html diff --git a/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java b/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java index a8ed15f0..7762901b 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java +++ b/src/main/java/org/programmers/signalbuddy/domain/member/controller/MemberWebController.java @@ -1,11 +1,14 @@ package org.programmers.signalbuddy.domain.member.controller; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.programmers.signalbuddy.domain.bookmark.dto.BookmarkResponse; import org.programmers.signalbuddy.domain.bookmark.service.BookmarkService; import org.programmers.signalbuddy.domain.feedback.dto.FeedbackResponse; import org.programmers.signalbuddy.domain.feedback.service.FeedbackService; +import org.programmers.signalbuddy.domain.member.dto.MemberJoinRequest; +import org.programmers.signalbuddy.domain.member.service.MemberService; import org.programmers.signalbuddy.global.annotation.CurrentUser; import org.programmers.signalbuddy.global.dto.CustomUser2Member; import org.springframework.data.domain.Page; @@ -15,6 +18,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.servlet.ModelAndView; @@ -26,6 +30,7 @@ public class MemberWebController { private final BookmarkService bookmarkService; private final FeedbackService feedbackService; + private final MemberService memberService; @ModelAttribute("user") public CustomUser2Member currentUser(@CurrentUser CustomUser2Member user) { @@ -69,5 +74,22 @@ public ModelAndView findPagedFeedbacks(@PageableDefault(page = 0, size = 5) Page mv.setViewName("member/feedback/list"); return mv; } + + @GetMapping("/signup") + public ModelAndView signup(ModelAndView mv) { + + mv.addObject("memberJoinRequest", new MemberJoinRequest()); + mv.setViewName("member/signup"); + return mv; + } + + @PostMapping("/signup") + public ModelAndView registerMember(@ModelAttribute @Valid MemberJoinRequest joinMember, + ModelAndView mv) { + + memberService.joinMember(joinMember); + mv.setViewName("redirect:/members/login"); + return mv; + } } diff --git a/src/main/java/org/programmers/signalbuddy/domain/member/dto/MemberJoinRequest.java b/src/main/java/org/programmers/signalbuddy/domain/member/dto/MemberJoinRequest.java index 92f64112..00599562 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/member/dto/MemberJoinRequest.java +++ b/src/main/java/org/programmers/signalbuddy/domain/member/dto/MemberJoinRequest.java @@ -9,13 +9,19 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; +@Setter @Builder @Getter -@NoArgsConstructor(access = AccessLevel.PRIVATE) -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor +@AllArgsConstructor public class MemberJoinRequest { + @Schema(description = "프로필 사진", requiredMode = RequiredMode.NOT_REQUIRED, defaultValue = "/images/member/profile-icon.png") + private MultipartFile profileImageUrl; + @Email(message = "이메일 형식에 맞지 않습니다.") @NotBlank(message = "이메일은 필수 입력 사항입니다.") @Schema(description = "이메일", requiredMode = RequiredMode.NOT_REQUIRED, defaultValue = "udpate@example.com") @@ -28,7 +34,4 @@ public class MemberJoinRequest { @NotBlank(message = "비밀번호는 필수 입력 사항입니다.") @Schema(description = "비밀번호", requiredMode = RequiredMode.NOT_REQUIRED, defaultValue = "password123") private String password; - - @Schema(description = "프로필 사진", requiredMode = RequiredMode.NOT_REQUIRED, defaultValue = "/images/test.png") - private String profileImageUrl; } diff --git a/src/main/java/org/programmers/signalbuddy/domain/member/exception/MemberErrorCode.java b/src/main/java/org/programmers/signalbuddy/domain/member/exception/MemberErrorCode.java index 9454664d..5e572c32 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/member/exception/MemberErrorCode.java +++ b/src/main/java/org/programmers/signalbuddy/domain/member/exception/MemberErrorCode.java @@ -13,7 +13,8 @@ public enum MemberErrorCode implements ErrorCode { NOT_FOUND_MEMBER(HttpStatus.NOT_FOUND, 60000, "해당 사용자를 찾을 수 없습니다."), ALREADY_EXIST_EMAIL(HttpStatus.CONFLICT, 60001, "이미 존재하는 이메일 입니다."), PROFILE_IMAGE_LOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 60002, "프로필 이미지 로드 중 오류가 발생했습니다."), - WITHDRAWN_MEMBER(HttpStatus.FORBIDDEN, 60003, "탈퇴한 회원입니다."); + WITHDRAWN_MEMBER(HttpStatus.FORBIDDEN, 60003, "탈퇴한 회원입니다."), + PROFILE_IMAGE_UPLOAD_FAILURE(HttpStatus.INTERNAL_SERVER_ERROR, 60004, "프로필 이미지 저장 중 오류가 발생했습니다."); private HttpStatus httpStatus; private int code; diff --git a/src/main/java/org/programmers/signalbuddy/domain/member/service/MemberService.java b/src/main/java/org/programmers/signalbuddy/domain/member/service/MemberService.java index a42ebdc2..2f802198 100644 --- a/src/main/java/org/programmers/signalbuddy/domain/member/service/MemberService.java +++ b/src/main/java/org/programmers/signalbuddy/domain/member/service/MemberService.java @@ -1,9 +1,12 @@ package org.programmers.signalbuddy.domain.member.service; +import java.io.IOException; import java.net.MalformedURLException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.programmers.signalbuddy.domain.member.dto.MemberJoinRequest; @@ -23,6 +26,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Slf4j @Service @@ -69,10 +73,16 @@ public MemberResponse joinMember(MemberJoinRequest memberJoinRequest) { throw new BusinessException(MemberErrorCode.ALREADY_EXIST_EMAIL); } + String profilePath = "none"; + + if(!memberJoinRequest.getProfileImageUrl().isEmpty()) { + profilePath = saveProfileImage(memberJoinRequest.getProfileImageUrl()); + } + Member joinMember = Member.builder().email(memberJoinRequest.getEmail()) .nickname(memberJoinRequest.getNickname()) .password(bCryptPasswordEncoder.encode(memberJoinRequest.getPassword())) - .profileImageUrl(memberJoinRequest.getProfileImageUrl()) + .profileImageUrl(profilePath) .memberStatus(MemberStatus.ACTIVITY).role(MemberRole.USER).build(); memberRepository.save(joinMember); @@ -81,7 +91,8 @@ public MemberResponse joinMember(MemberJoinRequest memberJoinRequest) { public Resource getProfileImage(String filename) { try { - final Path path = Paths.get(filePath).resolve(filename); + Path directoryPath = Paths.get("src", "main", "resources","static", "images"); + final Path path = Paths.get(directoryPath.toString()).resolve(filename); if (Files.notExists(path)) { return new ClassPathResource("static/images/member/profile-icon.png"); // 프로필 이미지가 없을 경우 기본 이미지 @@ -91,4 +102,25 @@ public Resource getProfileImage(String filename) { throw new BusinessException(MemberErrorCode.PROFILE_IMAGE_LOAD_ERROR); } } + + public String saveProfileImage(MultipartFile profileImage) { + + // static/images 경로 설정 + Path directoryPath = Paths.get("src", "main", "resources","static", "images"); + + String originalFilename = profileImage.getOriginalFilename(); + String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + + String newFilename = UUID.randomUUID().toString() + extension; + Path savePath = directoryPath.resolve(newFilename); + + try { + Files.copy(profileImage.getInputStream(), savePath, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + log.error("Error while saving profile image: ", e); + throw new BusinessException(MemberErrorCode.PROFILE_IMAGE_UPLOAD_FAILURE); + } + + return newFilename.toString(); + } } diff --git a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java index 28dc6226..943edcaa 100644 --- a/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java +++ b/src/main/java/org/programmers/signalbuddy/global/config/SecurityConfig.java @@ -48,9 +48,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/js/**", "/images/**", "/webjars/**").permitAll() - // 로그인 + // 로그인, 회원가입 .requestMatchers("/members/login", "admins/login", "/api/members/join", - "/api/admins/join").permitAll() + "/api/admins/join", "/members/signup").permitAll() // 북마크 .requestMatchers("/api/bookmarks/**", "/bookmarks/**").hasRole("USER") // 댓글 diff --git a/src/main/resources/static/css/admin/login.css b/src/main/resources/static/css/admin/login.css new file mode 100644 index 00000000..32ca4876 --- /dev/null +++ b/src/main/resources/static/css/admin/login.css @@ -0,0 +1,63 @@ +body { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: auto; + background-color: #2C2C2C; + font-family: Arial, sans-serif; +} + + +.signup-container { + width: 300px; + padding: 20px; + background: #ffffff; + border-radius: 10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + align-items: center; +} + +h2 { + margin-bottom: 50px; + font-size: 24px; + font-weight: bold; +} + +.form-group { + width: 100%; + margin-bottom: 15px; +} + + +.form-group label { + display: block; + margin-bottom: 5px; + font-size: 14px; +} + +.form-group input { + width: 100%; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + box-sizing: border-box; +} + +.sign-in-btn { + width: 100%; + padding: 10px; + margin: 0 auto; + border: none; + border-radius: 5px; + background-color: #333; + color: #fff; + font-size: 16px; + cursor: pointer; +} + +.sign-in-btn:hover { + background-color: #555; +} diff --git a/src/main/resources/static/css/member/info.css b/src/main/resources/static/css/member/info.css index a1a1f872..22fa472d 100644 --- a/src/main/resources/static/css/member/info.css +++ b/src/main/resources/static/css/member/info.css @@ -15,4 +15,17 @@ .cancel { background-color: #1b2b40; +} + +.profile-img { + position: relative; + display: block; + width: 120px; + height: 120px; + margin: 20px auto; + border-radius: 50%; + background-color: #e0e0e0; + background-size: cover; + background-position: center; + object-fit: cover; } \ No newline at end of file diff --git a/src/main/resources/static/css/member/login.css b/src/main/resources/static/css/member/login.css new file mode 100644 index 00000000..60b07968 --- /dev/null +++ b/src/main/resources/static/css/member/login.css @@ -0,0 +1,166 @@ +body { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: auto; + background-color: #f0f0f0; + font-family: Arial, sans-serif; + padding-top: 70px; +} + +/* 헤더 고정 위치 */ +header { + position: fixed; + top: 0; + left: 0; + width: 100%; + background-color: #fff; + z-index: 1000; /* 다른 요소들 위에 위치하도록 */ + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); /* 헤더 아래 그림자 효과 */ +} + +.login-container { + width: 350px; + padding: 20px; + background: #ffffff; + border-radius: 10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + align-items: center; +} + +form { + width: 90%; + margin: 3px; +} + +h2 { + text-align: center; + margin-bottom: 25px; + font-size: 24px; + font-weight: bold; +} + +.form-group { + width: 100%; + margin-bottom: 10px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-size: 14px; +} + +.form-group input { + width: 100%; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + box-sizing: border-box; +} + +.sign-in-btn { + width: 100%; + padding: 10px; + margin: 3px auto; + border: none; + border-radius: 5px; + background-color: #333; + color: #fff; + font-size: 16px; + cursor: pointer; +} + +.sign-in-btn:hover { + background-color: #555; +} + +.links { + display: flex; + justify-content: center; + width: 100%; + font-size: 12px; +} + +.links a { + text-decoration: none; + color: #333; +} + +.links a:hover { + text-decoration: underline; +} + +.options { + margin-top: 10px; + display: flex; + justify-content: space-between; + font-size: 14px; +} + +.divider { + display: flex; /* Flexbox 사용 */ + align-items: center; /* 세로 방향 가운데 정렬 */ + justify-content: center; /* 양옆 선의 균등 분배 */ + width: 100%; /* 부모 요소의 너비 꽉 채우기 */ + margin: 10px 0; +} + +.divider .line { + flex: 1; /* 양옆 선이 가능한 넓게 차지 */ + height: 1px; /* 선 두께 */ + background-color: #ddd; /* 선 색상 */ +} + +.divider span { + font-size: small; + margin: 0 10px; /* 선과 텍스트 사이 여백 */ + color: #b4b4b4; /* 텍스트 색상 */ + white-space: nowrap; /* 텍스트 줄바꿈 방지 */ +} + + +.oauth-buttons { + display: flex; + flex-direction: column; + width: 100%; +} + +.oauth-button { + display: flex; + align-items: center; + justify-content: center; + margin: 5px 0; + padding: 10px; + + border-radius: 5px; + border: 1px solid #ddd; + cursor: pointer; + background: white; + text-align: left; +} + +.oauth-button img { + height: 24px; /* 로고 높이 설정 */ + width: 24px; /* 로고 너비 설정 */ + object-fit: contain; /* 로고 비율 유지 */ + margin-right: 10px; /* 텍스트와의 간격 설정 */ +} + +.oauth-button.google { + background: white; +} + +.oauth-button.naver { + background: #2AC308; + color: white; +} + +.oauth-button.kakaotalk { + background: #FAE100; + color: #371D1E + +} \ No newline at end of file diff --git a/src/main/resources/static/css/member/signup.css b/src/main/resources/static/css/member/signup.css new file mode 100644 index 00000000..6c6a05c3 --- /dev/null +++ b/src/main/resources/static/css/member/signup.css @@ -0,0 +1,120 @@ +/* 전체 body 스타일 */ +body { + margin: 0; + font-family: Arial, sans-serif; + background-color: #f0f0f0; + padding-top: 70px; /* 헤더 높이에 맞춰 여백 추가 */ +} + +/* 헤더 고정 위치 */ +header { + position: fixed; + top: 0; + left: 0; + width: 100%; + background-color: #fff; + z-index: 1000; /* 다른 요소들 위에 위치하도록 */ + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); /* 헤더 아래 그림자 효과 */ +} + +.signup-container { + width: 350px; + padding: 20px; + background: #ffffff; + border-radius: 10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + margin: 50px auto 0; /* 80px로 상단 여백을 주어 헤더와 겹치지 않게 */ +} + +h2 { + text-align: center; + margin-bottom: 20px; + font-size: 24px; + font-weight: bold; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: bold; +} + +.form-group input[type="text"], +.form-group input[type="email"], +.form-group input[type="password"] { + width: 100%; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + margin: 0 auto; +} + +.profile-preview { + position: relative; + display: block; + width: 120px; + height: 120px; + margin: 20px auto; + border-radius: 50%; + background-color: #e0e0e0; + background-size: cover; + background-position: center; +} + +.camera-btn { + position: absolute; + right: 0px; + bottom: 0px; + width: 30px; + height: 30px; + border-radius: 50%; + border: none; + background-color: white; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} + +.camera-btn img { + width: 20px; + height: 20px; + object-fit: contain; +} + +.form-group input[type="file"] { + display: none; +} + +.signup-button { + width: 200px; + padding: 10px; + margin: 20px 50px 0px 50px; + border: none; + border-radius: 5px; + background-color: #333; + color: #fff; + font-size: 16px; + cursor: pointer; +} + +.signup-button:hover { + background-color: #555; +} + +.login-link { + display: block; + text-align: center; + margin-top: 10px; + color: #333; + text-decoration: none; +} + +.login-link:hover { + text-decoration: underline; +} diff --git a/src/main/resources/static/images/camera-icon.png b/src/main/resources/static/images/camera-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ff8b1eef564561e984868afefd740ecd3fbdb6fa GIT binary patch literal 9891 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/static/images/socialLogo/kakaotalk.png b/src/main/resources/static/images/socialLogo/kakaotalk.png new file mode 100644 index 0000000000000000000000000000000000000000..ab8d0835b678d094e43b79e8c19aca6fb29c8aba GIT binary patch literal 59168 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 Ps^83w*R9H3=^Xk$0+AS0 literal 0 HcmV?d00001 diff --git a/src/main/resources/static/images/socialLogo/naver.png b/src/main/resources/static/images/socialLogo/naver.png new file mode 100644 index 0000000000000000000000000000000000000000..ab1f40ba65fbbc3810f35d72904c9dcb2d6c990f GIT binary patch literal 9611 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

- + + + Signal Buddy - 관리자 로그인 + -

관리자 로그인 페이지

-

-
- - - -
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index ee7d00b5..e9034718 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -1,5 +1,6 @@ - + @@ -14,7 +15,7 @@