From d85e3984559d62742df31bb338849fa435d56861 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:01:37 +0900 Subject: [PATCH 001/132] =?UTF-8?q?[feat/OPS-262]=20GlobalExceptionHandler?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: GlobalExceptionHandler 진행 사항 저장. * feat/OPS-262 : GlobalExceptionHandler 작성 완료. --- .../exception/GlobalExceptionHandler.java | 161 +++++++++++++++++- 1 file changed, 157 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java index ddddb477..20d51072 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java @@ -1,13 +1,166 @@ package org.tuna.zoopzoop.backend.global.exception; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.NoResultException; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.tuna.zoopzoop.backend.global.rsData.RsData; + +import javax.naming.AuthenticationException; +import java.nio.file.AccessDeniedException; +import java.util.stream.Collectors; + +import static org.springframework.http.HttpStatus.*; @ControllerAdvice @Slf4j //Logger 선언 public class GlobalExceptionHandler { - /* - Todo - 필요 항목이 생길때 마다 작성. - */ + @Autowired + private ObjectMapper objectMapper; + + @ExceptionHandler(NoResultException.class) // 자료를 찾지 못했을 경우. + public ResponseEntity> handleNoResultException(NoResultException e) { + return new ResponseEntity<>( + new RsData<>( + "404", + e.getMessage() + ), + NOT_FOUND + ); + } + + @ExceptionHandler(IllegalArgumentException.class) // Request Body 입력 값이 부족할 경우 + public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + return new ResponseEntity<>( + new RsData<>( + "400", + e.getMessage() + ), + BAD_REQUEST + ); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) // 유효하지 않은 메소드 파라미터 예외 + public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String message = e.getBindingResult() + .getAllErrors() + .stream() + .filter(error -> error instanceof FieldError) + .map(error -> { + FieldError fe = (FieldError) error; + return fe.getField() + "-" + fe.getCode() + "-" + fe.getDefaultMessage(); + }) + .sorted() + .collect(Collectors.joining("\n")); + + return new ResponseEntity<>( + new RsData<>( + "400", + message + ), + BAD_REQUEST + ); + } + + @ExceptionHandler(ConstraintViolationException.class) // 제약사항 위반 예외 + public ResponseEntity> handleConstraintViolationException(ConstraintViolationException e) { + String message = e.getConstraintViolations() + .stream() + .map(violation -> { + String path = violation.getPropertyPath().toString(); + String field = path.contains(".") ? path.substring(path.indexOf('.') + 1) : path; + String[] bits = violation.getMessageTemplate().split("\\."); + String code = bits.length >= 2 ? bits[bits.length - 2] : "Unknown"; + String msg = violation.getMessage(); + return field + "-" + code + "-" + msg; + }) + .sorted() + .collect(Collectors.joining("\n")); + + return new ResponseEntity<>( + new RsData<>( + "400", + e.getMessage() + ), + BAD_REQUEST + ); + } + + @ExceptionHandler(AuthenticationException.class) // 인증/인가에 실패한 경우. + public ResponseEntity> handleAuthentication(AuthenticationException e) { + return new ResponseEntity<>( + new RsData<>( + "401", + e.getMessage() + ), + UNAUTHORIZED + ); + } + + @ExceptionHandler(AccessDeniedException.class) // 권한이 부족한 경우 + public ResponseEntity> handleAccessDenied(AccessDeniedException e) { + return new ResponseEntity<>( + new RsData<>( + "403", + e.getMessage() + ), + FORBIDDEN + ); + } + + @ExceptionHandler(DataIntegrityViolationException.class) // 중복된 데이터의 경우 + public ResponseEntity> handleConflict(DataIntegrityViolationException e) { + return new ResponseEntity<>( + new RsData<>( + "409", + e.getMessage() + ), + CONFLICT + ); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) // 파라미터 타입 관련 예외. + public ResponseEntity> handle(MethodArgumentTypeMismatchException ex) { + String requiredType = ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "알 수 없음"; + String msg = "파라미터 '" + ex.getName() + "'의 타입이 올바르지 않습니다. 요구되는 타입: " + requiredType; + return new ResponseEntity<>( + new RsData<>( + "400", + msg + ), + BAD_REQUEST + ); + } + + @ExceptionHandler(MissingServletRequestPartException.class) // multi-part 관련 예외 처리 + public ResponseEntity> handle(MissingServletRequestPartException e) { + String msg = "필수 multipart 파트 '" + e.getRequestPartName() + "'가 존재하지 않습니다."; + return new ResponseEntity<>( + new RsData<>( + "400", + msg + ), + BAD_REQUEST + ); + } + + @ExceptionHandler(Exception.class) // 내부 서버 에러(= 따로 Exception을 지정하지 않은 경우.) + public ResponseEntity> handleException(Exception e) { + return new ResponseEntity<>( + new RsData<>( + "500", + e.getMessage() + ), + INTERNAL_SERVER_ERROR + ); + } } \ No newline at end of file From 75251197c453691bf820a54832b5bed66cc21ae8 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Fri, 19 Sep 2025 11:30:08 +0900 Subject: [PATCH 002/132] =?UTF-8?q?[feat/OPS-126]=20Member=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B8=B0=EC=B4=88=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-127 : Member 엔티티 작성(필드, 연관 관계, 생성자, 메소드) * feat/OPS-128 : MemberRepository 작성(name, email 검색 옵션 추가) * feat: GlobalExceptionHandler 작성을 위한 진행 사항 저장. * feat/OPS-129 : MemberService 작성(조회, 생성/수정, 삭제) * feat/OPS-126 : Member 도메인 설계 완료. Service 테스트 코드 추가 --- db_dev.mv.db | Bin 0 -> 36864 bytes .../backend/domain/member/entity/Member.java | 12 +- .../member/repository/MemberRepository.java | 9 ++ .../domain/member/service/MemberService.java | 65 ++++++++ .../member/service/MemberServiceTest.java | 147 ++++++++++++++++++ 5 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 db_dev.mv.db create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java diff --git a/db_dev.mv.db b/db_dev.mv.db new file mode 100644 index 0000000000000000000000000000000000000000..b5a532a00834130b3adf4b43e48656be1c800182 GIT binary patch literal 36864 zcmeHQON<-Id2T*th9j*MEU?J}0fMxtU5{qPrTbO=px3teXlf{NI3tpyl`diWQLR;W zL=HyOT5^CydBH$n1V#`b*dPXcO%7SZhad~sr@%pAAclh&KIfR+a?3IKtEyilhok21 zj8^L{0>kQNS66-gbyao!-~U(7PEDz}hZFDpn!(b+gKy!QEJ;$ud++i1{hEuEig$>e zaZRpx$Jm)-zb0E2LYijDfnNzG#}A!pO|5)@j~_jljOhu%A)b2g;p3VsD+oJ=bwS`C z&H!hCGr$?(3~&ZG1DpZQ0B3+Rz!~5Sa0Wgz26njp|Cw>mE9MMv1~>zp0nPwtfHS}u z;0$mEI0Kvk&cKUefI0cGRyo9DkOGdJ`}k4KsvJESHz$wBQ*in}be_~?MUi0Rs3vDG0+OV9$d@Y92M=)#Nv0>MRvZTaWsdQaY0axlCmuu;_@4M_J|u!*OL!ox z<#U2y!H#y5rN8?cO>@oH^6MMFWx$b-gultH<%L{c_>%bLy90YDHv4vcXpb5Poo-81 zRHZDIFa2qb{VE;|?7rA(i@m*}Xy5G&h6AxYsF%e-y*m`QB-M8eMNuV5_Ds)Gx5SD*VZcfw#nZ zYq!%Agu-|7|CL``7i2}%ke>VFXYdj8MOhRt-3k#l>&+dzB@XM2t}T}L4;tN0vs^6? zcIy33@5ZR!Z|-#N*yXDi2*Kq}t1NbUL;D6?7P+S0LAQHhn-ojRRm3k{+6&9w?{{|V z{d?jq`(CQp@YfOiI#SExUN3cQp-NKQ_8*01tKyrN{s35mil2uSuD&HU_j-e2zYbti zRU|)>+EWm((z@W%TK3&k{#m#}Q3K-sCI1}e_Up}a-mo)Fego17?c%_`ePH*RGk5LYU}*1-q9db*ge25z z+9O@DnFBgiT82cPI-2xDOb1>F6i z_-<#oBg$gK?(X$&3`WDf5p6$0QHR$0UReD>uLERKUI$_e`e~=v94<76RP&)NWu(nF zVRrA!i~@3(24cI@9}KslFaF>9>xH!q!7#1bSKs-XP&A-Le>=CNNz;NdHDs+(7EgwM zmEqXVavenz*l34rq&i0Gexy4CsdTpUhF4CyY|(-%`{o&37_`k4lni|oU=?f7M}PjD zK02)5h(ty7+Bob-0n5x)2}Xqv|-H%*c&Q;&n`q?3_m>TF3E{3 zM|UJ6DX)(MS{`;>EGY(SN2+gO8h2UO9a50p6$Pc4T9# z68&)(3~mqN?_v0=$u0`*ZBwK6Hj;fMvbS|z)11oV@#9C>uTPWVC^5lhYI_HcQJHw& z<0EJ6J*}zu2N5(igl1A`T5SC%&FyC?E5^7;il1Qo6S~x)Kc3GFosoa)X-YZQN3=|Z3W4?VS?Qn zsTz^7O9?;CG8hoc=d=urh~@V3^X%#R!Ek4<4+=n8w0j4;SGTCRvsDqdKzVA7_IllW z@y@%D&)!O#*~M$CP297wlzHM_fr)!0HKiuaOUz18mriKzVJl%1__v|Wf9te1&vIvK z_)uL6QwIC8RW4Scad%H|+xvEZu-B`1vx#TLuK-2+nQfbS!+WtZ#*+j*{o%pB0qSV=E~g~XLbqAovgB+RN+e5S(Ui`Eq{-+uj<#kJQXUGeMRxLya1 z*KXg~>D;{4-Q_xCqS~gOI?0bCt-*Cht}{}nz~^3PEPfp(6XS3)G4IW4-)gmyUT+zw z*G0GAZ4cWA`#rT$zpd6s%%9Pmn}4Omaj-esd)=1Zr&HauYoquLm}>m{5=Tb5gyqwW zQ;?O#_Fmua+~^TcVj9DuTjP{clI!c+ZM#q0X5fj9y`AjJLbD1?0C0&mZxyctm%q1^ z%N_Zkck{;0dxN|79fVr9<<_vj-|Fn$4eOdW-Qqezm+B&qb>Q)5OLz>=K8?Z!Ky&{0 zXc0X)&*L@ps-x?m6`I7kd)A)|tbSL+R05Dg2>?;E-vB{^h!Dv1nFyiyDtIN~gQdvI z*FDW}d?%J1NJ&%|z|@x4U3_McNd&-07k427;Pc|z2k~16?K>yr|Kph;jQ)b9AK%>E zjK%NV{fqDBbDIK{qi4iwDr+Ot3sZlYczqVKrAQ>Sr2@FC`Lh}3bzI2sk_(^0_crwc z&>RY%XC-y6Dn%wTBuI^LZ{g1eiRqn-|5Fr7!X}d%UCoWRym-rx^ha$@f1H&aBW(`~ z3B^4Y+LQF`d2vrq$&O)v2YH$HVLj^h3KjQAw-3cf-BSb0_akwS?D{y;`VuXYpooSv zvP~j^P~fE0JvsRMP>pmoFGLL5CEM=~To zlq8qK^iwcdz>oT$3da2f)|F;J@Q znueYLg;CW*3B7EPo!3sG-=%Q-lwJktK?=7T{6nPnY9VBWLQZ16k_KVWVV?$jMUVrX zfhK`pGkWjR_^)}87{wUjsg?>Xi^9TEXeEVcy_e$W41zmG_Q;KdU^65;$uY&19H!`A zBTt6wf^g@bSqcL!-TxRAU-J3SzhWu><_vHKI0Kvk&H!hCGw?DnFmHb+vt^tp09qh> z77oyvnbL7VvHc&+*#8Qh2nJygLfZbPhJY$5i6KCyrd~Y7bG2}ui0Et-8%|u;r`>>A z-o@V$ek|m#2_;$4OciT>Nk%?$BpH_=L?2#DD9I%N=V9GYN~+@O7^!M03X_2i7s4kr zqohfiY`VxQA;ZvBFMy3OfTt7%%aov4#g`np1TQf39NBVU!&OYv49EtJ-zr6MRV7oF zJqh9vN~WSgC6-Y#UDJ|$StT0~%cH^}vf&4qZWxfCZ9x1I-S9D#O!8m_`D_E07O)L4 zwdh6wmrFKWI6}*Db>w>N7t5f(c&h8rUp!Zm+>#ln7V|you2G6Ymr4){;>emFT$vo-ufC^LAAay?IyuHw->vo@ zA0FOu4j*F@hBSP7ge!A-rcbJ2lnVA~Mw01`HiTGBj-S3cml+D|Prexy73JC}#l1EQ zf-HH?0piJ8p=V)bL?|SybpL)??a^JGkyF-EuRVGGg^Kkkkg>zQ2AXh;n$e^ z(OU?DCT_^YlsZ|_4~U}wx3Qv+pX)EtVy|eVj;kwpNauC&S%|T_G^K{i(yxMc&s;}} zyQtj@&jjJxZ_+MM$p0lxzi?lX{U6!iNuv!C`yV;7=Sb&i|08+E{%0!^z$-QH|JSIe zN`ku?yVQvNpK?P%Nz_loyiuBAeOj|WJ|Cu*g?}qag0Okw2o;Z7L9~UWPdaL`wHkcX zT3lU{Wz)F2#Fotf4J4#>H+FGOjI#?-{0n?#)~Q=Jya)F3tN2ayDo8dD|Z@1hYO zNi#m&$U_c2Ax6g@QKTP4=oWhf^G(k8i0F|NKA|E{dCD~o)yJJ3wuv`X^|Ulp`B!}S zhr#~ZYcEJQ;`VQQ}X2(6uAAr;H=^HKl?vu z%i|@v{U5&+ZiU!AZvUSqYJ~QGY=0*vft=X?vZ@%W9vHDHd}bSw{~un>k$wT7&H_PC zkhe6t06=xkSpguWwn0%;0I;Y@BP)`7MrfJ;|NGqk&;9?jM`f9bWXZGAY$cmpk^BG2 zG?M%O$v;H>#Gx+S|34p3l&$lDTQ;)~m)rl`{wL1eD7=i@|J?rn!utP(k2e1IbM)eD z;mXFpLVy#juKmR`p34rRv$}Tuzsa&szPdJi@#OiuR#5of+INaC?dsZV>mRPA zSJ&dmKddF5;`r@;r?0M!eTNWU@;tSO`3}h%`lY_ZrK{G9|kPBj=0H6>x z23Fdqe1~$7K#iQR%&8r@Inz#91~u)3U2t=%r@LS?LOszVtHw~_vKXsu1#kp)Rgdhl z7==|>1t^47h+jytDt$gZvY}?9V2v!MWqTxqgDj>Ek4cINi)o{P9C$rqA^+#F4{5c@ zQW#(9{&#;0DMVuY6TCi=e>el20nPwtfHS}u;0$mEI0Kvk&H!g1GXs(TKk~oFW<9vk R<2L}pe-1HJ>s { + Optional findByEmail(String email); + Optional findByName(String name); + List findByActiveTrue(); // 활성 사용자 조회 + List findByActiveFalse(); // 비활성 사용자 조회 } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java new file mode 100644 index 00000000..7d998dc1 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java @@ -0,0 +1,65 @@ +package org.tuna.zoopzoop.backend.domain.member.service; + +import jakarta.persistence.NoResultException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MemberService { + private final MemberRepository memberRepository; + + //회원 조회 관련 + public Member findById(Integer id) { + return memberRepository.findById(id).orElseThrow(() -> + new NoResultException(id + " id를 가진 사용자를 찾을 수 없습니다.") + ); + } + public Member findByName(String name){ + return memberRepository.findByName(name).orElseThrow(() -> + new NoResultException(name + " 이름을 가진 사용자를 찾을 수 없습니다.") + ); + } + public Member findByEmail(String email){ + return memberRepository.findByEmail(email).orElseThrow(() -> + new NoResultException(email + " 이메일을 가진 사용자를 찾을 수 없습니다.") + ); + } + public List findAll(){ return memberRepository.findAll(); } + public List findAllActive(){ return memberRepository.findByActiveTrue(); } + public List findAllInactive(){ return memberRepository.findByActiveFalse(); } + //빈 List를 전달하는 경우, 예외 처리를 할 지는 고민해봐야 할 사항. + + //회원 생성/정보 수정 관련 + public Member createMember(String name, String email, String profileUrl){ + if(memberRepository.findByEmail(email).isPresent()){ + throw new DataIntegrityViolationException("이미 사용중인 이메일입니다."); + } + if(memberRepository.findByName(name).isPresent()) { + throw new DataIntegrityViolationException("이미 사용중인 이름입니다."); + } + + Member member = Member.builder() + .name(name) + .email(email) + .profileImageUrl(profileUrl) + .build(); + return memberRepository.save(member); + } + public void updateMemberName(Member member, String newName){ + if(memberRepository.findByName(newName).isPresent()) { + throw new DataIntegrityViolationException("이미 사용중인 이름입니다."); + } + member.updateName(newName); + } + + //회원 삭제/복구 관련 + public void softDeleteMember(Member member){ member.deactivate(); } + public void hardDeleteMember(Member member){ memberRepository.delete(member); } + public void restoreMember(Member member){ member.activate(); } +} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java new file mode 100644 index 00000000..6ff325f5 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java @@ -0,0 +1,147 @@ +package org.tuna.zoopzoop.backend.domain.member.service; + +import jakarta.persistence.NoResultException; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class MemberServiceTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @BeforeEach + void setUp() { + Member member1 = memberService.createMember( + "test1", + "test1@test.com", + "url" ); + Member member2 = memberService.createMember( + "test2", + "test2@test.com", + "url" ); + } + + private Member createTestMember() { + return memberService.createMember( + "test3", + "test3@test.com", + "url" + ); + } + + @Test + @DisplayName("사용자 생성 - 성공") + void createMemberSuccess() { + Member member = createTestMember(); + assertNotNull(member.getId()); + assertEquals("test3", member.getName()); + assertEquals("test3@test.com", member.getEmail()); + } + + @Test + @DisplayName("사용자 생성 - 이메일 중복으로 인한 실패") + void createMemberFailedByEmail() { + memberService.createMember("dupName", "dup@test.com", "url"); + Exception ex = assertThrows(DataIntegrityViolationException.class, () -> { + memberService.createMember("otherName", "dup@test.com", "url"); + }); + assertTrue(ex.getMessage().contains("이미 사용중인 이메일입니다.")); + } + + @Test + @DisplayName("사용자 생성 - 이름 중복으로 인한 실패") + void createMemberFailedByName() { + memberService.createMember("dupName", "dup1@test.com", "url"); + Exception ex = assertThrows(DataIntegrityViolationException.class, () -> { + memberService.createMember("dupName", "dup2@test.com", "url"); + }); + assertTrue(ex.getMessage().contains("이미 사용중인 이름입니다.")); + } + + @Test + @DisplayName("사용자 이메일 기반 조회 - 성공") + void findByEmailSuccess() { + Member saved = createTestMember(); + Member found = memberService.findByEmail("test3@test.com"); + assertEquals(saved.getId(), found.getId()); + assertEquals(saved.getEmail(), found.getEmail()); + } + + @Test + @DisplayName("사용자 이메일 기반 조회 - 실패") + void findByEmailFailed() { + Exception ex = assertThrows(NoResultException.class, () -> { + memberService.findByEmail("wrong@test.com"); + }); + assertTrue(ex.getMessage().contains("이메일을 가진 사용자를 찾을 수 없습니다.")); + } + + @Test + @DisplayName("사용자 이름 기반 조회 - 성공") + void findByNameSuccess() { + Member saved = createTestMember(); + Member found = memberService.findByName("test3"); + assertEquals(saved.getId(), found.getId()); + assertEquals(saved.getName(), found.getName()); + } + + @Test + @DisplayName("사용자 이름 기반 조회 - 실패") + void findByNameFailed() { + Exception ex = assertThrows(NoResultException.class, () -> { + memberService.findByName("wrongName"); + }); + assertTrue(ex.getMessage().contains("이름을 가진 사용자를 찾을 수 없습니다.")); + } + + @Test + @DisplayName("사용자 이름 변경 - 성공") + void updateMemberNameSuccess() { + Member member = createTestMember(); + memberService.updateMemberName(member, "새이름"); + Member updated = memberService.findById(member.getId()); + assertEquals("새이름", updated.getName()); // JUnit 기본 검증 + } + + @Test + @DisplayName("사용자 이름 변경 - 이름 중복으로 인한 실패") + void updateMemberNameFailed() { + Member member = createTestMember(); + Exception ex = assertThrows(DataIntegrityViolationException.class, () -> { + memberService.updateMemberName(member, "test1"); + }); + assertTrue(ex.getMessage().contains("이미 사용중인 이름입니다.")); + } + + @Test + @DisplayName("사용자 삭제 - soft delete") + void softDeleteMember() { + Member saved = createTestMember(); + memberService.softDeleteMember(saved); + assertFalse(saved.isActive()); + } + + @Test + @DisplayName("사용자 삭제 - hard delete") + void hardDeleteMember() { + Member saved = createTestMember(); + memberService.hardDeleteMember(saved); + assertFalse(memberRepository.findById(saved.getId()).isPresent()); + } +} \ No newline at end of file From 4b3953841604ec076f31e6a9a0d1e56753ce600f Mon Sep 17 00:00:00 2001 From: Hyeok Jin Kim <71928299+EpicFn@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:14:00 +0900 Subject: [PATCH 003/132] =?UTF-8?q?[Chore/OPS-264]=20ci=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B0=9C=EC=84=A0=20(#1?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore/OPS-264 : ci.yml 수정 * Update .github/workflows/ci.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/ci.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1229302b..cd0db488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,23 @@ jobs: - name: Test with Gradle run: ./gradlew test - # 6. Gradle 빌드 실행 (테스트 성공 시) + # 6. 테스트 결과 요약 출력 (optional, CI 로그에서 확인 가능) + - name: Show test results + run: | + echo "==== Test Results ====" + if compgen -G "build/test-results/test/TEST-*.xml" > /dev/null; then + total=$(grep ' Date: Fri, 19 Sep 2025 14:08:34 +0900 Subject: [PATCH 004/132] Update .gitignore to exclude IntelliJ files (#11) Add .idea/ to .gitignore to exclude IntelliJ project files --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b58b35ef..ca804af2 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,8 @@ local.properties # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +.idea/ + # User-specific stuff .idea/**/workspace.xml .idea/**/tasks.xml @@ -369,4 +371,4 @@ gradle-app.setting # Java heap dump *.hprof -# End of https://www.toptal.com/developers/gitignore/api/java,gradle,intellij,visualstudiocode,kotlin,maven,git,windows,macos,netbeans,eclipse \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/java,gradle,intellij,visualstudiocode,kotlin,maven,git,windows,macos,netbeans,eclipse From 4cd3be6823d69f0d1899c0ab9fd8bb1bdad384ed Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:07:57 +0900 Subject: [PATCH 005/132] =?UTF-8?q?feat/OPS-134=20:=20ApiV1Controller=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EC=99=84=EB=A3=8C.=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ApiV1MemberController.java | 81 +++++++++++ .../dto/req/ReqBodyForEditMemberName.java | 12 ++ .../dto/res/ResBodyForEditMemberName.java | 9 ++ .../dto/res/ResBodyForGetMemberInfo.java | 19 +++ .../domain/member/service/MemberService.java | 4 + .../global/config/jwt/JwtProperties.java | 22 +++ .../exception/GlobalExceptionHandler.java | 12 +- .../backend/global/rsData/RsData.java | 3 +- .../global/security/SecurityConfig.java | 10 +- .../jwt/CustomAuthenticationEntryPoint.java | 27 ++++ .../security/jwt/CustomUserDetails.java | 46 ++++++ .../security/jwt/JwtAuthenticationFilter.java | 68 +++++++++ .../backend/global/security/jwt/JwtUtil.java | 129 +++++++++++++++++ .../service/CustomUserDetailsService.java | 25 ++++ .../controller/MemberControllerTest.java | 135 ++++++++++++++++++ 15 files changed, 590 insertions(+), 12 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMemberName.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMemberName.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/config/jwt/JwtProperties.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomAuthenticationEntryPoint.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java new file mode 100644 index 00000000..6b731da7 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java @@ -0,0 +1,81 @@ +package org.tuna.zoopzoop.backend.domain.member.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMemberName; +import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForEditMemberName; +import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfo; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/member") +@Tag(name = "ApiV1MemberController", description = "사용자 REST API 컨트롤러") +public class ApiV1MemberController { + private final MemberService memberService; + /// api/v1/member/me : 사용자 정보 조회 (GET) + /// api/v1/member/edit : 사용자 닉네임 수정 (PUT) + /// api/v1/member : 사용자 탈퇴 (DELETE) + @GetMapping("/me") + @Operation(summary = "사용자 정보 조회") + public ResponseEntity> getMemberInfo( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + return ResponseEntity + .status(HttpStatus.OK) + .body( + new RsData<>( + "200", + "사용자 정보를 조회했습니다.", + new ResBodyForGetMemberInfo(member) + ) + ); + } + + @PutMapping("/edit") + @Operation(summary = "사용자 닉네임 수정") + public ResponseEntity> editMemberName( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody ReqBodyForEditMemberName reqBodyForEditMemberName + ) { + Member member = userDetails.getMember(); + member.updateName(reqBodyForEditMemberName.newName()); + return ResponseEntity + .status(HttpStatus.OK) + .body( + new RsData<>( + "200", + "사용자의 닉네임을 변경했습니다.", + new ResBodyForEditMemberName(member.getName()) + ) + ); + } + + @DeleteMapping + @Operation(summary = "사용자 삭제") + public ResponseEntity> deleteMember( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + memberService.hardDeleteMember(member); + return ResponseEntity + .status(HttpStatus.OK) + .body( + new RsData<>( + "200", + "정상적으로 탈퇴되었습니다.", + null + ) + ); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMemberName.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMemberName.java new file mode 100644 index 00000000..0a4016b3 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMemberName.java @@ -0,0 +1,12 @@ +package org.tuna.zoopzoop.backend.domain.member.dto.req; + +import jakarta.validation.constraints.NotBlank; + +public record ReqBodyForEditMemberName( + @NotBlank(message = "잘못된 요청입니다.") //MethodArgumentException + String newName +) { + public ReqBodyForEditMemberName(String newName){ + this.newName = newName; + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMemberName.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMemberName.java new file mode 100644 index 00000000..fc54a867 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMemberName.java @@ -0,0 +1,9 @@ +package org.tuna.zoopzoop.backend.domain.member.dto.res; + +public record ResBodyForEditMemberName( + String name +) { + public ResBodyForEditMemberName(String name) { + this.name = name; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java new file mode 100644 index 00000000..085913e3 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java @@ -0,0 +1,19 @@ +package org.tuna.zoopzoop.backend.domain.member.dto.res; + +import org.tuna.zoopzoop.backend.domain.member.entity.Member; + +public record ResBodyForGetMemberInfo( + Integer id, + String name, + String email, + String profileUrl +) { + public ResBodyForGetMemberInfo(Member member){ + this( + member.getId(), + member.getName(), + member.getEmail(), + member.getProfileImageUrl() + ); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java index 7d998dc1..07c20f7d 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; @@ -36,6 +37,7 @@ public Member findByEmail(String email){ //빈 List를 전달하는 경우, 예외 처리를 할 지는 고민해봐야 할 사항. //회원 생성/정보 수정 관련 + @Transactional public Member createMember(String name, String email, String profileUrl){ if(memberRepository.findByEmail(email).isPresent()){ throw new DataIntegrityViolationException("이미 사용중인 이메일입니다."); @@ -51,6 +53,8 @@ public Member createMember(String name, String email, String profileUrl){ .build(); return memberRepository.save(member); } + + @Transactional public void updateMemberName(Member member, String newName){ if(memberRepository.findByName(newName).isPresent()) { throw new DataIntegrityViolationException("이미 사용중인 이름입니다."); diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/config/jwt/JwtProperties.java b/src/main/java/org/tuna/zoopzoop/backend/global/config/jwt/JwtProperties.java new file mode 100644 index 00000000..5f56f3a9 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/config/jwt/JwtProperties.java @@ -0,0 +1,22 @@ +package org.tuna.zoopzoop.backend.global.config.jwt; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "jwt") +@Getter +@Setter +public class JwtProperties { + //application.yml에 jwt 항목 작성 + //예시 + //jwt: + // secret-key: mySecretKeyForJWTTokenGenerationAndValidation1234567890 + // access-token-validity: 86400000 + // refresh-token-validity: 604800000 + private String secretKey; + private long accessTokenValidity; + private long refreshTokenValidity; +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java index 20d51072..92885d06 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java @@ -52,15 +52,11 @@ public ResponseEntity> handleIllegalArgument(IllegalArgumentExcepti @ExceptionHandler(MethodArgumentNotValidException.class) // 유효하지 않은 메소드 파라미터 예외 public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { String message = e.getBindingResult() - .getAllErrors() + .getFieldErrors() .stream() - .filter(error -> error instanceof FieldError) - .map(error -> { - FieldError fe = (FieldError) error; - return fe.getField() + "-" + fe.getCode() + "-" + fe.getDefaultMessage(); - }) - .sorted() - .collect(Collectors.joining("\n")); + .findFirst() + .map(FieldError::getDefaultMessage) + .orElse("잘못된 요청입니다."); // 메세지가 없을 경우 기본값 return new ResponseEntity<>( new RsData<>( diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/rsData/RsData.java b/src/main/java/org/tuna/zoopzoop/backend/global/rsData/RsData.java index 93e3a183..59528171 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/rsData/RsData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/rsData/RsData.java @@ -1,9 +1,10 @@ package org.tuna.zoopzoop.backend.global.rsData; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; public record RsData( - String resultCode, + @JsonProperty("status") String resultCode, @JsonIgnore int statusCode, String msg, diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java index 517ce934..ffeb9127 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java @@ -3,19 +3,21 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomAuthenticationEntryPoint; @Configuration @RequiredArgsConstructor public class SecurityConfig { + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // 모든 요청 허용 .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/member", "/api/v1/member/**").authenticated() .requestMatchers( "/", "/favicon.ico", @@ -38,8 +40,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // 기본 인증 비활성화 .httpBasic(httpBasic -> httpBasic.disable()) - .formLogin(formLogin -> formLogin.disable()); - + .formLogin(formLogin -> formLogin.disable()) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(customAuthenticationEntryPoint) + ); return http.build(); } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomAuthenticationEntryPoint.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..bfb962ba --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomAuthenticationEntryPoint.java @@ -0,0 +1,27 @@ +package org.tuna.zoopzoop.backend.global.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.global.rsData.RsData; + +import java.io.IOException; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 + + RsData body = new RsData<>("401", "액세스가 거부되었습니다.", null); + ObjectMapper objectMapper = new ObjectMapper(); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java new file mode 100644 index 00000000..b2d702ee --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java @@ -0,0 +1,46 @@ +package org.tuna.zoopzoop.backend.global.security.jwt; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; + +import java.util.Collection; + +public class CustomUserDetails implements UserDetails { + private final Member member; + public CustomUserDetails(Member member) { this.member = member; } + + public Member getMember() { return member; } + + @Override + public Collection getAuthorities() { return null; } + + @Override + public String getPassword() { return null; } + + @Override + public String getUsername() { return member.getEmail(); } + + public String getNickname() { return member.getName(); } + public String getProfileImageUrl() { return member.getProfileImageUrl(); } + + @Override + public boolean isAccountNonExpired() { + return true; // 계정 만료 여부 + } + + @Override + public boolean isAccountNonLocked() { + return true; // 계정 잠금 여부 + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return member.isActive(); // 계정 활성화 여부 + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..848a5b5b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,68 @@ +package org.tuna.zoopzoop.backend.global.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import org.tuna.zoopzoop.backend.global.security.service.CustomUserDetailsService; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; + private final CustomUserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = getTokenFromRequest(request); // Authorization 헤더에서 JWT 토큰 추출 + + if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) { // 토큰이 존재하고 유효한 경우 + String email = jwtUtil.getEmailFromToken(token); //토큰에서 이메일 추출 + + UserDetails userDetails = userDetailsService.loadUserByUsername(email); // 사용자 정보 로드 + + //권한 생성 로직 X (사용 안함.) + + UsernamePasswordAuthenticationToken authentication = // Spring Security 인증 객체 생성 + new UsernamePasswordAuthenticationToken( + userDetails, + null, + null + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); // SecurityContext에 인증 정보 설정 + } + filterChain.doFilter(request, response); // 다음 필터로 요청 전달 + } + + private String getTokenFromRequest(HttpServletRequest request) { // Authorization 헤더에서 Bearer 토큰 추출 + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { + return bearerToken.substring(7); // "Bearer " 제거 + } + + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("accessToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java new file mode 100644 index 00000000..bbcd1417 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java @@ -0,0 +1,129 @@ +package org.tuna.zoopzoop.backend.global.security.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.global.config.jwt.JwtProperties; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtUtil { + private final JwtProperties jwtProperties; + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes()); + } + + // JWT 토큰 생성 + public String generateToken(Member member) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtProperties.getAccessTokenValidity()); + + return Jwts.builder() + .setSubject(member.getEmail()) + .claim("userId", member.getId()) + .claim("name", member.getName()) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKey()) + .compact(); + } + + // 토큰에서 이메일 추출 + public String getEmailFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + return claims.getSubject(); + } + + // 토큰에서 이름 추출 + public String getNameFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + return claims.get("name").toString(); + } + + // 토큰에서 사용자 ID 추출 + public int getUserIdFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + return claims.get("userId", Integer.class); + } + + // 토큰 유효성 검증 + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token); + return true; + } catch (ExpiredJwtException e) { + log.error("JWT 토큰이 만료되었습니다: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + log.error("지원되지 않는 JWT 토큰입니다: {}", e.getMessage()); + } catch (MalformedJwtException e) { + log.error("잘못된 JWT 토큰입니다: {}", e.getMessage()); + } catch (SecurityException e) { + log.error("JWT 서명이 잘못되었습니다: {}", e.getMessage()); + } catch (IllegalArgumentException e) { + log.error("JWT 토큰이 비어있습니다: {}", e.getMessage()); + } + return false; + } + + // 리프레시 토큰 생성 + public String generateRefreshToken(Member member) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtProperties.getRefreshTokenValidity()); + + return Jwts.builder() + .setSubject(member.getEmail()) + .claim("userId", member.getId()) + .claim("name", member.getName()) + .claim("tokenType", "refresh") + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKey()) + .compact(); + } + + // 리프레시 토큰 여부 확인 + public boolean isRefreshToken(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + return "refresh".equals(claims.get("tokenType")); + } catch (Exception e) { + return false; + } + } + + // 토큰의 만료 날짜 추출 + public Date getExpirationDateFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + return claims.getExpiration(); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java new file mode 100644 index 00000000..64d9628a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java @@ -0,0 +1,25 @@ +package org.tuna.zoopzoop.backend.global.security.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final MemberService memberService; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberService.findByEmail(email); + if (!member.isActive()) { + throw new UsernameNotFoundException("비활성화된 계정입니다: " + email); + } + return new CustomUserDetails(member); + } +} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java new file mode 100644 index 00000000..35da3776 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java @@ -0,0 +1,135 @@ +package org.tuna.zoopzoop.backend.domain.member.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMemberName; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class MemberControllerTest { + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeAll + void setUp() { + memberService.createMember( + "test", + "test@test.com", + "url"); + memberService.createMember( + "test2", + "test2@test.com", + "url"); + memberService.createMember( + "test3", + "test3@test.com", + "url"); + } + + @Test + @WithUserDetails(value = "test@test.com", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("사용자 정보 조회 - 성공(200)") + void getMemberInfoSuccess() throws Exception { + mockMvc.perform(get("/api/v1/member/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.name").value("test")) + .andExpect(jsonPath("$.data.email").value("test@test.com")) + .andExpect(jsonPath("$.data.profileUrl").value("url")); + } + + @Test + @DisplayName("사용자 정보 조회 - 실패(401, Unauthorized)") + void getMemberInfoFailed() throws Exception { + mockMvc.perform(get("/api/v1/member/me")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status").value(401)) + .andExpect(jsonPath("$.msg").value("액세스가 거부되었습니다.")); + } + + @Test + @WithUserDetails(value = "test@test.com", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("사용자 이름 수정 - 성공(200)") + void editMemberNameSuccess() throws Exception { + ReqBodyForEditMemberName reqBodyForEditMemberName = new ReqBodyForEditMemberName("test3"); + mockMvc.perform(put("/api/v1/member/edit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(reqBodyForEditMemberName))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("사용자의 닉네임을 변경했습니다.")) + .andExpect(jsonPath("$.data.name").value("test3")); + } + + @Test + @WithUserDetails(value = "test@test.com", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("사용자 이름 수정 - 실패(400, Bad_Request)") + void editMemberNameFailedByBadRequest() throws Exception { + ReqBodyForEditMemberName reqBodyForEditMemberName = new ReqBodyForEditMemberName(""); + mockMvc.perform(put("/api/v1/member/edit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(reqBodyForEditMemberName))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.msg").value("잘못된 요청입니다.")); + } + + @Test + @DisplayName("사용자 이름 수정 - 실패(401, Unauthorized)") + void editMemberNameFailedByUnauthorized() throws Exception { + ReqBodyForEditMemberName reqBodyForEditMemberName = new ReqBodyForEditMemberName("test3"); + mockMvc.perform(put("/api/v1/member/edit") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(reqBodyForEditMemberName))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status").value(401)) + .andExpect(jsonPath("$.msg").value("액세스가 거부되었습니다.")); + } + + @Test + @WithUserDetails(value = "test3@test.com", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("사용자 삭제 - 성공(200)") + void deleteMemberSuccess() throws Exception { + mockMvc.perform(delete("/api/v1/member")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("정상적으로 탈퇴되었습니다.")); + } + + @Test + @DisplayName("사용자 삭제 - 실패(401, Unauthorized)") + void deleteMemberFailed() throws Exception { + mockMvc.perform(delete("/api/v1/member")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status").value(401)) + .andExpect(jsonPath("$.msg").value("액세스가 거부되었습니다.")); + } +} From b5158eace852fdfdae3b699f60d897809986a2cf Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Mon, 22 Sep 2025 08:55:47 +0900 Subject: [PATCH 006/132] =?UTF-8?q?[chore/OPS-268]=20CI=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B0=9C=EC=84=A0=20#2?= =?UTF-8?q?=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 헌재 소셜 로그인에 사용하는 특정 민감한 값(JWT 토큰, 소셜 client_id 등)을 @Value 어노테이션을 통해 받아오고 있습니다. 하지만 해당 민감한 값을 저장한 yaml 파일을 레포지토리에 바로 올릴 수 없기에, 레포지토리의 secrets에 해당 값들을 저장해놓고, CI 워크플로우에 이 값들을 사용해서 application-secrets.yml 파일을 작성하는 로직을 추가했습니다. --- .github/workflows/ci.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd0db488..023bc2d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,11 +43,17 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x ./gradlew - # 5. Gradle 테스트 실행 + # 5. application-secrets.yml 생성 + - name: Generate application-secrets.yml + run: | + mkdir -p src/main/resources + echo "${{ secrets.APPLICATION_SECRET_YML }}" > src/main/resources/application-secrets.yml + + # 6. Gradle 테스트 실행 - name: Test with Gradle run: ./gradlew test - # 6. 테스트 결과 요약 출력 (optional, CI 로그에서 확인 가능) + # 7. 테스트 결과 요약 출력 (optional, CI 로그에서 확인 가능) - name: Show test results run: | echo "==== Test Results ====" @@ -64,6 +70,6 @@ jobs: echo "No test results found." fi - # 7. Gradle 빌드 실행 (테스트 성공 시) + # 8. Gradle 빌드 실행 (테스트 성공 시) - name: Build with Gradle run: ./gradlew build From 7019bc7268f98b8a5f067b8968938dcf79954251 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Mon, 22 Sep 2025 09:25:46 +0900 Subject: [PATCH 007/132] =?UTF-8?q?[feat/OPS-155]=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84(=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4)=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-155 : 소셜 로그인/회원가입 기능 구현 (카카오) 완료. 수정 사항 반영. * feat/OPS-155 : 소셜 로그인/회원가입 기능 구현 (카카오) 완료. 수정 사항 반영 #2. * feat/OPS-155 : 소셜 로그인/회원가입 기능 구현 (카카오) 완료. 수정 사항 반영 #3. --- .gitignore | 4 + build.gradle | 3 + db_dev.mv.db | Bin 36864 -> 0 bytes db_dev.trace.db | 3 - .../auth/controller/KakaoLoginController.java | 57 +++++++++++++ .../domain/auth/dto/KakaoTokenResponse.java | 10 +++ .../auth/dto/KakaoUserInfoResponse.java | 10 +++ .../domain/auth/service/KakaoAuthService.java | 79 ++++++++++++++++++ .../home/controller/HomeController.java | 17 +++- .../dto/res/ResBodyForGetMemberInfo.java | 4 +- .../backend/domain/member/entity/Member.java | 9 +- .../member/repository/MemberRepository.java | 3 +- .../domain/member/service/MemberService.java | 21 +++-- .../config/webflux/WebClientConfig.java | 13 +++ .../backend/global/initData/BaseInitData.java | 32 ------- .../global/security/SecurityConfig.java | 1 + .../security/jwt/CustomUserDetails.java | 2 +- .../security/jwt/JwtAuthenticationFilter.java | 4 +- .../backend/global/security/jwt/JwtUtil.java | 18 +++- .../service/CustomUserDetailsService.java | 15 +++- .../application-secrets.yml.template | 8 ++ src/main/resources/application.yml | 2 + .../controller/MemberControllerTest.java | 15 ++-- .../member/service/MemberServiceTest.java | 77 ++++++++++------- 24 files changed, 308 insertions(+), 99 deletions(-) delete mode 100644 db_dev.mv.db delete mode 100644 db_dev.trace.db create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/KakaoLoginController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoTokenResponse.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoUserInfoResponse.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoAuthService.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/config/webflux/WebClientConfig.java create mode 100644 src/main/resources/application-secrets.yml.template diff --git a/.gitignore b/.gitignore index ca804af2..6499f8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ # Created by https://www.toptal.com/developers/gitignore/api/java,gradle,intellij,visualstudiocode,kotlin,maven,git,windows,macos,netbeans,eclipse # Edit at https://www.toptal.com/developers/gitignore?templates=java,gradle,intellij,visualstudiocode,kotlin,maven,git,windows,macos,netbeans,eclipse +### secret ### +application-secrets.yml + ### H2-DATABASE ### db_dev.mv.db +db_dev.trace.db ### Eclipse ### .metadata diff --git a/build.gradle b/build.gradle index d6120730..15c54e7e 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,9 @@ dependencies { // Spring Web (MVC) implementation 'org.springframework.boot:spring-boot-starter-web' + // WebFlux (소셜 로그인을 위한 비동기 프레임워크) + implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Bean Validation implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/db_dev.mv.db b/db_dev.mv.db deleted file mode 100644 index b5a532a00834130b3adf4b43e48656be1c800182..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36864 zcmeHQON<-Id2T*th9j*MEU?J}0fMxtU5{qPrTbO=px3teXlf{NI3tpyl`diWQLR;W zL=HyOT5^CydBH$n1V#`b*dPXcO%7SZhad~sr@%pAAclh&KIfR+a?3IKtEyilhok21 zj8^L{0>kQNS66-gbyao!-~U(7PEDz}hZFDpn!(b+gKy!QEJ;$ud++i1{hEuEig$>e zaZRpx$Jm)-zb0E2LYijDfnNzG#}A!pO|5)@j~_jljOhu%A)b2g;p3VsD+oJ=bwS`C z&H!hCGr$?(3~&ZG1DpZQ0B3+Rz!~5Sa0Wgz26njp|Cw>mE9MMv1~>zp0nPwtfHS}u z;0$mEI0Kvk&cKUefI0cGRyo9DkOGdJ`}k4KsvJESHz$wBQ*in}be_~?MUi0Rs3vDG0+OV9$d@Y92M=)#Nv0>MRvZTaWsdQaY0axlCmuu;_@4M_J|u!*OL!ox z<#U2y!H#y5rN8?cO>@oH^6MMFWx$b-gultH<%L{c_>%bLy90YDHv4vcXpb5Poo-81 zRHZDIFa2qb{VE;|?7rA(i@m*}Xy5G&h6AxYsF%e-y*m`QB-M8eMNuV5_Ds)Gx5SD*VZcfw#nZ zYq!%Agu-|7|CL``7i2}%ke>VFXYdj8MOhRt-3k#l>&+dzB@XM2t}T}L4;tN0vs^6? zcIy33@5ZR!Z|-#N*yXDi2*Kq}t1NbUL;D6?7P+S0LAQHhn-ojRRm3k{+6&9w?{{|V z{d?jq`(CQp@YfOiI#SExUN3cQp-NKQ_8*01tKyrN{s35mil2uSuD&HU_j-e2zYbti zRU|)>+EWm((z@W%TK3&k{#m#}Q3K-sCI1}e_Up}a-mo)Fego17?c%_`ePH*RGk5LYU}*1-q9db*ge25z z+9O@DnFBgiT82cPI-2xDOb1>F6i z_-<#oBg$gK?(X$&3`WDf5p6$0QHR$0UReD>uLERKUI$_e`e~=v94<76RP&)NWu(nF zVRrA!i~@3(24cI@9}KslFaF>9>xH!q!7#1bSKs-XP&A-Le>=CNNz;NdHDs+(7EgwM zmEqXVavenz*l34rq&i0Gexy4CsdTpUhF4CyY|(-%`{o&37_`k4lni|oU=?f7M}PjD zK02)5h(ty7+Bob-0n5x)2}Xqv|-H%*c&Q;&n`q?3_m>TF3E{3 zM|UJ6DX)(MS{`;>EGY(SN2+gO8h2UO9a50p6$Pc4T9# z68&)(3~mqN?_v0=$u0`*ZBwK6Hj;fMvbS|z)11oV@#9C>uTPWVC^5lhYI_HcQJHw& z<0EJ6J*}zu2N5(igl1A`T5SC%&FyC?E5^7;il1Qo6S~x)Kc3GFosoa)X-YZQN3=|Z3W4?VS?Qn zsTz^7O9?;CG8hoc=d=urh~@V3^X%#R!Ek4<4+=n8w0j4;SGTCRvsDqdKzVA7_IllW z@y@%D&)!O#*~M$CP297wlzHM_fr)!0HKiuaOUz18mriKzVJl%1__v|Wf9te1&vIvK z_)uL6QwIC8RW4Scad%H|+xvEZu-B`1vx#TLuK-2+nQfbS!+WtZ#*+j*{o%pB0qSV=E~g~XLbqAovgB+RN+e5S(Ui`Eq{-+uj<#kJQXUGeMRxLya1 z*KXg~>D;{4-Q_xCqS~gOI?0bCt-*Cht}{}nz~^3PEPfp(6XS3)G4IW4-)gmyUT+zw z*G0GAZ4cWA`#rT$zpd6s%%9Pmn}4Omaj-esd)=1Zr&HauYoquLm}>m{5=Tb5gyqwW zQ;?O#_Fmua+~^TcVj9DuTjP{clI!c+ZM#q0X5fj9y`AjJLbD1?0C0&mZxyctm%q1^ z%N_Zkck{;0dxN|79fVr9<<_vj-|Fn$4eOdW-Qqezm+B&qb>Q)5OLz>=K8?Z!Ky&{0 zXc0X)&*L@ps-x?m6`I7kd)A)|tbSL+R05Dg2>?;E-vB{^h!Dv1nFyiyDtIN~gQdvI z*FDW}d?%J1NJ&%|z|@x4U3_McNd&-07k427;Pc|z2k~16?K>yr|Kph;jQ)b9AK%>E zjK%NV{fqDBbDIK{qi4iwDr+Ot3sZlYczqVKrAQ>Sr2@FC`Lh}3bzI2sk_(^0_crwc z&>RY%XC-y6Dn%wTBuI^LZ{g1eiRqn-|5Fr7!X}d%UCoWRym-rx^ha$@f1H&aBW(`~ z3B^4Y+LQF`d2vrq$&O)v2YH$HVLj^h3KjQAw-3cf-BSb0_akwS?D{y;`VuXYpooSv zvP~j^P~fE0JvsRMP>pmoFGLL5CEM=~To zlq8qK^iwcdz>oT$3da2f)|F;J@Q znueYLg;CW*3B7EPo!3sG-=%Q-lwJktK?=7T{6nPnY9VBWLQZ16k_KVWVV?$jMUVrX zfhK`pGkWjR_^)}87{wUjsg?>Xi^9TEXeEVcy_e$W41zmG_Q;KdU^65;$uY&19H!`A zBTt6wf^g@bSqcL!-TxRAU-J3SzhWu><_vHKI0Kvk&H!hCGw?DnFmHb+vt^tp09qh> z77oyvnbL7VvHc&+*#8Qh2nJygLfZbPhJY$5i6KCyrd~Y7bG2}ui0Et-8%|u;r`>>A z-o@V$ek|m#2_;$4OciT>Nk%?$BpH_=L?2#DD9I%N=V9GYN~+@O7^!M03X_2i7s4kr zqohfiY`VxQA;ZvBFMy3OfTt7%%aov4#g`np1TQf39NBVU!&OYv49EtJ-zr6MRV7oF zJqh9vN~WSgC6-Y#UDJ|$StT0~%cH^}vf&4qZWxfCZ9x1I-S9D#O!8m_`D_E07O)L4 zwdh6wmrFKWI6}*Db>w>N7t5f(c&h8rUp!Zm+>#ln7V|you2G6Ymr4){;>emFT$vo-ufC^LAAay?IyuHw->vo@ zA0FOu4j*F@hBSP7ge!A-rcbJ2lnVA~Mw01`HiTGBj-S3cml+D|Prexy73JC}#l1EQ zf-HH?0piJ8p=V)bL?|SybpL)??a^JGkyF-EuRVGGg^Kkkkg>zQ2AXh;n$e^ z(OU?DCT_^YlsZ|_4~U}wx3Qv+pX)EtVy|eVj;kwpNauC&S%|T_G^K{i(yxMc&s;}} zyQtj@&jjJxZ_+MM$p0lxzi?lX{U6!iNuv!C`yV;7=Sb&i|08+E{%0!^z$-QH|JSIe zN`ku?yVQvNpK?P%Nz_loyiuBAeOj|WJ|Cu*g?}qag0Okw2o;Z7L9~UWPdaL`wHkcX zT3lU{Wz)F2#Fotf4J4#>H+FGOjI#?-{0n?#)~Q=Jya)F3tN2ayDo8dD|Z@1hYO zNi#m&$U_c2Ax6g@QKTP4=oWhf^G(k8i0F|NKA|E{dCD~o)yJJ3wuv`X^|Ulp`B!}S zhr#~ZYcEJQ;`VQQ}X2(6uAAr;H=^HKl?vu z%i|@v{U5&+ZiU!AZvUSqYJ~QGY=0*vft=X?vZ@%W9vHDHd}bSw{~un>k$wT7&H_PC zkhe6t06=xkSpguWwn0%;0I;Y@BP)`7MrfJ;|NGqk&;9?jM`f9bWXZGAY$cmpk^BG2 zG?M%O$v;H>#Gx+S|34p3l&$lDTQ;)~m)rl`{wL1eD7=i@|J?rn!utP(k2e1IbM)eD z;mXFpLVy#juKmR`p34rRv$}Tuzsa&szPdJi@#OiuR#5of+INaC?dsZV>mRPA zSJ&dmKddF5;`r@;r?0M!eTNWU@;tSO`3}h%`lY_ZrK{G9|kPBj=0H6>x z23Fdqe1~$7K#iQR%&8r@Inz#91~u)3U2t=%r@LS?LOszVtHw~_vKXsu1#kp)Rgdhl z7==|>1t^47h+jytDt$gZvY}?9V2v!MWqTxqgDj>Ek4cINi)o{P9C$rqA^+#F4{5c@ zQW#(9{&#;0DMVuY6TCi=e>el20nPwtfHS}u;0$mEI0Kvk&H!g1GXs(TKk~oFW<9vk R<2L}pe-1HJ>s>> kakaoCallback(@RequestParam String code) { + Map tokens = kakaoAuthService.loginWithKakao(code); + ResponseCookie accessCookie = ResponseCookie.from("accessToken", tokens.get("accessToken")) + .httpOnly(true) + .path("/") + .maxAge(jwtProperties.getAccessTokenValidity() / 1000) + .sameSite("Lax") + .build(); + + ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", tokens.get("refreshToken")) + .httpOnly(true) + .secure(false) + .path("/") + .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) + .sameSite("Lax") + .build(); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.SET_COOKIE, accessCookie.toString()); + headers.add(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + + return ResponseEntity + .status(HttpStatus.OK) + .headers(headers) + .body(new RsData<>( + "200", + "카카오 로그인에 성공했습니다.", + tokens + ) + ); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoTokenResponse.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoTokenResponse.java new file mode 100644 index 00000000..43c2366f --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoTokenResponse.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.auth.dto; + +public record KakaoTokenResponse( + String access_token, + String token_type, + String refresh_token, + Long expires_in, + String scope, + Long refresh_token_expires_in +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoUserInfoResponse.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoUserInfoResponse.java new file mode 100644 index 00000000..83c8f20f --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoUserInfoResponse.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.auth.dto; + +public record KakaoUserInfoResponse( + Long id, + KakaoAccount kakao_account +) { + public record KakaoAccount(Profile profile) { + public record Profile(String nickname, String profile_image_url) {} + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoAuthService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoAuthService.java new file mode 100644 index 00000000..812bb760 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoAuthService.java @@ -0,0 +1,79 @@ +package org.tuna.zoopzoop.backend.domain.auth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import org.tuna.zoopzoop.backend.domain.auth.dto.KakaoTokenResponse; +import org.tuna.zoopzoop.backend.domain.auth.dto.KakaoUserInfoResponse; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; + +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class KakaoAuthService { + private final WebClient webClient; + private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; + + @Value("${kakao.client_id}") + private String CLIENT_ID; + @Value("${kakao.redirect_uri}") + private String REDIRECT_URI; + + private static final String TOKEN_URL = "https://kauth.kakao.com/oauth/token"; + private static final String USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"; + + public Map loginWithKakao(String code) { + // 1. 카카오에서 토큰 발급 + KakaoTokenResponse tokenResponse = webClient.post() + .uri(TOKEN_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData("grant_type", "authorization_code") + .with("client_id", CLIENT_ID) + .with("redirect_uri", REDIRECT_URI) + .with("code", code)) + .retrieve() + .bodyToMono(KakaoTokenResponse.class) + .block(); + + // 2. 토큰에서 AccessToken 가져오기. + String accessToken = tokenResponse.access_token(); + + // 3. AccessToken을 통해 카카오 사용자 정보 가져오기. + KakaoUserInfoResponse userInfo = webClient.get() + .uri(USER_INFO_URL) + .headers(headers -> headers.setBearerAuth(accessToken)) + .retrieve() + .bodyToMono(KakaoUserInfoResponse.class) + .block(); + + // 4. Member 엔티티 리턴 + // a. kakaoKey 값을 가진 Member 객체가 이미 존재하는 경우, 그대로 가져옴. + // b. 존재하지 않을 경우, 새로 만듬. + Member member = memberRepository.findByKakaoKey(userInfo.id()) + .orElseGet(() -> memberRepository.save( + Member.builder() + .name(userInfo.kakao_account().profile().nickname()) + .profileImageUrl(userInfo.kakao_account().profile().profile_image_url()) + .kakaoKey(userInfo.id()) + .build() + )); + + // 5. AccessToken 및 RefreshToken 생성. + String jwtAccessToken = jwtUtil.generateToken(member); + String jwtRefreshToken = jwtUtil.generateRefreshToken(member); + + Map tokens = new HashMap<>(); + tokens.put("accessToken", jwtAccessToken); + tokens.put("refreshToken", jwtRefreshToken); + + return tokens; + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java index c4d77698..4690c808 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java @@ -3,6 +3,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.SneakyThrows; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -14,12 +15,23 @@ @RestController @Tag(name = "HomeController", description = "홈 컨트롤러") public class HomeController { + @Value("${kakao.client_id}") + private String kakaoClientId; + + @Value("${kakao.redirect_uri}") + private String kakaoRedirectUri; + @SneakyThrows @GetMapping(produces = TEXT_HTML_VALUE) @Operation(summary = "메인 페이지") public String main() { InetAddress localHost = getLocalHost(); + String kakaoLoginUrl = "https://kauth.kakao.com/oauth/authorize" + + "?response_type=code" + + "&client_id=" + kakaoClientId + + "&redirect_uri=" + kakaoRedirectUri; + return """

API 서버

Host Name: %s

@@ -27,6 +39,9 @@ public String main() { - """.formatted(localHost.getHostName(), localHost.getHostAddress()); + + """.formatted(localHost.getHostName(), localHost.getHostAddress(), kakaoLoginUrl); } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java index 085913e3..5bd2df27 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java @@ -5,14 +5,14 @@ public record ResBodyForGetMemberInfo( Integer id, String name, - String email, +// String email, String profileUrl ) { public ResBodyForGetMemberInfo(Member member){ this( member.getId(), member.getName(), - member.getEmail(), +// member.getEmail(), member.getProfileImageUrl() ); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java index 1d793d26..4d0e1ed1 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java @@ -20,8 +20,11 @@ public class Member extends BaseEntity { @Column(unique = true, nullable = false) private String name; +// @Column(unique = true, nullable = false) +// private String email; + @Column(unique = true, nullable = false) - private String email; + private Long kakaoKey; @Column private String profileImageUrl; @@ -38,9 +41,9 @@ public class Member extends BaseEntity { //---------- 생성자 ----------// @Builder - public Member(String name, String email, String profileImageUrl) { + public Member(String name, Long kakaoKey, String profileImageUrl) { this.name = name; - this.email = email; + this.kakaoKey = kakaoKey; this.profileImageUrl = profileImageUrl; this.active = true; this.personalArchive = new PersonalArchive(this); //Member 객체 생성 시 PersonalArchive 자동 생성. diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java index b02498d0..4162beb9 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java @@ -9,8 +9,9 @@ @Repository public interface MemberRepository extends JpaRepository { - Optional findByEmail(String email); +// Optional findByEmail(String email); Optional findByName(String name); + Optional findByKakaoKey(Long kakaoKey); List findByActiveTrue(); // 활성 사용자 조회 List findByActiveFalse(); // 비활성 사용자 조회 } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java index 07c20f7d..5e17b009 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java @@ -26,11 +26,16 @@ public Member findByName(String name){ new NoResultException(name + " 이름을 가진 사용자를 찾을 수 없습니다.") ); } - public Member findByEmail(String email){ - return memberRepository.findByEmail(email).orElseThrow(() -> - new NoResultException(email + " 이메일을 가진 사용자를 찾을 수 없습니다.") + public Member findByKakaoKey(Long kakaoKey){ + return memberRepository.findByKakaoKey(kakaoKey).orElseThrow(() -> + new NoResultException(kakaoKey + " 카카오 키를 가진 사용자를 찾을 수 없습니다.") ); } +// public Member findByEmail(String email){ +// return memberRepository.findByEmail(email).orElseThrow(() -> +// new NoResultException(email + " 이메일을 가진 사용자를 찾을 수 없습니다.") +// ); +// } public List findAll(){ return memberRepository.findAll(); } public List findAllActive(){ return memberRepository.findByActiveTrue(); } public List findAllInactive(){ return memberRepository.findByActiveFalse(); } @@ -38,17 +43,17 @@ public Member findByEmail(String email){ //회원 생성/정보 수정 관련 @Transactional - public Member createMember(String name, String email, String profileUrl){ - if(memberRepository.findByEmail(email).isPresent()){ - throw new DataIntegrityViolationException("이미 사용중인 이메일입니다."); - } + public Member createMember(String name, Long kakaoKey, String profileUrl){ +// if(memberRepository.findByEmail(email).isPresent()){ +// throw new DataIntegrityViolationException("이미 사용중인 이메일입니다."); +// } if(memberRepository.findByName(name).isPresent()) { throw new DataIntegrityViolationException("이미 사용중인 이름입니다."); } Member member = Member.builder() .name(name) - .email(email) + .kakaoKey(kakaoKey) .profileImageUrl(profileUrl) .build(); return memberRepository.save(member); diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/config/webflux/WebClientConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/config/webflux/WebClientConfig.java new file mode 100644 index 00000000..4cb39b21 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/config/webflux/WebClientConfig.java @@ -0,0 +1,13 @@ +package org.tuna.zoopzoop.backend.global.config.webflux; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + @Bean + public WebClient webClient(WebClient.Builder builder) { + return builder.build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java index d4bf8abc..ff4b4777 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java @@ -8,10 +8,8 @@ import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; import org.springframework.transaction.annotation.Transactional; -import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import org.tuna.zoopzoop.backend.domain.space.repository.SpaceRepository; -import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; @Configuration @RequiredArgsConstructor @@ -33,36 +31,6 @@ ApplicationRunner initData(){ @Transactional @Profile("!test") public void initalizeData() { - try { - Member member1 = Member.builder() - .name("Alice") - .email("alice@example.com") - .profileImageUrl("https://example.com/alice.png") - .build(); - Member member2 = Member.builder() - .name("Bob") - .email("bob@example.com") - .profileImageUrl("https://example.com/bob.png") - .build(); - - Space space1 = Space.builder() - .name("Space1") - .active(true) - .build(); - - Space space2 = Space.builder() - .name("Space2") - .active(true) - .build(); - - memberRepository.save(member1); - memberRepository.save(member2); - - spaceRepository.save(space1); - spaceRepository.save(space2); - } catch (Exception e) { - System.err.println("초기 데이터 생성 중 오류 발생: " + e.getMessage()); - } } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java index ffeb9127..7c76396b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java @@ -26,6 +26,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/v3/api-docs/**", "/swagger-ui.html", "/swagger-resources/**", + "/oauth/**", "/webjars/**", "/api/v1/**" // API 테스트용으로 모두 허용. 차후 필수로 변경 필요. ).permitAll() diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java index b2d702ee..68a90283 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java @@ -19,7 +19,7 @@ public class CustomUserDetails implements UserDetails { public String getPassword() { return null; } @Override - public String getUsername() { return member.getEmail(); } + public String getUsername() { return String.valueOf(member.getKakaoKey()); } public String getNickname() { return member.getName(); } public String getProfileImageUrl() { return member.getProfileImageUrl(); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java index 848a5b5b..48ba404c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java @@ -32,9 +32,9 @@ protected void doFilterInternal(HttpServletRequest request, String token = getTokenFromRequest(request); // Authorization 헤더에서 JWT 토큰 추출 if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) { // 토큰이 존재하고 유효한 경우 - String email = jwtUtil.getEmailFromToken(token); //토큰에서 이메일 추출 + String kakaoKey = jwtUtil.getKakaoKeyFromToken(token); //토큰에서 카카오 키 값 추출 - UserDetails userDetails = userDetailsService.loadUserByUsername(email); // 사용자 정보 로드 + UserDetails userDetails = userDetailsService.loadUserByUsername(kakaoKey); // 사용자 정보 로드 //권한 생성 로직 X (사용 안함.) diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java index bbcd1417..50bdcac6 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java @@ -26,7 +26,7 @@ public String generateToken(Member member) { Date expiryDate = new Date(now.getTime() + jwtProperties.getAccessTokenValidity()); return Jwts.builder() - .setSubject(member.getEmail()) + .setSubject(String.valueOf(member.getKakaoKey())) .claim("userId", member.getId()) .claim("name", member.getName()) .setIssuedAt(now) @@ -35,8 +35,18 @@ public String generateToken(Member member) { .compact(); } - // 토큰에서 이메일 추출 - public String getEmailFromToken(String token) { +// // 토큰에서 이메일 추출 +// public String getEmailFromToken(String token) { +// Claims claims = Jwts.parser() +// .verifyWith(getSigningKey()) +// .build() +// .parseSignedClaims(token) +// .getPayload(); +// return claims.getSubject(); +// } + + // 토큰에서 카카오 키 추출 + public String getKakaoKeyFromToken(String token) { Claims claims = Jwts.parser() .verifyWith(getSigningKey()) .build() @@ -93,7 +103,7 @@ public String generateRefreshToken(Member member) { Date expiryDate = new Date(now.getTime() + jwtProperties.getRefreshTokenValidity()); return Jwts.builder() - .setSubject(member.getEmail()) + .setSubject(String.valueOf(member.getKakaoKey())) .claim("userId", member.getId()) .claim("name", member.getName()) .claim("tokenType", "refresh") diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java index 64d9628a..855f9a3b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java @@ -15,11 +15,18 @@ public class CustomUserDetailsService implements UserDetailsService { private final MemberService memberService; @Override - public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - Member member = memberService.findByEmail(email); + public UserDetails loadUserByUsername(String kakaoKeyStr) throws UsernameNotFoundException { + Long kakaoKey; + try { + kakaoKey = Long.parseLong(kakaoKeyStr); + } catch (NumberFormatException e) { + throw new UsernameNotFoundException("잘못된 카카오 키: " + kakaoKeyStr, e); + } + + Member member = memberService.findByKakaoKey(kakaoKey); if (!member.isActive()) { - throw new UsernameNotFoundException("비활성화된 계정입니다: " + email); + throw new UsernameNotFoundException("비활성화된 계정입니다: " + kakaoKey); } return new CustomUserDetails(member); } -} +} \ No newline at end of file diff --git a/src/main/resources/application-secrets.yml.template b/src/main/resources/application-secrets.yml.template new file mode 100644 index 00000000..69809532 --- /dev/null +++ b/src/main/resources/application-secrets.yml.template @@ -0,0 +1,8 @@ +kakao: + client_id: {REST_API_KEY} + redirect_uri: {CALLBACK_URL} + +jwt: + secret-key: {JWT_SECRET_KEY} + access-token-validity: {ACCESSTOKEN_VALIDITY} + refresh-token-validity: {REFRESHTOKEN_VALIDITY} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6fda8e9e..fa52f271 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,6 +21,8 @@ spring: format_sql: true highlight_sql: true use_sql_comments: true + config: + import: optional:classpath:application-secrets.yml springdoc: default-produces-media-type: application/json;charset=UTF-8 logging: diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java index 35da3776..88be466b 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java @@ -42,27 +42,26 @@ public class MemberControllerTest { void setUp() { memberService.createMember( "test", - "test@test.com", + 4001L, "url"); memberService.createMember( "test2", - "test2@test.com", + 4002L, "url"); memberService.createMember( "test3", - "test3@test.com", + 4003L, "url"); } @Test - @WithUserDetails(value = "test@test.com", setupBefore = TestExecutionEvent.TEST_METHOD) + @WithUserDetails(value = "4001", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("사용자 정보 조회 - 성공(200)") void getMemberInfoSuccess() throws Exception { mockMvc.perform(get("/api/v1/member/me")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.data.name").value("test")) - .andExpect(jsonPath("$.data.email").value("test@test.com")) .andExpect(jsonPath("$.data.profileUrl").value("url")); } @@ -76,7 +75,7 @@ void getMemberInfoFailed() throws Exception { } @Test - @WithUserDetails(value = "test@test.com", setupBefore = TestExecutionEvent.TEST_METHOD) + @WithUserDetails(value = "4001", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("사용자 이름 수정 - 성공(200)") void editMemberNameSuccess() throws Exception { ReqBodyForEditMemberName reqBodyForEditMemberName = new ReqBodyForEditMemberName("test3"); @@ -90,7 +89,7 @@ void editMemberNameSuccess() throws Exception { } @Test - @WithUserDetails(value = "test@test.com", setupBefore = TestExecutionEvent.TEST_METHOD) + @WithUserDetails(value = "4001", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("사용자 이름 수정 - 실패(400, Bad_Request)") void editMemberNameFailedByBadRequest() throws Exception { ReqBodyForEditMemberName reqBodyForEditMemberName = new ReqBodyForEditMemberName(""); @@ -115,7 +114,7 @@ void editMemberNameFailedByUnauthorized() throws Exception { } @Test - @WithUserDetails(value = "test3@test.com", setupBefore = TestExecutionEvent.TEST_METHOD) + @WithUserDetails(value = "4003", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("사용자 삭제 - 성공(200)") void deleteMemberSuccess() throws Exception { mockMvc.perform(delete("/api/v1/member")) diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java index 6ff325f5..6fd868ea 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java @@ -29,18 +29,18 @@ class MemberServiceTest { void setUp() { Member member1 = memberService.createMember( "test1", - "test1@test.com", + 1001L, "url" ); Member member2 = memberService.createMember( "test2", - "test2@test.com", + 1002L, "url" ); } private Member createTestMember() { return memberService.createMember( "test3", - "test3@test.com", + 1003L, "url" ); } @@ -51,63 +51,80 @@ void createMemberSuccess() { Member member = createTestMember(); assertNotNull(member.getId()); assertEquals("test3", member.getName()); - assertEquals("test3@test.com", member.getEmail()); } - @Test - @DisplayName("사용자 생성 - 이메일 중복으로 인한 실패") - void createMemberFailedByEmail() { - memberService.createMember("dupName", "dup@test.com", "url"); - Exception ex = assertThrows(DataIntegrityViolationException.class, () -> { - memberService.createMember("otherName", "dup@test.com", "url"); - }); - assertTrue(ex.getMessage().contains("이미 사용중인 이메일입니다.")); - } +// @Test +// @DisplayName("사용자 생성 - 이메일 중복으로 인한 실패") +// void createMemberFailedByEmail() { +// memberService.createMember("dupName", 2001L,"url"); +// Exception ex = assertThrows(DataIntegrityViolationException.class, () -> { +// memberService.createMember("otherName", 2002L,"url"); +// }); +// assertTrue(ex.getMessage().contains("이미 사용중인 이메일입니다.")); +// } @Test @DisplayName("사용자 생성 - 이름 중복으로 인한 실패") void createMemberFailedByName() { - memberService.createMember("dupName", "dup1@test.com", "url"); + memberService.createMember("dupName", 3001L,"url"); Exception ex = assertThrows(DataIntegrityViolationException.class, () -> { - memberService.createMember("dupName", "dup2@test.com", "url"); + memberService.createMember("dupName", 3002L,"url"); }); assertTrue(ex.getMessage().contains("이미 사용중인 이름입니다.")); } +// @Test +// @DisplayName("사용자 이메일 기반 조회 - 성공") +// void findByEmailSuccess() { +// Member saved = createTestMember(); +// Member found = memberService.findByEmail("test3@test.com"); +// assertEquals(saved.getId(), found.getId()); +// assertEquals(saved.getEmail(), found.getEmail()); +// } +// +// @Test +// @DisplayName("사용자 이메일 기반 조회 - 실패") +// void findByEmailFailed() { +// Exception ex = assertThrows(NoResultException.class, () -> { +// memberService.findByEmail("wrong@test.com"); +// }); +// assertTrue(ex.getMessage().contains("이메일을 가진 사용자를 찾을 수 없습니다.")); +// } + @Test - @DisplayName("사용자 이메일 기반 조회 - 성공") - void findByEmailSuccess() { + @DisplayName("사용자 이름 기반 조회 - 성공") + void findByNameSuccess() { Member saved = createTestMember(); - Member found = memberService.findByEmail("test3@test.com"); + Member found = memberService.findByName("test3"); assertEquals(saved.getId(), found.getId()); - assertEquals(saved.getEmail(), found.getEmail()); + assertEquals(saved.getName(), found.getName()); } @Test - @DisplayName("사용자 이메일 기반 조회 - 실패") - void findByEmailFailed() { + @DisplayName("사용자 이름 기반 조회 - 실패") + void findByNameFailed() { Exception ex = assertThrows(NoResultException.class, () -> { - memberService.findByEmail("wrong@test.com"); + memberService.findByName("wrongName"); }); - assertTrue(ex.getMessage().contains("이메일을 가진 사용자를 찾을 수 없습니다.")); + assertTrue(ex.getMessage().contains("이름을 가진 사용자를 찾을 수 없습니다.")); } @Test - @DisplayName("사용자 이름 기반 조회 - 성공") - void findByNameSuccess() { + @DisplayName("Kakao 식별 키 기반 조회 - 성공") + void findByKakaoKeySuccess() { Member saved = createTestMember(); - Member found = memberService.findByName("test3"); + Member found = memberService.findByKakaoKey(1003L); assertEquals(saved.getId(), found.getId()); assertEquals(saved.getName(), found.getName()); } @Test - @DisplayName("사용자 이름 기반 조회 - 실패") - void findByNameFailed() { + @DisplayName("Kakao 식별 키 기반 조회 - 실패") + void findByKakaoKeyFailed() { Exception ex = assertThrows(NoResultException.class, () -> { - memberService.findByName("wrongName"); + memberService.findByKakaoKey(1004L); }); - assertTrue(ex.getMessage().contains("이름을 가진 사용자를 찾을 수 없습니다.")); + assertTrue(ex.getMessage().contains("카카오 키를 가진 사용자를 찾을 수 없습니다.")); } @Test From 29d7320e5245d7dd69818795edcb9376e94101bf Mon Sep 17 00:00:00 2001 From: taekkong <141305946+taekkong@users.noreply.github.com> Date: Mon, 22 Sep 2025 09:38:08 +0900 Subject: [PATCH 008/132] =?UTF-8?q?[chore/ops-130]=20Terraform=EC=9D=84=20?= =?UTF-8?q?=ED=99=9C=EC=9A=A9=ED=95=B4=20EC2=20=EC=9D=B8=EC=8A=A4=ED=84=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore/OPS-266 : EC2 인스턴스 생성 * chore/OPS-267 : VPC, IAM, SG 설정 * chore : 서브넷 설정 수정 및 nginx, mysql 볼륨 마운트 설정 추가 * chore : Dockerfile 수정 및 EC2 보안 그룹 수정 * chore : MYSQL 컨테이너 실행시 초기 DB 이름 지정하도록 설정 --- Dockerfile | 3 + infra/terraform/.gitignore | 7 +++ infra/terraform/main.tf | 44 +++++++++++++ infra/terraform/modules/ec2/main.tf | 56 +++++++++++++++++ infra/terraform/modules/ec2/outputs.tf | 3 + infra/terraform/modules/ec2/variables.tf | 10 +++ infra/terraform/modules/iam/main.tf | 31 +++++++++ infra/terraform/modules/iam/outputs.tf | 3 + infra/terraform/modules/iam/variables.tf | 1 + infra/terraform/modules/sg/ec2_sg.tf | 64 +++++++++++++++++++ infra/terraform/modules/sg/outputs.tf | 7 +++ infra/terraform/modules/sg/rds_sg.tf | 26 ++++++++ infra/terraform/modules/sg/variables.tf | 2 + infra/terraform/modules/vpc/main.tf | 80 ++++++++++++++++++++++++ infra/terraform/modules/vpc/outputs.tf | 10 +++ infra/terraform/modules/vpc/variables.tf | 2 + infra/terraform/outputs.tf | 3 + infra/terraform/variables.tf | 10 +++ 18 files changed, 362 insertions(+) create mode 100644 Dockerfile create mode 100644 infra/terraform/.gitignore create mode 100644 infra/terraform/main.tf create mode 100644 infra/terraform/modules/ec2/main.tf create mode 100644 infra/terraform/modules/ec2/outputs.tf create mode 100644 infra/terraform/modules/ec2/variables.tf create mode 100644 infra/terraform/modules/iam/main.tf create mode 100644 infra/terraform/modules/iam/outputs.tf create mode 100644 infra/terraform/modules/iam/variables.tf create mode 100644 infra/terraform/modules/sg/ec2_sg.tf create mode 100644 infra/terraform/modules/sg/outputs.tf create mode 100644 infra/terraform/modules/sg/rds_sg.tf create mode 100644 infra/terraform/modules/sg/variables.tf create mode 100644 infra/terraform/modules/vpc/main.tf create mode 100644 infra/terraform/modules/vpc/outputs.tf create mode 100644 infra/terraform/modules/vpc/variables.tf create mode 100644 infra/terraform/outputs.tf create mode 100644 infra/terraform/variables.tf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..bb43cff2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM openjdk:21-jdk-slim +COPY build/libs/backend-0.0.1-SNAPSHOT.jar /app.jar +ENTRYPOINT ["java","-jar","/app.jar"] \ No newline at end of file diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore new file mode 100644 index 00000000..233b2d07 --- /dev/null +++ b/infra/terraform/.gitignore @@ -0,0 +1,7 @@ +.terraform/ +terraform.tfstate +terraform.tfstate.backup +.terraform.tfstate.lock.info +.terraform.lock.hcl +env/*.tfvars +.terraform.tfstate.d/ \ No newline at end of file diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 00000000..88aafe4b --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,44 @@ +#루트에서 모듈 호출 +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + } + } +} + +provider "aws" { + region = var.region +} + + +module "vpc" { + source = "./modules/vpc" + prefix = var.prefix + region = var.region +} + +module "sg"{ + source = "./modules/sg" + vpc_id = module.vpc.vpc_id + prefix = var.prefix +} + +module "iam"{ + source = "./modules/iam" + prefix = var.prefix +} + +module "ec2" { + source = "./modules/ec2" + ami = var.ami + instance_type = var.instance_type + subnet_id = module.vpc.subnet_ids[0] + ec2_sg_id = module.sg.ec2_sg_id + iam_instance_profile = module.iam.instance_profile_name + key_name = var.key_name + prefix = var.prefix + mysql_root_password = var.mysql_root_password + mysql_db_name = var.mysql_db_name +} + diff --git a/infra/terraform/modules/ec2/main.tf b/infra/terraform/modules/ec2/main.tf new file mode 100644 index 00000000..cbd9b6da --- /dev/null +++ b/infra/terraform/modules/ec2/main.tf @@ -0,0 +1,56 @@ +locals { + user_data = <<-EOF +#!/bin/bash +# Swap 설정 +dd if=/dev/zero of=/swapfile bs=128M count=32 +chmod 600 /swapfile +mkswap /swapfile +swapon /swapfile +echo "/swapfile swap swap defaults 0 0" >> /etc/fstab + +# Docker 설치 +yum install -y docker +systemctl enable docker +systemctl start docker +docker network create common + +# Nginx, Redis, MySQL 컨테이너 실행 (테스트용) +docker run -d --name npm \ + --restart unless-stopped \ + --network common \ + -p 80:80 -p 443:443 -p 81:81 \ + -v /opt/npm/data:/data \ + -v /opt/npm/letsencrypt:/etc/letsencrypt \ + jc21/nginx-proxy-manager:latest +# docker run -d --name redis_1 --restart unless-stopped --network common -p 6379:6379 -e TZ=Asia/Seoul redis + +docker run -d --name mysql \ + --restart unless-stopped \ + --network common \ + -p 3306:3306 \ + -e MYSQL_ROOT_PASSWORD=${var.mysql_root_password} \ + -e MYSQL_DATABASE=${var.mysql_db_name} \ + -v /opt/mysql/data:/var/lib/mysql \ + mysql:latest +EOF +} + +resource "aws_instance" "this" { + ami = var.ami + instance_type = var.instance_type + subnet_id = var.subnet_id + vpc_security_group_ids = [var.ec2_sg_id] + iam_instance_profile = var.iam_instance_profile + associate_public_ip_address = true + key_name = var.key_name + + root_block_device { + volume_type = "gp3" + volume_size = 25 + tags = { Name = "${var.prefix}-root" } + } + + user_data = local.user_data + + tags = { Name = "${var.prefix}-ec2" } +} diff --git a/infra/terraform/modules/ec2/outputs.tf b/infra/terraform/modules/ec2/outputs.tf new file mode 100644 index 00000000..b7c05684 --- /dev/null +++ b/infra/terraform/modules/ec2/outputs.tf @@ -0,0 +1,3 @@ +output "ec2_public_ip" { + value = aws_instance.this.public_ip +} diff --git a/infra/terraform/modules/ec2/variables.tf b/infra/terraform/modules/ec2/variables.tf new file mode 100644 index 00000000..2daec17e --- /dev/null +++ b/infra/terraform/modules/ec2/variables.tf @@ -0,0 +1,10 @@ +variable "ami" { type = string } +variable "instance_type" { type = string } +variable "subnet_id" { type = string } +variable "ec2_sg_id" { type = string } +variable "iam_instance_profile" { type = string } +variable "key_name" { type = string } +variable "prefix" { type = string } +# variable "redis_password" { type = string } +variable "mysql_root_password" { type = string } +variable "mysql_db_name" {type=string} \ No newline at end of file diff --git a/infra/terraform/modules/iam/main.tf b/infra/terraform/modules/iam/main.tf new file mode 100644 index 00000000..190e2e3e --- /dev/null +++ b/infra/terraform/modules/iam/main.tf @@ -0,0 +1,31 @@ +# EC2 역할 생성 +resource "aws_iam_role" "ec2_role" { + name = "${var.prefix}-ec2-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ec2.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +# 역할에 S3 접근 정책 부착 (사용하지 않을 경우 주석 처리) +# resource "aws_iam_role_policy_attachment" "s3_full" { +# role = aws_iam_role.ec2_role.name +# policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess" +# } + +# 역할에 SSM 접근 정책 부착 (AWS Systems Manager) +resource "aws_iam_role_policy_attachment" "ssm" { + role = aws_iam_role.ec2_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM" +} + +# EC2에서 역할을 사용할 수 있게 인스턴스 프로파일 생성 +resource "aws_iam_instance_profile" "this" { + name = "${var.prefix}-instance-profile" + role = aws_iam_role.ec2_role.name +} diff --git a/infra/terraform/modules/iam/outputs.tf b/infra/terraform/modules/iam/outputs.tf new file mode 100644 index 00000000..87ca677d --- /dev/null +++ b/infra/terraform/modules/iam/outputs.tf @@ -0,0 +1,3 @@ +output "instance_profile_name" { + value = aws_iam_instance_profile.this.name +} diff --git a/infra/terraform/modules/iam/variables.tf b/infra/terraform/modules/iam/variables.tf new file mode 100644 index 00000000..c9bd54cb --- /dev/null +++ b/infra/terraform/modules/iam/variables.tf @@ -0,0 +1 @@ +variable "prefix" {type=string} \ No newline at end of file diff --git a/infra/terraform/modules/sg/ec2_sg.tf b/infra/terraform/modules/sg/ec2_sg.tf new file mode 100644 index 00000000..262b3b81 --- /dev/null +++ b/infra/terraform/modules/sg/ec2_sg.tf @@ -0,0 +1,64 @@ +resource "aws_security_group" "ec2_sg" { + name = "${var.prefix}-ec2-sg" + vpc_id = var.vpc_id + + description = "EC2 security group" + + ingress { + description = "Allow HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "Allow HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "Allow SSH" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["221.154.167.13/32"] + } + + ingress{ + from_port = 81 + to_port = 81 + protocol = "tcp" + cidr_blocks = ["221.154.167.13/32"] + } + + # 개발시에만 열어놓고, 운영시에는 닫기 + ingress{ + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = ["221.154.167.13/32"] + } + + # 개발시에만 열어놓고, 운영시에는 닫기 + ingress{ + from_port = 3306 + to_port = 3306 + protocol = "tcp" + cidr_blocks = ["221.154.167.13/32"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.prefix}-ec2-sg" + } +} diff --git a/infra/terraform/modules/sg/outputs.tf b/infra/terraform/modules/sg/outputs.tf new file mode 100644 index 00000000..0d0a1b08 --- /dev/null +++ b/infra/terraform/modules/sg/outputs.tf @@ -0,0 +1,7 @@ +output "ec2_sg_id" { + value = aws_security_group.ec2_sg.id +} + +output "rds_sg_id" { + value = aws_security_group.rds_sg.id +} \ No newline at end of file diff --git a/infra/terraform/modules/sg/rds_sg.tf b/infra/terraform/modules/sg/rds_sg.tf new file mode 100644 index 00000000..94879394 --- /dev/null +++ b/infra/terraform/modules/sg/rds_sg.tf @@ -0,0 +1,26 @@ +resource "aws_security_group" "rds_sg" { + name = "${var.prefix}-rds-sg" + vpc_id = var.vpc_id + + description = "RDS security group" + + # EC2에서 RDS 접속 허용 + ingress { + description = "Allow MySQL from EC2" + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_groups = [aws_security_group.ec2_sg.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.prefix}-rds-sg" + } +} diff --git a/infra/terraform/modules/sg/variables.tf b/infra/terraform/modules/sg/variables.tf new file mode 100644 index 00000000..7c895760 --- /dev/null +++ b/infra/terraform/modules/sg/variables.tf @@ -0,0 +1,2 @@ +variable "prefix" {type=string} +variable "vpc_id" {type=string} \ No newline at end of file diff --git a/infra/terraform/modules/vpc/main.tf b/infra/terraform/modules/vpc/main.tf new file mode 100644 index 00000000..17bba77e --- /dev/null +++ b/infra/terraform/modules/vpc/main.tf @@ -0,0 +1,80 @@ + +resource "aws_vpc" "this"{ + cidr_block = "10.0.0.0/16" + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "${var.prefix}-vpc" + } +} + +resource "aws_subnet" "public"{ + vpc_id = aws_vpc.this.id + cidr_block = "10.0.1.0/24" + availability_zone = "${var.region}a" + map_public_ip_on_launch = true + tags = {Name = "${var.prefix}-subnet-public"} +} + +resource "aws_subnet" "private"{ + vpc_id = aws_vpc.this.id + cidr_block = "10.0.2.0/24" + availability_zone = "${var.region}b" + map_public_ip_on_launch = false + tags = {Name = "${var.prefix}-subnet-private"} +} + +# 고가용성 구성이 필요할때 +# resource "aws_subnet" "c"{ +# vpc_id = aws_vpc.this.id +# cidr_block = "10.0.3.0/24" +# availability_zone = "${var.region}c" +# map_public_ip_on_launch = true +# tags = {Name = "${var.prefix}-subnet-c"} +# } +# +# resource "aws_subnet" "d"{ +# vpc_id = aws_vpc.this.id +# cidr_block = "10.0.4.0/24" +# availability_zone = "${var.region}d" +# map_public_ip_on_launch = true +# tags = {Name = "${var.prefix}-subnet-d"} +# } + +resource "aws_internet_gateway" "this"{ + vpc_id = aws_vpc.this.id + + tags = { + Name = "${var.prefix}-igw" + } +} + +resource "aws_route_table" "this" { + vpc_id = aws_vpc.this.id + + route{ + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id + } + + tags = { + Name = "${var.prefix}-public-rt" + } +} + +resource "aws_route_table_association" "public" { + subnet_id = aws_subnet.public.id + route_table_id = aws_route_table.this.id +} + +# 고가용성 구성이 필요할때 +# resource "aws_route_table_association" "c" { +# subnet_id = aws_subnet.c.id +# route_table_id = aws_route_table.this.id +# } +# +# resource "aws_route_table_association" "d" { +# subnet_id = aws_subnet.d.id +# route_table_id = aws_route_table.this.id +# } diff --git a/infra/terraform/modules/vpc/outputs.tf b/infra/terraform/modules/vpc/outputs.tf new file mode 100644 index 00000000..c01437ce --- /dev/null +++ b/infra/terraform/modules/vpc/outputs.tf @@ -0,0 +1,10 @@ +output "vpc_id" { + value = aws_vpc.this.id +} + +output "subnet_ids" { + value = [ + aws_subnet.public.id, + aws_subnet.private.id, + ] +} \ No newline at end of file diff --git a/infra/terraform/modules/vpc/variables.tf b/infra/terraform/modules/vpc/variables.tf new file mode 100644 index 00000000..04c7959d --- /dev/null +++ b/infra/terraform/modules/vpc/variables.tf @@ -0,0 +1,2 @@ +variable "prefix" {type=string} +variable "region" {type=string} \ No newline at end of file diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 00000000..9a446954 --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,3 @@ +output "ec2_public_ip" { + value = module.ec2.ec2_public_ip +} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 00000000..a67189b3 --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,10 @@ +#공통 변수 정의 + +variable "region" { type = string } +variable "prefix" { type = string } +variable "ami" { type = string } +variable "instance_type" { type = string } +variable "key_name" { type = string } +#variable "redis_password" { type = string } +variable "mysql_root_password" { type = string } +variable "mysql_db_name" {type = string} From f9aba31a2280b72385272be5c009b15cdbc043ee Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Mon, 22 Sep 2025 10:15:14 +0900 Subject: [PATCH 009/132] =?UTF-8?q?fix/OPS-271=20:=20SecurityConfig=20?= =?UTF-8?q?=EB=B0=8F=20Jwt=20=EC=9D=B8=EC=A6=9D=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zoopzoop/backend/global/security/SecurityConfig.java | 6 +++++- .../global/security/jwt/JwtAuthenticationFilter.java | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java index 7c76396b..7cd13165 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java @@ -5,12 +5,15 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.tuna.zoopzoop.backend.global.security.jwt.CustomAuthenticationEntryPoint; +import org.tuna.zoopzoop.backend.global.security.jwt.JwtAuthenticationFilter; @Configuration @RequiredArgsConstructor public class SecurityConfig { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -44,7 +47,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .formLogin(formLogin -> formLogin.disable()) .exceptionHandling(ex -> ex .authenticationEntryPoint(customAuthenticationEntryPoint) - ); + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);; return http.build(); } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java index 48ba404c..0030d427 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java @@ -30,11 +30,15 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = getTokenFromRequest(request); // Authorization 헤더에서 JWT 토큰 추출 + log.info("[JwtFilter] Token from request: {}", token); + log.info("[JwtFilter] Token valid? {}", jwtUtil.validateToken(token)); if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) { // 토큰이 존재하고 유효한 경우 String kakaoKey = jwtUtil.getKakaoKeyFromToken(token); //토큰에서 카카오 키 값 추출 + log.info("[JwtFilter] KakaoKey from token: {}", kakaoKey); UserDetails userDetails = userDetailsService.loadUserByUsername(kakaoKey); // 사용자 정보 로드 + log.info("[JwtFilter] UserDetails loaded: {}", userDetails); //권한 생성 로직 X (사용 안함.) From d7e462a9b448f786f20ef5cda8b990dd742c09c2 Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Mon, 22 Sep 2025 10:48:51 +0900 Subject: [PATCH 010/132] =?UTF-8?q?Feat/ops=20209=20be=20feat=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=83=9D=EC=84=B1=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: archive_id + name 복합 유니크 제약 추가 * refactor: folder_id + title 복합 유니크 제약 추가 * refactor: member_id, archive_id 단일 유니크 제약 추가 * refactor: 엔티티 제약 * feat/OPS-209-BE-feat-폴더-생성 완료 * feat/OPS-209-BE-feat-폴더-생성 완료 --- .../archive/archive/entity/Archive.java | 12 +- .../archive/entity/PersonalArchive.java | 27 ++-- .../repository/PersonalArchiveRepository.java | 23 ++++ .../folder/controller/FolderController.java | 51 +++++++ .../archive/folder/dto/FolderResponse.java | 7 + .../folder/dto/reqBodyForCreateFolder.java | 7 + .../folder/dto/resBodyForCreateFolder.java | 6 + .../domain/archive/folder/entity/Folder.java | 25 +++- .../folder/repository/FolderRepository.java | 28 ++++ .../archive/folder/service/FolderService.java | 130 ++++++++++++++++++ .../domain/datasource/dto/FileSummary.java | 9 ++ .../domain/datasource/dto/FolderFilesDto.java | 9 ++ .../domain/datasource/entity/DataSource.java | 15 +- .../repository/DataSourceRepository.java | 14 ++ .../backend/global/security/StubAuthUtil.java | 15 ++ .../controller/FolderControllerTest.java | 79 +++++++++++ .../folder/service/FolderServiceTest.java | 107 ++++++++++++++ 17 files changed, 550 insertions(+), 14 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/repository/PersonalArchiveRepository.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/FolderResponse.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/reqBodyForCreateFolder.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/resBodyForCreateFolder.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FolderFilesDto.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java index 51049ee9..617a1fe6 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java @@ -5,18 +5,26 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.tuna.zoopzoop.backend.domain.archive.archive.enums.ArchiveType; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; +import java.util.ArrayList; +import java.util.List; + @Getter @Setter @Entity @NoArgsConstructor -@Inheritance(strategy = InheritanceType.JOINED) public class Archive extends BaseEntity { - @Column + // Personal / Shared 생성 후 불변 + @Column(nullable = false, updatable = false) @Enumerated(EnumType.STRING) private ArchiveType archiveType; + //아카이브 삭제(아마도 계정 탈퇴) 시 폴더 일괄 삭제 + @OneToMany(mappedBy = "archive", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List folders = new ArrayList<>(); + public Archive(ArchiveType archiveType) { this.archiveType = archiveType; } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java index 69ad4ff6..dcf28b10 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java @@ -1,9 +1,6 @@ package org.tuna.zoopzoop.backend.domain.archive.archive.entity; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Entity; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToOne; +import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -15,13 +12,27 @@ @Setter @Entity @NoArgsConstructor +@Table( + uniqueConstraints = { + // Archive가 하나의 PersonalArchive에 연결됨 + @UniqueConstraint( + name = "uk_personal_archive__archive_id", + columnNames = "archive_id" + ), + // Member가 하나의 PersonalArchive만 가짐 + @UniqueConstraint( + name = "uk_personal_archive__member_id", + columnNames = "member_id" + ) + } +) public class PersonalArchive extends BaseEntity { - @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "archive_id", nullable = false) + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, optional = false) + @JoinColumn(name = "archive_id") public Archive archive; - @OneToOne - @JoinColumn(name = "member_id", nullable = false) + @OneToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") private Member member; public PersonalArchive(Member member) { diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/repository/PersonalArchiveRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/repository/PersonalArchiveRepository.java new file mode 100644 index 00000000..71417605 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/repository/PersonalArchiveRepository.java @@ -0,0 +1,23 @@ +package org.tuna.zoopzoop.backend.domain.archive.archive.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; + +import java.util.Optional; + +public interface PersonalArchiveRepository extends JpaRepository { + /** + * 회원의 PersonalArchive 조회 + * + * @param memberId 회원 Id + * @return PersonalArchive 엔티티 + */ + @Query(""" + select pa + from PersonalArchive pa + join fetch pa.archive a + where pa.member.id = :memberId + """) + Optional findByMemberId(Integer memberId); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java new file mode 100644 index 00000000..8babb8a8 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java @@ -0,0 +1,51 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.reqBodyForCreateFolder; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.resBodyForCreateFolder; +import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.StubAuthUtil; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/archive/folder") +@RequiredArgsConstructor +public class FolderController { + + private final FolderService folderService; + + /** + * 내 PersonalArchive 안에 새 폴더 생성 + * @param rq reqBodyForCreateFolder + * @return resBodyForCreateFolder + */ + @PostMapping("") + public RsData createFolder( + @Valid @RequestBody reqBodyForCreateFolder rq + ) { + // 임시 인증 정보 + Integer currentMemberId = StubAuthUtil.currentMemberId(); + FolderResponse createFile = folderService.createFolderForPersonal(currentMemberId, rq.folderName()); + + resBodyForCreateFolder rs = new resBodyForCreateFolder(createFile.folderName(), createFile.folderId()); + + return new RsData( + "200", + rq.folderName() + " 폴더가 생성됐습니다.", + rs + ); + + } + + + +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/FolderResponse.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/FolderResponse.java new file mode 100644 index 00000000..e6063343 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/FolderResponse.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.dto; + +public record FolderResponse( + int folderId, + String folderName + +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/reqBodyForCreateFolder.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/reqBodyForCreateFolder.java new file mode 100644 index 00000000..73690c42 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/reqBodyForCreateFolder.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.dto; + +import jakarta.validation.constraints.NotBlank; + +public record reqBodyForCreateFolder( + @NotBlank String folderName +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/resBodyForCreateFolder.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/resBodyForCreateFolder.java new file mode 100644 index 00000000..8288dd65 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/dto/resBodyForCreateFolder.java @@ -0,0 +1,6 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.dto; + +public record resBodyForCreateFolder( + String folderName, + int folderId +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java index 4bb27d21..5cb30dec 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java @@ -5,16 +5,33 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; +import java.util.ArrayList; +import java.util.List; + @Getter @Setter @Entity @NoArgsConstructor +@Table( + // 복합 Unique 제약(archive_id, name) + uniqueConstraints = { + @UniqueConstraint( + name = "uk_folder__archive_id__name", + columnNames = {"archive_id", "name"} + ) + }, + // Archive 별 조회 속도 개선 + indexes = { + @Index( name = "idx_folder__archive_id", columnList = "archive_id") + } +) public class Folder extends BaseEntity { //연결된 아카이브 id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "archive_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "archive_id") private Archive archive; //폴더 이름 @@ -24,4 +41,8 @@ public class Folder extends BaseEntity { //디폴트 폴더 여부 @Column(nullable = false) private boolean isDefault = false; + + // 폴더 삭제 시 데이터 일괄 삭제 + @OneToMany(mappedBy = "folder", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List dataSources = new ArrayList<>(); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java new file mode 100644 index 00000000..b2eda42b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java @@ -0,0 +1,28 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface FolderRepository extends JpaRepository{ + /** + * 폴더 중복명 검사 + * @param archiveId 아카이브 Id + * @param filename "파일명" + * @param filenameEnd "파일명 + \ufffff" + * @return + */ + @Query(""" + select f.name + from Folder f + where f.archive.id = :archiveId + and f.name >= :filename + and f.name < :filenameEnd + """) + List findNamesForConflictCheck(Integer archiveId, String filename, String filenameEnd); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java new file mode 100644 index 00000000..252fe94f --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java @@ -0,0 +1,130 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.service; + +import jakarta.persistence.NoResultException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; +import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FileSummary; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; +import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +public class FolderService { + + private final MemberRepository memberRepository; + private final PersonalArchiveRepository personalArchiveRepository; + private final FolderRepository folderRepository; + private final DataSourceRepository dataSourceRepository; + + /** + * 현재 로그인 사용자의 PersonalArchive에 폴더 생성 + * - 폴더명 중복 시 "(n)" 추가 + * - 동시성 충돌 시(더블 클릭, 브라우저 재전송) 재시도 + */ + @Transactional + public FolderResponse createFolderForPersonal(Integer currentMemberId, String folderName) { + if (folderName == null || folderName.trim().isEmpty()) { + throw new IllegalArgumentException("폴더 이름은 비어 있을 수 없습니다."); + } + + Member member = memberRepository.findById(currentMemberId) + .orElseThrow(() -> new IllegalArgumentException("멤버를 찾을 수 없습니다.")); + + Archive archive = personalArchiveRepository.findByMemberId(member.getId()) + .map(PersonalArchive::getArchive) + .orElseThrow(() -> new IllegalStateException("개인 아카이브가 없습니다.")); + + final String requested = folderName.trim(); + + // 동시성 춛돌시 2번 재시도 + String unique = generateUniqueFolderName(archive.getId(), requested); + for (int attempt = 0; attempt < 2; attempt++) { + try { + Folder folder = new Folder(); + folder.setArchive(archive); + folder.setName(unique); + folder.setDefault(false); + + Folder saved = folderRepository.save(folder); + return new FolderResponse( saved.getId(), saved.getName()); + } catch (DataIntegrityViolationException e) { + unique = generateUniqueFolderName(archive.getId(), requested); + } + } + throw new IllegalStateException("동시성 충돌로 폴더 생성에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + + private static final Pattern SUFFIX_PATTERN = Pattern.compile("^(.*?)(?: \\((\\d+)\\))?$"); + + /** + * 기존 file 명과 같지 않은 최솟값의 이름 생성 + * “폴더명”, "폴더명 (1)"→ "폴더명 (2)" + * "폴더명", "폴더명 (2)" -> "폴더명 (1)" + */ + private String generateUniqueFolderName(Integer archiveId, String requested) { + NameParts nameParts = NameParts.split(requested); + + // 중복 폴더명 탐색 + String file = nameParts.base(); + String fileEnd = file + "\uffff"; + + List existing = folderRepository.findNamesForConflictCheck(archiveId, file, fileEnd); + + return pickNextAvailable(file, existing); + } + + /** + * 이미 존재하는 이름들 중 가장 작은 비어 있는 번호 반환 + */ + private static String pickNextAvailable(String file, List existing) { + boolean baseUsed = false; + Set used = new HashSet<>(); + Pattern p = Pattern.compile("^" + Pattern.quote(file) + "(?: \\((\\d+)\\))?$"); + + for (String s : existing) { + var m = p.matcher(s); + if (m.matches()) { + if (m.group(1) == null) baseUsed = true; + else used.add(Integer.parseInt(m.group(1))); + } + } + if (!baseUsed) return file; + for (int k = 1; k <= used.size() + 1; k++) { + if (!used.contains(k)) return file + " (" + k + ")"; + } + return file + " (" + (used.size() + 1) + ")"; // fallback + } + + + /** + * 입력된 폴더명을 (폴더명, 숫자)로 분리하는 유틸 클래스 + * “폴더명” → (”폴더명”, null) + * “폴더명(3)” → (”폴더명”, 3) + */ + private record NameParts(String base, Integer num) { + static NameParts split(String name) { + var m = SUFFIX_PATTERN.matcher(name.trim()); + if (m.matches()) { + String base = m.group(1).trim(); + Integer n = m.group(2) != null ? Integer.valueOf(m.group(2)) : null; + return new NameParts(base, n); + } + return new NameParts(name.trim(), null); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java new file mode 100644 index 00000000..35193c7e --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java @@ -0,0 +1,9 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; + +import java.time.LocalDateTime; + +public record FileSummary( + Integer fileId, + String fileName, + LocalDateTime createdAt +) { } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FolderFilesDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FolderFilesDto.java new file mode 100644 index 00000000..2bc07907 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FolderFilesDto.java @@ -0,0 +1,9 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; + +import java.util.List; + +public record FolderFilesDto( + Integer folderId, + String folderName, + List files +) { } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java index fbc094a2..10349ab5 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java @@ -15,10 +15,21 @@ @Setter @Entity @NoArgsConstructor +@Table( + uniqueConstraints = { + // 복합 Unique 제약(folder_id, title) + // 같은 폴더 내에 자료 제목 중복 금지 + @UniqueConstraint(columnNames = {"folder_id", "title"}) + }, + // 폴더 내 자료 목록 조회 최적화 + indexes = { + @Index(name = "idx_datasource__folder_id", columnList = "folder_id") + } +) public class DataSource extends BaseEntity { //연결된 폴더 id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "folder_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "folder_id") private Folder folder; //제목 diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java new file mode 100644 index 00000000..f164a75f --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java @@ -0,0 +1,14 @@ +package org.tuna.zoopzoop.backend.domain.datasource.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; + +import java.util.List; + +@Repository +public interface DataSourceRepository extends JpaRepository { + List findAllByFolder(Folder folder); +} + diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java new file mode 100644 index 00000000..2135ee69 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java @@ -0,0 +1,15 @@ +package org.tuna.zoopzoop.backend.global.security; + +/** + * Spring Securiity 구현 전 임시 헬퍼 클래스 + * 추후 Spring Security 연동시 SecurityContext에서 불러오도록 수정 + */ + +public final class StubAuthUtil { + private StubAuthUtil() {} + + public static Integer currentMemberId() { + return 1; + } + +} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java new file mode 100644 index 00000000..d99a858c --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java @@ -0,0 +1,79 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.NoResultException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.reqBodyForCreateFolder; +import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; +import org.tuna.zoopzoop.backend.global.exception.GlobalExceptionHandler; + +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class FolderControllerTest { + + @Mock private FolderService folderService; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + FolderController controller = new FolderController(folderService); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + objectMapper = new ObjectMapper(); + } + + // CreateFile + @Test + @DisplayName("개인 아카이브 폴더 생성 - 성공 시 200과 응답 DTO 반환") + void createFolder_ok() throws Exception { + // given + when(folderService.createFolderForPersonal(anyInt(), eq("보고서"))) + .thenReturn(new FolderResponse(123, "보고서")); + var req = new reqBodyForCreateFolder("보고서"); + + // when & then + mockMvc.perform(post("/api/v1/archive/folder") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("200")) + .andExpect(jsonPath("$.msg").value("보고서 폴더가 생성됐습니다.")) + .andExpect(jsonPath("$.data.folderId").value(123)) + .andExpect(jsonPath("$.data.folderName").value("보고서")); + } + + @Test + @DisplayName("개인 아카이브 폴더 생성 - 폴더 이름 누락 시 400") + void createFolder_missingName() throws Exception { + // given + var req = new reqBodyForCreateFolder(null); + + // when & then + mockMvc.perform(post("/api/v1/archive/folder") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + } + + +} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java new file mode 100644 index 00000000..24abfbdc --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java @@ -0,0 +1,107 @@ +package org.tuna.zoopzoop.backend.domain.archive.folder.service; + +import jakarta.persistence.NoResultException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; +import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class FolderServiceTest { + + @Mock private MemberRepository memberRepository; + @Mock private PersonalArchiveRepository personalArchiveRepository; + @Mock private FolderRepository folderRepository; + + @InjectMocks private FolderService folderService; + + private Member member; + private Archive archive; + private PersonalArchive personalArchive; + + @BeforeEach + void setUp() { + this.member = new Member(); + ReflectionTestUtils.setField(member, "id", 1); + + this.archive = new Archive(); + ReflectionTestUtils.setField(archive, "id", 10); + + this.personalArchive = new PersonalArchive(); + ReflectionTestUtils.setField(personalArchive, "id", 100); + personalArchive.setMember(member); + personalArchive.setArchive(archive); + } + + // ---------- Create ---------- + @Test + @DisplayName("폴더 생성 성공(중복 없음)") + void createFolder_success() { + when(memberRepository.findById(1)).thenReturn(Optional.of(member)); + when(personalArchiveRepository.findByMemberId(1)).thenReturn(Optional.of(personalArchive)); + when(folderRepository.findNamesForConflictCheck(eq(archive.getId()), anyString(), anyString())) + .thenReturn(List.of()); // 충돌 없음 + + Folder saved = new Folder(); + saved.setName("보고서"); + saved.setArchive(archive); + ReflectionTestUtils.setField(saved, "id", 999); + + when(folderRepository.save(any(Folder.class))).thenReturn(saved); + + FolderResponse result = folderService.createFolderForPersonal(1, "보고서"); + + assertThat(result.folderId()).isEqualTo(999); + assertThat(result.folderName()).isEqualTo("보고서"); + } + + @Test + @DisplayName("폴더 이름 중복 시 '(1)' 붙여 생성") + void createFolder_withConflict() { + when(memberRepository.findById(1)).thenReturn(Optional.of(member)); + when(personalArchiveRepository.findByMemberId(1)).thenReturn(Optional.of(personalArchive)); + when(folderRepository.findNamesForConflictCheck(eq(archive.getId()), eq("보고서"), anyString())) + .thenReturn(List.of("보고서")); + + Folder saved = new Folder(); + saved.setName("보고서(1)"); + saved.setArchive(archive); + ReflectionTestUtils.setField(saved, "id", 1000); + + when(folderRepository.save(any(Folder.class))).thenReturn(saved); + + FolderResponse result = folderService.createFolderForPersonal(1, "보고서"); + + assertThat(result.folderName()).isEqualTo("보고서(1)"); + assertThat(result.folderId()).isEqualTo(1000); + } + + @Test + @DisplayName("멤버가 없으면 예외 발생") + void createFolder_memberNotFound() { + when(memberRepository.findById(2)).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, + () -> folderService.createFolderForPersonal(2, "보고서")); + } +} From d06806742d1455a3626a1dcb52204fd02eada5b8 Mon Sep 17 00:00:00 2001 From: Hyeok Jin Kim <71928299+EpicFn@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:10:58 +0900 Subject: [PATCH 011/132] =?UTF-8?q?fix=20:=20=EC=BB=A8=EB=B2=A4=EC=85=98?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: EpicFn --- .../global/exception/GlobalExceptionHandler.java | 12 +++++++++--- .../member/controller/MemberControllerTest.java | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java index 92885d06..cc6342dd 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java @@ -54,9 +54,15 @@ public ResponseEntity> handleMethodArgumentNotValidException(Method String message = e.getBindingResult() .getFieldErrors() .stream() - .findFirst() - .map(FieldError::getDefaultMessage) - .orElse("잘못된 요청입니다."); // 메세지가 없을 경우 기본값 + .map(error -> + // {필드명}-{에러코드}-{기본메시지} 형식으로 조합 + String.format("%s-%s-%s", + error.getField(), + error.getCode(), + error.getDefaultMessage()) + ) + .sorted() // 여러 에러가 있을 경우를 대비해 정렬 + .collect(Collectors.joining("\n")); // 여러 에러를 줄바꿈으로 연결 return new ResponseEntity<>( new RsData<>( diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java index 88be466b..95aabc88 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java @@ -98,7 +98,7 @@ void editMemberNameFailedByBadRequest() throws Exception { .content(objectMapper.writeValueAsString(reqBodyForEditMemberName))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value(400)) - .andExpect(jsonPath("$.msg").value("잘못된 요청입니다.")); + .andExpect(jsonPath("$.msg").value("newName-NotBlank-잘못된 요청입니다.")); } @Test From ce473507dbe38dfe2150e4e1526a55ef27e99063 Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:27:52 +0900 Subject: [PATCH 012/132] =?UTF-8?q?Feat/ops=20210=20be=20feat=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=82=AD=EC=A0=9C=20=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-210 : 폴더 삭제 구현 --- .../folder/controller/FolderController.java | 14 ++++++++++ .../archive/folder/service/FolderService.java | 13 ++++++++++ .../controller/FolderControllerTest.java | 26 +++++++++++++++++++ .../folder/service/FolderServiceTest.java | 26 +++++++++++++++++++ 4 files changed, 79 insertions(+) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java index 8babb8a8..fa502d14 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java @@ -46,6 +46,20 @@ public RsData createFolder( } + /** + * 내 PersonalArchive 안의 folder 삭제 + * @param folderId 삭제할 folderId + */ + @DeleteMapping("/{folderId}") + public ResponseEntity> deleteFolder(@PathVariable Integer folderId) { + String deletedFolderName = folderService.deleteFolder(folderId); + Map body = new HashMap<>(); + body.put("status", 200); + body.put("msg", deletedFolderName + " 폴더가 삭제됐습니다."); + body.put("data", null); + + return ResponseEntity.ok(body); + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java index 252fe94f..7b82312d 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java @@ -110,6 +110,19 @@ private static String pickNextAvailable(String file, List existing) { return file + " (" + (used.size() + 1) + ")"; // fallback } + /** + * folderId에 해당하는 폴더 삭제 + * soft delete 아직 구현 X + */ + @Transactional + public String deleteFolder(Integer folderId) { + Folder folder = folderRepository.findById(folderId) + .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); + + String name = folder.getName(); + folderRepository.delete(folder); + return name; + } /** * 입력된 폴더명을 (폴더명, 숫자)로 분리하는 유틸 클래스 diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java index d99a858c..8de178cc 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java @@ -75,5 +75,31 @@ void createFolder_missingName() throws Exception { .andExpect(status().isBadRequest()); } + // DeleteFile + @Test + @DisplayName("개인 아카이브 폴더 삭제 - 성공 시 200과 삭제 메시지 반환") + void deleteFolder_ok() throws Exception { + // given + when(folderService.deleteFolder(7)).thenReturn("보고서"); + + // when & then + mockMvc.perform(delete("/api/v1/archive/folder/{folderId}", 7)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("보고서 폴더가 삭제됐습니다.")); + } + @Test + @DisplayName("개인 아카이브 폴더 삭제 - 존재하지 않으면 404") + void deleteFolder_notFound() throws Exception { + // given + when(folderService.deleteFolder(404)) + .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); + + // when & then + mockMvc.perform(delete("/api/v1/archive/folder/{folderId}", 404)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value("존재하지 않는 폴더입니다.")); + } } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java index 24abfbdc..d80a9d72 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java @@ -104,4 +104,30 @@ void createFolder_memberNotFound() { assertThrows(IllegalArgumentException.class, () -> folderService.createFolderForPersonal(2, "보고서")); } + + // ---------- Delete ---------- + @Test + @DisplayName("폴더 삭제 성공") + void deleteFolder_success() { + Folder folder = new Folder(); + folder.setName("보고서"); + folder.setArchive(archive); + ReflectionTestUtils.setField(folder, "id", 500); + + when(folderRepository.findById(500)).thenReturn(Optional.of(folder)); + + String deletedName = folderService.deleteFolder(500); + + assertThat(deletedName).isEqualTo("보고서"); + verify(folderRepository, times(1)).delete(folder); + } + + @Test + @DisplayName("폴더 삭제 실패 - 존재하지 않는 폴더") + void deleteFolder_notFound() { + when(folderRepository.findById(999)).thenReturn(Optional.empty()); + + assertThrows(NoResultException.class, () -> folderService.deleteFolder(999)); + verify(folderRepository, never()).delete(any(Folder.class)); + } } From 9aad1424e549e6a99ff967f3c8ae40a2c4350a82 Mon Sep 17 00:00:00 2001 From: osh5030 <72571931+ohsoohyuk@users.noreply.github.com> Date: Mon, 22 Sep 2025 12:40:25 +0900 Subject: [PATCH 013/132] =?UTF-8?q?[feat/OPS-151]=20llm=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=95=EC=A0=9C?= =?UTF-8?q?=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - groq api를 이용하여 요약, 태그 추출 기능 구현 --- .github/workflows/ci.yml | 1 + .idea/.gitignore | 10 --- .idea/.name | 1 - .idea/compiler.xml | 18 ----- .idea/misc.xml | 13 --- .idea/vcs.xml | 6 -- build.gradle | 12 +++ .../backend/domain/datasource/entity/Tag.java | 8 +- .../datasource/repository/TagRepository.java | 14 ++++ .../domain/datasource/service/AiService.java | 79 +++++++++++++++++++ .../backend/global/initData/BaseInitData.java | 30 +++++++ .../backend/global/webMvc/AiConfig.java | 19 +++++ .../application-secrets.yml.template | 4 +- src/main/resources/application.yml | 9 +++ 14 files changed, 171 insertions(+), 53 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/.name delete mode 100644 .idea/compiler.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/vcs.xml create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiService.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/webMvc/AiConfig.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 023bc2d0..6c6b50c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,7 @@ jobs: run: | mkdir -p src/main/resources echo "${{ secrets.APPLICATION_SECRET_YML }}" > src/main/resources/application-secrets.yml + echo "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" >> src/main/resources/application-secrets.yml # 6. Gradle 테스트 실행 - name: Test with Gradle diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index c79ce29c..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# 디폴트 무시된 파일 -/shelf/ -/workspace.xml -# 에디터 기반 HTTP 클라이언트 요청 -/httpRequests/ -# 환경에 따라 달라지는 Maven 홈 디렉터리 -/mavenHomeManager.xml -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 98cd9e71..00000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -backend \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 83e01145..00000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index d963bed8..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddf..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/build.gradle b/build.gradle index 15c54e7e..598543cf 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,10 @@ repositories { mavenCentral() } +ext { + springAiVersion = "1.0.0" +} + dependencies { // Spring Data JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -67,6 +71,14 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + // Spring AI + implementation "org.springframework.ai:spring-ai-starter-model-openai" +} + +dependencyManagement { + imports { + mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}") + } } tasks.named('test') { diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Tag.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Tag.java index f5741a49..3a602f0a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Tag.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Tag.java @@ -1,19 +1,19 @@ package org.tuna.zoopzoop.backend.domain.datasource.entity; import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; @Getter @Setter @Entity @NoArgsConstructor +@AllArgsConstructor +@Builder public class Tag extends BaseEntity { //연결된 자료 id @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "data_source_id", nullable = false) + @JoinColumn(name = "data_source_id", nullable = true) // BaseInitData 때문에 nullable true로 변경 private DataSource dataSource; //태그명 diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java new file mode 100644 index 00000000..72a88027 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java @@ -0,0 +1,14 @@ +package org.tuna.zoopzoop.backend.domain.datasource.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; + +import java.util.List; + +@Repository +public interface TagRepository extends JpaRepository { + @Query("select t.tagName from Tag t") + List findAllTagNames(); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiService.java new file mode 100644 index 00000000..f194dbd4 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiService.java @@ -0,0 +1,79 @@ +package org.tuna.zoopzoop.backend.domain.datasource.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class AiService { + private final ChatClient chatClient; + private final TagRepository tagRepository; + + public Set duplicateTag() { + Set existingTags = new HashSet<>(); + existingTags.addAll(tagRepository.findAllTagNames()); + return existingTags; + } + + public Map summarizeAndTag(String text) { + Set existingTags = duplicateTag(); + String tagsForPrompt = String.join(", ", existingTags); + + String prompt = """ + 본문 요약 프롬프트: + 아래 본문을 무조건 50자 이상, 100자 이하로 요약해주세요. + + 핵심 태그 프롬프트: + 이미 존재하는 태그 목록은 다음과 같습니다: + [%s] + + 본문을 요약하고, 해당 본문과 관련된 태그 3~5개를 생성하세요. + - 태그는 반드시 본문과 관련된 것만 선택하세요. + - 기존 태그 중 본문과 관련 없는 것은 포함하지 마세요. + - 새로운 태그는 본문에 꼭 필요한 경우에만 생성하세요. + - 결과는 JSON 형식으로만 출력하세요. + + 본문: + %s + + 예시 출력: + { + "summary": "...", + "tags": ["...", "..."] + } + """.formatted(tagsForPrompt, text); + + String response = chatClient.prompt() + .user(prompt) + .call() + .content(); + + // JSON 시작/끝만 추출 + int start = response.indexOf("{"); + int end = response.lastIndexOf("}") + 1; + if (start >= 0 && end > start) { + response = response.substring(start, end); + } + + try { + ObjectMapper mapper = new ObjectMapper(); + Map map = mapper.readValue(response, new TypeReference>() {}); + + String summary = (String) map.get("summary"); + List tags = (List) map.get("tags"); + + return map; + } catch (Exception e) { + throw new RuntimeException("AI 응답 파싱 실패: " + response, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java index ff4b4777..3d714b6e 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java @@ -8,6 +8,9 @@ import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; +import org.tuna.zoopzoop.backend.domain.datasource.service.AiService; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import org.tuna.zoopzoop.backend.domain.space.repository.SpaceRepository; @@ -16,6 +19,7 @@ public class BaseInitData { private final MemberRepository memberRepository; private final SpaceRepository spaceRepository; + private final TagRepository tagRepository; @Autowired @Lazy @@ -25,6 +29,7 @@ public class BaseInitData { ApplicationRunner initData(){ return args -> { self.initalizeData(); + self.initTagData(); }; } @@ -33,4 +38,29 @@ ApplicationRunner initData(){ public void initalizeData() { } + + private final AiService aiService; + + @Transactional + public void initTagData() { + if (tagRepository.count() > 0) { + return; + } + + Tag tag1 = new Tag(null,"IT"); + Tag tag2 = new Tag(null, "자기소개"); + Tag tag3 = new Tag(null, "이름"); + + tagRepository.save(tag1); + tagRepository.save(tag2); + tagRepository.save(tag3); + + aiService.summarizeAndTag("안녕 내 이름은 오수혁이야"); + aiService.summarizeAndTag("D2Coding 1.3.2 버전을 릴리즈 합니다. ligature 관련 이슈를 수정하여, ligature 적용/미적용 폰트를 구분하여 배포합니다.\n" + + "\n" + + "기존 버전은 반드시 삭제후 설치 바랍니다.\n" + + "\n"); + + aiService.summarizeAndTag("Spring AI는 예외 발생 시 AiClientException, RetryableException 등으로 예외를 포장합니다. Spring의 기본 예외 처리 방식을 활용하면 에러 핸들링을 일관성 있게 구성할 수 있습니다."); + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/webMvc/AiConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/webMvc/AiConfig.java new file mode 100644 index 00000000..9d838bba --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/webMvc/AiConfig.java @@ -0,0 +1,19 @@ +package org.tuna.zoopzoop.backend.global.webMvc; + +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class AiConfig { + + private final OpenAiChatModel openAiChatModel; + + @Bean + public ChatClient chatClient() { + return ChatClient.builder(openAiChatModel).build(); + } +} diff --git a/src/main/resources/application-secrets.yml.template b/src/main/resources/application-secrets.yml.template index 69809532..3959e880 100644 --- a/src/main/resources/application-secrets.yml.template +++ b/src/main/resources/application-secrets.yml.template @@ -5,4 +5,6 @@ kakao: jwt: secret-key: {JWT_SECRET_KEY} access-token-validity: {ACCESSTOKEN_VALIDITY} - refresh-token-validity: {REFRESHTOKEN_VALIDITY} \ No newline at end of file + refresh-token-validity: {REFRESHTOKEN_VALIDITY} + +OPENAI_API_KEY: {OPENAI_API_KEY} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fa52f271..b68a982a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,15 @@ spring: use_sql_comments: true config: import: optional:classpath:application-secrets.yml + ai: + openai: + base-url: https://api.groq.com/openai # 내부 서버를 groq으로 + api-key: ${OPENAI_API_KEY} + chat: + options: + model: meta-llama/llama-4-scout-17b-16e-instruct + teperature: 0 + springdoc: default-produces-media-type: application/json;charset=UTF-8 logging: From a7467ff7e1fd0a7a8fe180eff976460d9bc67897 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:46:14 +0900 Subject: [PATCH 014/132] =?UTF-8?q?[feat/OPS-158]=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84(=EA=B5=AC?= =?UTF-8?q?=EA=B8=80)=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-158 : 소셜 로그인(구글) 구현, JWT, Member, Auth 도메인 고도화 * feat/OPS-158 : 테스트 케이스 수정. * fix/OPS-158 : template 충돌 해결. * fix/OPS-158 : @Transactional import 수정 --- build.gradle | 3 + .../auth/controller/ApiV1AuthController.java | 91 +++++++++++++++++++ .../auth/controller/KakaoLoginController.java | 57 ------------ .../auth/deprecated/KakaoAuthService.java | 67 ++++++++++++++ .../auth/deprecated/KakaoLoginController.java | 45 +++++++++ .../KakaoTokenResponse.java | 2 +- .../KakaoUserInfoResponse.java | 2 +- .../auth/handler/OAuth2SuccessHandler.java | 70 ++++++++++++++ .../auth/service/CustomOAuth2UserService.java | 35 +++++++ .../auth/service/GoogleUserInfoService.java | 33 +++++++ .../domain/auth/service/KakaoAuthService.java | 79 ---------------- .../auth/service/KakaoUserInfoService.java | 35 +++++++ .../auth/service/OAuth2UserInfoService.java | 10 ++ .../home/controller/HomeController.java | 26 +++--- .../backend/domain/member/entity/Member.java | 12 ++- .../backend/domain/member/enums/Provider.java | 6 ++ .../member/repository/MemberRepository.java | 4 +- .../domain/member/service/MemberService.java | 25 ++++- .../global/security/SecurityConfig.java | 19 +++- .../security/jwt/CustomUserDetails.java | 4 +- .../security/jwt/JwtAuthenticationFilter.java | 6 +- .../backend/global/security/jwt/JwtUtil.java | 23 ++++- .../service/CustomUserDetailsService.java | 28 ++++-- .../application-secrets.yml.template | 33 ++++++- .../controller/MemberControllerTest.java | 38 +++++--- .../member/service/MemberServiceTest.java | 34 ++++--- 26 files changed, 574 insertions(+), 213 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java delete mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/KakaoLoginController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoAuthService.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoLoginController.java rename src/main/java/org/tuna/zoopzoop/backend/domain/auth/{dto => deprecated}/KakaoTokenResponse.java (78%) rename src/main/java/org/tuna/zoopzoop/backend/domain/auth/{dto => deprecated}/KakaoUserInfoResponse.java (79%) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/CustomOAuth2UserService.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/GoogleUserInfoService.java delete mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoAuthService.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoUserInfoService.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/OAuth2UserInfoService.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/enums/Provider.java diff --git a/build.gradle b/build.gradle index 598543cf..59f387a7 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,9 @@ dependencies { // Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' + // Spring Oauth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // Spring Web (MVC) implementation 'org.springframework.boot:spring-boot-starter-web' diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java new file mode 100644 index 00000000..6e378ba3 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java @@ -0,0 +1,91 @@ +package org.tuna.zoopzoop.backend.domain.auth.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.global.config.jwt.JwtProperties; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/auth") +@Tag(name = "ApiV1AuthController", description = "인증/인가 REST API 컨트롤러") +public class ApiV1AuthController { + private final JwtUtil jwtUtil; + private final MemberService memberService; + private final JwtProperties jwtProperties; + + @GetMapping("/logout") + public ResponseEntity> logout(HttpServletResponse response) { + ResponseCookie accessCookie = ResponseCookie.from("accessToken", "") + .httpOnly(true) + .path("/") + .maxAge(0) // 쿠키 삭제 + .sameSite("Lax") + .build(); + + ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .path("/") + .maxAge(0) // 쿠키 삭제 + .sameSite("Lax") + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "정상적으로 로그아웃 했습니다.", + null + ) + ); + } + + @PostMapping("/refresh") + public ResponseEntity> refreshToken(@CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletResponse response) { + + if (refreshToken == null || !jwtUtil.validateToken(refreshToken) || !jwtUtil.isRefreshToken(refreshToken)) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(new RsData<>( + "401", + "유효하지 않은 리프레시 토큰입니다.", + null + )); + } + + String providerKey = jwtUtil.getProviderKeyFromToken(refreshToken); + Member member = memberService.findByProviderKey(providerKey); + + String newAccessToken = jwtUtil.generateToken(member); + + ResponseCookie accessCookie = ResponseCookie.from("accessToken", newAccessToken) + .httpOnly(true) + .path("/") + .maxAge(jwtUtil.getAccessTokenValiditySeconds()) + .sameSite("Lax") + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "액세스 토큰을 재발급 했습니다.", + null + )); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/KakaoLoginController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/KakaoLoginController.java deleted file mode 100644 index 08a7147f..00000000 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/KakaoLoginController.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.tuna.zoopzoop.backend.domain.auth.controller; - -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseCookie; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.tuna.zoopzoop.backend.domain.auth.service.KakaoAuthService; -import org.tuna.zoopzoop.backend.global.config.jwt.JwtProperties; -import org.tuna.zoopzoop.backend.global.rsData.RsData; -import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; - -import java.util.Map; - -@RestController -@RequiredArgsConstructor -public class KakaoLoginController { - private final KakaoAuthService kakaoAuthService; - private final JwtUtil jwtUtil; - private final JwtProperties jwtProperties; - - @GetMapping("/oauth/kakao") - public ResponseEntity>> kakaoCallback(@RequestParam String code) { - Map tokens = kakaoAuthService.loginWithKakao(code); - ResponseCookie accessCookie = ResponseCookie.from("accessToken", tokens.get("accessToken")) - .httpOnly(true) - .path("/") - .maxAge(jwtProperties.getAccessTokenValidity() / 1000) - .sameSite("Lax") - .build(); - - ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", tokens.get("refreshToken")) - .httpOnly(true) - .secure(false) - .path("/") - .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) - .sameSite("Lax") - .build(); - - HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.SET_COOKIE, accessCookie.toString()); - headers.add(HttpHeaders.SET_COOKIE, refreshCookie.toString()); - - return ResponseEntity - .status(HttpStatus.OK) - .headers(headers) - .body(new RsData<>( - "200", - "카카오 로그인에 성공했습니다.", - tokens - ) - ); - } -} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoAuthService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoAuthService.java new file mode 100644 index 00000000..4256c7c7 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoAuthService.java @@ -0,0 +1,67 @@ +package org.tuna.zoopzoop.backend.domain.auth.deprecated; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class KakaoAuthService { +// private final WebClient webClient; +// private final MemberRepository memberRepository; +// private final JwtUtil jwtUtil; +// +// @Value("${kakao.client_id}") +// private String CLIENT_ID; +// @Value("${kakao.redirect_uri}") +// private String REDIRECT_URI; +// +// private static final String TOKEN_URL = "https://kauth.kakao.com/oauth/token"; +// private static final String USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"; +// +// public Map loginWithKakao(String code) { +// // 1. 카카오에서 토큰 발급 +// KakaoTokenResponse tokenResponse = webClient.post() +// .uri(TOKEN_URL) +// .contentType(MediaType.APPLICATION_FORM_URLENCODED) +// .body(BodyInserters.fromFormData("grant_type", "authorization_code") +// .with("client_id", CLIENT_ID) +// .with("redirect_uri", REDIRECT_URI) +// .with("code", code)) +// .retrieve() +// .bodyToMono(KakaoTokenResponse.class) +// .block(); +// +// // 2. 토큰에서 AccessToken 가져오기. +// String accessToken = tokenResponse.access_token(); +// +// // 3. AccessToken을 통해 카카오 사용자 정보 가져오기. +// KakaoUserInfoResponse userInfo = webClient.get() +// .uri(USER_INFO_URL) +// .headers(headers -> headers.setBearerAuth(accessToken)) +// .retrieve() +// .bodyToMono(KakaoUserInfoResponse.class) +// .block(); +// +// // 4. Member 엔티티 리턴 +// // a. kakaoKey 값을 가진 Member 객체가 이미 존재하는 경우, 그대로 가져옴. +// // b. 존재하지 않을 경우, 새로 만듬. +// Member member = memberRepository.findByKakaoKey(userInfo.id()) +// .orElseGet(() -> memberRepository.save( +// Member.builder() +// .name(userInfo.kakao_account().profile().nickname()) +// .profileImageUrl(userInfo.kakao_account().profile().profile_image_url()) +// .kakaoKey(userInfo.id()) +// .build() +// )); +// +// // 5. AccessToken 및 RefreshToken 생성. +// String jwtAccessToken = jwtUtil.generateToken(member); +// String jwtRefreshToken = jwtUtil.generateRefreshToken(member); +// +// Map tokens = new HashMap<>(); +// tokens.put("accessToken", jwtAccessToken); +// tokens.put("refreshToken", jwtRefreshToken); +// +// return tokens; +// } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoLoginController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoLoginController.java new file mode 100644 index 00000000..af6a2e3c --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoLoginController.java @@ -0,0 +1,45 @@ +package org.tuna.zoopzoop.backend.domain.auth.deprecated; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class KakaoLoginController { +// private final KakaoAuthService kakaoAuthService; +// private final JwtUtil jwtUtil; +// private final JwtProperties jwtProperties; +// +// @GetMapping("/oauth/kakao") +// public ResponseEntity>> kakaoCallback(@RequestParam String code) { +// Map tokens = kakaoAuthService.loginWithKakao(code); +// ResponseCookie accessCookie = ResponseCookie.from("accessToken", tokens.get("accessToken")) +// .httpOnly(true) +// .path("/") +// .maxAge(jwtProperties.getAccessTokenValidity() / 1000) +// .sameSite("Lax") +// .build(); +// +// ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", tokens.get("refreshToken")) +// .httpOnly(true) +// .secure(false) +// .path("/") +// .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) +// .sameSite("Lax") +// .build(); +// +// HttpHeaders headers = new HttpHeaders(); +// headers.add(HttpHeaders.SET_COOKIE, accessCookie.toString()); +// headers.add(HttpHeaders.SET_COOKIE, refreshCookie.toString()); +// +// return ResponseEntity +// .status(HttpStatus.OK) +// .headers(headers) +// .body(new RsData<>( +// "200", +// "카카오 로그인에 성공했습니다.", +// tokens +// ) +// ); +// } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoTokenResponse.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoTokenResponse.java similarity index 78% rename from src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoTokenResponse.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoTokenResponse.java index 43c2366f..56e3338e 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoTokenResponse.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoTokenResponse.java @@ -1,4 +1,4 @@ -package org.tuna.zoopzoop.backend.domain.auth.dto; +package org.tuna.zoopzoop.backend.domain.auth.deprecated; public record KakaoTokenResponse( String access_token, diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoUserInfoResponse.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoUserInfoResponse.java similarity index 79% rename from src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoUserInfoResponse.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoUserInfoResponse.java index 83c8f20f..8ed5994e 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/KakaoUserInfoResponse.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/deprecated/KakaoUserInfoResponse.java @@ -1,4 +1,4 @@ -package org.tuna.zoopzoop.backend.domain.auth.dto; +package org.tuna.zoopzoop.backend.domain.auth.deprecated; public record KakaoUserInfoResponse( Long id, diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java new file mode 100644 index 00000000..41764e3f --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java @@ -0,0 +1,70 @@ +package org.tuna.zoopzoop.backend.domain.auth.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.global.config.jwt.JwtProperties; +import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + private final JwtProperties jwtProperties; + private final MemberRepository memberRepository; + private final MemberService memberService; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + String registrationId = ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId(); + + Member member; + + if ("kakao".equals(registrationId)) { + String kakaoId = oAuth2User.getAttributes().get("id").toString(); + member = memberService.findByKakaoKey(kakaoId); + } else if ("google".equals(registrationId)) { + String googleId = (String) oAuth2User.getAttributes().get("sub"); + member = memberService.findByGoogleKey(googleId); + } else { + throw new IllegalArgumentException("Unsupported provider: " + registrationId); + } + + String accessToken = jwtUtil.generateToken(member); + String refreshToken = jwtUtil.generateRefreshToken(member); + + ResponseCookie accessCookie = ResponseCookie.from("accessToken", accessToken) + .httpOnly(true) + .path("/") + .maxAge(jwtProperties.getAccessTokenValidity() / 1000) + .sameSite("Lax") + .build(); + + ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .path("/") + .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) + .sameSite("Lax") + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + response.sendRedirect("/login-success"); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/CustomOAuth2UserService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/CustomOAuth2UserService.java new file mode 100644 index 00000000..174d6590 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,35 @@ +package org.tuna.zoopzoop.backend.domain.auth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final List oauth2UserInfoServices; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = new DefaultOAuth2UserService().loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + OAuth2UserInfoService userInfoService = oauth2UserInfoServices.stream() + .filter(service -> service.supports(registrationId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported provider: " + registrationId)); + + Member member = userInfoService.processUser(oAuth2User.getAttributes()); + + return oAuth2User; // 필요 시 커스텀 OAuth2User로 변환 가능 + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/GoogleUserInfoService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/GoogleUserInfoService.java new file mode 100644 index 00000000..7513611c --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/GoogleUserInfoService.java @@ -0,0 +1,33 @@ +package org.tuna.zoopzoop.backend.domain.auth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class GoogleUserInfoService implements OAuth2UserInfoService { + + private final MemberRepository memberRepository; + private final MemberService memberService; + + @Override + public boolean supports(String registrationId) { + return "google".equalsIgnoreCase(registrationId); + } + + @Override + public Member processUser(Map attributes) { + String googleId = (String) attributes.get("sub"); // 구글 user-id + String name = (String) attributes.get("name"); + String profileImage = (String) attributes.get("picture"); + + return memberRepository.findByProviderAndProviderKey(Provider.GOOGLE, googleId) + .orElseGet(() -> memberService.createMember(name, profileImage, googleId, Provider.GOOGLE)); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoAuthService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoAuthService.java deleted file mode 100644 index 812bb760..00000000 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoAuthService.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.tuna.zoopzoop.backend.domain.auth.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.BodyInserters; -import org.springframework.web.reactive.function.client.WebClient; -import org.tuna.zoopzoop.backend.domain.auth.dto.KakaoTokenResponse; -import org.tuna.zoopzoop.backend.domain.auth.dto.KakaoUserInfoResponse; -import org.tuna.zoopzoop.backend.domain.member.entity.Member; -import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; -import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; - -import java.util.HashMap; -import java.util.Map; - -@Service -@RequiredArgsConstructor -public class KakaoAuthService { - private final WebClient webClient; - private final MemberRepository memberRepository; - private final JwtUtil jwtUtil; - - @Value("${kakao.client_id}") - private String CLIENT_ID; - @Value("${kakao.redirect_uri}") - private String REDIRECT_URI; - - private static final String TOKEN_URL = "https://kauth.kakao.com/oauth/token"; - private static final String USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"; - - public Map loginWithKakao(String code) { - // 1. 카카오에서 토큰 발급 - KakaoTokenResponse tokenResponse = webClient.post() - .uri(TOKEN_URL) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData("grant_type", "authorization_code") - .with("client_id", CLIENT_ID) - .with("redirect_uri", REDIRECT_URI) - .with("code", code)) - .retrieve() - .bodyToMono(KakaoTokenResponse.class) - .block(); - - // 2. 토큰에서 AccessToken 가져오기. - String accessToken = tokenResponse.access_token(); - - // 3. AccessToken을 통해 카카오 사용자 정보 가져오기. - KakaoUserInfoResponse userInfo = webClient.get() - .uri(USER_INFO_URL) - .headers(headers -> headers.setBearerAuth(accessToken)) - .retrieve() - .bodyToMono(KakaoUserInfoResponse.class) - .block(); - - // 4. Member 엔티티 리턴 - // a. kakaoKey 값을 가진 Member 객체가 이미 존재하는 경우, 그대로 가져옴. - // b. 존재하지 않을 경우, 새로 만듬. - Member member = memberRepository.findByKakaoKey(userInfo.id()) - .orElseGet(() -> memberRepository.save( - Member.builder() - .name(userInfo.kakao_account().profile().nickname()) - .profileImageUrl(userInfo.kakao_account().profile().profile_image_url()) - .kakaoKey(userInfo.id()) - .build() - )); - - // 5. AccessToken 및 RefreshToken 생성. - String jwtAccessToken = jwtUtil.generateToken(member); - String jwtRefreshToken = jwtUtil.generateRefreshToken(member); - - Map tokens = new HashMap<>(); - tokens.put("accessToken", jwtAccessToken); - tokens.put("refreshToken", jwtRefreshToken); - - return tokens; - } -} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoUserInfoService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoUserInfoService.java new file mode 100644 index 00000000..227e6894 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoUserInfoService.java @@ -0,0 +1,35 @@ +package org.tuna.zoopzoop.backend.domain.auth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class KakaoUserInfoService implements OAuth2UserInfoService { + private final MemberRepository memberRepository; + private final MemberService memberService; + + @Override + public boolean supports(String registrationId) { + return "kakao".equalsIgnoreCase(registrationId); + } + + @Override + public Member processUser(Map attributes) { + String kakaoId = attributes.get("id").toString(); + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + String name = (String) profile.get("nickname"); + String profileImage = (String) profile.get("profile_image_url"); + + return memberRepository.findByProviderAndProviderKey(Provider.KAKAO,kakaoId) + .orElseGet(() -> memberService.createMember(name, profileImage, kakaoId, Provider.KAKAO)); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/OAuth2UserInfoService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/OAuth2UserInfoService.java new file mode 100644 index 00000000..7a9f713a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/OAuth2UserInfoService.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.auth.service; + +import org.tuna.zoopzoop.backend.domain.member.entity.Member; + +import java.util.Map; + +public interface OAuth2UserInfoService { + boolean supports(String registrationId); // 이 서비스가 해당 provider를 처리하는지 + Member processUser(Map attributes); +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java index 4690c808..1256ba56 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java @@ -3,7 +3,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.SneakyThrows; -import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -15,11 +14,11 @@ @RestController @Tag(name = "HomeController", description = "홈 컨트롤러") public class HomeController { - @Value("${kakao.client_id}") - private String kakaoClientId; - - @Value("${kakao.redirect_uri}") - private String kakaoRedirectUri; +// @Value("${kakao.client_id}") +// private String kakaoClientId; +// +// @Value("${kakao.redirect_uri}") +// private String kakaoRedirectUri; @SneakyThrows @GetMapping(produces = TEXT_HTML_VALUE) @@ -27,10 +26,9 @@ public class HomeController { public String main() { InetAddress localHost = getLocalHost(); - String kakaoLoginUrl = "https://kauth.kakao.com/oauth/authorize" - + "?response_type=code" - + "&client_id=" + kakaoClientId - + "&redirect_uri=" + kakaoRedirectUri; + String kakaoLoginUrl = "http://localhost:8080/oauth2/authorization/kakao"; + String googleLoginUrl = "http://localhost:8080/oauth2/authorization/google"; + String logoutUrl = "http://localhost:8080/api/v1/auth/logout"; return """

API 서버

@@ -42,6 +40,12 @@ public String main() { - """.formatted(localHost.getHostName(), localHost.getHostAddress(), kakaoLoginUrl); + + + """.formatted(localHost.getHostName(), localHost.getHostAddress(), kakaoLoginUrl, googleLoginUrl, logoutUrl); } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java index 4d0e1ed1..2a145c1e 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java @@ -6,6 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; import org.tuna.zoopzoop.backend.domain.space.membership.entity.MemberShip; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; @@ -24,7 +25,11 @@ public class Member extends BaseEntity { // private String email; @Column(unique = true, nullable = false) - private Long kakaoKey; + private String providerKey; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Provider provider; @Column private String profileImageUrl; @@ -41,9 +46,10 @@ public class Member extends BaseEntity { //---------- 생성자 ----------// @Builder - public Member(String name, Long kakaoKey, String profileImageUrl) { + public Member(String name, String providerKey, Provider provider, String profileImageUrl) { this.name = name; - this.kakaoKey = kakaoKey; + this.providerKey = providerKey; + this.provider = provider; this.profileImageUrl = profileImageUrl; this.active = true; this.personalArchive = new PersonalArchive(this); //Member 객체 생성 시 PersonalArchive 자동 생성. diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/enums/Provider.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/enums/Provider.java new file mode 100644 index 00000000..0a46f086 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/enums/Provider.java @@ -0,0 +1,6 @@ +package org.tuna.zoopzoop.backend.domain.member.enums; + +public enum Provider { + KAKAO, + GOOGLE +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java index 4162beb9..0ef54156 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; import java.util.List; import java.util.Optional; @@ -11,7 +12,8 @@ public interface MemberRepository extends JpaRepository { // Optional findByEmail(String email); Optional findByName(String name); - Optional findByKakaoKey(Long kakaoKey); + Optional findByProviderAndProviderKey(Provider provider, String providerKey); + Optional findByProviderKey(String providerKey); List findByActiveTrue(); // 활성 사용자 조회 List findByActiveFalse(); // 비활성 사용자 조회 } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java index 5e17b009..ec99117f 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import java.util.List; @@ -26,11 +27,24 @@ public Member findByName(String name){ new NoResultException(name + " 이름을 가진 사용자를 찾을 수 없습니다.") ); } - public Member findByKakaoKey(Long kakaoKey){ - return memberRepository.findByKakaoKey(kakaoKey).orElseThrow(() -> - new NoResultException(kakaoKey + " 카카오 키를 가진 사용자를 찾을 수 없습니다.") + public Member findByKakaoKey(String key){ + return memberRepository.findByProviderAndProviderKey(Provider.KAKAO, key).orElseThrow(() -> + new NoResultException(key + " 카카오 키를 가진 사용자를 찾을 수 없습니다.") ); } + + public Member findByGoogleKey(String key){ + return memberRepository.findByProviderAndProviderKey(Provider.GOOGLE, key).orElseThrow(() -> + new NoResultException(key + " 구글 키를 가진 사용자를 찾을 수 없습니다.") + ); + } + + public Member findByProviderKey(String providerKey) { + return memberRepository.findByProviderKey(providerKey).orElseThrow(() -> + new NoResultException(providerKey + " 해당 키를 가진 사용자를 찾을 수 없습니다.") + ); + } + // public Member findByEmail(String email){ // return memberRepository.findByEmail(email).orElseThrow(() -> // new NoResultException(email + " 이메일을 가진 사용자를 찾을 수 없습니다.") @@ -43,7 +57,7 @@ public Member findByKakaoKey(Long kakaoKey){ //회원 생성/정보 수정 관련 @Transactional - public Member createMember(String name, Long kakaoKey, String profileUrl){ + public Member createMember(String name, String profileUrl, String key, Provider provider){ // if(memberRepository.findByEmail(email).isPresent()){ // throw new DataIntegrityViolationException("이미 사용중인 이메일입니다."); // } @@ -53,8 +67,9 @@ public Member createMember(String name, Long kakaoKey, String profileUrl){ Member member = Member.builder() .name(name) - .kakaoKey(kakaoKey) .profileImageUrl(profileUrl) + .providerKey(key) + .provider(provider) .build(); return memberRepository.save(member); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java index 7cd13165..e11d3f80 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java @@ -6,6 +6,8 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.tuna.zoopzoop.backend.domain.auth.handler.OAuth2SuccessHandler; +import org.tuna.zoopzoop.backend.domain.auth.service.CustomOAuth2UserService; import org.tuna.zoopzoop.backend.global.security.jwt.CustomAuthenticationEntryPoint; import org.tuna.zoopzoop.backend.global.security.jwt.JwtAuthenticationFilter; @@ -14,10 +16,15 @@ public class SecurityConfig { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http + // CSRF 비활성화 (H2 콘솔 사용 위해 필요) + .csrf(csrf -> csrf.disable()) + // 모든 요청 허용 .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/member", "/api/v1/member/**").authenticated() @@ -33,12 +40,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/webjars/**", "/api/v1/**" // API 테스트용으로 모두 허용. 차후 필수로 변경 필요. ).permitAll() - .anyRequest().denyAll() + .anyRequest().authenticated() + ) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + .successHandler(oAuth2SuccessHandler) ) - - // CSRF 비활성화 (H2 콘솔 사용 위해 필요) - .csrf(csrf -> csrf.disable()) - // H2 콘솔 사용 허용 .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())) diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java index 68a90283..3cfae617 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/CustomUserDetails.java @@ -19,7 +19,9 @@ public class CustomUserDetails implements UserDetails { public String getPassword() { return null; } @Override - public String getUsername() { return String.valueOf(member.getKakaoKey()); } + public String getUsername() { + return member.getProviderKey(); + } public String getNickname() { return member.getName(); } public String getProfileImageUrl() { return member.getProfileImageUrl(); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java index 0030d427..1bd15117 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java @@ -34,10 +34,10 @@ protected void doFilterInternal(HttpServletRequest request, log.info("[JwtFilter] Token valid? {}", jwtUtil.validateToken(token)); if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) { // 토큰이 존재하고 유효한 경우 - String kakaoKey = jwtUtil.getKakaoKeyFromToken(token); //토큰에서 카카오 키 값 추출 - log.info("[JwtFilter] KakaoKey from token: {}", kakaoKey); + String providerKey = jwtUtil.getProviderKeyFromToken(token); //토큰에서 키 값 추출 + log.info("[JwtFilter] KakaoKey from token: {}", providerKey); - UserDetails userDetails = userDetailsService.loadUserByUsername(kakaoKey); // 사용자 정보 로드 + UserDetails userDetails = userDetailsService.loadUserByUsername(providerKey); // 사용자 정보 로드 log.info("[JwtFilter] UserDetails loaded: {}", userDetails); //권한 생성 로직 X (사용 안함.) diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java index 50bdcac6..70f5389c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java @@ -25,8 +25,10 @@ public String generateToken(Member member) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + jwtProperties.getAccessTokenValidity()); + String subject = member.getProvider() + ":" + member.getProviderKey(); + return Jwts.builder() - .setSubject(String.valueOf(member.getKakaoKey())) + .setSubject(subject) .claim("userId", member.getId()) .claim("name", member.getName()) .setIssuedAt(now) @@ -46,13 +48,13 @@ public String generateToken(Member member) { // } // 토큰에서 카카오 키 추출 - public String getKakaoKeyFromToken(String token) { + public String getProviderKeyFromToken(String token) { Claims claims = Jwts.parser() .verifyWith(getSigningKey()) .build() .parseSignedClaims(token) .getPayload(); - return claims.getSubject(); + return claims.getSubject(); // "kakao:123456" 또는 "google:abcdef" } // 토큰에서 이름 추출 @@ -102,11 +104,12 @@ public String generateRefreshToken(Member member) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + jwtProperties.getRefreshTokenValidity()); + String subject = member.getProvider() + ":" + member.getProviderKey(); + return Jwts.builder() - .setSubject(String.valueOf(member.getKakaoKey())) + .setSubject(subject) .claim("userId", member.getId()) .claim("name", member.getName()) - .claim("tokenType", "refresh") .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey()) @@ -136,4 +139,14 @@ public Date getExpirationDateFromToken(String token) { .getPayload(); return claims.getExpiration(); } + + // 액세스 토큰의 유효 시간 추출 + public long getAccessTokenValiditySeconds() { + return jwtProperties.getAccessTokenValidity() / 1000; + } + + // 리프레시 토큰의 유효 시간 추출 + public long getRefreshTokenValiditySeconds() { + return jwtProperties.getRefreshTokenValidity() / 1000; + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java index 855f9a3b..b2291433 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/service/CustomUserDetailsService.java @@ -14,19 +14,29 @@ public class CustomUserDetailsService implements UserDetailsService { private final MemberService memberService; - @Override - public UserDetails loadUserByUsername(String kakaoKeyStr) throws UsernameNotFoundException { - Long kakaoKey; - try { - kakaoKey = Long.parseLong(kakaoKeyStr); - } catch (NumberFormatException e) { - throw new UsernameNotFoundException("잘못된 카카오 키: " + kakaoKeyStr, e); + public UserDetails loadUserByUsername(String subject) throws UsernameNotFoundException { + // subject = "provider:providerKey" + String[] parts = subject.split(":"); + if (parts.length != 2) { + throw new UsernameNotFoundException("잘못된 토큰 subject: " + subject); + } + + String provider = parts[0]; // kakao, google + String providerKey = parts[1]; + + Member member; + if ("KAKAO".equals(provider)) { + member = memberService.findByKakaoKey(providerKey); + } else if ("GOOGLE".equals(provider)) { + member = memberService.findByGoogleKey(providerKey); + } else { + throw new UsernameNotFoundException("지원하지 않는 provider: " + provider); } - Member member = memberService.findByKakaoKey(kakaoKey); if (!member.isActive()) { - throw new UsernameNotFoundException("비활성화된 계정입니다: " + kakaoKey); + throw new UsernameNotFoundException("비활성화된 계정입니다: " + providerKey); } + return new CustomUserDetails(member); } } \ No newline at end of file diff --git a/src/main/resources/application-secrets.yml.template b/src/main/resources/application-secrets.yml.template index 3959e880..c92a787b 100644 --- a/src/main/resources/application-secrets.yml.template +++ b/src/main/resources/application-secrets.yml.template @@ -1,6 +1,33 @@ -kakao: - client_id: {REST_API_KEY} - redirect_uri: {CALLBACK_URL} +spring: + security: + oauth2: + client: + registration: + kakao: + client-id: {KAKAO_CLIENT_ID} + client-secret: "" + redirect-uri: {KAKAO_REDIRECT_URL} + scope: + - {SCOPE_LIST} + authorization-grant-type: {AUTHORIZATION_GRANT_TYPE} + google: + client-id: {GOOGLE_CLIENT_ID} + client-secret: {GOOGLE_SECRET} + redirect-uri: {GOOGLE_REDIRECT_URL} + scope: + - {SCOPE_LIST} + authorization-grant-type: {AUTHORIZATION_GRANT_TYPE} + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + google: + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + user-name-attribute: sub jwt: secret-key: {JWT_SECRET_KEY} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java index 95aabc88..7cc3a3e1 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java @@ -14,6 +14,8 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMemberName; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; @@ -40,28 +42,34 @@ public class MemberControllerTest { @BeforeAll void setUp() { - memberService.createMember( - "test", - 4001L, - "url"); - memberService.createMember( + Member member1 = memberService.createMember( + "test1", + "url", + "1111", + Provider.KAKAO + ); + Member member2 = memberService.createMember( "test2", - 4002L, - "url"); - memberService.createMember( + "url", + "2222", + Provider.GOOGLE + ); + Member member3 = memberService.createMember( "test3", - 4003L, - "url"); + "url", + "3333", + Provider.GOOGLE + ); } @Test - @WithUserDetails(value = "4001", setupBefore = TestExecutionEvent.TEST_METHOD) + @WithUserDetails(value = "KAKAO:1111", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("사용자 정보 조회 - 성공(200)") void getMemberInfoSuccess() throws Exception { mockMvc.perform(get("/api/v1/member/me")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.data.name").value("test")) + .andExpect(jsonPath("$.data.name").value("test1")) .andExpect(jsonPath("$.data.profileUrl").value("url")); } @@ -75,7 +83,7 @@ void getMemberInfoFailed() throws Exception { } @Test - @WithUserDetails(value = "4001", setupBefore = TestExecutionEvent.TEST_METHOD) + @WithUserDetails(value = "GOOGLE:2222", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("사용자 이름 수정 - 성공(200)") void editMemberNameSuccess() throws Exception { ReqBodyForEditMemberName reqBodyForEditMemberName = new ReqBodyForEditMemberName("test3"); @@ -89,7 +97,7 @@ void editMemberNameSuccess() throws Exception { } @Test - @WithUserDetails(value = "4001", setupBefore = TestExecutionEvent.TEST_METHOD) + @WithUserDetails(value = "GOOGLE:2222", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("사용자 이름 수정 - 실패(400, Bad_Request)") void editMemberNameFailedByBadRequest() throws Exception { ReqBodyForEditMemberName reqBodyForEditMemberName = new ReqBodyForEditMemberName(""); @@ -114,7 +122,7 @@ void editMemberNameFailedByUnauthorized() throws Exception { } @Test - @WithUserDetails(value = "4003", setupBefore = TestExecutionEvent.TEST_METHOD) + @WithUserDetails(value = "GOOGLE:3333", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("사용자 삭제 - 성공(200)") void deleteMemberSuccess() throws Exception { mockMvc.perform(delete("/api/v1/member")) diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java index 6fd868ea..00de1503 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java @@ -1,7 +1,6 @@ package org.tuna.zoopzoop.backend.domain.member.service; import jakarta.persistence.NoResultException; -import jakarta.transaction.Transactional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,7 +8,9 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import static org.junit.jupiter.api.Assertions.*; @@ -29,19 +30,24 @@ class MemberServiceTest { void setUp() { Member member1 = memberService.createMember( "test1", - 1001L, - "url" ); + "url", + "1111", + Provider.KAKAO + ); Member member2 = memberService.createMember( "test2", - 1002L, - "url" ); + "url", + "2222", + Provider.GOOGLE + ); } private Member createTestMember() { return memberService.createMember( "test3", - 1003L, - "url" + "url", + "3333", + Provider.KAKAO ); } @@ -66,9 +72,9 @@ void createMemberSuccess() { @Test @DisplayName("사용자 생성 - 이름 중복으로 인한 실패") void createMemberFailedByName() { - memberService.createMember("dupName", 3001L,"url"); + memberService.createMember("dupName", "url", "4001", Provider.KAKAO); Exception ex = assertThrows(DataIntegrityViolationException.class, () -> { - memberService.createMember("dupName", 3002L,"url"); + memberService.createMember("dupName", "url", "4002", Provider.KAKAO); }); assertTrue(ex.getMessage().contains("이미 사용중인 이름입니다.")); } @@ -110,21 +116,21 @@ void findByNameFailed() { } @Test - @DisplayName("Kakao 식별 키 기반 조회 - 성공") + @DisplayName("식별 키 기반 조회 - 성공") void findByKakaoKeySuccess() { Member saved = createTestMember(); - Member found = memberService.findByKakaoKey(1003L); + Member found = memberService.findByProviderKey(saved.getProviderKey()); assertEquals(saved.getId(), found.getId()); assertEquals(saved.getName(), found.getName()); } @Test - @DisplayName("Kakao 식별 키 기반 조회 - 실패") + @DisplayName("식별 키 기반 조회 - 실패") void findByKakaoKeyFailed() { Exception ex = assertThrows(NoResultException.class, () -> { - memberService.findByKakaoKey(1004L); + memberService.findByProviderKey("5555"); }); - assertTrue(ex.getMessage().contains("카카오 키를 가진 사용자를 찾을 수 없습니다.")); + assertTrue(ex.getMessage().contains("해당 키를 가진 사용자를 찾을 수 없습니다.")); } @Test From 4a735c2a21063ccb9ae9a8c097040def80191afd Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:39:35 +0900 Subject: [PATCH 015/132] =?UTF-8?q?feat/OPS-211=20:=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-211 : 폴더 이름 변경 구현 * refactor/OPS-211 : 폴더명 중복 예외처리 추가 * refactor/OPS-211 : 폴더명 중복 예외처리 추가 --- .../folder/controller/FolderController.java | 68 ++++++++++++++++++- .../folder/repository/FolderRepository.java | 6 +- .../archive/folder/service/FolderService.java | 63 +++++++++++++++++ .../controller/FolderControllerTest.java | 40 +++++++++++ .../folder/service/FolderServiceTest.java | 46 +++++++++++++ 5 files changed, 220 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java index fa502d14..fe70cc3c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java @@ -38,7 +38,7 @@ public RsData createFolder( resBodyForCreateFolder rs = new resBodyForCreateFolder(createFile.folderName(), createFile.folderId()); - return new RsData( + return new RsData<>( "200", rq.folderName() + " 폴더가 생성됐습니다.", rs @@ -62,4 +62,70 @@ public ResponseEntity> deleteFolder(@PathVariable Integer fo return ResponseEntity.ok(body); } + /** + * 폴더 이름 수정 + * @param folderId 수정할 폴더 Id + * @param body 수정할 폴더 값 + */ + @PatchMapping("/{folderId}") + public ResponseEntity> updateFolderName( + @PathVariable Integer folderId, + @RequestBody Map body + ) { + String newName = body.get("folderName"); + String updatedName = folderService.updateFolderName(folderId, newName); + + Map response = new HashMap<>(); + response.put("status", 200); + response.put("msg", "폴더 이름이 " + updatedName + " 으로 변경됐습니다."); + response.put("data", Map.of("folderName", updatedName)); + + return ResponseEntity.ok(response); + } + + /** + * 개인 아카이브의 폴더 이름 전부 조회 + * "default", "폴더1", "폴더2" + */ + @GetMapping("") + public ResponseEntity getFolders() { + // 로그인된 멤버 ID 가져오기 + Integer currentMemberId = StubAuthUtil.currentMemberId(); + + // 내 personal archive 안의 폴더 조회 + List folders = folderService.getFoldersForPersonal(currentMemberId); + + return ResponseEntity.ok( + Map.of( + "status", 200, + "msg", "개인 아카이브의 폴더 목록을 불러왔습니다.", + "data", Map.of("folders", folders) + ) + ); + } + + /** + * 폴더(내 PersonalArchive 소속) 안의 파일 목록 조회 + */ + @GetMapping("/{folderId}/files") + public ResponseEntity getFilesInFolder(@PathVariable Integer folderId) { + Integer currentMemberId = StubAuthUtil.currentMemberId(); + + FolderFilesDto rs = folderService.getFilesInFolderForPersonal(currentMemberId, folderId); + + return ResponseEntity.ok( + Map.of( + "status", 200, + "msg", "해당 폴더의 파일 목록을 불러왔습니다.", + "data", Map.of( + "folder", Map.of( + "folderId", rs.folderId(), + "folderName", rs.folderName() + ), + "files", rs.files() + ) + ) + ); + } + } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java index b2eda42b..562713a3 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java @@ -5,7 +5,6 @@ import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; -import java.util.Collection; import java.util.List; import java.util.Optional; @@ -15,7 +14,6 @@ public interface FolderRepository extends JpaRepository{ * @param archiveId 아카이브 Id * @param filename "파일명" * @param filenameEnd "파일명 + \ufffff" - * @return */ @Query(""" select f.name @@ -25,4 +23,8 @@ public interface FolderRepository extends JpaRepository{ and f.name < :filenameEnd """) List findNamesForConflictCheck(Integer archiveId, String filename, String filenameEnd); + + List findByArchive(Archive archive); + + Optional findByIdAndArchive(Integer id, Archive archive); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java index 7b82312d..01aa2e36 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java @@ -124,6 +124,69 @@ public String deleteFolder(Integer folderId) { return name; } + /** + * folderId에 해당하는 이름 변경 + */ + @Transactional + public String updateFolderName(Integer folderId, String newName) { + Folder folder = folderRepository.findById(folderId) + .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); + + // 같은 아카이브 내에서 중복 폴더 이름 확인 + List existingNames = folderRepository.findNamesForConflictCheck( + folder.getArchive().getId(), + newName, + folder.getName() // 자기 자신은 제외 + ); + + if (!existingNames.isEmpty()) { + throw new IllegalArgumentException("이미 존재하는 폴더명입니다."); + } + + folder.setName(newName); + folderRepository.save(folder); + return newName; + } + + @Transactional(readOnly = true) + public List getFoldersForPersonal(Integer memberId) { + // 1. 해당 멤버의 personal archive 찾기 + PersonalArchive personalArchive = personalArchiveRepository.findByMemberId(memberId) + .orElseThrow(() -> new NoResultException("개인 아카이브가 존재하지 않습니다.")); + + // 2. personal archive → archive 꺼내오기 + Archive archive = personalArchive.getArchive(); + + // 3. archive 안의 폴더 전부 가져오기 + return folderRepository.findByArchive(archive).stream() + .map(folder -> new FolderResponse(folder.getId(), folder.getName())) + .toList(); + } + + @Transactional(readOnly = true) + public FolderFilesDto getFilesInFolderForPersonal(Integer memberId, Integer folderId) { + // 1) 내 PersonalArchive 찾기 + PersonalArchive pa = personalArchiveRepository.findByMemberId(memberId) + .orElseThrow(() -> new NoResultException("개인 아카이브가 존재하지 않습니다.")); + + Archive myArchive = pa.getArchive(); + + // 2) 해당 폴더가 내 아카이브인지 확인 + Folder folder = folderRepository.findByIdAndArchive(folderId, myArchive) + .orElseThrow(() -> new NoResultException("해당 폴더가 존재하지 않거나 내 아카이브 소속이 아닙니다.")); + + // 3) 폴더 안 파일(datasource) 조회 + List files = dataSourceRepository.findAllByFolder(folder).stream() + .map(ds -> new FileSummary( + ds.getId(), // 파일 Id + ds.getTitle(), // 파일명 (제목) + ds.getCreateDate() // 생성일 + )) + .toList(); + + return new FolderFilesDto(folder.getId(), folder.getName(), files); + } + /** * 입력된 폴더명을 (폴더명, 숫자)로 분리하는 유틸 클래스 * “폴더명” → (”폴더명”, null) diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java index 8de178cc..2f7d05c3 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java @@ -102,4 +102,44 @@ void deleteFolder_notFound() throws Exception { .andExpect(jsonPath("$.status").value("404")) .andExpect(jsonPath("$.msg").value("존재하지 않는 폴더입니다.")); } + + // UpdateFile + @Test + @DisplayName("개인 아카이브 폴더 이름 변경 - 성공 시 200과 변경된 이름 반환") + void updateFolder_ok() throws Exception { + // given + when(folderService.updateFolderName(10, "회의록")).thenReturn("회의록"); + + Map body = new HashMap<>(); + body.put("folderName", "회의록"); + + // when & then + mockMvc.perform(patch("/api/v1/archive/folder/{folderId}", 10) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("폴더 이름이 회의록 으로 변경됐습니다.")) + .andExpect(jsonPath("$.data.folderName").value("회의록")); + } + + @Test + @DisplayName("개인 아카이브 폴더 이름 변경 - 존재하지 않는 폴더면 404") + void updateFolder_notFound() throws Exception { + // given + when(folderService.updateFolderName(99, "회의록")) + .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); + + + Map body = new HashMap<>(); + body.put("folderName", "회의록"); + + // when & then + mockMvc.perform(patch("/api/v1/archive/folder/{folderId}", 99) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value("존재하지 않는 폴더입니다.")); + } } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java index d80a9d72..0f19b4fe 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java @@ -130,4 +130,50 @@ void deleteFolder_notFound() { assertThrows(NoResultException.class, () -> folderService.deleteFolder(999)); verify(folderRepository, never()).delete(any(Folder.class)); } + + // ---------- Update ---------- + @Test + @DisplayName("폴더 이름 변경 성공") + void updateFolderName_success() { + Folder folder = new Folder(); + folder.setName("기존이름"); + folder.setArchive(archive); + ReflectionTestUtils.setField(folder, "id", 700); + + when(folderRepository.findById(700)).thenReturn(Optional.of(folder)); + when(folderRepository.save(any(Folder.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + String updated = folderService.updateFolderName(700, "새이름"); + + assertThat(updated).isEqualTo("새이름"); + assertThat(folder.getName()).isEqualTo("새이름"); + verify(folderRepository, times(1)).save(folder); + } + + @Test + @DisplayName("폴더 이름 변경 실패 - 존재하지 않음") + void updateFolderName_notFound() { + when(folderRepository.findById(701)).thenReturn(Optional.empty()); + + assertThrows(NoResultException.class, () -> folderService.updateFolderName(701, "아무거나")); + verify(folderRepository, never()).save(any(Folder.class)); + } + + @Test + @DisplayName("폴더 이름 변경 실패 - 중복 이름 존재") + void updateFolderName_conflict() { + Folder folder = new Folder(); + folder.setName("기존이름"); + folder.setArchive(archive); + ReflectionTestUtils.setField(folder, "id", 700); + + when(folderRepository.findById(700)).thenReturn(Optional.of(folder)); + when(folderRepository.findNamesForConflictCheck(archive.getId(), "보고서", "기존이름")) + .thenReturn(List.of("보고서")); + + assertThrows(IllegalArgumentException.class, + () -> folderService.updateFolderName(700, "보고서")); + + verify(folderRepository, never()).save(any(Folder.class)); + } } From 26755d84b8d0e81d524dba1bc5e208d6997d8d02 Mon Sep 17 00:00:00 2001 From: taekkong <141305946+taekkong@users.noreply.github.com> Date: Tue, 23 Sep 2025 09:19:57 +0900 Subject: [PATCH 016/132] =?UTF-8?q?[chore/OPS-187]=20Terraform=EC=9D=84=20?= =?UTF-8?q?=ED=99=9C=EC=9A=A9=ED=95=B4=20RDS=20=EC=9D=B8=EC=8A=A4=ED=84=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore/OPS-187 : Terraform을 활용해 RDS 인스턴스 생성 및 설치 * chore : RDS 서브넷 그룹 private으로 수정 * chore : AWS 리소스 네이밍 규칙 적용 --- infra/terraform/main.tf | 30 +++++++++-- infra/terraform/modules/ec2/main.tf | 29 ++++++----- infra/terraform/modules/ec2/variables.tf | 16 ++++-- infra/terraform/modules/rds/main.tf | 22 ++++++++ infra/terraform/modules/rds/outputs.tf | 7 +++ infra/terraform/modules/rds/variables.tf | 14 ++++++ infra/terraform/modules/sg/outputs.tf | 2 +- infra/terraform/modules/sg/rds_sg.tf | 2 + infra/terraform/modules/sg/variables.tf | 3 +- infra/terraform/modules/vpc/main.tf | 23 +++------ infra/terraform/modules/vpc/outputs.tf | 8 +++ infra/terraform/variables.tf | 64 ++++++++++++++++++++++-- 12 files changed, 181 insertions(+), 39 deletions(-) create mode 100644 infra/terraform/modules/rds/main.tf create mode 100644 infra/terraform/modules/rds/outputs.tf create mode 100644 infra/terraform/modules/rds/variables.tf diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 88aafe4b..43e3be14 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -22,6 +22,7 @@ module "sg"{ source = "./modules/sg" vpc_id = module.vpc.vpc_id prefix = var.prefix + create_rds = var.create_rds } module "iam"{ @@ -32,13 +33,36 @@ module "iam"{ module "ec2" { source = "./modules/ec2" ami = var.ami - instance_type = var.instance_type + ec2_instance_type = var.ec2_instance_type subnet_id = module.vpc.subnet_ids[0] ec2_sg_id = module.sg.ec2_sg_id iam_instance_profile = module.iam.instance_profile_name key_name = var.key_name prefix = var.prefix - mysql_root_password = var.mysql_root_password - mysql_db_name = var.mysql_db_name + test_mysql_root_password = var.test_mysql_root_password + test_mysql_db_name = var.test_mysql_db_name + create_rds = var.create_rds } +module "rds" { + source = "./modules/rds" + + count = var.create_rds ? 1 : 0 + + identifier = var.identifier + engine = var.engine + engine_version = var.engine_version + rds_instance_class = var.rds_instance_type + allocated_storage = var.allocated_storage + storage_type = var.storage_type + prod_mysql_db_username = var.prod_mysql_db_username + prod_mysql_root_password = var.prod_mysql_root_password + prod_mysql_db_name = var.prod_mysql_db_name + vpc_security_group_ids = [module.sg.rds_sg_id] + private_subnet_ids = module.vpc.private_subnet_ids + multi_az = var.multi_az + skip_final_snapshot = var.skip_final_snapshot + tags = { + Name = "${var.prefix}-rds" + } +} diff --git a/infra/terraform/modules/ec2/main.tf b/infra/terraform/modules/ec2/main.tf index cbd9b6da..000697c5 100644 --- a/infra/terraform/modules/ec2/main.tf +++ b/infra/terraform/modules/ec2/main.tf @@ -1,5 +1,17 @@ locals { - user_data = <<-EOF + mysql_user_data= var.create_rds?"": <<-END1 + # MySQL 컨테이너 실행 (테스트 환경일 때만) +docker run -d --name mysql \ + --restart unless-stopped \ + --network common \ + -p 3306:3306 \ + -e MYSQL_ROOT_PASSWORD=${var.test_mysql_root_password} \ + -e MYSQL_DATABASE=${var.test_mysql_db_name} \ + -v /opt/mysql/data:/var/lib/mysql \ + mysql:latest +END1 + + user_data = <<-END2 #!/bin/bash # Swap 설정 dd if=/dev/zero of=/swapfile bs=128M count=32 @@ -24,20 +36,13 @@ docker run -d --name npm \ jc21/nginx-proxy-manager:latest # docker run -d --name redis_1 --restart unless-stopped --network common -p 6379:6379 -e TZ=Asia/Seoul redis -docker run -d --name mysql \ - --restart unless-stopped \ - --network common \ - -p 3306:3306 \ - -e MYSQL_ROOT_PASSWORD=${var.mysql_root_password} \ - -e MYSQL_DATABASE=${var.mysql_db_name} \ - -v /opt/mysql/data:/var/lib/mysql \ - mysql:latest -EOF +${local.mysql_user_data} +END2 } resource "aws_instance" "this" { ami = var.ami - instance_type = var.instance_type + instance_type = var.ec2_instance_type subnet_id = var.subnet_id vpc_security_group_ids = [var.ec2_sg_id] iam_instance_profile = var.iam_instance_profile @@ -47,7 +52,7 @@ resource "aws_instance" "this" { root_block_device { volume_type = "gp3" volume_size = 25 - tags = { Name = "${var.prefix}-root" } + tags = { Name = "${var.prefix}-ebs" } } user_data = local.user_data diff --git a/infra/terraform/modules/ec2/variables.tf b/infra/terraform/modules/ec2/variables.tf index 2daec17e..9980bedb 100644 --- a/infra/terraform/modules/ec2/variables.tf +++ b/infra/terraform/modules/ec2/variables.tf @@ -1,10 +1,20 @@ variable "ami" { type = string } -variable "instance_type" { type = string } +variable "ec2_instance_type" { type = string } variable "subnet_id" { type = string } variable "ec2_sg_id" { type = string } variable "iam_instance_profile" { type = string } variable "key_name" { type = string } variable "prefix" { type = string } # variable "redis_password" { type = string } -variable "mysql_root_password" { type = string } -variable "mysql_db_name" {type=string} \ No newline at end of file +variable "test_mysql_root_password" { + type = string + default = null +} +variable "test_mysql_db_name" { + type=string + default = null +} +variable "create_rds" { + type = bool + default = false +} \ No newline at end of file diff --git a/infra/terraform/modules/rds/main.tf b/infra/terraform/modules/rds/main.tf new file mode 100644 index 00000000..21aa8314 --- /dev/null +++ b/infra/terraform/modules/rds/main.tf @@ -0,0 +1,22 @@ +resource "aws_db_instance" "this" { + identifier = var.identifier + engine = var.engine + engine_version = var.engine_version + instance_class = var.rds_instance_class + allocated_storage = var.allocated_storage + storage_type = var.storage_type + username = var.prod_mysql_db_username + password = var.prod_mysql_root_password + db_name = var.prod_mysql_db_name + vpc_security_group_ids = var.vpc_security_group_ids + db_subnet_group_name = aws_db_subnet_group.this.name + skip_final_snapshot = var.skip_final_snapshot + multi_az = var.multi_az + tags = var.tags +} + +resource "aws_db_subnet_group" "this" { + name = "${var.identifier}-db-subnet-group" + subnet_ids = var.private_subnet_ids + tags = var.tags +} \ No newline at end of file diff --git a/infra/terraform/modules/rds/outputs.tf b/infra/terraform/modules/rds/outputs.tf new file mode 100644 index 00000000..e1166aa2 --- /dev/null +++ b/infra/terraform/modules/rds/outputs.tf @@ -0,0 +1,7 @@ +output "endpoint"{ + value = aws_db_instance.this.endpoint +} + +output "arn"{ + value = aws_db_instance.this.arn +} \ No newline at end of file diff --git a/infra/terraform/modules/rds/variables.tf b/infra/terraform/modules/rds/variables.tf new file mode 100644 index 00000000..83584a94 --- /dev/null +++ b/infra/terraform/modules/rds/variables.tf @@ -0,0 +1,14 @@ +variable "identifier" {} +variable "engine" {} +variable "engine_version" {} +variable "rds_instance_class" {} +variable "allocated_storage" {} +variable "storage_type" {} +variable "prod_mysql_db_username" {} +variable "prod_mysql_root_password" {} +variable "prod_mysql_db_name" {} +variable "vpc_security_group_ids" { type = list(string) } +variable "private_subnet_ids" { type = list(string) } +variable "multi_az" { type = bool } +variable "tags" { type = map(string) } +variable "skip_final_snapshot" { type = bool } \ No newline at end of file diff --git a/infra/terraform/modules/sg/outputs.tf b/infra/terraform/modules/sg/outputs.tf index 0d0a1b08..c598e120 100644 --- a/infra/terraform/modules/sg/outputs.tf +++ b/infra/terraform/modules/sg/outputs.tf @@ -3,5 +3,5 @@ output "ec2_sg_id" { } output "rds_sg_id" { - value = aws_security_group.rds_sg.id + value = var.create_rds?aws_security_group.rds_sg[0].id:null } \ No newline at end of file diff --git a/infra/terraform/modules/sg/rds_sg.tf b/infra/terraform/modules/sg/rds_sg.tf index 94879394..b8b940b1 100644 --- a/infra/terraform/modules/sg/rds_sg.tf +++ b/infra/terraform/modules/sg/rds_sg.tf @@ -1,4 +1,6 @@ resource "aws_security_group" "rds_sg" { + count = var.create_rds ? 1 : 0 + name = "${var.prefix}-rds-sg" vpc_id = var.vpc_id diff --git a/infra/terraform/modules/sg/variables.tf b/infra/terraform/modules/sg/variables.tf index 7c895760..97aeb59a 100644 --- a/infra/terraform/modules/sg/variables.tf +++ b/infra/terraform/modules/sg/variables.tf @@ -1,2 +1,3 @@ variable "prefix" {type=string} -variable "vpc_id" {type=string} \ No newline at end of file +variable "vpc_id" {type=string} +variable "create_rds" {type = string} \ No newline at end of file diff --git a/infra/terraform/modules/vpc/main.tf b/infra/terraform/modules/vpc/main.tf index 17bba77e..e210cb48 100644 --- a/infra/terraform/modules/vpc/main.tf +++ b/infra/terraform/modules/vpc/main.tf @@ -25,22 +25,13 @@ resource "aws_subnet" "private"{ tags = {Name = "${var.prefix}-subnet-private"} } -# 고가용성 구성이 필요할때 -# resource "aws_subnet" "c"{ -# vpc_id = aws_vpc.this.id -# cidr_block = "10.0.3.0/24" -# availability_zone = "${var.region}c" -# map_public_ip_on_launch = true -# tags = {Name = "${var.prefix}-subnet-c"} -# } -# -# resource "aws_subnet" "d"{ -# vpc_id = aws_vpc.this.id -# cidr_block = "10.0.4.0/24" -# availability_zone = "${var.region}d" -# map_public_ip_on_launch = true -# tags = {Name = "${var.prefix}-subnet-d"} -# } +resource "aws_subnet" "private2"{ + vpc_id = aws_vpc.this.id + cidr_block = "10.0.3.0/24" + availability_zone = "${var.region}c" + map_public_ip_on_launch = false + tags = {Name = "${var.prefix}-subnet-private2"} +} resource "aws_internet_gateway" "this"{ vpc_id = aws_vpc.this.id diff --git a/infra/terraform/modules/vpc/outputs.tf b/infra/terraform/modules/vpc/outputs.tf index c01437ce..a30b9948 100644 --- a/infra/terraform/modules/vpc/outputs.tf +++ b/infra/terraform/modules/vpc/outputs.tf @@ -6,5 +6,13 @@ output "subnet_ids" { value = [ aws_subnet.public.id, aws_subnet.private.id, + aws_subnet.private2.id + ] +} + +output "private_subnet_ids"{ + value = [ + aws_subnet.private.id, + aws_subnet.private2.id ] } \ No newline at end of file diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index a67189b3..061aa598 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -1,10 +1,68 @@ #공통 변수 정의 +#EC2 variable "region" { type = string } variable "prefix" { type = string } variable "ami" { type = string } -variable "instance_type" { type = string } +variable "ec2_instance_type" { type = string } variable "key_name" { type = string } #variable "redis_password" { type = string } -variable "mysql_root_password" { type = string } -variable "mysql_db_name" {type = string} +variable "test_mysql_root_password" { + type = string + default = null +} +variable "test_mysql_db_name" { + type = string + default = null +} + + +#RDS +variable "create_rds" { + description = "RDS 생성 여부" + type = bool +} +variable "identifier" { + type = string + default = null +} +variable "engine" { + type = string + default = null +} +variable "engine_version" { + type = string + default = null +} +variable "rds_instance_type" { + type = string + default = null +} +variable "allocated_storage" { + type = number + default = null +} +variable "storage_type" { + type = string + default = null +} +variable "multi_az" { + type = bool + default = null +} +variable "prod_mysql_db_username" { + type = string + default = null +} +variable "prod_mysql_root_password" { + type = string + default = null +} +variable "prod_mysql_db_name" { + type = string + default = null +} +variable "skip_final_snapshot" { + type = string + default = null +} \ No newline at end of file From 1e79e17f802f42bc302565835fde9ebc1645645c Mon Sep 17 00:00:00 2001 From: Hyeok Jin Kim <71928299+EpicFn@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:49:11 +0900 Subject: [PATCH 017/132] =?UTF-8?q?[feat]=20=EC=8A=A4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * new/OPS-222 : space controller, sevice, repository 빈 생성 # Conflicts: # src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java * feat/OPS-222 : 스페이스 생성 엔드포인트 추가 * new/OPS-222 : spaceControllerTest 파일 세팅 * refactor/OPS-222 : 테스트 클래스의 공용 파트 추상 클래스로 분리 * fix : 최신사항 반영 * fix/OPS-222 : 테스트 코드에서 형식 다르던 거 수정 * feat/OPS-222 : 스페이스 생성 구현 * feat/OPS-222 : space 단위 핸들러, 커스텀 에러 생성 * fix/OPS-222 : controller 생성 테스트 통과 * feat/OPS-222 : 스페이스 생성 로직 완성 * feat/OPS-222 : 스페이스 삭제 구현 * fix/OPS-222 : 테스트 케이스 통과 * feat/OPS-245 : controller 단위 테스트 케이스 작성 * feat/OPS-245 : 스페이스 명 변경 구현 * fix/OPS-244 : 이름 변경 시 saveAndFlush로 즉시 반영 * chore/OPS-222 : develop 최신 사항 반영 * fix : 저장 * fix/OPS-222 : globalExceptionHandler 컨벤션 통일 * feat/OPS-244 : 스페이스 목록 조회 controller 단위 테스트 작성 * feat/OPS-244 : membership 관리 빈 생성 * feat/OPS-244 : membership 생성 관련 테스트 케이스 작성 * fix : 테스트 케이스 간 충돌 해결 중 * fix : 최신 사항 반영 * 임시저장 * 초기 상태 * fix : 미봉책 적용 * feat/OPS-244 : 스페이스 멤버 추가 기능 * fix : spaceControllerTest와 memberControllerTest 간 충돌 해결 * feat/OPS-244 : 스페이스 목록 조회 엔드포인트 생성 * chore/OPS-244 : 인증 관련 코드 주석 처리 * feat/OPS-270 : 스페이스 로직에 사용자 정보 체크 추가 * feat/OPS-22 : 테스트 케이스에 WithUserDetails 반영 * feat/OPS-222 : 인증 관련 테스트 케이스 추가 * fix : 불필요한 주석 삭제 * new/OPS-222 : space controller, sevice, repository 빈 생성 # Conflicts: # src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java * feat/OPS-222 : 스페이스 생성 엔드포인트 추가 * new/OPS-222 : spaceControllerTest 파일 세팅 * refactor/OPS-222 : 테스트 클래스의 공용 파트 추상 클래스로 분리 * fix : 최신사항 반영 * fix/OPS-222 : 테스트 코드에서 형식 다르던 거 수정 * feat/OPS-222 : 스페이스 생성 구현 * feat/OPS-222 : space 단위 핸들러, 커스텀 에러 생성 * fix/OPS-222 : controller 생성 테스트 통과 * feat/OPS-222 : 스페이스 생성 로직 완성 * feat/OPS-222 : 스페이스 삭제 구현 * fix/OPS-222 : 테스트 케이스 통과 * feat/OPS-245 : controller 단위 테스트 케이스 작성 * feat/OPS-245 : 스페이스 명 변경 구현 * fix/OPS-244 : 이름 변경 시 saveAndFlush로 즉시 반영 * chore/OPS-222 : develop 최신 사항 반영 * fix : 저장 * fix/OPS-222 : globalExceptionHandler 컨벤션 통일 * feat/OPS-244 : 스페이스 목록 조회 controller 단위 테스트 작성 * feat/OPS-244 : membership 관리 빈 생성 * feat/OPS-244 : membership 생성 관련 테스트 케이스 작성 * fix : 테스트 케이스 간 충돌 해결 중 * fix : 최신 사항 반영 * 임시저장 * 초기 상태 * fix : 미봉책 적용 * feat/OPS-244 : 스페이스 멤버 추가 기능 * fix : spaceControllerTest와 memberControllerTest 간 충돌 해결 * feat/OPS-244 : 스페이스 목록 조회 엔드포인트 생성 * chore/OPS-244 : 인증 관련 코드 주석 처리 * feat/OPS-270 : 스페이스 로직에 사용자 정보 체크 추가 * feat/OPS-22 : 테스트 케이스에 WithUserDetails 반영 * feat/OPS-222 : 인증 관련 테스트 케이스 추가 * fix : 불필요한 주석 삭제 * fix : 불필요한 파일 삭제 --------- Co-authored-by: EpicFn --- .idea/modules.xml | 10 + .../backend/domain/member/entity/Member.java | 5 +- .../{MemberShip.java => Membership.java} | 2 +- .../space/membership/enums/JoinState.java | 6 + .../repository/MembershipRepository.java | 23 + .../membership/service/MembershipService.java | 86 +++ .../controller/ApiV1SpaceController.java | 138 +++++ .../space/space/dto/ReqBodyForSpaceSave.java | 12 + .../space/space/dto/ResBodyForSpaceList.java | 8 + .../space/space/dto/ResBodyForSpaceSave.java | 6 + .../space/space/dto/SpaceMembershipInfo.java | 10 + .../domain/space/space/entity/Space.java | 9 +- .../DuplicateSpaceNameException.java | 10 + .../exception/SpaceExceptionHandler.java | 29 + .../repository/SpaceRepository.java | 6 +- .../space/space/service/SpaceService.java | 98 ++++ .../exception/GlobalExceptionHandler.java | 2 +- .../backend/global/initData/BaseInitData.java | 2 +- .../member/service/MemberServiceTest.java | 4 +- .../service/MembershipServiceTest.java | 110 ++++ .../controller/ApiV1SpaceControllerTest.java | 503 ++++++++++++++++++ .../space/space/service/SpaceServiceTest.java | 141 +++++ .../testSupport/ControllerTestSupport.java | 180 +++++++ 23 files changed, 1387 insertions(+), 13 deletions(-) create mode 100644 .idea/modules.xml rename src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/entity/{MemberShip.java => Membership.java} (95%) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/enums/JoinState.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ReqBodyForSpaceSave.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceList.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceSave.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfo.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/exception/DuplicateSpaceNameException.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/exception/SpaceExceptionHandler.java rename src/main/java/org/tuna/zoopzoop/backend/domain/space/{ => space}/repository/SpaceRepository.java (59%) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceServiceTest.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..0ee26db9 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java index 2a145c1e..596c229b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java @@ -7,9 +7,8 @@ import lombok.Setter; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; import org.tuna.zoopzoop.backend.domain.member.enums.Provider; -import org.tuna.zoopzoop.backend.domain.space.membership.entity.MemberShip; +import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; - import java.util.List; @Setter @@ -42,7 +41,7 @@ public class Member extends BaseEntity { private PersonalArchive personalArchive; //PersonalArchive(Archive 매핑 테이블), 1:1 관계, cascade.all @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) - private List memberShips; //MemberShip, 1:N 관계, cascade.all + private List memberShips; //MemberShip, 1:N 관계, cascade.all //---------- 생성자 ----------// @Builder diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/entity/MemberShip.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/entity/Membership.java similarity index 95% rename from src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/entity/MemberShip.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/entity/Membership.java index af85fe3d..afe3fd0c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/entity/MemberShip.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/entity/Membership.java @@ -13,7 +13,7 @@ @Setter @Entity @NoArgsConstructor -public class MemberShip extends BaseEntity { +public class Membership extends BaseEntity { //Member 외래키 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/enums/JoinState.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/enums/JoinState.java new file mode 100644 index 00000000..5ea0da30 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/enums/JoinState.java @@ -0,0 +1,6 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.enums; + +public enum JoinState { + PENDING, // 가입 대기 + JOINED // 가입 완료 +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java new file mode 100644 index 00000000..5864f4ea --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java @@ -0,0 +1,23 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; + +import java.util.List; + +public interface MembershipRepository extends JpaRepository { + boolean existsByMemberAndSpace(Member member, Space space); + + List findAllByMemberAndAuthority(Member member, Authority authority); + + List findAllByMemberAndAuthorityIsNot(Member member, Authority authority); + + List findAllByMember(Member member); + + boolean existsByMemberAndSpaceAndAuthorityIsNot(Member member, Space space, Authority authority); + + boolean existsByMemberAndSpaceAndAuthority(Member member, Space space, Authority authority); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java new file mode 100644 index 00000000..40e2c2f1 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java @@ -0,0 +1,86 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.JoinState; +import org.tuna.zoopzoop.backend.domain.space.membership.repository.MembershipRepository; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MembershipService { + private final MembershipRepository membershipRepository; + + /** + * 멤버가 스페이스에 가입되어 있는지 여부 확인 (PENDING 상태 포함) + * @param member 확인할 멤버 + * @param space 확인할 스페이스 + * @return + */ + public boolean isMemberInSpace(Member member, Space space) { + return membershipRepository.existsByMemberAndSpace(member, space); + } + + /** + * 멤버가 스페이스에 가입되어 있는지 여부 확인 (PENDING 상태 제외) + * @param member 확인할 멤버 + * @param space 확인할 스페이스 + * @return + */ + public boolean isMemberJoinedSpace(Member member, Space space) { + return membershipRepository.existsByMemberAndSpaceAndAuthorityIsNot(member, space, Authority.PENDING); + } + + /** + * 멤버가 스페이스의 ADMIN 권한을 가지고 있는지 여부 확인 + * @param member 확인할 멤버 + * @param space 확인할 스페이스 + * @return + */ + public boolean isMemberAdminInSpace(Member member, Space space) { + return membershipRepository.existsByMemberAndSpaceAndAuthority(member, space, Authority.ADMIN); + } + + /** + * 스페이스에 멤버 추가 + * @param member 추가할 멤버 + * @param space 멤버가 추가될 스페이스 + * @param authority 멤버의 권한 + * @return 생성된 Membership 엔티티 + */ + public Membership addMemberToSpace(Member member, Space space, Authority authority) { + // 이미 해당 멤버가 스페이스에 속해있는지 확인 + if (membershipRepository.existsByMemberAndSpace(member, space)) { + throw new DataIntegrityViolationException("이미 스페이스에 속한 멤버입니다."); + } + + + Membership membership = new Membership(); + membership.setMember(member); + membership.setSpace(space); + membership.setAuthority(authority); + return membershipRepository.save(membership); + } + + /** + * 멤버가 속한 스페이스 목록 조회 + * @param member 조회할 멤버 + * @param state 멤버의 가입 상태로 필터링 (PENDING, JOINED, ALL) + * @return 멤버가 속한 스페이스 목록 + */ + public List findByMember(Member member, String state) { + if (state.equalsIgnoreCase("PENDING")) { + return membershipRepository.findAllByMemberAndAuthority(member, Authority.PENDING); + } else if (state.equalsIgnoreCase("JOINED")) { + return membershipRepository.findAllByMemberAndAuthorityIsNot(member, Authority.PENDING); + } else { + return membershipRepository.findAllByMember(member); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java new file mode 100644 index 00000000..b58683e9 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java @@ -0,0 +1,138 @@ +package org.tuna.zoopzoop.backend.domain.space.space.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.JoinState; +import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; +import org.tuna.zoopzoop.backend.domain.space.space.dto.ReqBodyForSpaceSave; +import org.tuna.zoopzoop.backend.domain.space.space.dto.ResBodyForSpaceList; +import org.tuna.zoopzoop.backend.domain.space.space.dto.SpaceMembershipInfo; +import org.tuna.zoopzoop.backend.domain.space.space.dto.ResBodyForSpaceSave; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +import java.nio.file.AccessDeniedException; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/v1/space") +@RequiredArgsConstructor +@Tag(name = "ApiV1SpaceController", description = "스페이스 관련 API") +public class ApiV1SpaceController { + private final SpaceService spaceService; + private final MembershipService membershipService; + + @PostMapping + @Operation(summary = "스페이스 생성") + public RsData createClub( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody ReqBodyForSpaceSave reqBody + ){ + Space newSpace = spaceService.createSpace(reqBody.name()); + + // ADMIN으로 입력 + Member member = userDetails.getMember(); + membershipService.addMemberToSpace(member, newSpace, Authority.ADMIN); + + return new RsData<>( + "201", + String.format("%s - 스페이스가 생성됐습니다.", newSpace.getName()), + new ResBodyForSpaceSave( + newSpace.getId(), + newSpace.getName() + ) + ); + } + + @DeleteMapping("/{spaceId}") + @Operation(summary = "스페이스 삭제") + public RsData deleteSpace( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId + ) throws AccessDeniedException { + // ADMIN 권한 체크 + Member member = userDetails.getMember(); + if(!membershipService.isMemberAdminInSpace(member, spaceService.getSpaceById(spaceId))) + throw new AccessDeniedException("스페이스의 ADMIN 권한이 필요합니다."); + + String deletedSpaceName = spaceService.deleteSpace(spaceId); + + return new RsData<>( + "200", + String.format("%s - 스페이스가 삭제됐습니다.", deletedSpaceName), + null + ); + } + + @PutMapping("/{spaceId}") + @Operation(summary = "스페이스 이름 변경") + public RsData updateSpaceName( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId, + @Valid @RequestBody ReqBodyForSpaceSave reqBody + ) throws AccessDeniedException { + // ADMIN 권한 체크 + Member member = userDetails.getMember(); + if(!membershipService.isMemberAdminInSpace(member, spaceService.getSpaceById(spaceId))) + throw new AccessDeniedException("스페이스의 ADMIN 권한이 필요합니다."); + + Space updatedSpace = spaceService.updateSpaceName(spaceId, reqBody.name()); + + return new RsData<>( + "200", + String.format("%s - 스페이스 이름이 변경됐습니다.", updatedSpace.getName()), + new ResBodyForSpaceSave( + updatedSpace.getId(), + updatedSpace.getName() + ) + ); + } + + @GetMapping + @Operation(summary = "스페이스 목록 조회") + public RsData getAllSpaces( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) JoinState state + ) { + // 현재 로그인한 사용자 정보 가져오기 + Member member = userDetails.getMember(); + + // 멤버가 속한 스페이스 목록 조회 + List memberships; + if (state == null) { + memberships = membershipService.findByMember(member, "ALL"); + } + else { + memberships = membershipService.findByMember(member, state.name()); + } + + // 반환 값 생성 + List spaceInfos = memberships.stream() + .map(membership -> new SpaceMembershipInfo( + membership.getSpace().getId(), + membership.getSpace().getName(), + membership.getAuthority() + )) + .collect(Collectors.toList()); + ResBodyForSpaceList resBody = new ResBodyForSpaceList(spaceInfos); + + return new RsData<>( + "200", + "스페이스 목록이 조회됐습니다.", + resBody + ); + } + + +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ReqBodyForSpaceSave.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ReqBodyForSpaceSave.java new file mode 100644 index 00000000..c91d9f5d --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ReqBodyForSpaceSave.java @@ -0,0 +1,12 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto; + + +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.Length; + +public record ReqBodyForSpaceSave( + @NotBlank + @Length(max = 50) + String name +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceList.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceList.java new file mode 100644 index 00000000..b97585aa --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceList.java @@ -0,0 +1,8 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto; + +import java.util.List; + +public record ResBodyForSpaceList( + List spaces +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceSave.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceSave.java new file mode 100644 index 00000000..1e7f32e5 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceSave.java @@ -0,0 +1,6 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto; + +public record ResBodyForSpaceSave( + Integer id, + String name +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfo.java new file mode 100644 index 00000000..e59273a6 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfo.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto; + +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; + +public record SpaceMembershipInfo( + Integer id, + String name, + Authority authority +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java index 349c0edd..1b03349a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java @@ -5,7 +5,7 @@ import lombok.Getter; import lombok.Setter; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.SharingArchive; -import org.tuna.zoopzoop.backend.domain.space.membership.entity.MemberShip; +import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; import java.util.List; @@ -29,7 +29,7 @@ public class Space extends BaseEntity { //연결된 MemberShip //Space 삭제시 cascade.all @OneToMany(mappedBy = "space", cascade = CascadeType.ALL, orphanRemoval = true) - private List memberShips; + private List memberShips; public Space() { this.sharingArchive = new SharingArchive(this); @@ -38,7 +38,10 @@ public Space() { @Builder public Space(String name, Boolean active) { this.name = name; - this.active = active; + + if (active != null) + this.active = active; + this.sharingArchive = new SharingArchive(this); } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/exception/DuplicateSpaceNameException.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/exception/DuplicateSpaceNameException.java new file mode 100644 index 00000000..bf1af936 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/exception/DuplicateSpaceNameException.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.space.space.exception; + +/** + * 스페이스 이름이 중복될 때 발생하는 예외 + */ +public class DuplicateSpaceNameException extends RuntimeException { + public DuplicateSpaceNameException(String message) { + super(message); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/exception/SpaceExceptionHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/exception/SpaceExceptionHandler.java new file mode 100644 index 00000000..9b00542a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/exception/SpaceExceptionHandler.java @@ -0,0 +1,29 @@ +package org.tuna.zoopzoop.backend.domain.space.space.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.tuna.zoopzoop.backend.global.rsData.RsData; + +@RestControllerAdvice(basePackages = "org.tuna.zoopzoop.backend.domain.space") // 👈 중요! +@Order(0) // 구체적인 핸들러이므로 우선순위를 높게 설정 +@Slf4j +public class SpaceExceptionHandler { + + // 중복된 스페이스 이름 예외 처리 + @ExceptionHandler(DuplicateSpaceNameException.class) + public ResponseEntity> handleDuplicateSpaceName(DuplicateSpaceNameException e) { + return new ResponseEntity<>( + new RsData<>( + "409", + e.getMessage(), + null + ), + HttpStatus.CONFLICT + ); + } + +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/repository/SpaceRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/repository/SpaceRepository.java similarity index 59% rename from src/main/java/org/tuna/zoopzoop/backend/domain/space/repository/SpaceRepository.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/space/space/repository/SpaceRepository.java index d24eca16..50e6267e 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/repository/SpaceRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/repository/SpaceRepository.java @@ -1,7 +1,11 @@ -package org.tuna.zoopzoop.backend.domain.space.repository; +package org.tuna.zoopzoop.backend.domain.space.space.repository; import org.springframework.data.jpa.repository.JpaRepository; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import java.util.Optional; + + public interface SpaceRepository extends JpaRepository { + Optional findByName(String name); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java new file mode 100644 index 00000000..6033f13a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java @@ -0,0 +1,98 @@ +package org.tuna.zoopzoop.backend.domain.space.space.service; + +import jakarta.persistence.NoResultException; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.hibernate.validator.constraints.Length; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.domain.space.space.exception.DuplicateSpaceNameException; +import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository; + +@Service +@RequiredArgsConstructor +public class SpaceService { + private final SpaceRepository spaceRepository; + + /** + * 스페이스 생성 + * @param name 스페이스 이름 + * @return 생성된 스페이스 + */ + public Space createSpace(@NotBlank @Length(max = 50) String name) { + Space newSpace = Space.builder() + .name(name) + .build(); + + try{ + return spaceRepository.save(newSpace); + }catch (DataIntegrityViolationException e) { + throw new DuplicateSpaceNameException("이미 존재하는 스페이스 이름입니다."); + } catch (Exception e) { + throw e; + } + } + + /** + * 스페이스 삭제 (hard delete) + * @param spaceId 스페이스 ID + * @return 삭제된 스페이스 이름 + * @throws IllegalArgumentException 스페이스가 존재하지 않을 경우 + */ + public String deleteSpace(Integer spaceId) { + Space space = spaceRepository.findById(spaceId) + .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); + String spaceName = space.getName(); + + spaceRepository.delete(space); + + return spaceName; + } + + /** + * 스페이스 ID로 스페이스 조회 + * @param spaceId 스페이스 ID + * @return 조회된 스페이스 + * @throws NoResultException 스페이스가 존재하지 않을 경우 + */ + public Space getSpaceById(Integer spaceId) { + return spaceRepository.findById(spaceId) + .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); + } + + /** + * 스페이스 이름으로 스페이스 조회 + * @param name 스페이스 이름 + * @return 조회된 스페이스 + * @throws NoResultException 스페이스가 존재하지 않을 경우 + */ + public Space findByName(String name) { + return spaceRepository.findByName(name) + .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); + } + + /** + * 스페이스 이름 변경 + * @param spaceId 스페이스 ID + * @param name 새로운 스페이스 이름 + * @return 변경된 스페이스 + * @throws IllegalArgumentException 스페이스가 존재하지 않을 경우 + * @throws DuplicateSpaceNameException 새로운 스페이스 이름이 중복될 경우 + */ + public Space updateSpaceName(Integer spaceId, @NotBlank @Length(max = 50) String name) { + Space space = spaceRepository.findById(spaceId) + .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); + + space.setName(name); + + try{ + return spaceRepository.saveAndFlush(space); + }catch (DataIntegrityViolationException e) { + throw new DuplicateSpaceNameException("이미 존재하는 스페이스 이름입니다."); + } catch (Exception e) { + throw e; + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java index cc6342dd..6011efed 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java @@ -91,7 +91,7 @@ public ResponseEntity> handleConstraintViolationException(Constrain return new ResponseEntity<>( new RsData<>( "400", - e.getMessage() + message ), BAD_REQUEST ); diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java index 3d714b6e..13397d21 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java @@ -12,7 +12,7 @@ import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; import org.tuna.zoopzoop.backend.domain.datasource.service.AiService; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; -import org.tuna.zoopzoop.backend.domain.space.repository.SpaceRepository; +import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository; @Configuration @RequiredArgsConstructor diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java index 00de1503..ed4b85a6 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java @@ -1,9 +1,7 @@ package org.tuna.zoopzoop.backend.domain.member.service; import jakarta.persistence.NoResultException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.dao.DataIntegrityViolationException; diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java new file mode 100644 index 00000000..8620a99e --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java @@ -0,0 +1,110 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.service; + +import jakarta.persistence.NoResultException; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; +import org.tuna.zoopzoop.backend.domain.space.membership.repository.MembershipRepository; +import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; + +import static org.junit.jupiter.api.Assertions.*; + +@ActiveProfiles("test") +@SpringBootTest +@Transactional +class MembershipServiceTest { + @Autowired + private SpaceService spaceService; + @Autowired + private MemberService memberService; + @Autowired + private MembershipService membershipService; + @Autowired + private MembershipRepository membershipRepository; + @Autowired + private MemberRepository memberRepository; + + @BeforeEach + void setUp() { + membershipRepository.deleteAll(); + setUpMember(); + setUpSpace(); + + memberRepository.findAll().forEach(member -> System.out.println("Member: " + member.getName())); + System.out.println("----- setUp 완료 -----"); + } + + void setUpSpace() { + spaceService.createSpace("기존 스페이스 1_forMembershipServiceTest"); + spaceService.createSpace("기존 스페이스 2_forMembershipServiceTest"); + } + + + void setUpMember() { + memberService.createMember( + "tester1_forMembershipServiceTest", + "url", + "ms1111", + Provider.KAKAO + ); + memberService.createMember( + "tester2_forMembershipServiceTest", + "url", + "ms2222", + Provider.KAKAO + ); + memberService.createMember( + "tester3_forMembershipServiceTest", + "url", + "ms3333", + Provider.KAKAO + ); + } + + // ============================= ADD MEMBER TO SPACE ============================= // + + @Test + @WithMockUser + @DisplayName("스페이스에 멤버 추가 - 성공") + void addMemberToSpace_Success() { + // Given + var member = memberService.findByKakaoKey("ms2222"); + var space = spaceService.findByName("기존 스페이스 1_forMembershipServiceTest"); + + // When + var membership = membershipService.addMemberToSpace(member, space, Authority.ADMIN); + + // Then + assertNotNull(membership); + assertNotNull(membership.getId()); + assertEquals(member.getId(), membership.getMember().getId()); + assertEquals(space.getId(), membership.getSpace().getId()); + assertEquals(Authority.ADMIN, membership.getAuthority()); + } + + @Test + @WithMockUser + @DisplayName("스페이스에 멤버 추가 - 실패 : 이미 멤버로 존재") + void addMemberToSpace_Fail_AlreadyMember() { + // Given + var member = memberService.findByKakaoKey("ms2222"); + var space = spaceService.findByName("기존 스페이스 1_forMembershipServiceTest"); + membershipService.addMemberToSpace(member, space, Authority.ADMIN); + + // When & Then + assertThrows(DataIntegrityViolationException.class, () -> { + membershipService.addMemberToSpace(member, space, Authority.READ_ONLY); + }); + } + +} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java new file mode 100644 index 00000000..7430ce8e --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java @@ -0,0 +1,503 @@ +package org.tuna.zoopzoop.backend.domain.space.space.controller; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; +import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; +import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; +import org.tuna.zoopzoop.backend.testSupport.ControllerTestSupport; + +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ApiV1SpaceControllerTest extends ControllerTestSupport { + @Autowired + private SpaceService spaceService; + @Autowired + private MemberService memberService; + @Autowired + private MembershipService membershipService; + + @BeforeAll + void setUp() { + setUpMember(); + setUpSpace(); + setUpMembership(); + } + + void setUpSpace() { + spaceService.createSpace("기존 스페이스 1_forSpaceControllerTest"); + spaceService.createSpace("기존 스페이스 2_forSpaceControllerTest"); + + } + + void setUpMember() { + memberService.createMember( + "spaceControllerTester1", + "url", + "sc1111", + Provider.KAKAO + ); + memberService.createMember( + "spaceControllerTester2", + "url", + "sc2222", + Provider.KAKAO + ); + memberService.createMember( + "spaceControllerTester3", + "url", + "sc3333", + Provider.KAKAO + ); + } + + void setUpMembership() { + // test1 -> 스페이스 1 가입 (ADMIN) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("sc1111"), + spaceService.findByName("기존 스페이스 1_forSpaceControllerTest"), + Authority.ADMIN + ); + // test2 -> 스페이스 1 가입 (PENDING) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("sc2222"), + spaceService.findByName("기존 스페이스 1_forSpaceControllerTest"), + Authority.PENDING + ); + // test1 -> 스페이스 2 가입 (PENDING) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("sc1111"), + spaceService.findByName("기존 스페이스 2_forSpaceControllerTest"), + Authority.PENDING + ); + } + + // ============================= CREATE ============================= // + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 생성 - 성공") + void createSpace_Success() throws Exception { + // Given + String url = "/api/v1/space"; + String requestBody = createDefaultSpaceCreateRequestBody(); + + // When + ResultActions resultActions = performPost(url, requestBody); + + // Then + expectCreated( + resultActions, + String.format("%s - 스페이스가 생성됐습니다.", "테스트 스페이스") + ); + resultActions + .andExpect(jsonPath("$.data.name").value("테스트 스페이스")); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 생성 - 실패 : 스페이스명 누락") + void createSpace_Fail_NameMissing() throws Exception { + // Given + String url = "/api/v1/space"; + String requestBody = """ + { + "name": "" + } + """; + + // When + ResultActions resultActions = performPost(url, requestBody); + + // Then + expectBadRequest( + resultActions, + "name-NotBlank-must not be blank" + ); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 생성 - 실패 : 스페이스명 길이 초과") + void createSpace_Fail_NameTooLong() throws Exception { + // Given + String url = "/api/v1/space"; + String requestBody = """ + { + "name": "테스트 스페이스 이름이 너무 길어서 50자를 초과하는 경우입니다. 테스트 스페이스 이름이 너무 길어서 50자를 초과하는 경우입니다." + } + """; + + // When + ResultActions resultActions = performPost(url, requestBody); + + // Then + expectBadRequest( + resultActions, + "name-Length-length must be between 0 and 50" + ); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 생성 - 실패 : 스페이스명 중복") + void createSpace_Fail_NameDuplicate() throws Exception { + // Given + String url = "/api/v1/space"; + String requestBody = createDefaultSpaceCreateRequestBody(); + performPost(url, requestBody); // 최초 생성 + + // When + ResultActions resultActions = performPost(url, requestBody); // 중복 생성 시도 + + // Then + resultActions.andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value("409")) + .andExpect(jsonPath("$.msg").value("이미 존재하는 스페이스 이름입니다.")) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + // ============================= DELETE ============================= // + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 삭제 - 성공") + void deleteSpace_Success() throws Exception { + // Given + Integer spaceId = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest").getId(); + String url = String.format("/api/v1/space/%d", spaceId); + + // When + ResultActions resultActions = performDelete(url); + + // Then + expectOk( + resultActions, + String.format("%s - 스페이스가 삭제됐습니다.", "기존 스페이스 1_forSpaceControllerTest") + ); + resultActions + .andExpect(jsonPath("$.data").value(nullValue())); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 삭제 - 실패 : 존재하지 않는 스페이스") + void deleteSpace_Fail_NotFound() throws Exception { + // Given + Integer spaceId = 9999; // 존재하지 않는 스페이스 ID + String url = String.format("/api/v1/space/%d", spaceId); + + // When + ResultActions resultActions = performDelete(url); + + // Then + resultActions.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value("존재하지 않는 스페이스입니다.")) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + @Test + @WithUserDetails(value = "KAKAO:sc2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 삭제 - 실패 : ADMIN 권한 없는 사용자") + void deleteSpace_Fail_NoAdminAuthority() throws Exception { + // Given + Integer spaceId = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest").getId(); + String url = String.format("/api/v1/space/%d", spaceId); + + // When + ResultActions resultActions = performDelete(url); + + // Then + resultActions.andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value("403")) + .andExpect(jsonPath("$.msg").value("스페이스의 ADMIN 권한이 필요합니다.")) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + + // ======================= Modify ======================== // + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 이름 변경 - 성공") + void modifySpaceName_Success() throws Exception { + // Given + Integer spaceId = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest").getId(); + String url = String.format("/api/v1/space/%d", spaceId); + String requestBody = """ + { + "name": "변경된 스페이스 이름" + } + """; + + // When + ResultActions resultActions = performPut(url, requestBody); + + // Then + expectOk( + resultActions, + String.format("%s - 스페이스 이름이 변경됐습니다.", "변경된 스페이스 이름") + ); + resultActions + .andExpect(jsonPath("$.data.name").value("변경된 스페이스 이름")); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 이름 변경 - 실패 : 존재하지 않는 스페이스") + void modifySpaceName_Fail_NotFound() throws Exception { + // Given + Integer spaceId = 9999; // 존재하지 않는 스페이스 ID + String url = String.format("/api/v1/space/%d", spaceId); + String requestBody = """ + { + "name": "변경된 스페이스 이름" + } + """; + + // When + ResultActions resultActions = performPut(url, requestBody); + + // Then + resultActions.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value("존재하지 않는 스페이스입니다.")) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 이름 변경 - 실패 : 스페이스명 누락") + void modifySpaceName_Fail_NameMissing() throws Exception { + // Given + Integer spaceId = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest").getId(); + String url = String.format("/api/v1/space/%d", spaceId); + String requestBody = """ + { + "name": "" + } + """; + + // When + ResultActions resultActions = performPut(url, requestBody); + + // Then + expectBadRequest( + resultActions, + "name-NotBlank-must not be blank" + ); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 이름 변경 - 실패 : 스페이스명 길이 초과") + void modifySpaceName_Fail_NameTooLong() throws Exception { + // Given + Integer spaceId = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest").getId(); + String url = String.format("/api/v1/space/%d", spaceId); + String requestBody = """ + { + "name": "테스트 스페이스 이름이 너무 길어서 50자를 초과하는 경우입니다. 테스트 스페이스 이름이 너무 길어서 50자를 초과하는 경우입니다." + } + """; + + // When + ResultActions resultActions = performPut(url, requestBody); + + // Then + expectBadRequest( + resultActions, + "name-Length-length must be between 0 and 50" + ); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 이름 변경 - 실패 : 스페이스명 중복") + void modifySpaceName_Fail_NameDuplicate() throws Exception { + // Given + Integer spaceId = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest").getId(); + String url = String.format("/api/v1/space/%d", spaceId); + String requestBody = """ + { + "name": "기존 스페이스 2_forSpaceControllerTest" + } + """; + + // When + ResultActions resultActions = performPut(url, requestBody); + + // Then + resultActions.andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value("409")) + .andExpect(jsonPath("$.msg").value("이미 존재하는 스페이스 이름입니다.")) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + @Test + @WithUserDetails(value = "KAKAO:sc2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 이름 변경 - 실패 : ADMIN 권한 없는 사용자") + void modifySpaceName_Fail_NoAdminAuthority() throws Exception { + // Given + Integer spaceId = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest").getId(); + String url = String.format("/api/v1/space/%d", spaceId); + String requestBody = """ + { + "name": "변경된 스페이스 이름" + } + """; + + // When + ResultActions resultActions = performPut(url, requestBody); + + // Then + resultActions.andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value("403")) + .andExpect(jsonPath("$.msg").value("스페이스의 ADMIN 권한이 필요합니다.")) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + // ======================= Read ======================= // + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("나의 스페이스 전체 조회 - 성공") + void getMySpaces_Success() throws Exception { + // Given + String url = "/api/v1/space"; + + // When + ResultActions resultActions = performGet(url); + + // Then + expectOk( + resultActions, + "스페이스 목록이 조회됐습니다." + ); + resultActions + .andExpect(jsonPath("$.data.spaces").isArray()) + .andExpect(jsonPath("$.data.spaces.length()").value(2)) + .andDo(print()); + + resultActions + .andExpect(jsonPath("$.data.spaces[0].id").isNumber()) + .andExpect(jsonPath("$.data.spaces[0].name").value("기존 스페이스 1_forSpaceControllerTest")) + .andExpect(jsonPath("$.data.spaces[0].authority").value("ADMIN")) + .andExpect(jsonPath("$.data.spaces[1].id").isNumber()) + .andExpect(jsonPath("$.data.spaces[1].name").value("기존 스페이스 2_forSpaceControllerTest")) + .andExpect(jsonPath("$.data.spaces[1].authority").value("PENDING")); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("초대받은 스페이스 전체 조회 - 성공") + void getInvitedSpaces_Success() throws Exception { + // Given + String url = "/api/v1/space?state=PENDING"; + + // When + ResultActions resultActions = performGet(url); + + // Then + expectOk( + resultActions, + "스페이스 목록이 조회됐습니다." + ); + resultActions + .andExpect(jsonPath("$.data.spaces").isArray()) + .andExpect(jsonPath("$.data.spaces.length()").value(1)) + .andDo(print()); + + resultActions + .andExpect(jsonPath("$.data.spaces[0].id").isNumber()) + .andExpect(jsonPath("$.data.spaces[0].name").value("기존 스페이스 2_forSpaceControllerTest")) + .andExpect(jsonPath("$.data.spaces[0].authority").value("PENDING")); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("가입 중인 스페이스 전체 조회 - 성공") + void getJoinedSpaces_Success() throws Exception { + // Given + String url = "/api/v1/space?state=JOINED"; + + // When + ResultActions resultActions = performGet(url); + + // Then + expectOk( + resultActions, + "스페이스 목록이 조회됐습니다." + ); + resultActions + .andExpect(jsonPath("$.data.spaces").isArray()) + .andExpect(jsonPath("$.data.spaces.length()").value(1)) + .andDo(print()); + + resultActions + .andExpect(jsonPath("$.data.spaces[0].id").isNumber()) + .andExpect(jsonPath("$.data.spaces[0].name").value("기존 스페이스 1_forSpaceControllerTest")) + .andExpect(jsonPath("$.data.spaces[0].authority").value("ADMIN")); + } + + // TODO : Spring Security 설정 이후 테스트 코드 활성화 +// @Test +// @DisplayName("나의 스페이스 전체 조회 - 실패 : 인증되지 않은 사용자") +// void getMySpaces_Fail_Unauthorized() throws Exception { +// // Given +// String url = "/api/v1/space"; +// +// // When +// ResultActions resultActions = performGet(url); +// +// // Then +// expectUnauthorized(resultActions); +// } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("나의 스페이스 전체 조회 - 실패 : 잘못된 state 파라미터") + void getMySpaces_Fail_InvalidState() throws Exception { + // Given + String url = "/api/v1/space?state=INVALID"; + + // When + ResultActions resultActions = performGet(url); + + // Then + expectBadRequest( + resultActions, + "파라미터 'state'의 타입이 올바르지 않습니다. 요구되는 타입: JoinState" + ); + } + + // ======================= TEST DATA FACTORIES ======================== // + + private String createDefaultSpaceCreateRequestBody() { + return """ + { + "name": "테스트 스페이스" + } + """; + } + + +} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceServiceTest.java new file mode 100644 index 00000000..d66c1e36 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceServiceTest.java @@ -0,0 +1,141 @@ +package org.tuna.zoopzoop.backend.domain.space.space.service; + +import jakarta.persistence.NoResultException; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.domain.space.space.exception.DuplicateSpaceNameException; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +@ActiveProfiles("test") +@SpringBootTest +@Transactional +class SpaceServiceTest { + @Autowired + private SpaceService spaceService; + + @BeforeEach + void setUp() { + spaceService.createSpace("기존 스페이스 1_forSpaceServiceTest"); + spaceService.createSpace("기존 스페이스 2_forSpaceServiceTest"); + } + + // ============================= CREATE ============================= // + @Test + @DisplayName("스페이스 생성 - 성공") + void createSpace_Success() { + // Given + String spaceName = "테스트 스페이스"; + + // When + var createdSpace = spaceService.createSpace(spaceName); + + // Then + // 생성된 스페이스 검증 + Assertions.assertThat(createdSpace).isNotNull(); + Assertions.assertThat(createdSpace.getId()).isNotNull(); + Assertions.assertThat(createdSpace.getName()).isEqualTo(spaceName); + Assertions.assertThat(createdSpace.isActive()).isTrue(); + + // 연관된 SharingArchive 검증 + Assertions.assertThat(createdSpace.getSharingArchive()).isNotNull(); + Assertions.assertThat(createdSpace.getSharingArchive().getSpace()).isEqualTo(createdSpace); + Assertions.assertThat(createdSpace.getSharingArchive().getArchive()).isNotNull(); + Assertions.assertThat(createdSpace.getSharingArchive().getArchive().getArchiveType()) + .isEqualTo(org.tuna.zoopzoop.backend.domain.archive.archive.enums.ArchiveType.SHARED); + } + + @Test + @DisplayName("스페이스 생성 - 실패 : 중복된 스페이스 이름") + void createSpace_Fail_DuplicateName() { + // Given + String spaceName = "중복 스페이스"; + spaceService.createSpace(spaceName); + + // When & Then + assertThatThrownBy(() -> spaceService.createSpace(spaceName)) + .isInstanceOf(DuplicateSpaceNameException.class); + } + + // ============================= DELETE ============================= // + + @Test + @DisplayName("스페이스 삭제 - 성공") + void deleteSpace_Success() { + // Given + Space space = spaceService.findByName("기존 스페이스 1_forSpaceServiceTest"); + Integer spaceId = space.getId(); + String spaceName = space.getName(); + + // When + String deletedSpaceName = spaceService.deleteSpace(spaceId); + + // Then + Assertions.assertThat(deletedSpaceName).isEqualTo(spaceName); + assertThatThrownBy(() -> spaceService.getSpaceById(spaceId)) + .isInstanceOf(NoResultException.class); + } + + @Test + @DisplayName("스페이스 삭제 - 실패 : 존재하지 않는 스페이스") + void deleteSpace_Fail_NotFound() { + // Given + Integer nonExistentSpaceId = 9999; + + // When & Then + assertThatThrownBy(() -> spaceService.deleteSpace(nonExistentSpaceId)) + .isInstanceOf(NoResultException.class); + } + + // ============================= Modify ============================= // + + @Test + @DisplayName("스페이스 이름 변경 - 성공") + void updateSpaceName_Success() { + // Given + Space space = spaceService.findByName("기존 스페이스 1_forSpaceServiceTest"); + Integer spaceId = space.getId(); + String newName = "변경된 스페이스 이름_forSpaceServiceTest"; + + // When + Space updatedSpace = spaceService.updateSpaceName(spaceId, newName); + + // Then + Assertions.assertThat(updatedSpace).isNotNull(); + Assertions.assertThat(updatedSpace.getId()).isEqualTo(spaceId); + Assertions.assertThat(updatedSpace.getName()).isEqualTo(newName); + } + + @Test + @DisplayName("스페이스 이름 변경 - 실패 : 존재하지 않는 스페이스") + void updateSpaceName_Fail_NotFound() { + // Given + Integer nonExistentSpaceId = 9999; + String newName = "변경된 스페이스 이름"; + + // When & Then + assertThatThrownBy(() -> spaceService.updateSpaceName(nonExistentSpaceId, newName)) + .isInstanceOf(NoResultException.class); + } + + @Test + @DisplayName("스페이스 이름 변경 - 실패 : 중복된 스페이스 이름") + void updateSpaceName_Fail_DuplicateName() { + // Given + Space space = spaceService.findByName("기존 스페이스 1_forSpaceServiceTest"); + Integer spaceId = space.getId(); + String duplicateName = "기존 스페이스 2_forSpaceServiceTest"; + + // When & Then + assertThatThrownBy(() -> spaceService.updateSpaceName(spaceId, duplicateName)) + .isInstanceOf(DuplicateSpaceNameException.class); + } + +} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java b/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java new file mode 100644 index 00000000..a5cda70d --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java @@ -0,0 +1,180 @@ +package org.tuna.zoopzoop.backend.testSupport; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Controller 계층의 테스트를 위한 추상 클래스 + * MockMvc와 ObjectMapper를 자동으로 주입받아 자식 클래스에서 사용하도록 제공한다. + * 또한 공통적인 요청 수행 메서드와 응답 검증 메서드를 제공한다. + */ +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +public abstract class ControllerTestSupport { + @Autowired + protected MockMvc mvc; // 자식 클래스에서 직접 접근할 수도 있도록 protected로 변경 + + @Autowired + protected ObjectMapper objectMapper; + + // ========================== HELPER METHODS (Request) ========================== // + + /** + * GET 요청을 수행하는 헬퍼 메서드 + * @param url - 요청할 URL + * @return ResultActions - MockMvc의 ResultActions 객체 + * @throws Exception - 예외 발생 시 던짐 + */ + protected ResultActions performGet(String url) throws Exception { + return mvc.perform(get(url) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()); + } + + /** + * POST 요청을 수행하는 헬퍼 메서드 + * @param url - 요청할 URL + * @param body - 요청 바디 (객체 형태) + * @return ResultActions - MockMvc의 ResultActions 객체 + * @throws Exception - 예외 발생 시 던짐 + */ + protected ResultActions performPost(String url, String body) throws Exception { + return mvc.perform(post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()); + } + + /** + * PATCH 요청을 수행하는 헬퍼 메서드 + * @param url - 요청할 URL + * @param body - 요청 바디 (객체 형태) + * @return ResultActions - MockMvc의 ResultActions 객체 + * @throws Exception - 예외 발생 시 던짐 + */ + protected ResultActions performPatch(String url, String body) throws Exception { + return mvc.perform(patch(url) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()); + } + + /** + * PUT 요청을 수행하는 헬퍼 메서드 + * @param url - 요청할 URL + * @param body - 요청 바디 (객체 형태) + * @return ResultActions - MockMvc의 ResultActions 객체 + * @throws Exception - 예외 발생 시 던짐 + */ + protected ResultActions performPut(String url, String body) throws Exception { + return mvc.perform(put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()); + } + + /** + * DELETE 요청을 수행하는 헬퍼 메서드 + * @param url - 요청할 URL + * @return ResultActions - MockMvc의 ResultActions 객체 + * @throws Exception - 예외 발생 시 던짐 + */ + protected ResultActions performDelete(String url) throws Exception { + return mvc.perform(delete(url) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()); + } + + // ====================== COMMON ASSERTIONS (Response) ======================= // + + /** + * 200 OK 응답을 기대하는 헬퍼 메서드 + * @param resultActions - MockMvc의 ResultActions 객체 + * @param msg - 기대하는 메시지 + * @throws Exception - 예외 발생 시 던짐 + */ + protected void expectOk(ResultActions resultActions, String msg) throws Exception { + resultActions.andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("200")) + .andExpect(jsonPath("$.msg").value(msg)); + } + + /** + * 201 Created 응답을 기대하는 헬퍼 메서드 + * @param resultActions - MockMvc의 ResultActions 객체 + * @param msg - 기대하는 메시지 + * @throws Exception - 예외 발생 시 던짐 + */ + protected void expectCreated(ResultActions resultActions, String msg) throws Exception { + resultActions.andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("201")) + .andExpect(jsonPath("$.msg").value(msg)); + } + + /** + * 400 Bad Request 응답을 기대하는 헬퍼 메서드 + * @param resultActions - MockMvc의 ResultActions 객체 + * @param msg - 기대하는 메시지 + * @throws Exception - 예외 발생 시 던짐 + */ + protected void expectBadRequest(ResultActions resultActions, String msg) throws Exception { + resultActions.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("400")) + .andExpect(jsonPath("$.msg").value(msg)) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + /** + * 401 Unauthorized 응답을 기대하는 헬퍼 메서드 + * @param resultActions - MockMvc의 ResultActions 객체 + * @throws Exception - 예외 발생 시 던짐 + */ + protected void expectUnauthorized(ResultActions resultActions) throws Exception { + resultActions.andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.status").value("401")) + .andExpect(jsonPath("$.msg").value("액세스가 거부되었습니다.")) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + /** + * 403 Forbidden 응답을 기대하는 헬퍼 메서드 + * @param resultActions - MockMvc의 ResultActions 객체 + * @throws Exception - 예외 발생 시 던짐 + */ + protected void expectForbidden(ResultActions resultActions) throws Exception { + resultActions.andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value("403")) + .andExpect(jsonPath("$.msg").value("권한이 없습니다.")) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + /** + * 403 Forbidden 응답을 기대하는 헬퍼 메서드 (메시지 커스터마이징) + * @param resultActions - MockMvc의 ResultActions 객체 + * @param msg - 기대하는 메시지 + * @throws Exception - 예외 발생 시 던짐 + */ + protected void expectForbidden(ResultActions resultActions, String msg) throws Exception { + resultActions.andExpect(status().isForbidden()) + .andExpect(jsonPath("$.status").value("403")) + .andExpect(jsonPath("$.msg").value(msg)) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + +} From 761c7a96dea80973fd5bced422f6110d8326a200 Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:05:11 +0900 Subject: [PATCH 018/132] =?UTF-8?q?Ops=20272=20be=20feat=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=9D=B4=EB=A6=84=20=EC=A1=B0=ED=9A=8C=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-211 : 폴더 이름 변경 구현 * refactor/OPS-211 : 폴더명 중복 예외처리 추가 * refactor/OPS-211 : 폴더명 중복 예외처리 추가 * feat/OPS-272 : 폴더/파일 조회 구현 * refactor/OPS-272 : tag 생성자 추가 --- .../folder/repository/FolderRepository.java | 3 - .../archive/folder/service/FolderService.java | 40 ++++---- .../domain/datasource/dto/FileSummary.java | 15 ++- .../backend/domain/datasource/entity/Tag.java | 6 +- .../controller/FolderControllerTest.java | 90 ++++++++++++++++++ .../folder/service/FolderServiceTest.java | 91 +++++++++++++++++++ 6 files changed, 219 insertions(+), 26 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java index 562713a3..df36315f 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java @@ -6,7 +6,6 @@ import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import java.util.List; -import java.util.Optional; public interface FolderRepository extends JpaRepository{ /** @@ -25,6 +24,4 @@ public interface FolderRepository extends JpaRepository{ List findNamesForConflictCheck(Integer archiveId, String filename, String filenameEnd); List findByArchive(Archive archive); - - Optional findByIdAndArchive(Integer id, Archive archive); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java index 01aa2e36..95b9ef14 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java @@ -119,6 +119,9 @@ public String deleteFolder(Integer folderId) { Folder folder = folderRepository.findById(folderId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); + if (folder.isDefault()) + throw new IllegalArgumentException("default 폴더는 삭제할 수 없습니다."); + String name = folder.getName(); folderRepository.delete(folder); return name; @@ -148,39 +151,40 @@ public String updateFolderName(Integer folderId, String newName) { return newName; } + /** + * Personal Archive의 폴더명 전부 조회 + * @param memberId Personal Archive 회원 Id + */ @Transactional(readOnly = true) public List getFoldersForPersonal(Integer memberId) { - // 1. 해당 멤버의 personal archive 찾기 PersonalArchive personalArchive = personalArchiveRepository.findByMemberId(memberId) .orElseThrow(() -> new NoResultException("개인 아카이브가 존재하지 않습니다.")); - - // 2. personal archive → archive 꺼내오기 Archive archive = personalArchive.getArchive(); - // 3. archive 안의 폴더 전부 가져오기 return folderRepository.findByArchive(archive).stream() .map(folder -> new FolderResponse(folder.getId(), folder.getName())) .toList(); } + /** + * 폴더 하위 파일(datasource) 조회 + * @param memberId Personal Archive 회원 Id + * @param folderId 조회할 folder Id + */ @Transactional(readOnly = true) public FolderFilesDto getFilesInFolderForPersonal(Integer memberId, Integer folderId) { - // 1) 내 PersonalArchive 찾기 - PersonalArchive pa = personalArchiveRepository.findByMemberId(memberId) - .orElseThrow(() -> new NoResultException("개인 아카이브가 존재하지 않습니다.")); - - Archive myArchive = pa.getArchive(); - - // 2) 해당 폴더가 내 아카이브인지 확인 - Folder folder = folderRepository.findByIdAndArchive(folderId, myArchive) - .orElseThrow(() -> new NoResultException("해당 폴더가 존재하지 않거나 내 아카이브 소속이 아닙니다.")); + Folder folder = folderRepository.findById(folderId) + .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); - // 3) 폴더 안 파일(datasource) 조회 - List files = dataSourceRepository.findAllByFolder(folder).stream() + var files = dataSourceRepository.findAllByFolder(folder).stream() .map(ds -> new FileSummary( - ds.getId(), // 파일 Id - ds.getTitle(), // 파일명 (제목) - ds.getCreateDate() // 생성일 + ds.getId(), + ds.getTitle(), + ds.getCreateDate(), // LocalDateTime + ds.getSummary(), + ds.getSourceUrl(), + ds.getThumbnailUrl(), + ds.getTags() == null ? List.of() : ds.getTags() )) .toList(); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java index 35193c7e..1efc5084 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java @@ -1,9 +1,16 @@ package org.tuna.zoopzoop.backend.domain.datasource.dto; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; + import java.time.LocalDateTime; +import java.util.List; public record FileSummary( - Integer fileId, - String fileName, - LocalDateTime createdAt -) { } + Integer dataSourceId, + String title, + LocalDateTime createdAt, + String summary, + String sourceUrl, + String imageUrl, + List tags +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Tag.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Tag.java index 3a602f0a..98a0ecaf 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Tag.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Tag.java @@ -19,4 +19,8 @@ public class Tag extends BaseEntity { //태그명 @Column(nullable = false) private String tagName; -} + + public Tag(String tagName) { + this.tagName = tagName; + } +} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java index 2f7d05c3..74ec68b7 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; @@ -14,13 +15,19 @@ import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.reqBodyForCreateFolder; import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FileSummary; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.global.exception.GlobalExceptionHandler; +import org.tuna.zoopzoop.backend.global.security.StubAuthUtil; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -142,4 +149,87 @@ void updateFolder_notFound() throws Exception { .andExpect(jsonPath("$.status").value("404")) .andExpect(jsonPath("$.msg").value("존재하지 않는 폴더입니다.")); } + + // ReadFolder + // Read: 내 폴더 목록 + @Test + @DisplayName("개인 아카이브 폴더 목록 조회 - 성공") + void getFolders_success() throws Exception { + List folders = List.of( + new FolderResponse(1, "default"), + new FolderResponse(2, "docs") + ); + + try (MockedStatic mocked = mockStatic(StubAuthUtil.class)) { + mocked.when(StubAuthUtil::currentMemberId).thenReturn(100); + when(folderService.getFoldersForPersonal(100)).thenReturn(folders); + + mockMvc.perform(get("/api/v1/archive/folder") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("개인 아카이브의 폴더 목록을 불러왔습니다.")) + .andExpect(jsonPath("$.data.folders.length()").value(2)) + .andExpect(jsonPath("$.data.folders[0].folderId").value(1)) + .andExpect(jsonPath("$.data.folders[0].folderName").value("default")) + .andExpect(jsonPath("$.data.folders[1].folderName").value("docs")); + } + } + + // Read: 폴더 내 파일 목록 + @Test + @DisplayName("폴더 내 파일 목록 조회 - 성공") + void getFilesInFolder_success() throws Exception { + // given + FolderFilesDto rs = new FolderFilesDto( + 2, "docs", + List.of( + new FileSummary(10, "spec.pdf", null, "요약 A", "http://src/a", "http://img/a", + List.of(new Tag("tag1"), new Tag("tag2"))), + new FileSummary(11, "notes.txt", null, "요약 B", "http://src/b", "http://img/b", + List.of()) + ) + ); + + try (MockedStatic mocked = mockStatic(StubAuthUtil.class)) { + mocked.when(StubAuthUtil::currentMemberId).thenReturn(100); + when(folderService.getFilesInFolderForPersonal(100, 2)).thenReturn(rs); + + // when & then + mockMvc.perform(get("/api/v1/archive/folder/{folderId}/files", 2) + .accept(MediaType.APPLICATION_JSON)) + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("해당 폴더의 파일 목록을 불러왔습니다.")) + .andExpect(jsonPath("$.data.files").isArray()) + .andExpect(jsonPath("$.data.files.length()").value(2)) + .andExpect(jsonPath("$.data.files[0].dataSourceId").value(10)) + .andExpect(jsonPath("$.data.files[0].title").value("spec.pdf")) + .andExpect(jsonPath("$.data.files[0].summary").value("요약 A")) + .andExpect(jsonPath("$.data.files[0].sourceUrl").value("http://src/a")) + .andExpect(jsonPath("$.data.files[0].imageUrl").value("http://img/a")) + .andExpect(jsonPath("$.data.files[0].tags[0].tagName").value("tag1")); + } + } + + @Test + @DisplayName("폴더 내 파일 목록 조회 - 폴더가 없으면 404") + void getFilesInFolder_notFound() throws Exception { + // given + try (MockedStatic mocked = mockStatic(StubAuthUtil.class)) { + mocked.when(StubAuthUtil::currentMemberId).thenReturn(100); + when(folderService.getFilesInFolderForPersonal(100, 999)) + .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); + + // when & then + mockMvc.perform(get("/api/v1/archive/folder/{folderId}/files", 999) + .accept(MediaType.APPLICATION_JSON)) + // then + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value("존재하지 않는 폴더입니다.")); + } + } + } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java index 0f19b4fe..c2c8ed62 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java @@ -15,6 +15,11 @@ import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FileSummary; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; +import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; @@ -32,6 +37,7 @@ class FolderServiceTest { @Mock private MemberRepository memberRepository; @Mock private PersonalArchiveRepository personalArchiveRepository; @Mock private FolderRepository folderRepository; + @Mock private DataSourceRepository dataSourceRepository; @InjectMocks private FolderService folderService; @@ -176,4 +182,89 @@ void updateFolderName_conflict() { verify(folderRepository, never()).save(any(Folder.class)); } + + // Read: Personal Archive 내 폴더 목록 + @Test + @DisplayName("개인 아카이브 폴더 목록 조회 - 성공") + void getFoldersForPersonal_success() { + // given + Folder f1 = new Folder(); f1.setName("default"); f1.setArchive(archive); ReflectionTestUtils.setField(f1, "id", 1); + Folder f2 = new Folder(); f2.setName("docs"); f2.setArchive(archive); ReflectionTestUtils.setField(f2, "id", 2); + + when(personalArchiveRepository.findByMemberId(1)).thenReturn(Optional.of(personalArchive)); + when(folderRepository.findByArchive(archive)).thenReturn(List.of(f1, f2)); + + // when + List rs = folderService.getFoldersForPersonal(1); + + // then + assertThat(rs).hasSize(2); + assertThat(rs.get(0).folderId()).isEqualTo(1); + assertThat(rs.get(0).folderName()).isEqualTo("default"); + assertThat(rs.get(1).folderName()).isEqualTo("docs"); + verify(folderRepository, times(1)).findByArchive(archive); + } + + // Read: 폴더 내 파일 목록 + @Test + @DisplayName("폴더 내 파일 목록 조회") + void getFilesInFolderForPersonal_success() { + // given + Integer folderId = 2; + + Folder folder = new Folder(); + folder.setName("docs"); + folder.setArchive(archive); + ReflectionTestUtils.setField(folder, "id", folderId); + when(folderRepository.findById(folderId)).thenReturn(Optional.of(folder)); + + DataSource d1 = new DataSource(); + ReflectionTestUtils.setField(d1, "id", 10); + d1.setTitle("spec.pdf"); + d1.setFolder(folder); + d1.setSummary("요약 A"); + d1.setSourceUrl("http://src/a"); + d1.setThumbnailUrl("http://img/a"); + d1.setTags(List.of(new Tag("tag1"), new Tag("tag2"))); + + DataSource d2 = new DataSource(); + ReflectionTestUtils.setField(d2, "id", 11); + d2.setTitle("notes.txt"); + d2.setFolder(folder); + d2.setSummary("요약 B"); + d2.setSourceUrl("http://src/b"); + d2.setThumbnailUrl("http://img/b"); + d2.setTags(List.of()); + + when(dataSourceRepository.findAllByFolder(folder)).thenReturn(List.of(d1, d2)); + + // when + FolderFilesDto dto = folderService.getFilesInFolderForPersonal(1, folderId); + + // then + assertThat(dto.files()).hasSize(2); + FileSummary f0 = dto.files().get(0); + assertThat(f0.dataSourceId()).isEqualTo(10); + assertThat(f0.title()).isEqualTo("spec.pdf"); + assertThat(f0.summary()).isEqualTo("요약 A"); + assertThat(f0.sourceUrl()).isEqualTo("http://src/a"); + assertThat(f0.imageUrl()).isEqualTo("http://img/a"); + assertThat(f0.tags()).extracting(Tag::getTagName).containsExactly("tag1", "tag2"); + } + + @Test + @DisplayName("폴더 내 파일 목록 조회 - 폴더가 없으면 예외 발생") + void getFilesInFolderForPersonal_notFound() { + // given + Integer folderId = 999; + when(folderRepository.findById(folderId)).thenReturn(Optional.empty()); + + // when & then + assertThrows(NoResultException.class, + () -> folderService.getFilesInFolderForPersonal(1, folderId)); + + // then(verify) + verify(dataSourceRepository, never()).findAllByFolder(any()); + } + } From e440ab2328d317b0dccbba08ce00ba38597f3353 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:11:06 +0900 Subject: [PATCH 019/132] =?UTF-8?q?feat/OPS-195=20:=20=EB=89=B4=EC=8A=A4?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84(=EC=B5=9C=EC=8B=A0=20=EB=89=B4=EC=8A=A4,?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B8=B0=EB=B0=98).=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80.=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend.iml | 12 ++++ .../auth/controller/ApiV1AuthController.java | 3 + .../home/controller/HomeController.java | 23 ++++++++ .../backend/domain/member/entity/Member.java | 3 - .../news/controller/ApiV1NewsController.java | 56 ++++++++++++++++++ .../news/dto/req/ReqBodyForKeyword.java | 8 +++ .../news/dto/res/ResBodyForNaverNews.java | 30 ++++++++++ .../news/service/NewsSearchService.java | 59 +++++++++++++++++++ .../application-secrets.yml.template | 4 ++ .../domain/news/service/NewsServiceTest.java | 56 ++++++++++++++++++ 10 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 backend.iml create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/req/ReqBodyForKeyword.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/res/ResBodyForNaverNews.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java diff --git a/backend.iml b/backend.iml new file mode 100644 index 00000000..e4706098 --- /dev/null +++ b/backend.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java index 6e378ba3..5a07db9b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java @@ -1,5 +1,6 @@ package org.tuna.zoopzoop.backend.domain.auth.controller; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -24,6 +25,7 @@ public class ApiV1AuthController { private final JwtProperties jwtProperties; @GetMapping("/logout") + @Operation(summary = "사용자 로그아웃") public ResponseEntity> logout(HttpServletResponse response) { ResponseCookie accessCookie = ResponseCookie.from("accessToken", "") .httpOnly(true) @@ -53,6 +55,7 @@ public ResponseEntity> logout(HttpServletResponse response) { } @PostMapping("/refresh") + @Operation(summary = "사용자 액세스 토큰 재발급 (리프레시 토큰이 유효할 경우)") public ResponseEntity> refreshToken(@CookieValue(name = "refreshToken", required = false) String refreshToken, HttpServletResponse response) { diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java index 1256ba56..8ba4e537 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java @@ -2,9 +2,14 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.tuna.zoopzoop.backend.domain.news.service.NewsSearchService; +import reactor.core.publisher.Mono; import java.net.InetAddress; @@ -12,6 +17,7 @@ import static org.springframework.util.MimeTypeUtils.TEXT_HTML_VALUE; @RestController +@RequiredArgsConstructor @Tag(name = "HomeController", description = "홈 컨트롤러") public class HomeController { // @Value("${kakao.client_id}") @@ -19,6 +25,7 @@ public class HomeController { // // @Value("${kakao.redirect_uri}") // private String kakaoRedirectUri; + private final NewsSearchService newsSearchService; @SneakyThrows @GetMapping(produces = TEXT_HTML_VALUE) @@ -46,6 +53,22 @@ public String main() { + +

뉴스 검색 테스트

+
+ + +
""".formatted(localHost.getHostName(), localHost.getHostAddress(), kakaoLoginUrl, googleLoginUrl, logoutUrl); } + + @GetMapping(value = "/search-news", produces = MediaType.TEXT_HTML_VALUE) + public Mono searchNews(@RequestParam String query) { + return newsSearchService.searchNews(query, 5, 1, "sim") + .map(""" +

검색 결과

+
%s
+ 뒤로가기 + """::formatted); + } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java index 596c229b..1aba551b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java @@ -20,9 +20,6 @@ public class Member extends BaseEntity { @Column(unique = true, nullable = false) private String name; -// @Column(unique = true, nullable = false) -// private String email; - @Column(unique = true, nullable = false) private String providerKey; diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java new file mode 100644 index 00000000..4aa23248 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java @@ -0,0 +1,56 @@ +package org.tuna.zoopzoop.backend.domain.news.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.news.dto.req.ReqBodyForKeyword; +import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; +import org.tuna.zoopzoop.backend.domain.news.service.NewsSearchService; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import reactor.core.publisher.Mono; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/news") +@Tag(name = "ApiV1NewsController", description = "뉴스 API 기반 검색 컨트롤러") +public class ApiV1NewsController { + private final NewsSearchService newsSearchService; + + @GetMapping + @Operation(summary = "최신 뉴스 목록 조회") + public Mono>> searchRecentNews( + @RequestParam(defaultValue = "10") int display + ) { + return newsSearchService.searchNews("뉴스", display, 1, "date") + .map(response -> ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "최신 뉴스 목록을 조회했습니다.", + response + ))); + } + + @PostMapping("/keywords") + @Operation(summary = "최신 뉴스 목록 조회") + public Mono>> searchNewsByKeywords( + @RequestParam(defaultValue = "10") int display, + @RequestBody ReqBodyForKeyword dto + ) { + String query = String.join(" ", dto.keywords()); + // AND, OR 연산 쿼리를 지원한다고는 하는데, 정확한지는 모름. + // String query = String.join("+", dto.keywords()); // AND 연산 키워드 검색 + // String query = String.join("|", dto.keywords()); // OR 연산 키워드 검색 + return newsSearchService.searchNews(query, display, 1, "sim") + .map(response -> ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "키워드 기반 뉴스 목록을 조회했습니다.", + response + ))); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/req/ReqBodyForKeyword.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/req/ReqBodyForKeyword.java new file mode 100644 index 00000000..65b4eadc --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/req/ReqBodyForKeyword.java @@ -0,0 +1,8 @@ +package org.tuna.zoopzoop.backend.domain.news.dto.req; + +import java.util.List; + +public record ReqBodyForKeyword ( + List keywords +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/res/ResBodyForNaverNews.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/res/ResBodyForNaverNews.java new file mode 100644 index 00000000..5cd47800 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/res/ResBodyForNaverNews.java @@ -0,0 +1,30 @@ +package org.tuna.zoopzoop.backend.domain.news.dto.res; + +import java.util.List; + +public record ResBodyForNaverNews( + String lastBuildDate, + int total, + int start, + int display, + List items +) { + public record NewsItem( + String title, + String link, + String description, + String pubDate + ) { + public NewsItem(String title, String link, String description, String pubDate) { + this.title = cleanText(title); + this.link = link; + this.description = cleanText(description); + this.pubDate = pubDate; + } + + private static String cleanText(String text) { + if (text == null) return null; + return text.replaceAll("<.*?>", ""); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java new file mode 100644 index 00000000..8d1d1581 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java @@ -0,0 +1,59 @@ +package org.tuna.zoopzoop.backend.domain.news.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; +import reactor.core.publisher.Mono; + +@Service +public class NewsSearchService { + private final WebClient webClient; + + @Value("${naver.client_id}") + private String client_id; + + @Value("${naver.client_secret}") + private String client_secret; + + public NewsSearchService(WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder.baseUrl("https://openapi.naver.com").build(); + } + + /** + * 네이버 뉴스 API + * @param query 검색어 (UTF-8로 인코딩 필수) + * @param display 한 번에 표시할 결과 수 (기본 값 10, 최대 100) + * @param start 검색 시작 위치 (기본값 1, 최대 1000) + * @param sort 정렬 방식 ("sim", "date") sim: 정확도 순, date: 날짜 순, 둘다 내림차 순 정렬. + */ + + /* + Q. 어째서 WebClient를 사용하는가? + A. WebClient -> 비동기/논블로킹 HTTP 클라이언트. + 즉, 현재 우리 시스템처럼 여러명의 사용자가 뉴스 API를 통한 검색을 요청할 경우, + 기존의 Spring MVC, RestTemplate를 사용하면 블로킹된 쓰레드가 바생하여 서버의 리소스를 효율적으로 사용하지 못함. + + 추가로, WebFlux의 Mono/Flux의 경우엔 Backpressure를 지원하므로, 데이터가 너무 많이 들어올 경우 서버가 감당 가능하도록 흐름을 조절. + *Backpressure(백프레셔) : 수신자가 처리할 수 있는 속도로 발신자가 데이터를 보내도록 하는 것. + */ + + public Mono searchNews(String query, Integer display, Integer start, String sort) { + int finalDisplay = (display == null) ? 10 : Math.min(display, 100); + int finalStart = (start == null) ? 1 : Math.min(start, 1000); + String finalSort = (sort == null || (!sort.equals("sim") && !sort.equals("date"))) ? "sim" : sort; + + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path("v1/search/news.json") + .queryParam("query", query) + .queryParam("display", finalDisplay) + .queryParam("start", finalStart) + .queryParam("sort", finalSort) + .build()) + .header("X-Naver-Client-Id", client_id) + .header("X-Naver-Client-Secret", client_secret) + .retrieve() + .bodyToMono(ResBodyForNaverNews.class); + } +} diff --git a/src/main/resources/application-secrets.yml.template b/src/main/resources/application-secrets.yml.template index c92a787b..040cd1e8 100644 --- a/src/main/resources/application-secrets.yml.template +++ b/src/main/resources/application-secrets.yml.template @@ -29,6 +29,10 @@ spring: user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo user-name-attribute: sub +naver: + client_id: {NAVER_CLIENT_ID} + client_secret: {NAVER_CLIENT_SECRET} + jwt: secret-key: {JWT_SECRET_KEY} access-token-validity: {ACCESSTOKEN_VALIDITY} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java new file mode 100644 index 00000000..c91b27fa --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java @@ -0,0 +1,56 @@ +package org.tuna.zoopzoop.backend.domain.news.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + + +@SpringBootTest +@ActiveProfiles("test") +class NewsServiceTest { + + @Test + @DisplayName("뉴스 서비스 테스트 - 정상적인 JSON 구조 반환 여부 확인") + void newsJsonStructureTest() { + // JSON 구조용 더미 데이터 + ResBodyForNaverNews dummyResponse = new ResBodyForNaverNews( + "Mon, 22 Sep 2025 17:35:10 +0900", // lastBuildDate + 505376, // total + 1, // start + 5, // display + List.of( + new ResBodyForNaverNews.NewsItem( // items + "뉴스 제목", // title + "링크", // link + "설명", // description + "발행일" // pubDate + ) + ) + ); + + Mono result = Mono.just(dummyResponse); + + // JSON 구조 확인 + result.doOnNext(res -> { + assertNotNull(res.lastBuildDate()); + assertNotNull(res.total()); + assertNotNull(res.start()); + assertNotNull(res.display()); + assertNotNull(res.items()); + + res.items().forEach(item -> { + assertNotNull(item.title()); + assertNotNull(item.link()); + assertNotNull(item.description()); + assertNotNull(item.pubDate()); + }); + }).block(); // Mono 블로킹. + } +} \ No newline at end of file From b169fe8a77b045c2d3de1946638e445d31df9d80 Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:16:04 +0900 Subject: [PATCH 020/132] =?UTF-8?q?refactor/OPS-277=20:=20default=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../archive/archive/entity/Archive.java | 16 ++++++ .../archive/entity/PersonalArchive.java | 5 ++ .../domain/archive/folder/entity/Folder.java | 6 +++ .../folder/service/FolderServiceTest.java | 12 +++++ .../repository/MemberRepositoryTest.java | 53 +++++++++++++++++++ 5 files changed, 92 insertions(+) create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java index 617a1fe6..32e01fe0 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/Archive.java @@ -28,4 +28,20 @@ public class Archive extends BaseEntity { public Archive(ArchiveType archiveType) { this.archiveType = archiveType; } + + public void addFolder(Folder folder) { + if (!this.folders.contains(folder)) { + this.folders.add(folder); + } + if (folder.getArchive() != this) { + folder.setArchive(this); + } + } + + public void removeFolder(Folder folder) { + this.folders.remove(folder); + if (folder.getArchive() == this) { + folder.setArchive(null); + } + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java index dcf28b10..4cd90b9c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/archive/entity/PersonalArchive.java @@ -5,6 +5,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.tuna.zoopzoop.backend.domain.archive.archive.enums.ArchiveType; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; @@ -38,5 +39,9 @@ public class PersonalArchive extends BaseEntity { public PersonalArchive(Member member) { this.member = member; this.archive = new Archive(ArchiveType.PERSONAL); + + // default 폴더 자동 생성 및 연결 + Folder defaultFolder = new Folder("default"); + archive.addFolder(defaultFolder); } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java index 5cb30dec..da984bd9 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java @@ -45,4 +45,10 @@ public class Folder extends BaseEntity { // 폴더 삭제 시 데이터 일괄 삭제 @OneToMany(mappedBy = "folder", cascade = CascadeType.REMOVE, orphanRemoval = true) private List dataSources = new ArrayList<>(); + + + public Folder(String name) { + this.name = name; + this.isDefault = true; + } } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java index c2c8ed62..2cf6c984 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java @@ -137,6 +137,18 @@ void deleteFolder_notFound() { verify(folderRepository, never()).delete(any(Folder.class)); } + @Test + @DisplayName("default 폴더는 삭제할 수 없다") + void deleteFolder_default_forbidden() { + Folder defaultFolder = new Folder("default"); // isDefault=true + ReflectionTestUtils.setField(defaultFolder, "id", 42); + + when(folderRepository.findById(42)).thenReturn(Optional.of(defaultFolder)); + + assertThrows(IllegalArgumentException.class, () -> folderService.deleteFolder(42)); + verify(folderRepository, never()).delete(any()); + } + // ---------- Update ---------- @Test @DisplayName("폴더 이름 변경 성공") diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java new file mode 100644 index 00000000..27eaf7c8 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java @@ -0,0 +1,53 @@ +package org.tuna.zoopzoop.backend.domain.member.repository; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +public class MemberRepositoryTest { + @Autowired + MemberRepository memberRepository; + + + @Test + @DisplayName("Member 저장 시 PersonalArchive + Archive + default 폴더가 자동 생성된다") + void memberPersistsWithDefaultFolder() { + // given: Personal Archive 생성 + Personal Archive 생성자가 Archive와 Default 폴더 생성 + Member m = Member.builder() + .name("alice") + .providerKey("kakao-123") + .provider(Provider.KAKAO) + .profileImageUrl(null) + .build(); + + // when + Member saved = memberRepository.save(m); + + // then + var pa = saved.getPersonalArchive(); + assertThat(pa).isNotNull(); + + Archive archive = pa.getArchive(); + assertThat(archive).isNotNull(); + + List folders = archive.getFolders(); + assertThat(folders).isNotEmpty(); + Folder defaultFolder = folders.stream().filter(Folder::isDefault).findFirst().orElse(null); + + assertThat(defaultFolder).isNotNull(); + assertThat(defaultFolder.getName()).isEqualTo("default"); + assertThat(defaultFolder.getArchive()).isSameAs(archive); // 양방향 일관성 + } +} From 1821d0a4d172d7f66762a1f192600b99f853cf1d Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Tue, 23 Sep 2025 14:47:42 +0900 Subject: [PATCH 021/132] =?UTF-8?q?[chore/OPS-278]=20Auth,=20Member,=20New?= =?UTF-8?q?s=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AC=B8=EC=84=9C=ED=99=94?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B3=A0=EB=8F=84=ED=99=94.=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore/OPS-278 : Auth 도메인 문서화 완료 & ApiV1MemberController 추가 기능 구현. * refactor/OPS-278 : ApiV1MemberController 추가 사항에 맞게 테스트 추가 구현. * refactor/OPS-278 : Member 생성 시, 랜덤한 UUID의 앞 5글자를 태그로 사용하도록 변경. --- .gitignore | 4 ++ .../auth/handler/OAuth2SuccessHandler.java | 19 ++++- .../auth/service/CustomOAuth2UserService.java | 13 +++- .../auth/service/GoogleUserInfoService.java | 7 +- .../auth/service/KakaoUserInfoService.java | 10 +++ .../auth/service/OAuth2UserInfoService.java | 4 +- .../controller/ApiV1MemberController.java | 60 ++++++++++++++++ .../member/repository/MemberRepository.java | 2 +- .../domain/member/service/MemberService.java | 21 ++++-- .../news/controller/ApiV1NewsController.java | 2 + .../controller/MemberControllerTest.java | 72 ++++++++++++++++++- .../member/service/MemberServiceTest.java | 34 +++++---- 12 files changed, 217 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 6499f8bc..7207562e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Created by https://www.toptal.com/developers/gitignore/api/java,gradle,intellij,visualstudiocode,kotlin,maven,git,windows,macos,netbeans,eclipse # Edit at https://www.toptal.com/developers/gitignore?templates=java,gradle,intellij,visualstudiocode,kotlin,maven,git,windows,macos,netbeans,eclipse +### .iml ### +main.iml +test.iml + ### secret ### application-secrets.yml diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java index 41764e3f..1a6e4e7a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java @@ -31,11 +31,14 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + // OAuth2 로그인 사용자의 속성 OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + + // 소셜 로그인 공급자(Google, Kakao) String registrationId = ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId(); + // 공급자 별로 DB 에서 회원 조회 Member member; - if ("kakao".equals(registrationId)) { String kakaoId = oAuth2User.getAttributes().get("id").toString(); member = memberService.findByKakaoKey(kakaoId); @@ -43,9 +46,10 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo String googleId = (String) oAuth2User.getAttributes().get("sub"); member = memberService.findByGoogleKey(googleId); } else { - throw new IllegalArgumentException("Unsupported provider: " + registrationId); + throw new IllegalArgumentException(registrationId + "는 지원하지 않는 소셜 로그인입니다."); } + // 조회된 회원 정보를 기반으로 AccessToken 및 RefreshToken 생성 String accessToken = jwtUtil.generateToken(member); String refreshToken = jwtUtil.generateRefreshToken(member); @@ -53,6 +57,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo .httpOnly(true) .path("/") .maxAge(jwtProperties.getAccessTokenValidity() / 1000) + // .domain() // 프론트엔드 & 백엔드 상위 도메인 + // .secure(true) // https 필수 설정. .sameSite("Lax") .build(); @@ -60,11 +66,20 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo .httpOnly(true) .path("/") .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) + // .domain() // 프론트엔드 & 백엔드 상위 도메인 + // .secure(true) // https 필수 설정. .sameSite("Lax") .build(); + // HTTP 응답에서 쿠키 값 추가. response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + + // 로그인 성공 후 리다이렉트. + // 배포 시에 프론트엔드와 조율이 필요한 부분일 듯 함. response.sendRedirect("/login-success"); + + // 보안을 좀 더 강화하고자 한다면 CSRF 토큰 같은 걸 생각해볼 수 있겠으나, + // 일단은 구현하지 않음.(개발 과정 중에 번거로워질 수 있을 듯 함.) } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/CustomOAuth2UserService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/CustomOAuth2UserService.java index 174d6590..56573948 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/CustomOAuth2UserService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/CustomOAuth2UserService.java @@ -14,22 +14,29 @@ @Service @RequiredArgsConstructor public class CustomOAuth2UserService implements OAuth2UserService { - + // @RequiredArgsConstructor 어노테이션을 통해, OAuth2UserInfoService를 인터페이스로 사용하는 + // GoogleUserInfoService, KakaoUserInfoService를 한번에 주입. private final List oauth2UserInfoServices; @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + // SpringBoot OAuth2 공급자에서 사용자 정보 받아오기. OAuth2User oAuth2User = new DefaultOAuth2UserService().loadUser(userRequest); + // 공급자(Google, Kakao) 받아오기. String registrationId = userRequest.getClientRegistration().getRegistrationId(); + // oauth2UserInfoService 리스트를 순회하며 공급자를 지원하는 서비스를 찾음. OAuth2UserInfoService userInfoService = oauth2UserInfoServices.stream() .filter(service -> service.supports(registrationId)) .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unsupported provider: " + registrationId)); + .orElseThrow(() -> new IllegalArgumentException(registrationId + "는 지원하지 않는 소셜 로그인입니다.")); + // 지원하지 않는 공급자의 경우 예외 발생. + // 하지만 발생할 일 없는 예외. + // 선택된 서비스에서 사용자 정보 처리. Member member = userInfoService.processUser(oAuth2User.getAttributes()); - return oAuth2User; // 필요 시 커스텀 OAuth2User로 변환 가능 + return oAuth2User; } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/GoogleUserInfoService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/GoogleUserInfoService.java index 7513611c..286992d5 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/GoogleUserInfoService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/GoogleUserInfoService.java @@ -12,15 +12,20 @@ @Service @RequiredArgsConstructor public class GoogleUserInfoService implements OAuth2UserInfoService { - + // Google 소셜 로그인의 경우 private final MemberRepository memberRepository; private final MemberService memberService; + // 이 서비스(=GoogleUserInfoService)가, 해당 공급자를 지원하는 지에 대한 여부 확인. + // Google 공급자만 지원. @Override public boolean supports(String registrationId) { return "google".equalsIgnoreCase(registrationId); } + + // Google 에서 받은 사용자 정보 Map(=attributes)에서 필요한 값 추출. + // 이후 추출한 값을 통해 Member 엔티티 생성. @Override public Member processUser(Map attributes) { String googleId = (String) attributes.get("sub"); // 구글 user-id diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoUserInfoService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoUserInfoService.java index 227e6894..682dbb17 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoUserInfoService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/KakaoUserInfoService.java @@ -12,20 +12,30 @@ @Service @RequiredArgsConstructor public class KakaoUserInfoService implements OAuth2UserInfoService { + // Kakao 소셜 로그인의 경우 private final MemberRepository memberRepository; private final MemberService memberService; + // 이 서비스(=KakaoUserInfoService)가, 해당 공급자를 지원하는 지에 대한 여부 확인. + // Kakao 공급자만 지원. @Override public boolean supports(String registrationId) { return "kakao".equalsIgnoreCase(registrationId); } + // Kakao 에서 받은 사용자 정보 Map(=attributes)에서 필요한 값 추출. + // 이후 추출한 값을 통해 Member 엔티티 생성. @Override public Member processUser(Map attributes) { String kakaoId = attributes.get("id").toString(); Map kakaoAccount = (Map) attributes.get("kakao_account"); Map profile = (Map) kakaoAccount.get("profile"); + /* + Kakao API의 경우, 허용한 사용자 정보(nickname, profile_image, email 등)를 profile Map 으로 묶어서 전달. + 즉, 필요한 값을 추출하기 위해선 attributes 에서 profile을 가져오고, profile 에서 필요한 값을 추출해야 함. + */ + String name = (String) profile.get("nickname"); String profileImage = (String) profile.get("profile_image_url"); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/OAuth2UserInfoService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/OAuth2UserInfoService.java index 7a9f713a..3ad044d5 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/OAuth2UserInfoService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/OAuth2UserInfoService.java @@ -5,6 +5,6 @@ import java.util.Map; public interface OAuth2UserInfoService { - boolean supports(String registrationId); // 이 서비스가 해당 provider를 처리하는지 - Member processUser(Map attributes); + boolean supports(String registrationId); // 이 서비스가 해당 provider(Google, Kakao)를 처리하는지 + Member processUser(Map attributes); // 받아온 정보를 바탕으로 Member 엔티티 생성 or 가져오기 } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java index 6b731da7..ac9f14df 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java @@ -16,6 +16,8 @@ import org.tuna.zoopzoop.backend.global.rsData.RsData; import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; +import java.util.List; + @RestController @RequiredArgsConstructor @RequestMapping("api/v1/member") @@ -25,6 +27,11 @@ public class ApiV1MemberController { /// api/v1/member/me : 사용자 정보 조회 (GET) /// api/v1/member/edit : 사용자 닉네임 수정 (PUT) /// api/v1/member : 사용자 탈퇴 (DELETE) + /// + /// 아래 기능은 혹시 몰라 추가적으로 구현한 조회 기능입니다. + /// api/v1/member/all : 모든 사용자 목록 조회 (GET) + /// api/v1/member/{id} : id 기반 사용자 조회 (GET) + /// api/v1/member?name={name} : 이름 기반 사용자 조회 (GET) @GetMapping("/me") @Operation(summary = "사용자 정보 조회") public ResponseEntity> getMemberInfo( @@ -78,4 +85,57 @@ public ResponseEntity> deleteMember( ) ); } + + @GetMapping("/all") + @Operation(summary = "모든 사용자 정보 조회") + public ResponseEntity>> getMemberInfoAll( + ) { + List members = memberService.findAll(); + List memberDtos = members.stream() + .map(ResBodyForGetMemberInfo::new) + .toList(); + return ResponseEntity + .status(HttpStatus.OK) + .body( + new RsData<>( + "200", + "모든 사용자 정보를 조회했습니다.", + memberDtos + ) + ); + } + + @GetMapping("/{id}") + @Operation(summary = "id 기반 사용자 정보 조회") + public ResponseEntity> getMemberInfoById( + @PathVariable Integer id + ) { + Member member = memberService.findById(id); + return ResponseEntity + .status(HttpStatus.OK) + .body( + new RsData<>( + "200", + id + " id를 가진 사용자 정보를 조회했습니다.", + new ResBodyForGetMemberInfo(member) + ) + ); + } + + @GetMapping + @Operation(summary = "이름 기반 사용자 정보 조회") + public ResponseEntity> getMemberInfoByName( + @RequestParam String name + ) { + Member member = memberService.findByName(name); + return ResponseEntity + .status(HttpStatus.OK) + .body( + new RsData<>( + "200", + name + " 이름을 가진 사용자 정보를 조회했습니다.", + new ResBodyForGetMemberInfo(member) + ) + ); + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java index 0ef54156..feae63be 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepository.java @@ -10,10 +10,10 @@ @Repository public interface MemberRepository extends JpaRepository { -// Optional findByEmail(String email); Optional findByName(String name); Optional findByProviderAndProviderKey(Provider provider, String providerKey); Optional findByProviderKey(String providerKey); + boolean existsByName(String name); List findByActiveTrue(); // 활성 사용자 조회 List findByActiveFalse(); // 비활성 사용자 조회 } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java index ec99117f..51b070ef 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java @@ -10,6 +10,7 @@ import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import java.util.List; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -58,15 +59,12 @@ public Member findByProviderKey(String providerKey) { //회원 생성/정보 수정 관련 @Transactional public Member createMember(String name, String profileUrl, String key, Provider provider){ -// if(memberRepository.findByEmail(email).isPresent()){ -// throw new DataIntegrityViolationException("이미 사용중인 이메일입니다."); -// } if(memberRepository.findByName(name).isPresent()) { throw new DataIntegrityViolationException("이미 사용중인 이름입니다."); } Member member = Member.builder() - .name(name) + .name(generateUniqueUserNameTag(name)) .profileImageUrl(profileUrl) .providerKey(key) .provider(provider) @@ -74,16 +72,29 @@ public Member createMember(String name, String profileUrl, String key, Provider return memberRepository.save(member); } + //사용자 이름 수정 @Transactional public void updateMemberName(Member member, String newName){ if(memberRepository.findByName(newName).isPresent()) { throw new DataIntegrityViolationException("이미 사용중인 이름입니다."); } - member.updateName(newName); + member.updateName(generateUniqueUserNameTag(newName)); } //회원 삭제/복구 관련 public void softDeleteMember(Member member){ member.deactivate(); } public void hardDeleteMember(Member member){ memberRepository.delete(member); } + + //soft-delete한 회원 복구 public void restoreMember(Member member){ member.activate(); } + + //사용자 이름에 UUID 난수를 맨 앞 5개만 뗴서 붙임. + private String generateUniqueUserNameTag(String baseName) { + String candidate; + do { + String tag = UUID.randomUUID().toString().substring(0, 5); + candidate = baseName + "#" + tag; + } while(memberRepository.existsByName(candidate)); + return candidate; + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java index 4aa23248..918e4d47 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java @@ -24,6 +24,8 @@ public class ApiV1NewsController { public Mono>> searchRecentNews( @RequestParam(defaultValue = "10") int display ) { + // Naver 뉴스 API에선 Non-keyword 방식의 검색을 지원하지 않음. + // 그래서 일단 그냥 검색 쿼리를 '뉴스'라고 지정하고 해 보았는데, 꽤나 좋은 결과를 받아옴. (목표하던 기능과 비슷함.) return newsSearchService.searchNews("뉴스", display, 1, "date") .map(response -> ResponseEntity .status(HttpStatus.OK) diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java index 7cc3a3e1..f98f24bd 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java @@ -66,11 +66,12 @@ void setUp() { @WithUserDetails(value = "KAKAO:1111", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("사용자 정보 조회 - 성공(200)") void getMemberInfoSuccess() throws Exception { + Member member = memberService.findByProviderKey("1111"); mockMvc.perform(get("/api/v1/member/me")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.data.name").value("test1")) - .andExpect(jsonPath("$.data.profileUrl").value("url")); + .andExpect(jsonPath("$.data.name").value(member.getName())) + .andExpect(jsonPath("$.data.profileUrl").value(member.getProfileImageUrl())); } @Test @@ -82,6 +83,73 @@ void getMemberInfoFailed() throws Exception { .andExpect(jsonPath("$.msg").value("액세스가 거부되었습니다.")); } + @Test + @WithUserDetails(value = "KAKAO:1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("모든 사용자 정보 조회 - 성공(200)") + void getMemberInfoAllSuccess() throws Exception { + mockMvc.perform(get("/api/v1/member/all")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("200")) // status는 문자열 + .andExpect(jsonPath("$.msg").value("모든 사용자 정보를 조회했습니다.")) + .andExpect(jsonPath("$.data[0].profileUrl").value("url")); + } + + @Test + @WithUserDetails(value = "KAKAO:1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("id 기반 사용자 정보 조회 - 성공(200)") + void getMemberInfoByIdSuccess() throws Exception { + Member member = memberService.findByProviderKey("1111"); + int testId = member.getId(); + mockMvc.perform(get("/api/v1/member/{id}", testId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.name").value(member.getName())) + .andExpect(jsonPath("$.data.profileUrl").value(member.getProfileImageUrl())); + } + + @Test + @WithUserDetails(value = "KAKAO:1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("id 기반 사용자 정보 조회 - 실패(404, Not_Found)") + void getMemberInfoByIdNotFound() throws Exception { + mockMvc.perform(get("/api/v1/member/{id}", 10001)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("id 기반 사용자 정보 조회 - 실패(401, Unauthorized)") + void getMemberInfoByIdUnauthorized() throws Exception { + mockMvc.perform(get("/api/v1/member/{id}", 10001)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = "KAKAO:1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("이름 기반 사용자 정보 조회 - 성공(200)") + void getMemberInfoByNameSuccess() throws Exception { + Member memberByKey = memberService.findByProviderKey("1111"); + Member memberByName = memberService.findByName(memberByKey.getName()); + mockMvc.perform(get("/api/v1/member?name={name}", memberByName.getName())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.name").value(memberByName.getName())) + .andExpect(jsonPath("$.data.profileUrl").value(memberByName.getProfileImageUrl())); + } + + @Test + @WithUserDetails(value = "KAKAO:1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("이름 기반 사용자 정보 조회 - 실패(404, Not_Found)") + void getMemberInfoByNameNotFound() throws Exception { + mockMvc.perform(get("/api/v1/member?name={name}", "failedName")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("이름 기반 사용자 정보 조회 - 실패(401, Unauthorized)") + void getMemberInfoByNameUnauthorized() throws Exception { + mockMvc.perform(get("/api/v1/member?name={name}", "failedName")) + .andExpect(status().isUnauthorized()); + } + @Test @WithUserDetails(value = "GOOGLE:2222", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("사용자 이름 수정 - 성공(200)") diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java index ed4b85a6..3940bed4 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java @@ -1,10 +1,11 @@ package org.tuna.zoopzoop.backend.domain.member.service; import jakarta.persistence.NoResultException; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.member.entity.Member; @@ -54,7 +55,7 @@ private Member createTestMember() { void createMemberSuccess() { Member member = createTestMember(); assertNotNull(member.getId()); - assertEquals("test3", member.getName()); + assertEquals("url", member.getProfileImageUrl()); } // @Test @@ -70,11 +71,12 @@ void createMemberSuccess() { @Test @DisplayName("사용자 생성 - 이름 중복으로 인한 실패") void createMemberFailedByName() { - memberService.createMember("dupName", "url", "4001", Provider.KAKAO); - Exception ex = assertThrows(DataIntegrityViolationException.class, () -> { - memberService.createMember("dupName", "url", "4002", Provider.KAKAO); - }); - assertTrue(ex.getMessage().contains("이미 사용중인 이름입니다.")); +// memberService.createMember("dupName", "url", "4001", Provider.KAKAO); +// Exception ex = assertThrows(DataIntegrityViolationException.class, () -> { +// memberService.createMember("dupName", "url", "4002", Provider.KAKAO); +// }); +// assertTrue(ex.getMessage().contains("이미 사용중인 이름입니다.")); + // 유저 이름에 난수 태그를 붙이는 것으로 인해, 거의 테스트가 불가능해짐. } // @Test @@ -99,7 +101,7 @@ void createMemberFailedByName() { @DisplayName("사용자 이름 기반 조회 - 성공") void findByNameSuccess() { Member saved = createTestMember(); - Member found = memberService.findByName("test3"); + Member found = memberService.findByName(saved.getName()); assertEquals(saved.getId(), found.getId()); assertEquals(saved.getName(), found.getName()); } @@ -137,17 +139,19 @@ void updateMemberNameSuccess() { Member member = createTestMember(); memberService.updateMemberName(member, "새이름"); Member updated = memberService.findById(member.getId()); - assertEquals("새이름", updated.getName()); // JUnit 기본 검증 + assertEquals("새이름", updated.getName().substring(0, 3)); } @Test @DisplayName("사용자 이름 변경 - 이름 중복으로 인한 실패") void updateMemberNameFailed() { - Member member = createTestMember(); - Exception ex = assertThrows(DataIntegrityViolationException.class, () -> { - memberService.updateMemberName(member, "test1"); - }); - assertTrue(ex.getMessage().contains("이미 사용중인 이름입니다.")); +// Member curMember = memberService.findById(1); +// Member member = createTestMember(); +// Exception ex = assertThrows(DataIntegrityViolationException.class, () -> { +// memberService.updateMemberName(member, curMember.getName()); +// }); +// assertTrue(ex.getMessage().contains("이미 사용중인 이름입니다.")); + // 유저 이름에 난수 태그를 붙이는 것으로 인해, 거의 테스트가 불가능해짐. } @Test From e7829c1ad7ac19f32f3b432cfc323483576bab33 Mon Sep 17 00:00:00 2001 From: Hyeok Jin Kim <71928299+EpicFn@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:17:48 +0900 Subject: [PATCH 022/132] =?UTF-8?q?[Feat/OPS-186]=20=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B4=88=EB=8C=80=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * new/OPS-189 : 초대 관리 Controller 및 테스트 클래스 생성 * feat/OPS-189 : 초대 수락 관련 테스트 코드 작성 * feat : 멤버 권한 변경 함수 생성 * feat/OPS-189 : 초대 수락 구현 * feat/OPS-190 : 초대 거절 테스트 케이스 작성 * feat/OPS-190 : 초대 거절 엔드포인트 생성 * feat/OPS-190 : membershipService 관련 단위 테스트 추가 * refactor : 코드 간단한 위치 조정 * feat/OPS-188 : 스페이스 초대 목록 조회 완료 * fix/OPS-188 : dto 네이밍 수정 * refactor/OPS-188 : 스페이스에 초대된 유저 목록 조회의 controller 위치 변경 * feat : 사용자에게 온 스페이스 초대 목록 조회 엔드포인트 생성 * feat : 사용자에게 온 스페이스 초대 목록 조회 테스트 케이스 작성 --------- Co-authored-by: EpicFn --- .../controller/ApiV1InviteController.java | 104 +++++++ .../controller/ApiV1MembershipController.java | 65 ++++ .../dto/ResBodyForSpaceInvitationList.java | 11 + .../repository/MembershipRepository.java | 5 + .../membership/service/MembershipService.java | 113 ++++++- .../controller/ApiV1SpaceController.java | 7 +- .../space/dto/ResBodyForSpaceInviteList.java | 8 + .../SpaceMembershipInfoWithoutAuthority.java | 7 + .../space/space/service/SpaceService.java | 49 +-- .../controller/ApiV1InviteControllerTest.java | 289 ++++++++++++++++++ .../ApiV1MembershipControllerTest.java | 168 ++++++++++ .../service/MembershipServiceTest.java | 136 ++++++++- .../space/space/service/SpaceServiceTest.java | 2 +- .../testSupport/ControllerTestSupport.java | 25 ++ 14 files changed, 942 insertions(+), 47 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/ResBodyForSpaceInvitationList.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceInviteList.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfoWithoutAuthority.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipControllerTest.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java new file mode 100644 index 00000000..e5478d3d --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java @@ -0,0 +1,104 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfo; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.space.membership.dto.ResBodyForSpaceInvitationList; +import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; +import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; +import org.tuna.zoopzoop.backend.domain.space.space.dto.ResBodyForSpaceInviteList; +import org.tuna.zoopzoop.backend.domain.space.space.dto.ResBodyForSpaceSave; +import org.tuna.zoopzoop.backend.domain.space.space.dto.SpaceMembershipInfoWithoutAuthority; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +import java.nio.file.AccessDeniedException; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/invite") +@RequiredArgsConstructor +@Tag(name = "ApiV1MembershipController", description = "사용자에게 온 스페이스 초대 관리 API") +public class ApiV1InviteController { + private final MembershipService membershipService; + private final SpaceService spaceService; + + @PostMapping("/{inviteId}/accept") + @Operation(summary = "스페이스 초대 수락") + public RsData acceptInvite( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer inviteId + ) throws AccessDeniedException { + Member member = userDetails.getMember(); + + // membership 가져오기 + Membership membership = membershipService.findById(inviteId); + membershipService.validateMembershipInvitation(membership, member); // 초대 수락 가능 여부 검증 + + membershipService.acceptInvitation(membership); // 초대 수락 처리 + + return new RsData<>( + "200", + "스페이스 초대가 수락됐습니다.", + new ResBodyForSpaceSave( + membership.getSpace().getId(), + membership.getSpace().getName() + ) + ); + } + + @PostMapping("/{inviteId}/reject") + @Operation(summary = "스페이스 초대 거절") + public RsData rejectInvite( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer inviteId + ) throws AccessDeniedException { + Member member = userDetails.getMember(); + + // membership 가져오기 + Membership membership = membershipService.findById(inviteId); + membershipService.validateMembershipInvitation(membership, member); // 초대 거절 가능 여부 검증 + membershipService.rejectInvitation(membership); // 초대 거절 처리 + return new RsData<>( + "200", + "스페이스 초대가 거절됐습니다.", + new ResBodyForSpaceSave( + membership.getSpace().getId(), + membership.getSpace().getName() + ) + ); + } + + @GetMapping + @Operation(summary = "사용자에게 온 스페이스 초대 목록 조회") + public RsData getMyInvites( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + + // 멤버십(초대) 목록 조회 + List invitations = membershipService.findByMember(member, "PENDING"); + List invitationInfos = invitations.stream() + .map(membership -> new SpaceMembershipInfoWithoutAuthority( + membership.getSpace().getId(), + membership.getSpace().getName() + )) + .toList(); + + return new RsData<>( + "200", + "사용자에게 온 스페이스 초대 목록을 조회했습니다.", + new ResBodyForSpaceInviteList( + invitationInfos + ) + ); + } + +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipController.java new file mode 100644 index 00000000..affa0f64 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipController.java @@ -0,0 +1,65 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfo; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.space.membership.dto.ResBodyForSpaceInvitationList; +import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; +import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +import java.nio.file.AccessDeniedException; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/space/member") +@RequiredArgsConstructor +@Tag(name = "ApiV1MembershipController", description = "스페이스 멤버 관리 API") +public class ApiV1MembershipController { + private final MembershipService membershipService; + private final SpaceService spaceService; + + @GetMapping("/invite/{spaceId}") + @Operation(summary = "스페이스에 초대된 유저 목록 조회") + public RsData getInvites( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId + ) throws AccessDeniedException { + Member member = userDetails.getMember(); + Space space = spaceService.findById(spaceId); + + // 스페이스에 멤버가 속해있는지 확인 + if(!membershipService.isMemberJoinedSpace(member, space)) { + throw new AccessDeniedException("액세스가 거부되었습니다."); + } + + // 멤버십(초대) 목록 조회 + List invitations = membershipService.findInvitationsBySpace(space); + List invitationInfos = invitations.stream() + .map(membership -> new ResBodyForGetMemberInfo( + membership.getMember().getId(), + membership.getMember().getName(), + membership.getMember().getProfileImageUrl() + )) + .toList(); + + return new RsData<>( + "200", + "스페이스 초대 목록을 조회했습니다.", + new ResBodyForSpaceInvitationList( + space.getId(), + invitationInfos + ) + ); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/ResBodyForSpaceInvitationList.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/ResBodyForSpaceInvitationList.java new file mode 100644 index 00000000..151ea058 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/ResBodyForSpaceInvitationList.java @@ -0,0 +1,11 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.dto; + +import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfo; + +import java.util.List; + +public record ResBodyForSpaceInvitationList( + Integer spaceId, + List invitedUsers +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java index 5864f4ea..3e8e9c1e 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java @@ -7,6 +7,7 @@ import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import java.util.List; +import java.util.Optional; public interface MembershipRepository extends JpaRepository { boolean existsByMemberAndSpace(Member member, Space space); @@ -20,4 +21,8 @@ public interface MembershipRepository extends JpaRepository boolean existsByMemberAndSpaceAndAuthorityIsNot(Member member, Space space, Authority authority); boolean existsByMemberAndSpaceAndAuthority(Member member, Space space, Authority authority); + + Optional findByMemberAndSpace(Member member, Space space); + + List findAllBySpaceAndAuthority(Space space, Authority authority); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java index 40e2c2f1..382dccb1 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java @@ -1,5 +1,6 @@ package org.tuna.zoopzoop.backend.domain.space.membership.service; +import jakarta.persistence.NoResultException; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -10,6 +11,7 @@ import org.tuna.zoopzoop.backend.domain.space.membership.repository.MembershipRepository; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import java.nio.file.AccessDeniedException; import java.util.List; @Service @@ -17,6 +19,59 @@ public class MembershipService { private final MembershipRepository membershipRepository; + // ======================== 멤버십 조회 ======================== // + + /** + * 멤버십 ID로 Membership 조회 + * @param id 조회할 멤버십 ID + * @return 해당 ID에 해당하는 Membership 엔티티 + * @throws NoResultException 해당 ID의 멤버십이 존재하지 않는 경우 + */ + public Membership findById(Integer id) { + return membershipRepository.findById(id) + .orElseThrow(() -> new NoResultException("해당 멤버십이 존재하지 않습니다.")); + } + + /** + * 멤버와 스페이스로 Membership 조회 + * @param member 조회할 멤버 + * @param space 조회할 스페이스 + * @return 해당 멤버와 스페이스에 해당하는 Membership 엔티티 + * @throws NoResultException 해당 멤버가 스페이스에 속해있지 않은 경우 + */ + public Membership findByMemberAndSpace(Member member, Space space) { + return membershipRepository.findByMemberAndSpace(member, space) + .orElseThrow(() -> new NoResultException("해당 멤버는 스페이스에 속해있지 않습니다.")); + } + + /** + * 멤버가 속한 스페이스 목록 조회 + * @param member 조회할 멤버 + * @param state 멤버의 가입 상태로 필터링 (PENDING, JOINED, ALL) + * @return 멤버가 속한 스페이스 목록 + */ + public List findByMember(Member member, String state) { + if (state.equalsIgnoreCase("PENDING")) { + return membershipRepository.findAllByMemberAndAuthority(member, Authority.PENDING); + } else if (state.equalsIgnoreCase("JOINED")) { + return membershipRepository.findAllByMemberAndAuthorityIsNot(member, Authority.PENDING); + } else { + return membershipRepository.findAllByMember(member); + } + } + + /** + * 스페이스에 속한 멤버 중 초대 상태(PENDING)인 멤버십 목록 조회 + * @param space 조회할 스페이스 + * @return 해당 스페이스에 속한 초대 상태(PENDING)인 멤버십 목록 + */ + public List findInvitationsBySpace(Space space) { + return membershipRepository.findAllBySpaceAndAuthority(space, Authority.PENDING); + } + + + // ======================== 멤버십 존재 여부 확인 ======================== // + /** * 멤버가 스페이스에 가입되어 있는지 여부 확인 (PENDING 상태 포함) * @param member 확인할 멤버 @@ -47,8 +102,11 @@ public boolean isMemberAdminInSpace(Member member, Space space) { return membershipRepository.existsByMemberAndSpaceAndAuthority(member, space, Authority.ADMIN); } + + // ======================== 멤버십 생성 및 수정 ======================== // + /** - * 스페이스에 멤버 추가 + * 스페이스에 멤버 추가 (멤버십 생성) * @param member 추가할 멤버 * @param space 멤버가 추가될 스페이스 * @param authority 멤버의 권한 @@ -60,7 +118,6 @@ public Membership addMemberToSpace(Member member, Space space, Authority authori throw new DataIntegrityViolationException("이미 스페이스에 속한 멤버입니다."); } - Membership membership = new Membership(); membership.setMember(member); membership.setSpace(space); @@ -69,18 +126,48 @@ public Membership addMemberToSpace(Member member, Space space, Authority authori } /** - * 멤버가 속한 스페이스 목록 조회 - * @param member 조회할 멤버 - * @param state 멤버의 가입 상태로 필터링 (PENDING, JOINED, ALL) - * @return 멤버가 속한 스페이스 목록 + * 멤버의 권한 변경 + * @param membership 권한을 변경할 Membership 엔티티 + * @param newAuthority 새로운 권한 + * @return 변경된 Membership 엔티티 */ - public List findByMember(Member member, String state) { - if (state.equalsIgnoreCase("PENDING")) { - return membershipRepository.findAllByMemberAndAuthority(member, Authority.PENDING); - } else if (state.equalsIgnoreCase("JOINED")) { - return membershipRepository.findAllByMemberAndAuthorityIsNot(member, Authority.PENDING); - } else { - return membershipRepository.findAllByMember(member); + public Membership changeAuthority(Membership membership, Authority newAuthority) { + membership.setAuthority(newAuthority); + return membershipRepository.save(membership); + } + + + // ======================== 멤버십 초대 처리 ======================== // + + /** + * 멤버십 초대의 적절성 확인: 멤버십의 멤버가 일치하고, 권한이 PENDING이어야 함. + * 일치하지 않으면 AccessDeniedException, 권한이 PENDING이 아니면 DataIntegrityViolationException 발생. + */ + public void validateMembershipInvitation(Membership membership, Member member) throws AccessDeniedException { + if (!membership.getMember().equals(member)) { + throw new AccessDeniedException("액세스가 거부되었습니다."); + } + if (membership.getAuthority() != Authority.PENDING) { + throw new DataIntegrityViolationException("이미 완료된 초대입니다."); } } + + /** + * 초대 수락 처리: 멤버십의 권한을 READ_ONLY로 변경 + * @param membership + */ + public void acceptInvitation(Membership membership) { + if (membership.getAuthority() != Authority.PENDING) { + throw new DataIntegrityViolationException("이미 완료된 초대입니다."); + } + changeAuthority(membership, Authority.READ_ONLY); + } + + /** + * 초대 거절 처리: 멤버십 엔티티 삭제 + * @param membership + */ + public void rejectInvitation(Membership membership) { + membershipRepository.delete(membership); + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java index b58683e9..fa96d88c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java @@ -22,7 +22,6 @@ import java.nio.file.AccessDeniedException; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; @RestController @@ -55,6 +54,7 @@ public RsData createClub( ); } + @DeleteMapping("/{spaceId}") @Operation(summary = "스페이스 삭제") public RsData deleteSpace( @@ -63,7 +63,7 @@ public RsData deleteSpace( ) throws AccessDeniedException { // ADMIN 권한 체크 Member member = userDetails.getMember(); - if(!membershipService.isMemberAdminInSpace(member, spaceService.getSpaceById(spaceId))) + if(!membershipService.isMemberAdminInSpace(member, spaceService.findById(spaceId))) throw new AccessDeniedException("스페이스의 ADMIN 권한이 필요합니다."); String deletedSpaceName = spaceService.deleteSpace(spaceId); @@ -84,7 +84,7 @@ public RsData updateSpaceName( ) throws AccessDeniedException { // ADMIN 권한 체크 Member member = userDetails.getMember(); - if(!membershipService.isMemberAdminInSpace(member, spaceService.getSpaceById(spaceId))) + if(!membershipService.isMemberAdminInSpace(member, spaceService.findById(spaceId))) throw new AccessDeniedException("스페이스의 ADMIN 권한이 필요합니다."); Space updatedSpace = spaceService.updateSpaceName(spaceId, reqBody.name()); @@ -135,4 +135,5 @@ public RsData getAllSpaces( } + } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceInviteList.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceInviteList.java new file mode 100644 index 00000000..6d9390b4 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceInviteList.java @@ -0,0 +1,8 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto; + +import java.util.List; + +public record ResBodyForSpaceInviteList( + List spaces +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfoWithoutAuthority.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfoWithoutAuthority.java new file mode 100644 index 00000000..60d6b679 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfoWithoutAuthority.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto; + +public record SpaceMembershipInfoWithoutAuthority( + Integer id, + String name +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java index 6033f13a..0073949f 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java @@ -6,7 +6,6 @@ import org.hibernate.validator.constraints.Length; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; -import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.exception.DuplicateSpaceNameException; import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository; @@ -16,6 +15,32 @@ public class SpaceService { private final SpaceRepository spaceRepository; + // ======================== 스페이스 조회 ======================== // + + /** + * 스페이스 ID로 스페이스 조회 + * @param spaceId 스페이스 ID + * @return 조회된 스페이스 + * @throws NoResultException 스페이스가 존재하지 않을 경우 + */ + public Space findById(Integer spaceId) { + return spaceRepository.findById(spaceId) + .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); + } + + /** + * 스페이스 이름으로 스페이스 조회 + * @param name 스페이스 이름 + * @return 조회된 스페이스 + * @throws NoResultException 스페이스가 존재하지 않을 경우 + */ + public Space findByName(String name) { + return spaceRepository.findByName(name) + .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); + } + + // ======================== 스페이스 생성/수정/삭제 ======================== // + /** * 스페이스 생성 * @param name 스페이스 이름 @@ -51,28 +76,6 @@ public String deleteSpace(Integer spaceId) { return spaceName; } - /** - * 스페이스 ID로 스페이스 조회 - * @param spaceId 스페이스 ID - * @return 조회된 스페이스 - * @throws NoResultException 스페이스가 존재하지 않을 경우 - */ - public Space getSpaceById(Integer spaceId) { - return spaceRepository.findById(spaceId) - .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); - } - - /** - * 스페이스 이름으로 스페이스 조회 - * @param name 스페이스 이름 - * @return 조회된 스페이스 - * @throws NoResultException 스페이스가 존재하지 않을 경우 - */ - public Space findByName(String name) { - return spaceRepository.findByName(name) - .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); - } - /** * 스페이스 이름 변경 * @param spaceId 스페이스 ID diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java new file mode 100644 index 00000000..88ffb0f4 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java @@ -0,0 +1,289 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.controller; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; +import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; +import org.tuna.zoopzoop.backend.testSupport.ControllerTestSupport; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ApiV1InviteControllerTest extends ControllerTestSupport { + @Autowired + private SpaceService spaceService; + @Autowired + private MemberService memberService; + @Autowired + private MembershipService membershipService; + + @BeforeAll + void setUp() { + setUpMember(); + setUpSpace(); + setUpMembership(); + } + + void setUpSpace() { + spaceService.createSpace("기존 스페이스 1_forInviteControllerTest"); + spaceService.createSpace("기존 스페이스 2_forInviteControllerTest"); + + } + + void setUpMember() { + memberService.createMember( + "InviteControllerTester1", + "url", + "ic1111", + Provider.KAKAO + ); + memberService.createMember( + "InviteControllerTester2", + "url", + "ic2222", + Provider.KAKAO + ); + memberService.createMember( + "InviteControllerTester3", + "url", + "ic3333", + Provider.KAKAO + ); + } + + void setUpMembership() { + // test1 -> 스페이스 1 가입 (ADMIN) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("ic1111"), + spaceService.findByName("기존 스페이스 1_forInviteControllerTest"), + Authority.ADMIN + ); + + // test2 -> 스페이스 1 가입 (PENDING) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("ic2222"), + spaceService.findByName("기존 스페이스 1_forInviteControllerTest"), + Authority.PENDING + ); + + // test3 -> 스페이스 1 가입 (PENDING) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("ic3333"), + spaceService.findByName("기존 스페이스 1_forInviteControllerTest"), + Authority.PENDING + ); + + // test2 -> 스페이스 2 가입 (PENDING) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("ic2222"), + spaceService.findByName("기존 스페이스 2_forInviteControllerTest"), + Authority.PENDING + ); + // test1 -> 스페이스 2 가입 (PENDING) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("ic1111"), + spaceService.findByName("기존 스페이스 2_forInviteControllerTest"), + Authority.PENDING + ); + + } + + // ============================= ACCEPT ============================= // + + @Test + @WithUserDetails(value = "KAKAO:ic1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("초대 수락 - 성공") + void acceptInvite_Success() throws Exception { + // given + var member = memberService.findByKakaoKey("ic1111"); + var space = spaceService.findByName("기존 스페이스 2_forInviteControllerTest"); + Integer inviteId = membershipService.findByMemberAndSpace(member, space).getId(); + + String url = "/api/v1/invite/%d/accept".formatted(inviteId); + + // when + ResultActions resultActions = performPost(url); + + // then + expectOk(resultActions, "스페이스 초대가 수락됐습니다."); + + resultActions + .andExpect(jsonPath("$.data.id").value(space.getId())) + .andExpect(jsonPath("$.data.name").value(space.getName())); + } + + @Test + @WithUserDetails(value = "KAKAO:ic1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("초대 수락 - 실패 : 존재하지 않는 초대") + void acceptInvite_Fail_NotExistInvite() throws Exception { + // given + Integer inviteId = 9999; + String url = "/api/v1/invite/%d/accept".formatted(inviteId); + + // when + ResultActions resultActions = performPost(url); + + // then + expectNotFound(resultActions, "해당 멤버십이 존재하지 않습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:ic2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("초대 수락 - 실패 : 본인의 초대가 아님") + void acceptInvite_Fail_NotYourInvite() throws Exception { + // given + var member = memberService.findByKakaoKey("ic1111"); + var space = spaceService.findByName("기존 스페이스 2_forInviteControllerTest"); + Integer inviteId = membershipService.findByMemberAndSpace(member, space).getId(); + String url = "/api/v1/invite/%d/accept".formatted(inviteId); + + // when + ResultActions resultActions = performPost(url); + + // then + expectForbidden(resultActions, "액세스가 거부되었습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:ic1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("초대 수락 - 실패 : 초대 상태가 아님") + void acceptInvite_Fail_NotPendingStatus() throws Exception { + // given + var member = memberService.findByKakaoKey("ic1111"); + var space = spaceService.findByName("기존 스페이스 1_forInviteControllerTest"); + Integer inviteId = membershipService.findByMemberAndSpace(member, space).getId(); + String url = "/api/v1/invite/%d/accept".formatted(inviteId); + + // when + ResultActions resultActions = performPost(url); + + // then + resultActions. + andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value("409")) + .andExpect(jsonPath("$.msg").value("이미 완료된 초대입니다.")); + } + + // ============================= REJECT ============================= // + + @Test + @WithUserDetails(value = "KAKAO:ic1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("초대 거절 - 성공") + void rejectInvite_Success() throws Exception { + // given + var member = memberService.findByKakaoKey("ic1111"); + var space = spaceService.findByName("기존 스페이스 2_forInviteControllerTest"); + Integer inviteId = membershipService.findByMemberAndSpace(member, space).getId(); + + String url = "/api/v1/invite/%d/reject".formatted(inviteId); + + // when + ResultActions resultActions = performPost(url); + + // then + expectOk(resultActions, "스페이스 초대가 거절됐습니다."); + + resultActions + .andExpect(jsonPath("$.data.id").value(space.getId())) + .andExpect(jsonPath("$.data.name").value(space.getName())); + } + + @Test + @WithUserDetails(value = "KAKAO:ic1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("초대 거절 - 실패 : 존재하지 않는 초대") + void rejectInvite_Fail_NotExistInvite() throws Exception { + // given + Integer inviteId = 9999; + String url = "/api/v1/invite/%d/reject".formatted(inviteId); + + // when + ResultActions resultActions = performPost(url); + + // then + expectNotFound(resultActions, "해당 멤버십이 존재하지 않습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:ic2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("초대 거절 - 실패 : 본인의 초대가 아님") + void rejectInvite_Fail_NotYourInvite() throws Exception { + // given + var member = memberService.findByKakaoKey("ic1111"); + var space = spaceService.findByName("기존 스페이스 2_forInviteControllerTest"); + Integer inviteId = membershipService.findByMemberAndSpace(member, space).getId(); + String url = "/api/v1/invite/%d/reject".formatted(inviteId); + + // when + ResultActions resultActions = performPost(url); + + // then + expectForbidden(resultActions, "액세스가 거부되었습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:ic1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("초대 거절 - 실패 : 초대 상태가 아님") + void rejectInvite_Fail_NotPendingStatus() throws Exception { + // given + var member = memberService.findByKakaoKey("ic1111"); + var space = spaceService.findByName("기존 스페이스 1_forInviteControllerTest"); + Integer inviteId = membershipService.findByMemberAndSpace(member, space).getId(); + String url = "/api/v1/invite/%d/reject".formatted(inviteId); + + // when + ResultActions resultActions = performPost(url); + + // then + resultActions. + andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value("409")) + .andExpect(jsonPath("$.msg").value("이미 완료된 초대입니다.")); + } + + // ============================= GET MY INVITES ============================= // + @Test + @WithUserDetails(value = "KAKAO:ic2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("나에게 온 초대 목록 조회 - 성공") + void getMyInvites_Success() throws Exception { + // given + String url = "/api/v1/invite"; + + Space space1 = spaceService.findByName("기존 스페이스 1_forInviteControllerTest"); + Space space2 = spaceService.findByName("기존 스페이스 2_forInviteControllerTest"); + + // when + ResultActions resultActions = performGet(url); + + // then + expectOk(resultActions, "사용자에게 온 스페이스 초대 목록을 조회했습니다."); + + resultActions + .andExpect(jsonPath("$.data.spaces").isArray()) + .andExpect(jsonPath("$.data.spaces.length()").value(2)) + .andExpect(jsonPath("$.data.spaces[0].id").value(space1.getId())) + .andExpect(jsonPath("$.data.spaces[0].name").value(space1.getName())) + .andExpect(jsonPath("$.data.spaces[1].id").value(space2.getId())) + .andExpect(jsonPath("$.data.spaces[1].name").value(space2.getName())); + } + + +} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipControllerTest.java new file mode 100644 index 00000000..64ac319e --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipControllerTest.java @@ -0,0 +1,168 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.controller; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; +import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; +import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; +import org.tuna.zoopzoop.backend.testSupport.ControllerTestSupport; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ApiV1MembershipControllerTest extends ControllerTestSupport { + @Autowired + private SpaceService spaceService; + @Autowired + private MemberService memberService; + @Autowired + private MembershipService membershipService; + + @BeforeAll + void setUp() { + setUpMember(); + setUpSpace(); + setUpMembership(); + } + + void setUpSpace() { + spaceService.createSpace("기존 스페이스 1_forMembershipControllerTest"); + spaceService.createSpace("기존 스페이스 2_forMembershipControllerTest"); + + } + + void setUpMember() { + memberService.createMember( + "MembershipControllerTester1", + "url", + "mc1111", + Provider.KAKAO + ); + memberService.createMember( + "MembershipControllerTester2", + "url", + "mc2222", + Provider.KAKAO + ); + memberService.createMember( + "MembershipControllerTester3", + "url", + "mc3333", + Provider.KAKAO + ); + } + + void setUpMembership() { + // test1 -> 스페이스 1 가입 (ADMIN) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc1111"), + spaceService.findByName("기존 스페이스 1_forMembershipControllerTest"), + Authority.ADMIN + ); + + // test2 -> 스페이스 1 가입 (PENDING) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc2222"), + spaceService.findByName("기존 스페이스 1_forMembershipControllerTest"), + Authority.PENDING + ); + + // test3 -> 스페이스 1 가입 (PENDING) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc3333"), + spaceService.findByName("기존 스페이스 1_forMembershipControllerTest"), + Authority.PENDING + ); + + // test2 -> 스페이스 2 가입 (PENDING) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc2222"), + spaceService.findByName("기존 스페이스 2_forMembershipControllerTest"), + Authority.PENDING + ); + // test1 -> 스페이스 2 가입 (PENDING) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc1111"), + spaceService.findByName("기존 스페이스 2_forMembershipControllerTest"), + Authority.PENDING + ); + } + + + // ============================= LIST INVITED USERS ============================= // + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스에서 보낸 초대 목록 조회 - 성공") + void listPendingInvites_Success() throws Exception { + // given + var member1 = memberService.findByKakaoKey("mc1111"); + var member2 = memberService.findByKakaoKey("mc2222"); + var member3 = memberService.findByKakaoKey("mc3333"); + var space = spaceService.findByName("기존 스페이스 1_forMembershipControllerTest"); + String url = "/api/v1/space/member/invite/%d".formatted(space.getId()); + + // when + ResultActions resultActions = performGet(url); + + // then + expectOk(resultActions, "스페이스 초대 목록을 조회했습니다."); + + resultActions + .andExpect(jsonPath("$.data.invitedUsers.length()").value(2)) + .andExpect(jsonPath("$.data.spaceId").value(space.getId())) + .andExpect(jsonPath("$.data.invitedUsers[0].id").exists()) + .andExpect(jsonPath("$.data.invitedUsers[0].name").value(member2.getName())) + .andExpect(jsonPath("$.data.invitedUsers[0].profileUrl").value(member2.getProfileImageUrl())) + .andExpect(jsonPath("$.data.invitedUsers[1].id").exists()) + .andExpect(jsonPath("$.data.invitedUsers[1].name").value(member3.getName())) + .andExpect(jsonPath("$.data.invitedUsers[1].profileUrl").value(member3.getProfileImageUrl())); + } + + @Test + @WithUserDetails(value = "KAKAO:mc2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스에서 보낸 초대 목록 조회 - 실패 : 스페이스 멤버가 아님") + void listPendingInvites_Fail_NotSpaceMember() throws Exception { + // given + var space = spaceService.findByName("기존 스페이스 2_forMembershipControllerTest"); + String url = "/api/v1/space/member/invite/%d".formatted(space.getId()); + + // when + ResultActions resultActions = performGet(url); + + // then + expectForbidden(resultActions, "액세스가 거부되었습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스에서 보낸 초대 목록 조회 - 실패 : 스페이스가 존재하지 않음") + void listPendingInvites_Fail_NotExistSpace() throws Exception { + // given + Integer spaceId = 9999; + String url = "/api/v1/space/member/invite/%d".formatted(spaceId); + + // when + ResultActions resultActions = performGet(url); + + // then + expectNotFound(resultActions, "존재하지 않는 스페이스입니다."); + } + +} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java index 8620a99e..6b027638 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java @@ -17,6 +17,8 @@ import org.tuna.zoopzoop.backend.domain.space.membership.repository.MembershipRepository; import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; +import java.nio.file.AccessDeniedException; + import static org.junit.jupiter.api.Assertions.*; @ActiveProfiles("test") @@ -39,9 +41,7 @@ void setUp() { membershipRepository.deleteAll(); setUpMember(); setUpSpace(); - - memberRepository.findAll().forEach(member -> System.out.println("Member: " + member.getName())); - System.out.println("----- setUp 완료 -----"); + setUpMembership(); } void setUpSpace() { @@ -71,14 +71,26 @@ void setUpMember() { ); } + void setUpMembership() { + var member1 = memberService.findByKakaoKey("ms1111"); + var member2 = memberService.findByKakaoKey("ms2222"); + var space1 = spaceService.findByName("기존 스페이스 1_forMembershipServiceTest"); + var space2 = spaceService.findByName("기존 스페이스 2_forMembershipServiceTest"); + + membershipService.addMemberToSpace(member1, space1, Authority.ADMIN); + membershipService.addMemberToSpace(member1, space2, Authority.PENDING); + membershipService.addMemberToSpace(member2, space1, Authority.READ_ONLY); + membershipService.addMemberToSpace(member2, space2, Authority.ADMIN); + + } + // ============================= ADD MEMBER TO SPACE ============================= // @Test - @WithMockUser @DisplayName("스페이스에 멤버 추가 - 성공") void addMemberToSpace_Success() { // Given - var member = memberService.findByKakaoKey("ms2222"); + var member = memberService.findByKakaoKey("ms3333"); var space = spaceService.findByName("기존 스페이스 1_forMembershipServiceTest"); // When @@ -93,11 +105,10 @@ void addMemberToSpace_Success() { } @Test - @WithMockUser @DisplayName("스페이스에 멤버 추가 - 실패 : 이미 멤버로 존재") void addMemberToSpace_Fail_AlreadyMember() { // Given - var member = memberService.findByKakaoKey("ms2222"); + var member = memberService.findByKakaoKey("ms3333"); var space = spaceService.findByName("기존 스페이스 1_forMembershipServiceTest"); membershipService.addMemberToSpace(member, space, Authority.ADMIN); @@ -107,4 +118,115 @@ void addMemberToSpace_Fail_AlreadyMember() { }); } + // ============================= ACCEPT INVITE ============================= // + @Test + @DisplayName("초대 수락 - 성공") + void acceptInvitation_Success() { + // Given + var member = memberService.findByKakaoKey("ms1111"); + var space = spaceService.findByName("기존 스페이스 2_forMembershipServiceTest"); + var membership = membershipService.findByMemberAndSpace(member, space); + + // When + membershipService.acceptInvitation(membership); + var updatedMembership = membershipService.findById(membership.getId()); + + // Then + assertEquals(Authority.READ_ONLY, updatedMembership.getAuthority()); + } + + @Test + @DisplayName("초대 수락 - 실패 : PENDING 상태가 아님") + void acceptInvitation_Fail_NotPending() { + // Given + var member = memberService.findByKakaoKey("ms1111"); + var space = spaceService.findByName("기존 스페이스 1_forMembershipServiceTest"); + var membership = membershipService.findByMemberAndSpace(member, space); + + // When & Then + assertThrows(DataIntegrityViolationException.class, () -> { + membershipService.acceptInvitation(membership); + }); + } + + // ============================= REJECT INVITE ============================= // + + @Test + @DisplayName("초대 거절 - 성공") + void rejectInvitation_Success() { + // Given + var member = memberService.findByKakaoKey("ms1111"); + var space = spaceService.findByName("기존 스페이스 2_forMembershipServiceTest"); + var membership = membershipService.findByMemberAndSpace(member, space); + + // When + membershipService.rejectInvitation(membership); + + // Then + assertThrows(NoResultException.class, () -> { + membershipService.findById(membership.getId()); + }); + } + + // ============================= CHECK INVITATION VALIDATION ============================= // + + @Test + @DisplayName("초대 검증 - 성공") + void validateMembershipInvitation_Success() { + // Given + var member = memberService.findByKakaoKey("ms1111"); + var space = spaceService.findByName("기존 스페이스 2_forMembershipServiceTest"); + var membership = membershipService.findByMemberAndSpace(member, space); + + // When & Then + assertDoesNotThrow(() -> { + membershipService.validateMembershipInvitation(membership, member); + }); + } + + @Test + @DisplayName("초대 검증 - 실패 : 멤버 불일치") + void validateMembershipInvitation_Fail_MemberMismatch() { + // Given + var member1 = memberService.findByKakaoKey("ms1111"); + var member2 = memberService.findByKakaoKey("ms2222"); + var space = spaceService.findByName("기존 스페이스 2_forMembershipServiceTest"); + var membership = membershipService.findByMemberAndSpace(member1, space); + + // When & Then + assertThrows(AccessDeniedException.class, () -> { + membershipService.validateMembershipInvitation(membership, member2); + }); + } + + @Test + @DisplayName("초대 검증 - 실패 : PENDING 상태가 아님") + void validateMembershipInvitation_Fail_NotPending() { + // Given + var member = memberService.findByKakaoKey("ms1111"); + var space = spaceService.findByName("기존 스페이스 1_forMembershipServiceTest"); + var membership = membershipService.findByMemberAndSpace(member, space); + + // When & Then + assertThrows(DataIntegrityViolationException.class, () -> { + membershipService.validateMembershipInvitation(membership, member); + }); + } + + // ============================= CHANGE AUTHORITY ============================= // + + @Test + @DisplayName("권한 변경 - 성공") + void changeAuthority_Success() { + // Given + var member = memberService.findByKakaoKey("ms2222"); + var space = spaceService.findByName("기존 스페이스 1_forMembershipServiceTest"); + var membership = membershipService.findByMemberAndSpace(member, space); + + // When + var updatedMembership = membershipService.changeAuthority(membership, Authority.ADMIN); + + // Then + assertEquals(Authority.ADMIN, updatedMembership.getAuthority()); + } } \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceServiceTest.java index d66c1e36..5b05b541 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceServiceTest.java @@ -79,7 +79,7 @@ void deleteSpace_Success() { // Then Assertions.assertThat(deletedSpaceName).isEqualTo(spaceName); - assertThatThrownBy(() -> spaceService.getSpaceById(spaceId)) + assertThatThrownBy(() -> spaceService.findById(spaceId)) .isInstanceOf(NoResultException.class); } diff --git a/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java b/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java index a5cda70d..73663db0 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java +++ b/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java @@ -60,6 +60,18 @@ protected ResultActions performPost(String url, String body) throws Exception { .andDo(print()); } + /** + * POST 요청을 수행하는 헬퍼 메서드 (바디 없는 경우) + * @param url - 요청할 URL + * @return ResultActions - MockMvc의 ResultActions 객체 + * @throws Exception - 예외 발생 시 던짐 + */ + protected ResultActions performPost(String url) throws Exception { + return mvc.perform(post(url) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()); + } + /** * PATCH 요청을 수행하는 헬퍼 메서드 * @param url - 요청할 URL @@ -176,5 +188,18 @@ protected void expectForbidden(ResultActions resultActions, String msg) throws E .andExpect(jsonPath("$.data").value(nullValue())); } + /** + * 404 Not Found 응답을 기대하는 헬퍼 메서드 + * @param resultActions - MockMvc의 ResultActions 객체 + * @param msg - 기대하는 메시지 + * @throws Exception - 예외 발생 시 던짐 + */ + protected void expectNotFound(ResultActions resultActions, String msg) throws Exception { + resultActions.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value(msg)) + .andExpect(jsonPath("$.data").value(nullValue())); + } + } From 44fe25cf99f4a3e23b0733680927f8e7eef938e3 Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:18:04 +0900 Subject: [PATCH 023/132] =?UTF-8?q?refactor/OPS-283=20:=20datasource=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=88=98=EC=A0=95=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/datasource/entity/Category.java | 24 +++++++++++++++++++ .../domain/datasource/entity/DataSource.java | 9 +++++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Category.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Category.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Category.java new file mode 100644 index 00000000..fc9baa47 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/Category.java @@ -0,0 +1,24 @@ +package org.tuna.zoopzoop.backend.domain.datasource.entity; + +public enum Category { + POLITICS("정치"), + ECONOMY("경제"), + SOCIETY("사회"), + IT("IT"), + SCIENCE("과학"), + CULTURE("문화"), + SPORTS("스포츠"), + ENVIRONMENT("환경"), + HISTORY("역사"), + WORLD("세계"); + + private final String name; + + Category (String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java index 10349ab5..a1a735ea 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java @@ -7,7 +7,7 @@ import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -43,7 +43,7 @@ public class DataSource extends BaseEntity { //소스 데이터의 작성일자 //DB 저장용 createdDate와 다름. @Column(nullable = false) - private LocalDateTime dataCreatedDate; + private LocalDate dataCreatedDate; //소스 데이터 URL @Column(nullable = false) @@ -57,6 +57,11 @@ public class DataSource extends BaseEntity { @OneToMany(mappedBy = "dataSource", cascade = CascadeType.ALL, orphanRemoval = true) private List tags = new ArrayList<>(); + // 카테고리 목록 + @Enumerated(EnumType.STRING) // IT, SCIENCE 등 ENUM 이름으로 저장 + @Column(nullable = false) + private Category category; + // 활성화 여부 @Column(nullable = false) private boolean isActive = true; From c02f0537d2bb41eedffe68bbb105fb6f7b0fe215 Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:54:23 +0900 Subject: [PATCH 024/132] =?UTF-8?q?refactor/OPS-285=20:=20datasource=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20thumbnailUrl=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-285 : datasource 엔티티 thumbnailUrl 이름 변경 * refactor/OPS-285 : datasource 엔티티 thumbnailUrl 이름 변경 --- .../backend/domain/archive/folder/service/FolderService.java | 2 +- .../zoopzoop/backend/domain/datasource/entity/DataSource.java | 2 +- .../domain/archive/folder/service/FolderServiceTest.java | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java index 95b9ef14..849ace79 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java @@ -183,7 +183,7 @@ public FolderFilesDto getFilesInFolderForPersonal(Integer memberId, Integer fold ds.getCreateDate(), // LocalDateTime ds.getSummary(), ds.getSourceUrl(), - ds.getThumbnailUrl(), + ds.getImageUrl(), ds.getTags() == null ? List.of() : ds.getTags() )) .toList(); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java index a1a735ea..8d9c3f7b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java @@ -51,7 +51,7 @@ public class DataSource extends BaseEntity { //썸네일 이미지 URL @Column - private String thumbnailUrl; + private String imageUrl; // 태그 목록 @OneToMany(mappedBy = "dataSource", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java index 2cf6c984..b84a7ce6 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java @@ -236,7 +236,7 @@ void getFilesInFolderForPersonal_success() { d1.setFolder(folder); d1.setSummary("요약 A"); d1.setSourceUrl("http://src/a"); - d1.setThumbnailUrl("http://img/a"); + d1.setImageUrl("http://img/a"); d1.setTags(List.of(new Tag("tag1"), new Tag("tag2"))); DataSource d2 = new DataSource(); @@ -245,7 +245,7 @@ void getFilesInFolderForPersonal_success() { d2.setFolder(folder); d2.setSummary("요약 B"); d2.setSourceUrl("http://src/b"); - d2.setThumbnailUrl("http://img/b"); + d2.setImageUrl("http://img/b"); d2.setTags(List.of()); when(dataSourceRepository.findAllByFolder(folder)).thenReturn(List.of(d1, d2)); From e02a09e5c95ecaaa2814f12c584688b3a08ea3da Mon Sep 17 00:00:00 2001 From: osh5030 <72571931+ohsoohyuk@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:17:50 +0900 Subject: [PATCH 025/132] =?UTF-8?q?[feat/OPS-175]=20=EC=9B=B9=20=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=EB=A7=81=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=95?= =?UTF-8?q?=EC=A0=9C=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-175 : 웹 크롤링 데이터 정제화 기능 구현 --- build.gradle | 3 + .../{ => ai}/service/AiService.java | 2 +- .../controller/CrawlerTestController.java | 21 +++ .../datasource/crawler/service/Crawler.java | 12 ++ .../service/CrawlerManagerService.java | 30 ++++ .../crawler/service/GenericCrawler.java | 28 ++++ .../crawler/service/NaverNewsCrawler.java | 51 +++++++ .../crawler/service/SupportedDomain.java | 15 ++ .../domain/datasource/dto/ArticleData.java | 13 ++ .../backend/global/initData/BaseInitData.java | 2 +- src/main/resources/application.yml | 2 +- .../service/CrawlerManagerServiceTest.java | 139 ++++++++++++++++++ 12 files changed, 315 insertions(+), 3 deletions(-) rename src/main/java/org/tuna/zoopzoop/backend/domain/datasource/{ => ai}/service/AiService.java (97%) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/SupportedDomain.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java diff --git a/build.gradle b/build.gradle index 59f387a7..9fb388f8 100644 --- a/build.gradle +++ b/build.gradle @@ -76,6 +76,9 @@ dependencies { // Spring AI implementation "org.springframework.ai:spring-ai-starter-model-openai" + + // 크롤링 + implementation("org.jsoup:jsoup:1.21.2") } dependencyManagement { diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java similarity index 97% rename from src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiService.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java index f194dbd4..2632b9c6 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java @@ -1,4 +1,4 @@ -package org.tuna.zoopzoop.backend.domain.datasource.service; +package org.tuna.zoopzoop.backend.domain.datasource.ai.service; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java new file mode 100644 index 00000000..0eee5692 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java @@ -0,0 +1,21 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.CrawlerManagerService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; + +@RestController +@RequestMapping("api/v1") +@RequiredArgsConstructor +public class CrawlerTestController { + private final CrawlerManagerService crawlerManagerService; + + @GetMapping("/crawl") + public ArticleData crawl(@RequestParam String url) throws Exception { + return crawlerManagerService.extractContent(url); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java new file mode 100644 index 00000000..d61b8331 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java @@ -0,0 +1,12 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; + +import org.jsoup.nodes.Document; +import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; + +import java.time.LocalDate; + +public interface Crawler { + boolean supports(String domain); + ArticleData extract(Document doc); + LocalDate transLocalDate(String rawDate); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java new file mode 100644 index 00000000..38d6bcef --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java @@ -0,0 +1,30 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; + +import lombok.RequiredArgsConstructor; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; + +import java.io.IOException; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CrawlerManagerService { + private final List crawlers; + + public ArticleData extractContent(String url) throws IOException { + Document doc = Jsoup.connect(url) + .userAgent("Mozilla/5.0") + .timeout(10000) + .get(); + + for (Crawler crawler : crawlers) { + if (crawler.supports(url)) { + return crawler.extract(doc); + } + } + return null; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java new file mode 100644 index 00000000..181d0f10 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java @@ -0,0 +1,28 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; + +import org.jsoup.nodes.Document; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; + +import java.time.LocalDate; + +@Component +@Order(Ordered.LOWEST_PRECEDENCE) // 모든 URL 대응 (우선순위 맨 뒤) +public class GenericCrawler implements Crawler { + @Override + public boolean supports(String url) { + return true; + } + + @Override + public ArticleData extract(Document doc) { + return new ArticleData(null, null, null, null, null, doc.outerHtml()); + } + + @Override + public LocalDate transLocalDate(String rawDate) { + return null; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java new file mode 100644 index 00000000..00b3ba73 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java @@ -0,0 +1,51 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; + +import org.jsoup.nodes.Document; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class NaverNewsCrawler implements Crawler { + private static final SupportedDomain DOMAIN = SupportedDomain.NAVERNEWS; + private static final DateTimeFormatter NAVERNEWS_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); // 날짜 형식 + + @Override + public boolean supports(String domain) { + return domain.contains(DOMAIN.getDomain()); + } + + @Override + public ArticleData extract(Document doc) { + // 제목 + String title = doc.selectFirst("h2#title_area").text(); + + // 작성 날짜 + String publishedAt = doc.selectFirst( + "span.media_end_head_info_datestamp_time._ARTICLE_DATE_TIME" + ).attr("data-date-time"); + LocalDate dataCreatedDate = transLocalDate(publishedAt); + + // 내용(ai한테 줘야함) + String content = doc.select("article").text(); + + // 썸네일 이미지 url + String imageUrl = doc.selectFirst("img#img1._LAZY_LOADING._LAZY_LOADING_INIT_HIDE").attr("data-src"); + + // 출처 + String sources = doc.selectFirst("span.media_end_head_top_logo_text").text(); + + return new ArticleData(title, dataCreatedDate, content, imageUrl, sources, null); + } + + @Override + public LocalDate transLocalDate(String rawDate) { + return LocalDate.parse(rawDate, NAVERNEWS_FORMATTER); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/SupportedDomain.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/SupportedDomain.java new file mode 100644 index 00000000..956669d0 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/SupportedDomain.java @@ -0,0 +1,15 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; + +public enum SupportedDomain { + NAVERNEWS("n.news.naver.com"); + + private final String domain; + + SupportedDomain(String domain) { + this.domain = domain; + } + + public String getDomain() { + return domain; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java new file mode 100644 index 00000000..cf608b9f --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java @@ -0,0 +1,13 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; + +import java.time.LocalDate; + +public record ArticleData( + String title, // 제목 + LocalDate dataCreatedDate, // 작성일자 + String content, // ai한테 줘야할 내용 + String imageUrl, // 이미지 url + String sources, // 출처 + String rawHtml // GenericCrawler 용 +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java index 13397d21..1dfc9f5a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java @@ -10,7 +10,7 @@ import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; -import org.tuna.zoopzoop.backend.domain.datasource.service.AiService; +import org.tuna.zoopzoop.backend.domain.datasource.ai.service.AiService; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b68a982a..60a0d4f7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -30,7 +30,7 @@ spring: chat: options: model: meta-llama/llama-4-scout-17b-16e-instruct - teperature: 0 + temperature: 0 springdoc: default-produces-media-type: application/json;charset=UTF-8 diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java new file mode 100644 index 00000000..98265680 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java @@ -0,0 +1,139 @@ +package org.tuna.zoopzoop.backend.domain.datasource.service; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.CrawlerManagerService; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.GenericCrawler; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.NaverNewsCrawler; +import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class CrawlerManagerServiceTest { + @InjectMocks + private CrawlerManagerService crawlerManagerService; // List에 Mock들이 주입됨 + + @Mock + private NaverNewsCrawler naverNewsCrawler; + + @Mock + private GenericCrawler genericCrawler; + + @BeforeEach + void setUp() { + // 직접 리스트를 생성자에 주입 + crawlerManagerService = new CrawlerManagerService( + List.of(naverNewsCrawler, genericCrawler) + ); + } + + @Test + void FetchHtmlUrlTest() throws IOException { + // given + String url = "https://n.news.naver.com/mnews/article/008/0005254080"; // 원하는 URL 넣기 + + // when + Document doc = Jsoup.connect(url) + .userAgent("Mozilla/5.0") // 크롤링 차단 방지를 위해 user-agent 설정 권장 + .timeout(10 * 1000) // 타임아웃 (10초) + .get(); + + Elements articles = doc.select("article"); + + System.out.println(doc.select("article").text()); + + String html = doc.outerHtml(); // HTML 전체 소스 + + if (!articles.isEmpty()) { + for (Element article : articles) { + // 3. 텍스트 추출 + String text = article.text(); + System.out.println("본문 내용:\n" + text); + } + } else { + System.out.println("article 태그를 찾을 수 없습니다."); + } + + // then + System.out.println(articles); + System.out.println(html); + assertThat(html).contains(" Date: Wed, 24 Sep 2025 12:26:17 +0900 Subject: [PATCH 026/132] =?UTF-8?q?[refactor/OPS-287]=20News=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B3=A0=EB=8F=84=ED=99=94=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-287 : newsSearch 메소드 로직 변경 및 일부 API 문서화. * refactor/OPS-287 : NewsServiceTest API를 호출하는 방식으로 변경. --- .../auth/controller/ApiV1AuthController.java | 9 +++ .../home/controller/HomeController.java | 13 ---- .../controller/ApiV1MemberController.java | 32 +++++++++ .../domain/member/service/MemberService.java | 5 -- .../news/controller/ApiV1NewsController.java | 17 +++-- .../news/dto/res/ResBodyForNaverNews.java | 6 +- .../news/service/NewsSearchService.java | 72 +++++++++++++------ .../controller/ApiV1InviteController.java | 5 -- .../domain/news/service/NewsServiceTest.java | 26 ++----- 9 files changed, 111 insertions(+), 74 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java index 5a07db9b..227ec21c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java @@ -24,6 +24,10 @@ public class ApiV1AuthController { private final MemberService memberService; private final JwtProperties jwtProperties; + /** + * 사용자 로그아웃 API + * @param response Servlet 기반 웹에서 server -> client로 http 응답을 보내기 위한 객체, 자동 주입. + */ @GetMapping("/logout") @Operation(summary = "사용자 로그아웃") public ResponseEntity> logout(HttpServletResponse response) { @@ -54,6 +58,11 @@ public ResponseEntity> logout(HttpServletResponse response) { ); } + /** + * refreshToken 기반으로 accessToken 재발급 + * @param refreshToken 쿠키에 포함된 현재 로그인한 사용자의 refreshToken + * @param response Servlet 기반 웹에서 server -> client로 http 응답을 보내기 위한 객체, 자동 주입. + */ @PostMapping("/refresh") @Operation(summary = "사용자 액세스 토큰 재발급 (리프레시 토큰이 유효할 경우)") public ResponseEntity> refreshToken(@CookieValue(name = "refreshToken", required = false) String refreshToken, diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java index 8ba4e537..90c94ed7 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java @@ -4,12 +4,9 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; -import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.tuna.zoopzoop.backend.domain.news.service.NewsSearchService; -import reactor.core.publisher.Mono; import java.net.InetAddress; @@ -61,14 +58,4 @@ public String main() { """.formatted(localHost.getHostName(), localHost.getHostAddress(), kakaoLoginUrl, googleLoginUrl, logoutUrl); } - - @GetMapping(value = "/search-news", produces = MediaType.TEXT_HTML_VALUE) - public Mono searchNews(@RequestParam String query) { - return newsSearchService.searchNews(query, 5, 1, "sim") - .map(""" -

검색 결과

-
%s
- 뒤로가기 - """::formatted); - } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java index ac9f14df..a38d67ad 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java @@ -32,6 +32,12 @@ public class ApiV1MemberController { /// api/v1/member/all : 모든 사용자 목록 조회 (GET) /// api/v1/member/{id} : id 기반 사용자 조회 (GET) /// api/v1/member?name={name} : 이름 기반 사용자 조회 (GET) + + /** + * 현재 로그인한 사용자의 정보를 조회하는 API + * HTTP METHOD: GET + * @param userDetails @AuthenticationPrincipal로 받아오는 현재 사용자 정보 + */ @GetMapping("/me") @Operation(summary = "사용자 정보 조회") public ResponseEntity> getMemberInfo( @@ -49,6 +55,12 @@ public ResponseEntity> getMemberInfo( ); } + /** + * 현재 로그인한 사용자의 이름을 변경하는 API + * HTTP METHOD: PUT + * @param userDetails @AuthenticationPrincipal로 받아오는 현재 사용자 정보 + * @param reqBodyForEditMemberName 수정할 닉네임을 받아오는 reqDto + */ @PutMapping("/edit") @Operation(summary = "사용자 닉네임 수정") public ResponseEntity> editMemberName( @@ -68,6 +80,12 @@ public ResponseEntity> editMemberName( ); } + /** + * 현재 로그인한 사용자를 삭제하는 API + * 사용할 지 모르겠음. + * HTTP METHOD: DELETE + * @param userDetails @AuthenticationPrincipal로 받아오는 현재 사용자 정보 + */ @DeleteMapping @Operation(summary = "사용자 삭제") public ResponseEntity> deleteMember( @@ -86,6 +104,10 @@ public ResponseEntity> deleteMember( ); } + /** + * 모든 사용자의 정보를 조회하는 API + * HTTP METHOD: GET + */ @GetMapping("/all") @Operation(summary = "모든 사용자 정보 조회") public ResponseEntity>> getMemberInfoAll( @@ -105,6 +127,11 @@ public ResponseEntity>> getMemberInfoAll( ); } + /** + * ID 기반으로 사용자의 정보를 조회하는 API + * HTTP METHOD: GET + * @param id 조회할 사용자의 ID + */ @GetMapping("/{id}") @Operation(summary = "id 기반 사용자 정보 조회") public ResponseEntity> getMemberInfoById( @@ -122,6 +149,11 @@ public ResponseEntity> getMemberInfoById( ); } + /** + * 이름 기반으로 사용자의 정보를 조회하는 API + * HTTP METHOD: GET + * @param name 조회할 사용자의 name + */ @GetMapping @Operation(summary = "이름 기반 사용자 정보 조회") public ResponseEntity> getMemberInfoByName( diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java index 51b070ef..691fea94 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java @@ -46,11 +46,6 @@ public Member findByProviderKey(String providerKey) { ); } -// public Member findByEmail(String email){ -// return memberRepository.findByEmail(email).orElseThrow(() -> -// new NoResultException(email + " 이메일을 가진 사용자를 찾을 수 없습니다.") -// ); -// } public List findAll(){ return memberRepository.findAll(); } public List findAllActive(){ return memberRepository.findByActiveTrue(); } public List findAllInactive(){ return memberRepository.findByActiveFalse(); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java index 918e4d47..4c812eaf 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java @@ -19,14 +19,18 @@ public class ApiV1NewsController { private final NewsSearchService newsSearchService; + /** + * 최신 뉴스 목록을 조회하는 API + * 한번에 100개를 조회 합니다. + * HTTP METHOD: GET + */ @GetMapping @Operation(summary = "최신 뉴스 목록 조회") public Mono>> searchRecentNews( - @RequestParam(defaultValue = "10") int display ) { // Naver 뉴스 API에선 Non-keyword 방식의 검색을 지원하지 않음. // 그래서 일단 그냥 검색 쿼리를 '뉴스'라고 지정하고 해 보았는데, 꽤나 좋은 결과를 받아옴. (목표하던 기능과 비슷함.) - return newsSearchService.searchNews("뉴스", display, 1, "date") + return newsSearchService.searchNews("뉴스", "date") .map(response -> ResponseEntity .status(HttpStatus.OK) .body(new RsData<>( @@ -36,17 +40,22 @@ public Mono>> searchRecentNews( ))); } + /** + * 입력한 키워드 기반으로 뉴스 목록을 조회하는 API + * HTTP METHOD: GET + * 한번에 100개를 조회 합니다. + * @param dto 키워드를 받아오는 reqDto + */ @PostMapping("/keywords") @Operation(summary = "최신 뉴스 목록 조회") public Mono>> searchNewsByKeywords( - @RequestParam(defaultValue = "10") int display, @RequestBody ReqBodyForKeyword dto ) { String query = String.join(" ", dto.keywords()); // AND, OR 연산 쿼리를 지원한다고는 하는데, 정확한지는 모름. // String query = String.join("+", dto.keywords()); // AND 연산 키워드 검색 // String query = String.join("|", dto.keywords()); // OR 연산 키워드 검색 - return newsSearchService.searchNews(query, display, 1, "sim") + return newsSearchService.searchNews(query, "sim") .map(response -> ResponseEntity .status(HttpStatus.OK) .body(new RsData<>( diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/res/ResBodyForNaverNews.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/res/ResBodyForNaverNews.java index 5cd47800..19891fdc 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/res/ResBodyForNaverNews.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/dto/res/ResBodyForNaverNews.java @@ -3,10 +3,7 @@ import java.util.List; public record ResBodyForNaverNews( - String lastBuildDate, int total, - int start, - int display, List items ) { public record NewsItem( @@ -24,7 +21,8 @@ public NewsItem(String title, String link, String description, String pubDate) { private static String cleanText(String text) { if (text == null) return null; - return text.replaceAll("<.*?>", ""); + String noTags = text.replaceAll("<.*?>", ""); + return noTags.replaceAll("&[a-zA-Z0-9#]+;", ""); } } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java index 8d1d1581..1dad2c56 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @Service @@ -23,37 +24,66 @@ public NewsSearchService(WebClient.Builder webClientBuilder) { /** * 네이버 뉴스 API * @param query 검색어 (UTF-8로 인코딩 필수) - * @param display 한 번에 표시할 결과 수 (기본 값 10, 최대 100) - * @param start 검색 시작 위치 (기본값 1, 최대 1000) * @param sort 정렬 방식 ("sim", "date") sim: 정확도 순, date: 날짜 순, 둘다 내림차 순 정렬. */ /* Q. 어째서 WebClient를 사용하는가? A. WebClient -> 비동기/논블로킹 HTTP 클라이언트. - 즉, 현재 우리 시스템처럼 여러명의 사용자가 뉴스 API를 통한 검색을 요청할 경우, - 기존의 Spring MVC, RestTemplate를 사용하면 블로킹된 쓰레드가 바생하여 서버의 리소스를 효율적으로 사용하지 못함. + 즉, 현재 우리 시스템처럼 여러명의 사용자가 뉴스 API를 통한 검색을 요청할 경우, + 기존의 Spring MVC, RestTemplate를 사용하면 블로킹된 쓰레드가 바생하여 서버의 리소스를 효율적으로 사용하지 못함. - 추가로, WebFlux의 Mono/Flux의 경우엔 Backpressure를 지원하므로, 데이터가 너무 많이 들어올 경우 서버가 감당 가능하도록 흐름을 조절. - *Backpressure(백프레셔) : 수신자가 처리할 수 있는 속도로 발신자가 데이터를 보내도록 하는 것. + 추가로, WebFlux의 Mono/Flux의 경우엔 Backpressure를 지원하므로, + 데이터가 너무 많이 들어올 경우 서버가 감당 가능하도록 흐름을 조절. + *Backpressure(백프레셔) : 수신자가 처리할 수 있는 속도로 발신자가 데이터를 보내도록 하는 것. + + Q. 중복 뉴스나 불필요한 데이터는? + A. filter()를 통해 네이버 뉴스 본문 링크만 걸러내고, + distinct()를 적용하여 동일한 링크를 가진 뉴스는 제거. + → API가 중복된 데이터를 반환해도, 최종적으로는 고유한 뉴스만 내려줌. + + n.news.naver.com 도메인의 아닌 경우, 아직 크롤링 기능을 지원할 지 미정인 상태이므로, + 마찬가지로 예외 처리한다. (크롤링하지 않으면 요약 기능을 사용할 수 없기 때문.) + + Q. display=10 으로 요청했는데, 링크 필터링으로 인해 결과가 10개보다 작을 경우는? + A. 한 페이지만 요청할 경우, 필터링과 중복 제거 과정에서 일부 데이터가 빠져 결과의 개수가 적을 수 있다. + + 그래서 현재 로직 + 1. 여러 페이지를 호출한다.(.range(0, 1000 / finalDisplay), 최대 1000건 까지.) + 2. 필터링 & 중복 제거 + 3. display 값 만큼 최종 개수 제한(.take(display)) + 4. 그렇게 추출한 데이터를 collectList()로 모은 후, 다시 ResBodyForNaverNews로 감싸기. + + 위 과정을 거쳐도 개수가 부족한 경우엔 부족한 만큼 내보낸다. */ - public Mono searchNews(String query, Integer display, Integer start, String sort) { - int finalDisplay = (display == null) ? 10 : Math.min(display, 100); - int finalStart = (start == null) ? 1 : Math.min(start, 1000); + public Mono searchNews(String query, String sort) { + int finalDisplay = 100; String finalSort = (sort == null || (!sort.equals("sim") && !sort.equals("date"))) ? "sim" : sort; - return webClient.get() - .uri(uriBuilder -> uriBuilder - .path("v1/search/news.json") - .queryParam("query", query) - .queryParam("display", finalDisplay) - .queryParam("start", finalStart) - .queryParam("sort", finalSort) - .build()) - .header("X-Naver-Client-Id", client_id) - .header("X-Naver-Client-Secret", client_secret) - .retrieve() - .bodyToMono(ResBodyForNaverNews.class); + return Flux + .range(0, 10000 / finalDisplay) + .concatMap(page -> webClient.get() + .uri(uriBuilder -> uriBuilder + .path("v1/search/news.json") + .queryParam("query", query) + .queryParam("display", finalDisplay) + .queryParam("start", page * finalDisplay + 1) + .queryParam("sort", finalSort) + .build()) + .header("X-Naver-Client-Id", client_id) + .header("X-Naver-Client-Secret", client_secret) + .retrieve() + .bodyToMono(ResBodyForNaverNews.class) + .flatMapMany(res -> Flux.fromIterable(res.items())) + .filter(item -> item.link().startsWith("https://n.news.naver.com/")) + ) + .distinct(ResBodyForNaverNews.NewsItem::link) + .take(100) + .collectList() + .map(items -> new ResBodyForNaverNews( + items.size(), + items + )); } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java index e5478d3d..cbc6a530 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java @@ -5,16 +5,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfo; import org.tuna.zoopzoop.backend.domain.member.entity.Member; -import org.tuna.zoopzoop.backend.domain.space.membership.dto.ResBodyForSpaceInvitationList; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; -import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; import org.tuna.zoopzoop.backend.domain.space.space.dto.ResBodyForSpaceInviteList; import org.tuna.zoopzoop.backend.domain.space.space.dto.ResBodyForSpaceSave; import org.tuna.zoopzoop.backend.domain.space.space.dto.SpaceMembershipInfoWithoutAuthority; -import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; import org.tuna.zoopzoop.backend.global.rsData.RsData; import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; @@ -100,5 +96,4 @@ public RsData getMyInvites( ) ); } - } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java index c91b27fa..1c90cc1f 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java @@ -2,47 +2,29 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; import reactor.core.publisher.Mono; -import java.util.List; - import static org.junit.jupiter.api.Assertions.assertNotNull; @SpringBootTest @ActiveProfiles("test") class NewsServiceTest { + @Autowired + private NewsSearchService newsSearchService; @Test @DisplayName("뉴스 서비스 테스트 - 정상적인 JSON 구조 반환 여부 확인") void newsJsonStructureTest() { - // JSON 구조용 더미 데이터 - ResBodyForNaverNews dummyResponse = new ResBodyForNaverNews( - "Mon, 22 Sep 2025 17:35:10 +0900", // lastBuildDate - 505376, // total - 1, // start - 5, // display - List.of( - new ResBodyForNaverNews.NewsItem( // items - "뉴스 제목", // title - "링크", // link - "설명", // description - "발행일" // pubDate - ) - ) - ); - - Mono result = Mono.just(dummyResponse); + Mono result = newsSearchService.searchNews("뉴스", "sim"); // JSON 구조 확인 result.doOnNext(res -> { - assertNotNull(res.lastBuildDate()); assertNotNull(res.total()); - assertNotNull(res.start()); - assertNotNull(res.display()); assertNotNull(res.items()); res.items().forEach(item -> { From ac4597bc9997537d725fd67bbe0e5188624974cd Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:22:27 +0900 Subject: [PATCH 027/132] =?UTF-8?q?Ops=20226=20be=20feat=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EB=93=B1=EB=A1=9D=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-283 : datasource 엔티티 수정 * [Feat/OPS-186] 스페이스 초대 관리 (#38) * new/OPS-189 : 초대 관리 Controller 및 테스트 클래스 생성 * feat/OPS-189 : 초대 수락 관련 테스트 코드 작성 * feat : 멤버 권한 변경 함수 생성 * feat/OPS-189 : 초대 수락 구현 * feat/OPS-190 : 초대 거절 테스트 케이스 작성 * feat/OPS-190 : 초대 거절 엔드포인트 생성 * feat/OPS-190 : membershipService 관련 단위 테스트 추가 * refactor : 코드 간단한 위치 조정 * feat/OPS-188 : 스페이스 초대 목록 조회 완료 * fix/OPS-188 : dto 네이밍 수정 * refactor/OPS-188 : 스페이스에 초대된 유저 목록 조회의 controller 위치 변경 * feat : 사용자에게 온 스페이스 초대 목록 조회 엔드포인트 생성 * feat : 사용자에게 온 스페이스 초대 목록 조회 테스트 케이스 작성 --------- Co-authored-by: EpicFn * refactor/OPS-285 : datasource 엔티티 thumbnailUrl 이름 변경 (#40) * refactor/OPS-285 : datasource 엔티티 thumbnailUrl 이름 변경 * refactor/OPS-285 : datasource 엔티티 thumbnailUrl 이름 변경 * feat/OPS-226 : 자료 등록 구현 * refactor/OPS-226 : 메서드 명 수정 --------- Co-authored-by: Hyeok Jin Kim <71928299+EpicFn@users.noreply.github.com> Co-authored-by: EpicFn --- .../domain/archive/folder/entity/Folder.java | 2 +- .../folder/repository/FolderRepository.java | 5 + .../controller/DatasourceController.java | 38 +++++ .../dto/reqBodyForCreateDataSource.java | 7 + .../repository/DataSourceRepository.java | 7 + .../datasource/service/DataSourceService.java | 81 +++++++++++ .../controller/DatasourceControllerTest.java | 79 +++++++++++ .../service/DataSourceServiceTest.java | 131 ++++++++++++++++++ 8 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForCreateDataSource.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java index da984bd9..8d448394 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/entity/Folder.java @@ -39,7 +39,7 @@ public class Folder extends BaseEntity { private String name; //디폴트 폴더 여부 - @Column(nullable = false) + @Column(nullable = false, name = "is_default") private boolean isDefault = false; // 폴더 삭제 시 데이터 일괄 삭제 diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java index df36315f..647272af 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java @@ -6,6 +6,7 @@ import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import java.util.List; +import java.util.Optional; public interface FolderRepository extends JpaRepository{ /** @@ -24,4 +25,8 @@ public interface FolderRepository extends JpaRepository{ List findNamesForConflictCheck(Integer archiveId, String filename, String filenameEnd); List findByArchive(Archive archive); + + Optional findByName(String name); + + Optional findByArchiveIdAndIsDefaultTrue(Integer archiveId); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java new file mode 100644 index 00000000..e7fba6d1 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java @@ -0,0 +1,38 @@ +package org.tuna.zoopzoop.backend.domain.datasource.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.datasource.dto.*; +import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; +import org.tuna.zoopzoop.backend.global.security.StubAuthUtil; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/archive") +@RequiredArgsConstructor +public class DatasourceController { + + private final DataSourceService dataSourceService; + + /** + * 자료 등록 + * sourceUrl 등록할 자료 url + * folderId 등록될 폴더 위치(null 이면 default) + */ + @PostMapping("") + public ResponseEntity createDataSource(@Valid @RequestBody reqBodyForCreateDataSource rq) { + + //임시 인증 정보 + Integer currentMemberId = StubAuthUtil.currentMemberId(); + int rs = dataSourceService.createDataSource(currentMemberId, rq.sourceUrl(), rq.folderId()); + return ResponseEntity.ok() + .body( + new ApiResponse<>(200, "새로운 자료가 등록됐습니다.", rs) + ); + } + + record ApiResponse(int status, String msg, T data) {} +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForCreateDataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForCreateDataSource.java new file mode 100644 index 00000000..5c39df28 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForCreateDataSource.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; +import jakarta.validation.constraints.NotBlank; + +public record reqBodyForCreateDataSource( + @NotBlank String sourceUrl, + Integer folderId // null 일 경우 default 폴더(최상위 폴더) +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java index f164a75f..85647acb 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java @@ -1,14 +1,21 @@ package org.tuna.zoopzoop.backend.domain.datasource.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import java.util.Collection; import java.util.List; @Repository public interface DataSourceRepository extends JpaRepository { List findAllByFolder(Folder folder); + + @Query("select d.id from DataSource d where d.id in ?1") + java.util.List findExistingIds(Collection ids); + + boolean existsByFolder_IdAndTitle(Integer folderId, String title); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java new file mode 100644 index 00000000..5c8186d1 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java @@ -0,0 +1,81 @@ +package org.tuna.zoopzoop.backend.domain.datasource.service; + +import jakarta.persistence.NoResultException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; +import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; + +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class DataSourceService { + private final DataSourceRepository dataSourceRepository; + private final FolderRepository folderRepository; + private final PersonalArchiveRepository personalArchiveRepository; + + /** + * 지정한 folder 위치에 자료 생성 + * @param currentMemberId 현재 로그인한 유저 Id + * @param sourceUrl 생성할 자료의 url + * @param folderId 생성될 폴더 위치 Id + */ + @Transactional + public int createDataSource(int currentMemberId, String sourceUrl, Integer folderId) { + Folder folder; + // default 폴더에 데이터 넣을 경우 + if(folderId == null) + folder = findDefaultFolder(currentMemberId); + // Id에 해당하는 폴더에 데이터 넣을 경우 + else + folder = folderRepository.findById(folderId) + .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); + + // 임시 파일 생성 메서드 + DataSource ds = buildDataSource(sourceUrl, folder); + DataSource saved = dataSourceRepository.save(ds); + + return saved.getId(); + } + + /** + * 임시 data build 메서드 + * 추후 title,summary, tag, category, imgUrl 불러올 예정 + */ + private DataSource buildDataSource(String sourceUrl, Folder folder) { + DataSource ds = new DataSource(); + ds.setFolder(folder); + ds.setSourceUrl(sourceUrl); + ds.setTitle("자료 제목"); + ds.setSummary("설명"); + ds.setImageUrl("www.example.com/img"); + ds.setDataCreatedDate(LocalDate.now()); + ds.setActive(true); + return ds; + } + + /** + * default 폴더에 해당하는 FolderId 반환 + * folder의 isDefault 속성 + 인덱스(archiveId)로 탐색 + */ + private Folder findDefaultFolder(int currentMemberId) { + // 현재 로그인 Id 기반 Personal Archive Id 탐색 + PersonalArchive pa = personalArchiveRepository.findByMemberId(currentMemberId) + .orElseThrow(() -> new NoResultException("개인 아카이브를 찾을 수 없습니다.")); + + // 2. PersonalArchive 안에 연결된 Archive 조회 + Integer archiveId = pa.getArchive().getId(); + + // 3. 해당 Archive 내 default 폴더 조회 + return folderRepository.findByArchiveIdAndIsDefaultTrue(archiveId) + .orElseThrow(() -> new NoResultException("default 폴더를 찾을 수 없습니다.")); + } +} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java new file mode 100644 index 00000000..01b337bc --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java @@ -0,0 +1,79 @@ +package org.tuna.zoopzoop.backend.domain.datasource.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.NoResultException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForCreateDataSource; +import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; +import org.tuna.zoopzoop.backend.global.exception.GlobalExceptionHandler; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class DatasourceControllerTest { + + @Mock private DataSourceService dataSourceService; + @InjectMocks private DatasourceController datasourceController; + + private MockMvc mockMvc; + private ObjectMapper om; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + om = new ObjectMapper(); + + // ✅ 한 번만 생성 + 전역 예외핸들러 등록 + mockMvc = MockMvcBuilders + .standaloneSetup(datasourceController) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + // create + @Test + @DisplayName("자료 생성 성공 - folderId=null → default 폴더에 등록") + void create_defaultFolder() throws Exception { + var rq = new reqBodyForCreateDataSource("https://example.com/a", null); + + when(dataSourceService.createDataSource(anyInt(), eq(rq.sourceUrl()), isNull())) + .thenReturn(1001); + + mockMvc.perform(post("/api/v1/archive") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(rq))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("새로운 자료가 등록됐습니다.")) + .andExpect(jsonPath("$.data").value(1001)); + } + + @Test + @DisplayName("자료 생성 성공 - folderId 지정 → 해당 폴더에 등록") + void create_specificFolder() throws Exception { + var rq = new reqBodyForCreateDataSource("https://example.com/b", 55); + + when(dataSourceService.createDataSource(anyInt(), eq(rq.sourceUrl()), eq(rq.folderId()))) + .thenReturn(2002); + + mockMvc.perform(post("/api/v1/archive") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(rq))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("새로운 자료가 등록됐습니다.")) + .andExpect(jsonPath("$.data").value(2002)); + } +} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java new file mode 100644 index 00000000..88b8f9b6 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java @@ -0,0 +1,131 @@ +package org.tuna.zoopzoop.backend.domain.datasource.service; + +import jakarta.persistence.NoResultException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; +import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DataSourceServiceTest { + + @Mock private DataSourceRepository dataSourceRepository; + @Mock private FolderRepository folderRepository; + @Mock private PersonalArchiveRepository personalArchiveRepository; + + @InjectMocks private DataSourceService dataSourceService; + + // create + @Test + @DisplayName("폴더 생성 성공- folderId=null 이면 default 폴더에 자료 생성") + void createDataSource_defaultFolder() { + int currentMemberId = 10; + String sourceUrl = "https://example.com/a"; + + // PersonalArchive 생성 시 Archive + default folder 자동 생성됨 + Member member = new Member("u1", "k-1", Provider.KAKAO, null); + PersonalArchive pa = new PersonalArchive(member); + Integer archiveId = pa.getArchive().getId(); // 실제 id는 없지만, 아래 anyInt()로 받게 스텁함 + + when(personalArchiveRepository.findByMemberId(eq(currentMemberId))) + .thenReturn(Optional.of(pa)); + + Folder defaultFolder = new Folder("default"); + // 리얼 구현은 archiveId 기준으로 찾으니 시그니처 맞추기 + when(folderRepository.findByArchiveIdAndIsDefaultTrue(anyInt())) + .thenReturn(Optional.of(defaultFolder)); + + when(dataSourceRepository.save(any(DataSource.class))) + .thenAnswer(inv -> { + DataSource ds = inv.getArgument(0); + ReflectionTestUtils.setField(ds, "id", 123); + return ds; + }); + + int id = dataSourceService.createDataSource(currentMemberId, sourceUrl, null); + assertThat(id).isEqualTo(123); + } + + + @Test + @DisplayName("폴더 생성 성공- folderId가 주어지면 해당 폴더에 자료 생성") + void createDataSource_specificFolder() { + // given + int currentMemberId = 10; + String sourceUrl = "https://example.com/b"; + Integer folderId = 77; + + Folder target = new Folder("target"); + // BaseEntity.id 는 protected setter → 리플렉션으로 주입 + org.springframework.test.util.ReflectionTestUtils.setField(target, "id", folderId); + + when(folderRepository.findById(eq(folderId))).thenReturn(Optional.of(target)); + + // save(...) 시에 PK가 채워진 것처럼 반환 + when(dataSourceRepository.save(any(DataSource.class))) + .thenAnswer(inv -> { + DataSource ds = inv.getArgument(0); + org.springframework.test.util.ReflectionTestUtils.setField(ds, "id", 456); + return ds; + }); + + // when + int id = dataSourceService.createDataSource(currentMemberId, sourceUrl, folderId); + + // then + assertThat(id).isEqualTo(456); + } + + @Test + @DisplayName("폴대 생성 실패 - folderId가 주어졌는데 대상 폴더가 없으면 예외") + void createDataSource_folderNotFound() { + // given + Integer folderId = 999; + + when(folderRepository.findById(eq(folderId))).thenReturn(Optional.empty()); + + // when / then + assertThrows(NoResultException.class, () -> + dataSourceService.createDataSource(1, "https://x", folderId) + ); + } + + @Test + @DisplayName("폴더 생성 실패 - folderId=null이고 default 폴더를 못 찾으면 예외") + void createDataSource_defaultFolderNotFound() { + // given + int currentMemberId = 10; + + PersonalArchive pa = new PersonalArchive(new Member("u1","p", Provider.KAKAO,null)); + when(personalArchiveRepository.findByMemberId(eq(currentMemberId))) + .thenReturn(Optional.of(pa)); + when(folderRepository.findByArchiveIdAndIsDefaultTrue(anyInt())) + .thenReturn(Optional.empty()); + + // when / then + assertThrows(NoResultException.class, () -> + dataSourceService.createDataSource(currentMemberId, "https://x", null) + ); + } + +} From bec419afc2084ae225fcfbf75fc20c74afc91b45 Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:15:17 +0900 Subject: [PATCH 028/132] =?UTF-8?q?Ops=20215=20be=20feat=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=82=AD=EC=A0=9C=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-283 : datasource 엔티티 수정 * [Feat/OPS-186] 스페이스 초대 관리 (#38) * new/OPS-189 : 초대 관리 Controller 및 테스트 클래스 생성 * feat/OPS-189 : 초대 수락 관련 테스트 코드 작성 * feat : 멤버 권한 변경 함수 생성 * feat/OPS-189 : 초대 수락 구현 * feat/OPS-190 : 초대 거절 테스트 케이스 작성 * feat/OPS-190 : 초대 거절 엔드포인트 생성 * feat/OPS-190 : membershipService 관련 단위 테스트 추가 * refactor : 코드 간단한 위치 조정 * feat/OPS-188 : 스페이스 초대 목록 조회 완료 * fix/OPS-188 : dto 네이밍 수정 * refactor/OPS-188 : 스페이스에 초대된 유저 목록 조회의 controller 위치 변경 * feat : 사용자에게 온 스페이스 초대 목록 조회 엔드포인트 생성 * feat : 사용자에게 온 스페이스 초대 목록 조회 테스트 케이스 작성 --------- Co-authored-by: EpicFn * refactor/OPS-285 : datasource 엔티티 thumbnailUrl 이름 변경 (#40) * refactor/OPS-285 : datasource 엔티티 thumbnailUrl 이름 변경 * refactor/OPS-285 : datasource 엔티티 thumbnailUrl 이름 변경 * feat/OPS-226 : 자료 등록 구현 * feat/OPS-215 : 자료 삭제 구현 * feat/OPS-215 : 자료 삭제 구현 * feat/OPS-215 : 자료 삭제 구현 * feat/OPS-215 : 자료 삭제 구현 --------- Co-authored-by: Hyeok Jin Kim <71928299+EpicFn@users.noreply.github.com> Co-authored-by: EpicFn --- .../controller/DatasourceController.java | 33 ++++++++ .../datasource/dto/reqBodyForDeleteMany.java | 10 +++ .../dto/reqBodyForMoveDataSource.java | 7 ++ .../datasource/dto/reqBodyForMoveMany.java | 11 +++ .../dto/resBodyForMoveDataSource.java | 6 ++ .../datasource/service/DataSourceService.java | 42 +++++++++- .../controller/DatasourceControllerTest.java | 76 +++++++++++++++++++ .../service/DataSourceServiceTest.java | 59 ++++++++++++++ 8 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForDeleteMany.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveDataSource.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveMany.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/resBodyForMoveDataSource.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java index e7fba6d1..5727130f 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java @@ -34,5 +34,38 @@ public ResponseEntity createDataSource(@Valid @RequestBody reqBodyForCreateDa ); } + /** + * 자료 단건 삭제 + */ + @DeleteMapping("/{dataSourceId}") + public ResponseEntity> delete(@PathVariable Integer dataSourceId) { + int deletedId = dataSourceService.deleteById(dataSourceId); + return ResponseEntity.ok( + Map.of( + "status", 200, + "msg", deletedId + "번 자료가 삭제됐습니다.", + "data", Map.of("dataSourceId", deletedId) + ) + ); + } + + /** + * 자료 다건 삭제 + */ + @PostMapping("/delete") + public ResponseEntity> deleteMany( + @Valid @RequestBody reqBodyForDeleteMany body + ) { + dataSourceService.deleteMany(body.dataSourceId()); + + // ✅ Map.of 는 null 불가 → LinkedHashMap 사용 + Map res = new java.util.LinkedHashMap<>(); + res.put("status", 200); + res.put("msg", "복수개의 자료가 삭제됐습니다."); + res.put("data", null); + + return ResponseEntity.ok(res); + } + record ApiResponse(int status, String msg, T data) {} } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForDeleteMany.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForDeleteMany.java new file mode 100644 index 00000000..24db66bc --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForDeleteMany.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; + +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +public record reqBodyForDeleteMany( + @NotEmpty(message = "dataSourceId 배열은 비어있을 수 없습니다.") + List dataSourceId +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveDataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveDataSource.java new file mode 100644 index 00000000..10b8ccdc --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveDataSource.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; + +import jakarta.validation.constraints.NotNull; + +public record reqBodyForMoveDataSource( + @NotNull Integer folderId +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveMany.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveMany.java new file mode 100644 index 00000000..e98ebf6d --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveMany.java @@ -0,0 +1,11 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record reqBodyForMoveMany( + @NotNull Integer folderId, + @NotEmpty List dataSourceId +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/resBodyForMoveDataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/resBodyForMoveDataSource.java new file mode 100644 index 00000000..f286a0e4 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/resBodyForMoveDataSource.java @@ -0,0 +1,6 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; + +public record resBodyForMoveDataSource( + Integer dataSourceId, + Integer folderId +) {} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java index 5c8186d1..75a0f96b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java @@ -8,6 +8,7 @@ import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dto.resBodyForMoveDataSource; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; @@ -34,7 +35,7 @@ public int createDataSource(int currentMemberId, String sourceUrl, Integer folde // default 폴더에 데이터 넣을 경우 if(folderId == null) folder = findDefaultFolder(currentMemberId); - // Id에 해당하는 폴더에 데이터 넣을 경우 + // Id에 해당하는 폴더에 데이터 넣을 경우 else folder = folderRepository.findById(folderId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); @@ -65,7 +66,7 @@ private DataSource buildDataSource(String sourceUrl, Folder folder) { /** * default 폴더에 해당하는 FolderId 반환 * folder의 isDefault 속성 + 인덱스(archiveId)로 탐색 - */ + */ private Folder findDefaultFolder(int currentMemberId) { // 현재 로그인 Id 기반 Personal Archive Id 탐색 PersonalArchive pa = personalArchiveRepository.findByMemberId(currentMemberId) @@ -78,4 +79,41 @@ private Folder findDefaultFolder(int currentMemberId) { return folderRepository.findByArchiveIdAndIsDefaultTrue(archiveId) .orElseThrow(() -> new NoResultException("default 폴더를 찾을 수 없습니다.")); } + + /** + * 자료 단건 삭제 + * soft delete 추후 구현 예정 + * @param dataSourceId 삭제할 자료 Id + */ + @Transactional + public int deleteById(Integer dataSourceId) { + DataSource ds = dataSourceRepository.findById(dataSourceId) + .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); + + /* 추후 권한 체크 예외 필요 */ + + dataSourceRepository.delete(ds); + return dataSourceId; + } + + /** + * 자료 다건 삭제 + * 모든 자료 id가 존재해야 함 (부분 존재 시 404) + */ + @Transactional + public void deleteMany(List ids) { + if (ids == null || ids.isEmpty()) { + throw new IllegalArgumentException("삭제할 자료 id 배열이 비어있습니다."); + } + + // 존재 여부 검증 (부분 존재 시 누락 ID 명시) + List existing = dataSourceRepository.findExistingIds(ids); + if (existing.size() != ids.size()) { + Set missing = new HashSet<>(ids); + missing.removeAll(new HashSet<>(existing)); + throw new NoResultException("존재하지 않는 자료 ID 포함: " + missing); + } + + dataSourceRepository.deleteAllByIdInBatch(ids); + } } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java index 01b337bc..92fbf4a2 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java @@ -12,6 +12,9 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForCreateDataSource; +import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForDeleteMany; +import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForMoveDataSource; +import org.tuna.zoopzoop.backend.domain.datasource.dto.resBodyForMoveDataSource; import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; import org.tuna.zoopzoop.backend.global.exception.GlobalExceptionHandler; @@ -76,4 +79,77 @@ void create_specificFolder() throws Exception { .andExpect(jsonPath("$.msg").value("새로운 자료가 등록됐습니다.")) .andExpect(jsonPath("$.data").value(2002)); } + + // delete + @Test + @DisplayName("단건 삭제 성공 -> 200") + void delete_success() throws Exception { + // given + int id = 123; + when(dataSourceService.deleteById(id)).thenReturn(id); + + // when & then + mockMvc.perform(delete("/api/v1/archive/{id}", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value(id + "번 자료가 삭제됐습니다.")) + .andExpect(jsonPath("$.data.dataSourceId").value(id)); + } + + @Test + @DisplayName("단건 삭제 실패: 자료 없음 → 404 Not Found") + void delete_notFound() throws Exception { + int id = 999; + when(dataSourceService.deleteById(id)) + .thenThrow(new NoResultException("존재하지 않는 자료입니다.")); + + mockMvc.perform(delete("/api/v1/archive/{id}", id)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value("존재하지 않는 자료입니다.")); + } + + // deleteMany + @Test + @DisplayName("다건 삭제 성공 -> 200") + void deleteMany_success() throws Exception { + var body = new reqBodyForDeleteMany(List.of(10, 20, 30)); + doNothing().when(dataSourceService).deleteMany(anyList()); + + mockMvc.perform(post("/api/v1/archive/delete") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("복수개의 자료가 삭제됐습니다.")) + .andExpect(jsonPath("$.data").value(org.hamcrest.Matchers.nullValue())); + } + + @Test + @DisplayName("다건 삭제 실패: 배열 비어있음 → 400 Bad Request") + void deleteMany_empty() throws Exception { + // @NotEmpty로 잡히면 MethodArgumentNotValidException(400), 서비스에서 잡히면 IllegalArgumentException(400) + var empty = new reqBodyForDeleteMany(List.of()); + + mockMvc.perform(post("/api/v1/archive/delete") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(empty))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("400")); + } + + @Test + @DisplayName("다건 삭제 실패: 일부 ID 미존재 → 404 Not Found") + void deleteMany_partialMissing() throws Exception { + var body = new reqBodyForDeleteMany(List.of(1, 2, 3)); + doThrow(new NoResultException("존재하지 않는 자료 ID 포함: [2]")) + .when(dataSourceService).deleteMany(anyList()); + + mockMvc.perform(post("/api/v1/archive/delete") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(body))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value("존재하지 않는 자료 ID 포함: [2]")); + } } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java index 88b8f9b6..77ffa13c 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java @@ -12,6 +12,7 @@ import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dto.resBodyForMoveDataSource; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; import org.tuna.zoopzoop.backend.domain.member.entity.Member; @@ -128,4 +129,62 @@ void createDataSource_defaultFolderNotFound() { ); } + // delete + @Test + @DisplayName("단건 삭제 성공 - 존재하는 자료 삭제 시 ID 반환") + void deleteById_success() { + // given + int id = 123; + DataSource mockData = new DataSource(); + when(dataSourceRepository.findById(id)).thenReturn(Optional.of(mockData)); + + // when + int deletedId = dataSourceService.deleteById(id); + + // then + assertThat(deletedId).isEqualTo(id); + verify(dataSourceRepository).delete(mockData); + } + + @Test + @DisplayName("단건 삭제 실패 - 자료가 존재하지 않으면 예외 발생") + void deleteById_notFound() { + // given + int id = 999; + when(dataSourceRepository.findById(id)).thenReturn(Optional.empty()); + + // when & then + assertThrows(NoResultException.class, () -> dataSourceService.deleteById(id)); + verify(dataSourceRepository, never()).delete(any()); + } + + // deleteMany + @Test + @DisplayName("다건 삭제 성공 - 일괄 삭제") + void deleteMany_success() { + List ids = List.of(1, 2, 3); + when(dataSourceRepository.findExistingIds(ids)).thenReturn(ids); + + dataSourceService.deleteMany(ids); + + verify(dataSourceRepository).deleteAllByIdInBatch(ids); + } + + @Test + @DisplayName("다건 삭제 실패 - 요청 배열이 비어있음 → 400") + void deleteMany_empty() { + assertThrows(IllegalArgumentException.class, () -> dataSourceService.deleteMany(List.of())); + verifyNoInteractions(dataSourceRepository); + } + + @Test + @DisplayName("다건 삭제 실패 - 일부 ID 미존재 → 404") + void deleteMany_partialMissing() { + List ids = List.of(1, 2, 3); + when(dataSourceRepository.findExistingIds(ids)).thenReturn(List.of(1, 3)); + + assertThrows(NoResultException.class, () -> dataSourceService.deleteMany(ids)); + + verify(dataSourceRepository, never()).deleteAllByIdInBatch(any()); + } } From 3b25c413fdbe8fdba94798f54799e5bd172920de Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:24:49 +0900 Subject: [PATCH 029/132] =?UTF-8?q?[feat/OPS-293]=20LiveBlocks=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20React-flow=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EC=99=84=EB=A3=8C.=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80.=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-293 : LiveBlocks를 위한 React-flow 데이터 관리 도메인 설계 완료. 테스트 추가. * feat/OPS-293 : folder 테스트에 @Transactional 추가. * feat/OPS-293 : MemberServiceTest 수정 #1 * feat/OPS-293 : MemberServiceTest 수정 #2 * feat/OPS-293 : Folder 테스트에 ActiveProfile(test) 추가. * feat/OPS-293 : 테스트에 무결성 검증을 위한 @AfterAll, @AfterEach 추가. * feat/OPS-293 : 일부 수정. * feat/OPS-293 : 진짜 마지막 수정. --- .../controller/ApiV1GraphController.java | 60 ++++++++ .../domain/graph/dto/BodyForReactFlow.java | 108 +++++++++++++++ .../backend/domain/graph/entity/Edge.java | 38 ++++++ .../backend/domain/graph/entity/Graph.java | 22 +++ .../backend/domain/graph/entity/Node.java | 38 ++++++ .../backend/domain/graph/enums/EdgeType.java | 8 ++ .../backend/domain/graph/enums/NodeType.java | 5 + .../graph/repository/EdgeRepository.java | 7 + .../graph/repository/GraphRepository.java | 10 ++ .../graph/repository/NodeRepository.java | 7 + .../domain/graph/service/GraphService.java | 26 ++++ .../backend/domain/member/entity/Member.java | 1 + .../controller/FolderControllerTest.java | 4 + .../folder/service/FolderServiceTest.java | 4 + .../graph/controller/GraphControllerTest.java | 129 ++++++++++++++++++ .../graph/service/GraphServiceTest.java | 78 +++++++++++ .../controller/MemberControllerTest.java | 11 +- .../repository/MemberRepositoryTest.java | 6 + .../member/service/MemberServiceTest.java | 11 +- 19 files changed, 567 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/controller/ApiV1GraphController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/dto/BodyForReactFlow.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Edge.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Graph.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Node.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/EdgeType.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/NodeType.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/EdgeRepository.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/GraphRepository.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/NodeRepository.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphService.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/graph/controller/GraphControllerTest.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphServiceTest.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/controller/ApiV1GraphController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/controller/ApiV1GraphController.java new file mode 100644 index 00000000..3cf584f8 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/controller/ApiV1GraphController.java @@ -0,0 +1,60 @@ +package org.tuna.zoopzoop.backend.domain.graph.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.graph.dto.BodyForReactFlow; +import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; +import org.tuna.zoopzoop.backend.domain.graph.service.GraphService; +import org.tuna.zoopzoop.backend.global.rsData.RsData; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/graph") +@Tag(name = "ApiV1GraphController", description = "React-flow 데이터 컨트롤러") +public class ApiV1GraphController { + private final GraphService graphService; + + /** + * LiveBlocks를 위한 React-flow 데이터 저장 API + * @param bodyForReactFlow React-flow 데이터를 가지고 있는 Dto + */ + @PostMapping + @Operation(summary = "React-flow 데이터 저장") + public ResponseEntity> createGraph( + @RequestBody BodyForReactFlow bodyForReactFlow + ) { + Graph graph = bodyForReactFlow.toEntity(); + graphService.saveGraph(graph); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "React-flow 데이터를 저장 했습니다.", + null + )); + } + + /** + * LiveBlocks를 위한 React-flow 데이터 조회 API + * @param id React-flow 데이터의 graph 식별 id + */ + @GetMapping("/{id}") + @Operation(summary = "React-flow 데이터 조회") + public ResponseEntity> getGraph( + @PathVariable Integer id + ) { + Graph graph = graphService.getGraph(id); + return ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "ID: " + id + " 의 React-flow 데이터를 조회했습니다.", + BodyForReactFlow.from(graph) + )); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/dto/BodyForReactFlow.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/dto/BodyForReactFlow.java new file mode 100644 index 00000000..79d97b13 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/dto/BodyForReactFlow.java @@ -0,0 +1,108 @@ +package org.tuna.zoopzoop.backend.domain.graph.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.tuna.zoopzoop.backend.domain.graph.entity.Edge; +import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; +import org.tuna.zoopzoop.backend.domain.graph.entity.Node; +import org.tuna.zoopzoop.backend.domain.graph.enums.EdgeType; +import org.tuna.zoopzoop.backend.domain.graph.enums.NodeType; + +import java.util.List; +import java.util.Map; + +// Request, Response 범용 Dto +public record BodyForReactFlow( + List nodes, + List edges +) { + public record NodeDto( + @JsonProperty("id") String nodeKey, + @JsonProperty("type") String nodeType, + Map data, + @JsonProperty("position") PositionDto positionDto + ) { + public record PositionDto( + @JsonProperty("x") double x, + @JsonProperty("y") double y + ) {} + } + + public record EdgeDto( + @JsonProperty("id") String edgeKey, + @JsonProperty("source") String sourceNodeKey, + @JsonProperty("target") String targetNodeKey, + @JsonProperty("type") String edgeType, + @JsonProperty("animated") boolean isAnimated, + @JsonProperty("style") StyleDto styleDto + ) { + public record StyleDto( + String stroke, + Double strokeWidth + ) {} + } + + // DTO -> Entity, BodyForReactFlow를 Graph 엔티티로 변환 + public Graph toEntity() { + Graph graph = new Graph(); + + List nodeEntities = this.nodes().stream() + .map(dto -> { + Node node = new Node(); + node.setNodeKey(dto.nodeKey()); + node.setNodeType(NodeType.valueOf(dto.nodeType().toUpperCase())); + node.setData(dto.data()); + node.setPositonX(dto.positionDto().x()); + node.setPositonY(dto.positionDto().y()); + node.setGraph(graph); // 연관관계 설정 + return node; + }) + .toList(); + + List edgeEntities = this.edges().stream() + .map(dto -> { + Edge edge = new Edge(); + edge.setEdgeKey(dto.edgeKey()); + edge.setSourceNodeKey(dto.sourceNodeKey()); + edge.setTargetNodeKey(dto.targetNodeKey()); + edge.setEdgeType(EdgeType.valueOf(dto.edgeType().toUpperCase())); + edge.setAnimated(dto.isAnimated()); + if (dto.styleDto() != null) { + edge.setStroke(dto.styleDto().stroke()); + edge.setStrokeWidth(dto.styleDto().strokeWidth()); + } + edge.setGraph(graph); // 연관관계 설정 + return edge; + }) + .toList(); + + graph.getNodes().addAll(nodeEntities); + graph.getEdges().addAll(edgeEntities); + + return graph; + } + + // Entity -> DTO, Graph 엔티티를 ResBodyForReactFlow로 변환 + public static BodyForReactFlow from(Graph graph) { + List nodeDtos = graph.getNodes().stream() + .map(n -> new NodeDto( + n.getNodeKey(), + n.getNodeType().name().toUpperCase(), + n.getData(), + new NodeDto.PositionDto(n.getPositonX(), n.getPositonY()) + )) + .toList(); + + List edgeDtos = graph.getEdges().stream() + .map(e -> new EdgeDto( + e.getEdgeKey(), + e.getSourceNodeKey(), + e.getTargetNodeKey(), + e.getEdgeType().name().toUpperCase(), + e.isAnimated(), + new EdgeDto.StyleDto(e.getStroke(), e.getStrokeWidth()) + )) + .toList(); + + return new BodyForReactFlow(nodeDtos, edgeDtos); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Edge.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Edge.java new file mode 100644 index 00000000..e6fdf5de --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Edge.java @@ -0,0 +1,38 @@ +package org.tuna.zoopzoop.backend.domain.graph.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.tuna.zoopzoop.backend.domain.graph.enums.EdgeType; +import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; + +@Getter +@Setter +@Entity +public class Edge extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "graph_id") + private Graph graph; + + @Column + private String edgeKey; + + @Column + private String sourceNodeKey; + + @Column + private String targetNodeKey; + + @Column + @Enumerated(EnumType.STRING) + private EdgeType edgeType; + + @Column + boolean isAnimated; + + @Column + private String stroke; + + @Column + private Double strokeWidth; +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Graph.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Graph.java new file mode 100644 index 00000000..53c10d0a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Graph.java @@ -0,0 +1,22 @@ +package org.tuna.zoopzoop.backend.domain.graph.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import lombok.Getter; +import lombok.Setter; +import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@Entity +public class Graph extends BaseEntity { + @OneToMany(mappedBy = "graph", cascade = CascadeType.ALL, orphanRemoval = true) + private List nodes = new ArrayList<>(); + + @OneToMany(mappedBy = "graph", cascade = CascadeType.ALL, orphanRemoval = true) + private List edges = new ArrayList<>(); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Node.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Node.java new file mode 100644 index 00000000..5a835461 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Node.java @@ -0,0 +1,38 @@ +package org.tuna.zoopzoop.backend.domain.graph.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.tuna.zoopzoop.backend.domain.graph.enums.NodeType; +import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; + +import java.util.HashMap; +import java.util.Map; + +@Getter +@Setter +@Entity +public class Node extends BaseEntity { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "graph_id") + private Graph graph; + + @Column + private String nodeKey; + + @Column + @Enumerated(EnumType.STRING) + private NodeType nodeType; + + @ElementCollection + @CollectionTable(name = "node_data", joinColumns = @JoinColumn(name = "node_id")) + @MapKeyColumn(name = "key") + @Column(name = "value") + private Map data = new HashMap<>(); + + @Column + private double positonX; + + @Column + private double positonY; +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/EdgeType.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/EdgeType.java new file mode 100644 index 00000000..e184fc13 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/EdgeType.java @@ -0,0 +1,8 @@ +package org.tuna.zoopzoop.backend.domain.graph.enums; + +public enum EdgeType { + DEFAULT, + STRAIGHT, + STEP, + SMOOTHSTEP +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/NodeType.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/NodeType.java new file mode 100644 index 00000000..872f054c --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/NodeType.java @@ -0,0 +1,5 @@ +package org.tuna.zoopzoop.backend.domain.graph.enums; + +public enum NodeType { + CUSTOM +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/EdgeRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/EdgeRepository.java new file mode 100644 index 00000000..1c1eb6de --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/EdgeRepository.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.graph.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.tuna.zoopzoop.backend.domain.graph.entity.Edge; + +public interface EdgeRepository extends JpaRepository { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/GraphRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/GraphRepository.java new file mode 100644 index 00000000..87da826a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/GraphRepository.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.graph.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; + +import java.util.Optional; + +public interface GraphRepository extends JpaRepository { + Optional findGraphById(Integer id); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/NodeRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/NodeRepository.java new file mode 100644 index 00000000..c3082b45 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/NodeRepository.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.graph.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.tuna.zoopzoop.backend.domain.graph.entity.Node; + +public interface NodeRepository extends JpaRepository { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphService.java new file mode 100644 index 00000000..bb8b44c4 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphService.java @@ -0,0 +1,26 @@ +package org.tuna.zoopzoop.backend.domain.graph.service; + +import jakarta.persistence.NoResultException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; +import org.tuna.zoopzoop.backend.domain.graph.repository.GraphRepository; + +@Service +@RequiredArgsConstructor +public class GraphService { + private final GraphRepository graphRepository; + + @Transactional + public Graph saveGraph(Graph graph) { + return graphRepository.save(graph); + } + + public Graph getGraph(Integer id) { + return graphRepository.findGraphById(id).orElseThrow(() -> + new NoResultException(id + " id를 가진 그래프를 찾을 수 없습니다.") + ); + } +} + diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java index 1aba551b..a438e413 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java @@ -9,6 +9,7 @@ import org.tuna.zoopzoop.backend.domain.member.enums.Provider; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; + import java.util.List; @Setter diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java index 74ec68b7..f09e2569 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java @@ -10,8 +10,10 @@ import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.reqBodyForCreateFolder; import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; @@ -33,6 +35,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @ExtendWith(MockitoExtension.class) +@Transactional +@ActiveProfiles("test") class FolderControllerTest { @Mock private FolderService folderService; diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java index b84a7ce6..41d34372 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java @@ -8,7 +8,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; @@ -32,6 +34,8 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) +@Transactional +@ActiveProfiles("test") class FolderServiceTest { @Mock private MemberRepository memberRepository; diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/graph/controller/GraphControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/graph/controller/GraphControllerTest.java new file mode 100644 index 00000000..2676dc2b --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/graph/controller/GraphControllerTest.java @@ -0,0 +1,129 @@ +package org.tuna.zoopzoop.backend.domain.graph.controller; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; +import org.tuna.zoopzoop.backend.domain.graph.repository.GraphRepository; + +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +@Transactional +class GraphControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private GraphRepository graphRepository; + + @BeforeEach + void setUp() { + graphRepository.deleteAll(); // 테스트 전 DB 초기화 + } + + @AfterEach + void cleanUp() { + graphRepository.deleteAll(); // Graph만 삭제 + // 필요하면 다른 Repository도 순서대로 삭제 + } + + // 단위 테스트가 백엔드 컨벡션 원칙이나, 서비스 특성 상 단위 테스트가 어려워 + // 일단 통합 테스트로 진행합니다. + @Test + @DisplayName("React-flow 데이터 저장 및 조회 테스트 - JSON 방식") + void createAndGetGraphTest() throws Exception { + // 테스트 용 React-flow JSON + String jsonBody = """ + { + "nodes": [ + { + "id": "1", + "type": "CUSTOM", + "data": { + "title": "노드1", + "description": "설명1" + }, + "position": { + "x": 100, + "y": 200 + } + }, + { + "id": "2", + "type": "CUSTOM", + "data": { + "title": "노드2" + }, + "position": { + "x": 300, + "y": 400 + } + } + ], + "edges": [ + { + "id": "e1-2", + "source": "1", + "target": "2", + "type": "SMOOTHSTEP", + "animated": true, + "style": { + "stroke": "#999", + "strokeWidth": 2.0 + } + } + ] + } + """; + + // React-flow 데이터 저장 + mockMvc.perform(post("/api/v1/graph") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("200")) + .andExpect(jsonPath("$.msg").value("React-flow 데이터를 저장 했습니다.")) + .andExpect(jsonPath("$.data").isEmpty()); + + // DB 무결성 검증 + assertEquals(1, graphRepository.count()); + Graph savedGraph = graphRepository.findAll().get(0); + assertEquals(2, savedGraph.getNodes().size()); + assertEquals(1, savedGraph.getEdges().size()); + + // 저장된 React-flow 데이터 조회 + mockMvc.perform(get("/api/v1/graph/{id}", savedGraph.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("200")) + .andExpect(jsonPath("$.msg").value("ID: " + savedGraph.getId() + " 의 React-flow 데이터를 조회했습니다.")) + .andExpect(jsonPath("$.data.nodes", hasSize(2))) + .andExpect(jsonPath("$.data.edges", hasSize(1))) + .andExpect(jsonPath("$.data.nodes[0].id").value("1")) + .andExpect(jsonPath("$.data.nodes[0].type").value("CUSTOM")) + .andExpect(jsonPath("$.data.nodes[0].data.title").value("노드1")) + .andExpect(jsonPath("$.data.edges[0].id").value("e1-2")) + .andExpect(jsonPath("$.data.edges[0].animated").value(true)); + } + + +} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphServiceTest.java new file mode 100644 index 00000000..6fcde640 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphServiceTest.java @@ -0,0 +1,78 @@ +package org.tuna.zoopzoop.backend.domain.graph.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.graph.entity.Edge; +import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; +import org.tuna.zoopzoop.backend.domain.graph.entity.Node; +import org.tuna.zoopzoop.backend.domain.graph.enums.EdgeType; +import org.tuna.zoopzoop.backend.domain.graph.enums.NodeType; +import org.tuna.zoopzoop.backend.domain.graph.repository.GraphRepository; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +public class GraphServiceTest { + @Autowired + private GraphRepository graphRepository; + + @AfterEach + void cleanUp() { + graphRepository.deleteAll(); // Graph만 삭제 + // 필요하면 다른 Repository도 순서대로 삭제 + } + + @Test + @DisplayName("Graph + Node + Edge 저장 테스트") + void saveGraphWithNodesAndEdges() { + // Graph 생성 + Graph graph = new Graph(); + + // Node 생성 + Node node1 = new Node(); + node1.setNodeKey("1"); + node1.setNodeType(NodeType.CUSTOM); + node1.setData(Map.of("title", "노드 제목", "description", "노드 설명")); + node1.setPositonX(100); + node1.setPositonY(200); + node1.setGraph(graph); // 연관관계 주인 설정 + + Node node2 = new Node(); + node2.setNodeKey("2"); + node2.setNodeType(NodeType.CUSTOM); + node2.setPositonX(300); + node2.setPositonY(400); + node2.setGraph(graph); + + // Edge 생성 + Edge edge = new Edge(); + edge.setEdgeKey("e1-2"); + edge.setSourceNodeKey("1"); + edge.setTargetNodeKey("2"); + edge.setEdgeType(EdgeType.SMOOTHSTEP); + edge.setAnimated(true); + edge.setStroke("#999"); + edge.setStrokeWidth(2.0); + edge.setGraph(graph); + + // graph와 연결 + graph.getNodes().add(node1); + graph.getNodes().add(node2); + graph.getEdges().add(edge); + Graph savedGraph = graphRepository.save(graph); + + assertNotNull(savedGraph.getId()); + assertEquals(2, savedGraph.getNodes().size()); + assertEquals(1, savedGraph.getEdges().size()); + } +} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java index f98f24bd..06af9ce6 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java @@ -1,10 +1,7 @@ package org.tuna.zoopzoop.backend.domain.member.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -62,6 +59,12 @@ void setUp() { ); } + @AfterAll + void cleanUp() { + memberRepository.deleteAll(); // Graph만 삭제 + // 필요하면 다른 Repository도 순서대로 삭제 + } + @Test @WithUserDetails(value = "KAKAO:1111", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("사용자 정보 조회 - 성공(200)") diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java index 27eaf7c8..4a745e5e 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java @@ -1,5 +1,6 @@ package org.tuna.zoopzoop.backend.domain.member.repository; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -20,6 +21,11 @@ public class MemberRepositoryTest { @Autowired MemberRepository memberRepository; + @AfterEach + void cleanUp() { + memberRepository.deleteAll(); // Graph만 삭제 + // 필요하면 다른 Repository도 순서대로 삭제 + } @Test @DisplayName("Member 저장 시 PersonalArchive + Archive + default 폴더가 자동 생성된다") diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java index 3940bed4..e8da38cd 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/service/MemberServiceTest.java @@ -1,6 +1,7 @@ package org.tuna.zoopzoop.backend.domain.member.service; import jakarta.persistence.NoResultException; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -28,13 +29,13 @@ class MemberServiceTest { @BeforeEach void setUp() { Member member1 = memberService.createMember( - "test1", + "testtest1", "url", "1111", Provider.KAKAO ); Member member2 = memberService.createMember( - "test2", + "testtest2", "url", "2222", Provider.GOOGLE @@ -50,6 +51,12 @@ private Member createTestMember() { ); } + @AfterEach + void cleanUp() { + memberRepository.deleteAll(); // Graph만 삭제 + // 필요하면 다른 Repository도 순서대로 삭제 + } + @Test @DisplayName("사용자 생성 - 성공") void createMemberSuccess() { From b4de269b943feec279aeeb60e90d6342ede6633a Mon Sep 17 00:00:00 2001 From: taekkong <141305946+taekkong@users.noreply.github.com> Date: Thu, 25 Sep 2025 09:24:54 +0900 Subject: [PATCH 030/132] =?UTF-8?q?[chore/ops-132]=20CI/CD=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EA=B5=AC=EC=B6=95=20(#4?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore/OPS-133 : CI 파이프라인 구축 * chore: 테스트/운영 환경용 GitHub Actions Terraform 수동 인프라 적용 워크플로우 추가 * chore : 환경변수로 test/prod 서버 분리 가능하도록 설정 * chore/OPS-135 : CD 워크플로우 추가 * chore: CI 후 CD 실행되도록 설정 * chore : CI 워크플로우 job 분리(테스트,빌드/도커 빌드,ghcr push) * chore : docker build jobs에서 checkout 단계 추가 * chore: CI 워크플로우 job 구분 없앰 * chore : develop/main 브랜치에 따라 CI/CD 워크플로우 분리 * chore : Spring Boot Acutator 의존성 추가 * chore: Actions에서 SSH 접속 후 docker 실행 권한 갖도록 설정 * chore: 컨테이너 실행 로그 터미널에 출력 설정 * chore : 헬스체크 설정 * chore: secrets 직접 ssh 환경 변수로 전달 * chore: 환경변수 직접 docker run에 넣기 * chore: spdocker run 에서 ring profile 제거 * chore: nginx 설정 * chore: test-cd 수정 * chore: test-cd 수정2 * chore: ghcr 로그인 토큰 수정 * chore: 도커 이미지 pull 이름 수정 * chore: ssh 내 안전한 ghcr 로그인 * chore : 이미 pull한 이미지와 run 이미지 이름 일치시키기 * chore: mysql driver 의존성 추가 * chore : 헬스체크 요청 스프링 시큐리티 필터에 걸리지 않도록 설정 * chore: nginx 포트 스위칭 설정 * chore: nginx 포트 스위칭 설정 수정 * chore: nginx 포트 스위칭 설정 수정2 * chore: 운영서버 CD 워크플로우 수정 * chore : prod-server 워크플로우 활성화 경로 설정 * chore : test commit 나중에 지우기 --- .github/workflows/ci.yml | 76 ------ .github/workflows/prod-server.yml | 156 ++++++++++++ .github/workflows/terraform.yml | 56 ++++ .github/workflows/test-server.yml | 241 ++++++++++++++++++ .idea/modules.xml | 10 - Dockerfile | 3 + build.gradle | 8 + .../global/security/SecurityConfig.java | 3 +- src/main/resources/application-server.yml | 18 ++ 9 files changed, 484 insertions(+), 87 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/prod-server.yml create mode 100644 .github/workflows/terraform.yml create mode 100644 .github/workflows/test-server.yml delete mode 100644 .idea/modules.xml create mode 100644 src/main/resources/application-server.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 6c6b50c4..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,76 +0,0 @@ -# 워크플로우 이름 -name: Spring CI on main/develop - -# 워크플로우 실행 조건: main 또는 develop 브랜치로 Pull Request가 생성될 때 실행 -on: - pull_request: - branches: [ "main", "develop" ] - paths: - - 'src/**' # src 디렉토리 하위 파일이 변경될 때만 실행 - -jobs: - # ================================== - # CI Job: Gradle 테스트 및 빌드 실행 - # ================================== - build-and-test: - runs-on: ubuntu-latest - - steps: - # 1. 소스 코드 체크아웃 - - name: Checkout source code - uses: actions/checkout@v4 - - # 2. JDK 21 설치 - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - # 3. Gradle 캐시 설정 - # 프로젝트 루트의 gradle 파일들을 기준으로 캐시를 설정합니다. - - name: Cache Gradle packages - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - # 4. gradlew 실행 권한 부여 - - name: Grant execute permission for gradlew - run: chmod +x ./gradlew - - # 5. application-secrets.yml 생성 - - name: Generate application-secrets.yml - run: | - mkdir -p src/main/resources - echo "${{ secrets.APPLICATION_SECRET_YML }}" > src/main/resources/application-secrets.yml - echo "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" >> src/main/resources/application-secrets.yml - - # 6. Gradle 테스트 실행 - - name: Test with Gradle - run: ./gradlew test - - # 7. 테스트 결과 요약 출력 (optional, CI 로그에서 확인 가능) - - name: Show test results - run: | - echo "==== Test Results ====" - if compgen -G "build/test-results/test/TEST-*.xml" > /dev/null; then - total=$(grep ' ./infra/terraform/env/terraform.tfvars + else + echo "${{secrets.TFVARS_TEST}}" > ./infra/terraform/env/terraform.tfvars + fi + + - name: Select or Create Workspace + working-directory: ./infra/terraform + run: | + if terraform workspace list | grep -q "$ENV"; then + terraform workspace select "$ENV" + else + terraform workspace new "$ENV" + fi + + - name: Terraform Apply + working-directory: ./infra/terraform/env + run: terraform apply -auto-approve -var-file="terraform.tfvars" + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/test-server.yml b/.github/workflows/test-server.yml new file mode 100644 index 00000000..cc23696d --- /dev/null +++ b/.github/workflows/test-server.yml @@ -0,0 +1,241 @@ +# 워크플로우 이름 +name: Spring CI/CD Pipeline (Develop) + +# develop 브랜치 PR에서만 실행 +on: + pull_request: + branches: + - develop + paths: + - 'src/**' + - 'build.gradle*' + - 'settings.gradle*' + - 'gradle/**' + - 'Dockerfile' + - '.github/workflows/**' + +jobs: + # ================================== + # CI: Test and Build and Push Docker Image + # ================================== + ci: + runs-on: ubuntu-latest + + steps: + # 1. 소스 코드 체크아웃 + - name: Checkout source code + uses: actions/checkout@v4 + + # 2. JDK 21 설치 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + # 3. Gradle 캐시 설정 + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # 4. gradlew 실행 권한 부여 + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # 5. application-secrets.yml 생성 + - name: Generate application-secrets.yml + run: | + mkdir -p src/main/resources + echo "${{ secrets.APPLICATION_SECRET_YML }}" > src/main/resources/application-secrets.yml + echo "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" >> src/main/resources/application-secrets.yml + + # 6. Gradle 테스트 실행 + - name: Test with Gradle + run: ./gradlew test + + # 7. 테스트 결과 요약 출력 + - name: Show test results + run: | + echo "==== Test Results ====" + if compgen -G "build/test-results/test/TEST-*.xml" > /dev/null; then + total=$(grep ' - - - - - - - - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index bb43cff2..e7568269 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,6 @@ FROM openjdk:21-jdk-slim COPY build/libs/backend-0.0.1-SNAPSHOT.jar /app.jar + +ENV SPRING_PROFILES_ACTIVE=server + ENTRYPOINT ["java","-jar","/app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 9fb388f8..f0e18d8e 100644 --- a/build.gradle +++ b/build.gradle @@ -77,8 +77,16 @@ dependencies { // Spring AI implementation "org.springframework.ai:spring-ai-starter-model-openai" + // 크롤링 implementation("org.jsoup:jsoup:1.21.2") + + // Spring Boot Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Mysql driver + implementation 'mysql:mysql-connector-java:8.0.33' + } dependencyManagement { diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java index e11d3f80..93eb25bc 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java @@ -38,7 +38,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/swagger-resources/**", "/oauth/**", "/webjars/**", - "/api/v1/**" // API 테스트용으로 모두 허용. 차후 필수로 변경 필요. + "/api/v1/**", // API 테스트용으로 모두 허용. 차후 필수로 변경 필요. + "/actuator/health" // health 체크용 ).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/resources/application-server.yml b/src/main/resources/application-server.yml new file mode 100644 index 00000000..ccac4471 --- /dev/null +++ b/src/main/resources/application-server.yml @@ -0,0 +1,18 @@ +spring: + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + + jpa: + hibernate: + ddl-auto: update + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: always \ No newline at end of file From ced66623b016edb72ae3e5f0fe778ad946b6f99e Mon Sep 17 00:00:00 2001 From: Hyeok Jin Kim <71928299+EpicFn@users.noreply.github.com> Date: Thu, 25 Sep 2025 09:51:40 +0900 Subject: [PATCH 031/132] =?UTF-8?q?[Feat/OPS-214]=20=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=A9=A4=EB=B2=84=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-227 : 스페이스 멤버 목록 조회 테스트 케이스 작성 * feat/OPS-227 : 스페이스 목록 반환 엔드포인트 완성 * feat/OPS-251 : 권한 변경 테스트 케이스 작성 * feat/OPS-214 : 권한 변경 엔드포인트 구현 * fix/OPS-214 : 단순 메세지 오류 테스트 케이스 수정 * fix/OPS-214 : 로직 관련 테스트 케이스 수정 * fix/OPS-251 : globalExceptionHandler에 역직렬화 실패 에러 추가 * refactor/OPS-214 : 비즈니스 로직을 서비스 계층에 일임 * feat/OPS-231 : 스페이스 초대 컨트롤러 테스트케이스 작성 * feat/OPS-231 : 스페이스 초대 로직 작성 * feat/OPS-231 : 스페이스초대 서비스 빈 테스트 케이스 추가 * feat/OPS-228 : 팀원 퇴출 컨트롤러 테스트 케이스 작성 * feeat/OPS-229 : 팀원 퇴출 구현 완료 * fix : 기존 테스트 케이스 일관성 있게 수정 * feat : 멤버 퇴출 서비스 테스트 콛드 작성 * refactor : DTO 패키지 변경 * feat/OPS-294 : 스페이스 나가기 컨트롤러 단위 테스트 작성 * feat/OPS-294 : 스페이스 나가기 로직 완성 * refactor : space 도메인 dto 패키지 변경 * feat/OPS-227 : 스페이스 멤버 목록 조회 테스트 케이스 작성 * feat/OPS-227 : 스페이스 목록 반환 엔드포인트 완성 * feat/OPS-251 : 권한 변경 테스트 케이스 작성 * feat/OPS-214 : 권한 변경 엔드포인트 구현 * fix/OPS-214 : 단순 메세지 오류 테스트 케이스 수정 * fix/OPS-214 : 로직 관련 테스트 케이스 수정 * fix/OPS-251 : globalExceptionHandler에 역직렬화 실패 에러 추가 * refactor/OPS-214 : 비즈니스 로직을 서비스 계층에 일임 * feat/OPS-231 : 스페이스 초대 컨트롤러 테스트케이스 작성 * feat : 최신 사항 반영 * feat/OPS-231 : 스페이스초대 서비스 빈 테스트 케이스 추가 * feat/OPS-228 : 팀원 퇴출 컨트롤러 테스트 케이스 작성 * feeat/OPS-229 : 팀원 퇴출 구현 완료 * fix : 기존 테스트 케이스 일관성 있게 수정 * feat : 멤버 퇴출 서비스 테스트 콛드 작성 * refactor : DTO 패키지 변경 * feat/OPS-294 : 스페이스 나가기 컨트롤러 단위 테스트 작성 * feat/OPS-294 : 스페이스 나가기 로직 완성 * refactor : space 도메인 dto 패키지 변경 --------- Co-authored-by: EpicFn --- .../domain/member/dto/etc/SimpleUserInfo.java | 7 + .../domain/member/service/MemberService.java | 10 + .../controller/ApiV1InviteController.java | 6 +- .../controller/ApiV1MembershipController.java | 179 +++- .../membership/dto/etc/SpaceMemberInfo.java | 11 + .../req/ReqBodyForChangeMemberAuthority.java | 15 + .../dto/req/ReqBodyForExpelMember.java | 6 + .../dto/req/ReqBodyForInviteMembers.java | 8 + .../res/ResBodyForChangeMemberAuthority.java | 10 + .../dto/res/ResBodyForExpelMember.java | 10 + .../dto/res/ResBodyForInviteMembers.java | 12 + .../ResBodyForSpaceInvitationList.java | 2 +- .../dto/res/ResBodyForSpaceMemberList.java | 12 + .../space/membership/enums/Authority.java | 5 + .../repository/MembershipRepository.java | 4 + .../membership/service/MembershipService.java | 156 ++++ .../controller/ApiV1SpaceController.java | 8 +- .../space/dto/ResBodyForSpaceInviteList.java | 8 - .../space/space/dto/ResBodyForSpaceList.java | 8 - .../dto/{ => etc}/SpaceMembershipInfo.java | 2 +- .../SpaceMembershipInfoWithoutAuthority.java | 2 +- .../dto/{ => req}/ReqBodyForSpaceSave.java | 2 +- .../dto/res/ResBodyForSpaceInviteList.java | 10 + .../space/dto/res/ResBodyForSpaceList.java | 10 + .../dto/{ => res}/ResBodyForSpaceSave.java | 2 +- .../exception/GlobalExceptionHandler.java | 42 + .../ApiV1MembershipControllerTest.java | 776 +++++++++++++++++- .../service/MembershipServiceTest.java | 140 +++- .../controller/ApiV1SpaceControllerTest.java | 2 +- .../testSupport/ControllerTestSupport.java | 14 + 30 files changed, 1438 insertions(+), 41 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/etc/SimpleUserInfo.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/etc/SpaceMemberInfo.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForChangeMemberAuthority.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForExpelMember.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForInviteMembers.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForChangeMemberAuthority.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForExpelMember.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForInviteMembers.java rename src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/{ => res}/ResBodyForSpaceInvitationList.java (77%) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForSpaceMemberList.java delete mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceInviteList.java delete mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceList.java rename src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/{ => etc}/SpaceMembershipInfo.java (75%) rename src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/{ => etc}/SpaceMembershipInfoWithoutAuthority.java (61%) rename src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/{ => req}/ReqBodyForSpaceSave.java (76%) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInviteList.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceList.java rename src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/{ => res}/ResBodyForSpaceSave.java (56%) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/etc/SimpleUserInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/etc/SimpleUserInfo.java new file mode 100644 index 00000000..83e62145 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/etc/SimpleUserInfo.java @@ -0,0 +1,7 @@ +package org.tuna.zoopzoop.backend.domain.member.dto.etc; + +public record SimpleUserInfo( + Integer id, + String name +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java index 691fea94..0b2ec51b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java @@ -10,6 +10,7 @@ import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import java.util.List; +import java.util.Optional; import java.util.UUID; @Service @@ -46,6 +47,15 @@ public Member findByProviderKey(String providerKey) { ); } + public Optional findOptionalByName(String name) { + return memberRepository.findByName(name); + } + +// public Member findByEmail(String email){ +// return memberRepository.findByEmail(email).orElseThrow(() -> +// new NoResultException(email + " 이메일을 가진 사용자를 찾을 수 없습니다.") +// ); +// } public List findAll(){ return memberRepository.findAll(); } public List findAllActive(){ return memberRepository.findByActiveTrue(); } public List findAllInactive(){ return memberRepository.findByActiveFalse(); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java index cbc6a530..8944a7fa 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java @@ -8,9 +8,9 @@ import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; -import org.tuna.zoopzoop.backend.domain.space.space.dto.ResBodyForSpaceInviteList; -import org.tuna.zoopzoop.backend.domain.space.space.dto.ResBodyForSpaceSave; -import org.tuna.zoopzoop.backend.domain.space.space.dto.SpaceMembershipInfoWithoutAuthority; +import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceInviteList; +import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceSave; +import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfoWithoutAuthority; import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; import org.tuna.zoopzoop.backend.global.rsData.RsData; import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipController.java index affa0f64..61d263e3 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipController.java @@ -2,17 +2,22 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.member.dto.etc.SimpleUserInfo; import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfo; import org.tuna.zoopzoop.backend.domain.member.entity.Member; -import org.tuna.zoopzoop.backend.domain.space.membership.dto.ResBodyForSpaceInvitationList; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.domain.space.membership.dto.etc.SpaceMemberInfo; +import org.tuna.zoopzoop.backend.domain.space.membership.dto.req.ReqBodyForChangeMemberAuthority; +import org.tuna.zoopzoop.backend.domain.space.membership.dto.req.ReqBodyForExpelMember; +import org.tuna.zoopzoop.backend.domain.space.membership.dto.req.ReqBodyForInviteMembers; +import org.tuna.zoopzoop.backend.domain.space.membership.dto.res.*; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; +import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceSave; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; import org.tuna.zoopzoop.backend.global.rsData.RsData; @@ -28,6 +33,7 @@ public class ApiV1MembershipController { private final MembershipService membershipService; private final SpaceService spaceService; + private final MemberService memberService; @GetMapping("/invite/{spaceId}") @Operation(summary = "스페이스에 초대된 유저 목록 조회") @@ -62,4 +68,167 @@ public RsData getInvites( ) ); } + + @GetMapping("/{spaceId}") + @Operation(summary = "스페이스의 멤버 목록 조회") + public RsData getMembers( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId + ) throws AccessDeniedException { + Member member = userDetails.getMember(); + Space space = spaceService.findById(spaceId); + + // 스페이스에 멤버가 속해있는지 확인 + if(!membershipService.isMemberJoinedSpace(member, space)) { + throw new AccessDeniedException("액세스가 거부되었습니다."); + } + + // 멤버십(멤버) 목록 조회 + List memberships = membershipService.findMembersBySpace(space); + List memberInfos = memberships.stream() + .map(membership -> new SpaceMemberInfo( + membership.getMember().getId(), + membership.getMember().getName(), + membership.getMember().getProfileImageUrl(), + membership.getAuthority() + )) + .toList(); + + return new RsData<>( + "200", + "스페이스 멤버 목록을 조회했습니다.", + new ResBodyForSpaceMemberList( + space.getId(), + space.getName(), + memberInfos + ) + ); + } + + @PutMapping("/{spaceId}") + @Operation(summary = "스페이스 멤버 권한 변경") + public RsData changeMemberAuthority( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId, + @RequestBody @Valid ReqBodyForChangeMemberAuthority reqBody + ) throws AccessDeniedException { + Member requester = userDetails.getMember(); + Space space = spaceService.findById(spaceId); + + Membership changeResult = membershipService.changeMemberAuthority( + requester, + space, + reqBody.memberId(), + reqBody.newAuthority() + ); + + SpaceMemberInfo memberInfo = new SpaceMemberInfo( + changeResult.getMember().getId(), + changeResult.getMember().getName(), + changeResult.getMember().getProfileImageUrl(), + changeResult.getAuthority() + ); + + return new RsData<>( + "200", + "멤버 권한을 변경했습니다.", + new ResBodyForChangeMemberAuthority( + space.getId(), + space.getName(), + memberInfo + ) + ); + } + + @PostMapping("/{spaceId}") + @Operation(summary = "스페이스 멤버 초대") + public RsData inviteMember( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId, + @RequestBody @Valid ReqBodyForInviteMembers reqBody + ) throws AccessDeniedException { + Member requester = userDetails.getMember(); + Space space = spaceService.findById(spaceId); + + membershipService.checkAdminAuthority(requester, space); + + List inviteResults = membershipService.inviteMembersToSpace( + space, + reqBody.memberNames() + ); + + List invitedMemberInfos = inviteResults.stream() + .map(membership -> new SimpleUserInfo( + membership.getMember().getId(), + membership.getMember().getName() + )) + .toList(); + + return new RsData<>( + "200", + "사용자를 스페이스에 초대했습니다.", + new ResBodyForInviteMembers( + space.getId(), + space.getName(), + invitedMemberInfos + ) + ); + } + + @DeleteMapping("/{spaceId}") + @Operation(summary = "스페이스 멤버 퇴출") + public RsData expelMember( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId, + @RequestBody @Valid ReqBodyForExpelMember reqBody + ) throws AccessDeniedException { + Member requester = userDetails.getMember(); + Space space = spaceService.findById(spaceId); + Member targetMember = memberService.findById(reqBody.memberId()); + + membershipService.checkAdminAuthority(requester, space); + + // 본인 강퇴 방지 + if(requester.equals(targetMember)) { + throw new AccessDeniedException("본인은 강퇴할 수 없습니다."); + } + + membershipService.expelMemberFromSpace(targetMember, space); + + ResBodyForGetMemberInfo expelledMemberInfo = new ResBodyForGetMemberInfo( + targetMember.getId(), + targetMember.getName(), + targetMember.getProfileImageUrl() + ); + return new RsData<>( + "200", + "멤버를 스페이스에서 퇴출했습니다.", + new ResBodyForExpelMember( + space.getId(), + space.getName(), + expelledMemberInfo + ) + ); + } + + @DeleteMapping("/me/{spaceId}") + @Operation(summary = "스페이스 탈퇴") + public RsData leaveSpace( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId + ) { + Member requester = userDetails.getMember(); + Space space = spaceService.findById(spaceId); + + membershipService.leaveSpace(requester, space); + + return new RsData<>( + "200", + "스페이스에서 탈퇴했습니다.", + new ResBodyForSpaceSave( + space.getId(), + space.getName() + ) + ); + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/etc/SpaceMemberInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/etc/SpaceMemberInfo.java new file mode 100644 index 00000000..330ed2c7 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/etc/SpaceMemberInfo.java @@ -0,0 +1,11 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.dto.etc; + +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; + +public record SpaceMemberInfo( + Integer id, + String name, + String profileUrl, + Authority authority +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForChangeMemberAuthority.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForChangeMemberAuthority.java new file mode 100644 index 00000000..63255937 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForChangeMemberAuthority.java @@ -0,0 +1,15 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.dto.req; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; + +public record ReqBodyForChangeMemberAuthority( + @NotNull + Authority newAuthority, + + @NotNull + @PositiveOrZero + Integer memberId +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForExpelMember.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForExpelMember.java new file mode 100644 index 00000000..2957e873 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForExpelMember.java @@ -0,0 +1,6 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.dto.req; + +public record ReqBodyForExpelMember( + Integer memberId +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForInviteMembers.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForInviteMembers.java new file mode 100644 index 00000000..afbe4ed7 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/req/ReqBodyForInviteMembers.java @@ -0,0 +1,8 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.dto.req; + +import java.util.List; + +public record ReqBodyForInviteMembers( + List memberNames +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForChangeMemberAuthority.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForChangeMemberAuthority.java new file mode 100644 index 00000000..dba68823 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForChangeMemberAuthority.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.dto.res; + +import org.tuna.zoopzoop.backend.domain.space.membership.dto.etc.SpaceMemberInfo; + +public record ResBodyForChangeMemberAuthority( + Integer spaceId, + String spaceName, + SpaceMemberInfo member +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForExpelMember.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForExpelMember.java new file mode 100644 index 00000000..1c11d7f5 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForExpelMember.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.dto.res; + +import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfo; + +public record ResBodyForExpelMember( + Integer spaceId, + String spaceName, + ResBodyForGetMemberInfo expelledMemberInfo +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForInviteMembers.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForInviteMembers.java new file mode 100644 index 00000000..356ab6cf --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForInviteMembers.java @@ -0,0 +1,12 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.dto.res; + +import org.tuna.zoopzoop.backend.domain.member.dto.etc.SimpleUserInfo; + +import java.util.List; + +public record ResBodyForInviteMembers( + Integer spaceId, + String spaceName, + List invitedUsers +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/ResBodyForSpaceInvitationList.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForSpaceInvitationList.java similarity index 77% rename from src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/ResBodyForSpaceInvitationList.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForSpaceInvitationList.java index 151ea058..f50f8863 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/ResBodyForSpaceInvitationList.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForSpaceInvitationList.java @@ -1,4 +1,4 @@ -package org.tuna.zoopzoop.backend.domain.space.membership.dto; +package org.tuna.zoopzoop.backend.domain.space.membership.dto.res; import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfo; diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForSpaceMemberList.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForSpaceMemberList.java new file mode 100644 index 00000000..74d5dec2 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/dto/res/ResBodyForSpaceMemberList.java @@ -0,0 +1,12 @@ +package org.tuna.zoopzoop.backend.domain.space.membership.dto.res; + +import org.tuna.zoopzoop.backend.domain.space.membership.dto.etc.SpaceMemberInfo; + +import java.util.List; + +public record ResBodyForSpaceMemberList( + Integer spaceId, + String spaceName, + List members +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/enums/Authority.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/enums/Authority.java index d4e58613..4bb0960e 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/enums/Authority.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/enums/Authority.java @@ -5,4 +5,9 @@ public enum Authority { READ_ONLY, //읽기만 가능 READ_WRITE, //읽고 쓰기 가능 ADMIN //READ & WRITE, 관리 권한 + ; + + public boolean canManageMembers() { + return this == ADMIN; + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java index 3e8e9c1e..a39a6b09 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java @@ -25,4 +25,8 @@ public interface MembershipRepository extends JpaRepository Optional findByMemberAndSpace(Member member, Space space); List findAllBySpaceAndAuthority(Space space, Authority authority); + + List findAllBySpaceAndAuthorityIsNot(Space space, Authority authority); + + long countBySpaceAndAuthority(Space space, Authority authority); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java index 382dccb1..0d646f5c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java @@ -1,23 +1,29 @@ package org.tuna.zoopzoop.backend.domain.space.membership.service; import jakarta.persistence.NoResultException; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; import org.tuna.zoopzoop.backend.domain.space.membership.enums.JoinState; import org.tuna.zoopzoop.backend.domain.space.membership.repository.MembershipRepository; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.global.rsData.RsData; import java.nio.file.AccessDeniedException; import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor public class MembershipService { private final MembershipRepository membershipRepository; + private final MemberService memberService; // ======================== 멤버십 조회 ======================== // @@ -69,6 +75,54 @@ public List findInvitationsBySpace(Space space) { return membershipRepository.findAllBySpaceAndAuthority(space, Authority.PENDING); } + /** + * 스페이스에 속한 멤버 중 가입 상태(JOINED)인 멤버십 목록 조회 + * @param space 조회할 스페이스 + * @return 해당 스페이스에 속한 가입 상태(JOINED)인 멤버십 목록 + */ + public List findMembersBySpace(Space space) { + return membershipRepository.findAllBySpaceAndAuthorityIsNot(space, Authority.PENDING); + } + + // ======================== 권한 조회 ======================== // + /** + * 멤버가 스페이스의 어드민 권한을 가지고 있는지 확인 + * @param member 조회할 멤버 + * @param space 조회할 스페이스 + * @throws NoResultException 해당 멤버가 권한이 없는 경우 + */ + public void checkAdminAuthority(Member member, Space space) throws AccessDeniedException { + // 스페이스에 요청자가 속해있는지 확인 + if(!isMemberJoinedSpace(member, space)) { + throw new AccessDeniedException("액세스가 거부되었습니다."); + } + + // 요청자의 권한이 멤버 관리 권한이 있는지 확인 + Membership requesterMembership = findByMemberAndSpace(member, space); + if(!requesterMembership.getAuthority().canManageMembers()) { + throw new AccessDeniedException("액세스가 거부되었습니다."); + } + } + + + /** + * requester가 스페이스의 유일한 ADMIN인지 확인 + * @param requester 요청자 멤버 + * @param space 확인할 스페이스 + */ + public boolean checkIsOnlyAdmin(Member requester, Space space) { + // 1. 요청자가 ADMIN 권한을 가지고 있는지 확인 + if (!isMemberAdminInSpace(requester, space)) { + return false; + } + + // 2. 스페이스의 ADMIN 멤버 수 조회 + long adminCount = membershipRepository.countBySpaceAndAuthority(space, Authority.ADMIN); + + // 3. ADMIN 멤버가 1명인지 확인 + return adminCount == 1; + } + // ======================== 멤버십 존재 여부 확인 ======================== // @@ -125,6 +179,39 @@ public Membership addMemberToSpace(Member member, Space space, Authority authori return membershipRepository.save(membership); } + + /** + * 스페이스에 멤버 초대 (여러 명) + * @param space 멤버가 추가될 스페이스 + * @param invitedName 초대할 멤버 이름 목록 + * @return 생성된 Membership 엔티티 목록 + */ + public List inviteMembersToSpace(Space space, List invitedName) { + // 1. 이름 중복 제거 + List uniqueNames = invitedName.stream().distinct().toList(); + + // 2. 존재하는 멤버만 필터링 + List members = uniqueNames.stream() + .map(memberService::findOptionalByName) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + + // 3. 이미 초대된 멤버는 제외 + List invitedMemberships = members.stream() + .filter(member -> !membershipRepository.existsByMemberAndSpace(member, space)) + .map(member -> { + Membership membership = new Membership(); + membership.setMember(member); + membership.setSpace(space); + membership.setAuthority(Authority.PENDING); + return membership; + }) + .toList(); + + return membershipRepository.saveAll(invitedMemberships); + } + /** * 멤버의 권한 변경 * @param membership 권한을 변경할 Membership 엔티티 @@ -132,10 +219,51 @@ public Membership addMemberToSpace(Member member, Space space, Authority authori * @return 변경된 Membership 엔티티 */ public Membership changeAuthority(Membership membership, Authority newAuthority) { + membership.setAuthority(newAuthority); return membershipRepository.save(membership); } + /** + * 멤버의 권한 변경 처리 (요청자 검증 포함) + * @param requester 권한 변경을 요청하는 멤버 + * @param space 변경이 이루어질 스페이스 + * @param targetMemberId 권한이 변경될 대상 멤버의 ID + * @param newAuthority 새로운 권한 + * @return 변경된 Membership 엔티티 + * @throws AccessDeniedException 요청자가 멤버를 관리할 권한이 없는 경우 + */ + public Membership changeMemberAuthority(Member requester, Space space, Integer targetMemberId, Authority newAuthority) throws AccessDeniedException { + // 1. 요청자가 멤버를 관리할 권한이 있는지 확인 (기존 로직 재사용) + checkAdminAuthority(requester, space); + + // 2. 변경 대상 멤버 조회 + // ※ MemberService가 필요하므로 의존성 주입(DI)이 필요할 수 있습니다. + Member targetMember = memberService.findById(targetMemberId); + + // 3. 자기 자신의 권한을 변경하는지 확인 + if (targetMember.equals(requester)) { + throw new IllegalArgumentException("본인의 권한은 변경할 수 없습니다."); + } + + // 4. PENDING 상태로 변경하려고 하는지 확인 + if (newAuthority == Authority.PENDING) { + throw new IllegalArgumentException("멤버 권한을 PENDING(가입 대기)으로 변경할 수 없습니다."); + } + + // 5. 대상 멤버의 현재 멤버십 정보 조회 + Membership targetMembership = findByMemberAndSpace(targetMember, space); + + // 6. 이미 같은 권한인지 확인 + if (targetMembership.getAuthority() == newAuthority) { + // ※ 409 Conflict에 해당하는 예외를 사용하는 것이 좋습니다. + throw new DataIntegrityViolationException("이미 요청된 권한을 가지고 있습니다."); + } + + // 7. 모든 검증 통과 후, 권한 변경 로직 실행 + return changeAuthority(targetMembership, newAuthority); + } + // ======================== 멤버십 초대 처리 ======================== // @@ -170,4 +298,32 @@ public void acceptInvitation(Membership membership) { public void rejectInvitation(Membership membership) { membershipRepository.delete(membership); } + + // ======================== 멤버십 삭제 ======================== // + /** + * 멤버를 스페이스에서 퇴출 (멤버십 삭제) + * @param member 탈퇴할 멤버 + * @param space 탈퇴할 스페이스 + */ + public void expelMemberFromSpace(Member member, Space space) { + Membership membership = findByMemberAndSpace(member, space); + membershipRepository.delete(membership); + } + + public void leaveSpace(Member member, Space space) { + // 유일한 어드민은 탈퇴할 수 없음 + if(checkIsOnlyAdmin(member, space)) { + throw new IllegalArgumentException("유일한 어드민은 탈퇴할 수 없습니다."); + } + + // 초대 상태면 탈퇴할 수 없음 -> 초대 거절 로직 사용 + if(!isMemberJoinedSpace(member, space)) { + throw new NoResultException("해당 멤버는 스페이스에 속해있지 않습니다."); + } + + Membership membership = findByMemberAndSpace(member, space); + membershipRepository.delete(membership); + } + + } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java index fa96d88c..0057a3e8 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java @@ -11,10 +11,10 @@ import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; import org.tuna.zoopzoop.backend.domain.space.membership.enums.JoinState; import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; -import org.tuna.zoopzoop.backend.domain.space.space.dto.ReqBodyForSpaceSave; -import org.tuna.zoopzoop.backend.domain.space.space.dto.ResBodyForSpaceList; -import org.tuna.zoopzoop.backend.domain.space.space.dto.SpaceMembershipInfo; -import org.tuna.zoopzoop.backend.domain.space.space.dto.ResBodyForSpaceSave; +import org.tuna.zoopzoop.backend.domain.space.space.dto.req.ReqBodyForSpaceSave; +import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceList; +import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfo; +import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceSave; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; import org.tuna.zoopzoop.backend.global.rsData.RsData; diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceInviteList.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceInviteList.java deleted file mode 100644 index 6d9390b4..00000000 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceInviteList.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.tuna.zoopzoop.backend.domain.space.space.dto; - -import java.util.List; - -public record ResBodyForSpaceInviteList( - List spaces -) { -} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceList.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceList.java deleted file mode 100644 index b97585aa..00000000 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceList.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.tuna.zoopzoop.backend.domain.space.space.dto; - -import java.util.List; - -public record ResBodyForSpaceList( - List spaces -) { -} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfo.java similarity index 75% rename from src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfo.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfo.java index e59273a6..83de0386 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfo.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfo.java @@ -1,4 +1,4 @@ -package org.tuna.zoopzoop.backend.domain.space.space.dto; +package org.tuna.zoopzoop.backend.domain.space.space.dto.etc; import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfoWithoutAuthority.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfoWithoutAuthority.java similarity index 61% rename from src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfoWithoutAuthority.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfoWithoutAuthority.java index 60d6b679..a27b2624 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/SpaceMembershipInfoWithoutAuthority.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfoWithoutAuthority.java @@ -1,4 +1,4 @@ -package org.tuna.zoopzoop.backend.domain.space.space.dto; +package org.tuna.zoopzoop.backend.domain.space.space.dto.etc; public record SpaceMembershipInfoWithoutAuthority( Integer id, diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ReqBodyForSpaceSave.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/req/ReqBodyForSpaceSave.java similarity index 76% rename from src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ReqBodyForSpaceSave.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/req/ReqBodyForSpaceSave.java index c91d9f5d..7a516720 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ReqBodyForSpaceSave.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/req/ReqBodyForSpaceSave.java @@ -1,4 +1,4 @@ -package org.tuna.zoopzoop.backend.domain.space.space.dto; +package org.tuna.zoopzoop.backend.domain.space.space.dto.req; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInviteList.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInviteList.java new file mode 100644 index 00000000..40a0e83e --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInviteList.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto.res; + +import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfoWithoutAuthority; + +import java.util.List; + +public record ResBodyForSpaceInviteList( + List spaces +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceList.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceList.java new file mode 100644 index 00000000..de2cf46c --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceList.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto.res; + +import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfo; + +import java.util.List; + +public record ResBodyForSpaceList( + List spaces +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceSave.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceSave.java similarity index 56% rename from src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceSave.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceSave.java index 1e7f32e5..4fd5e95f 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/ResBodyForSpaceSave.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceSave.java @@ -1,4 +1,4 @@ -package org.tuna.zoopzoop.backend.domain.space.space.dto; +package org.tuna.zoopzoop.backend.domain.space.space.dto.res; public record ResBodyForSpaceSave( Integer id, diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java index 6011efed..88c25588 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java @@ -1,12 +1,16 @@ package org.tuna.zoopzoop.backend.global.exception; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import jakarta.persistence.NoResultException; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; @@ -17,6 +21,7 @@ import javax.naming.AuthenticationException; import java.nio.file.AccessDeniedException; +import java.util.List; import java.util.stream.Collectors; import static org.springframework.http.HttpStatus.*; @@ -38,6 +43,24 @@ public ResponseEntity> handleNoResultException(NoResultException e) ); } + @ExceptionHandler(HttpMessageNotReadableException.class) // Request Body의 역직렬화에 실패 + public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + String fieldName = getErrorFieldName(e); + + String userFriendlyMessage = String.format( + "Invalid value provided for field '%s'. Please check the allowed values.", + fieldName + ); + + return new ResponseEntity<>( + new RsData<>( + "400", + userFriendlyMessage + ), + BAD_REQUEST + ); + } + @ExceptionHandler(IllegalArgumentException.class) // Request Body 입력 값이 부족할 경우 public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { return new ResponseEntity<>( @@ -165,4 +188,23 @@ public ResponseEntity> handleException(Exception e) { INTERNAL_SERVER_ERROR ); } + + // =================================== Private Methods =================================== + + private String getErrorFieldName(HttpMessageNotReadableException e) { + Throwable cause = e.getCause(); + + if (cause instanceof InvalidFormatException) { + // InvalidFormatException holds a path of references to the error location. + List path = ((InvalidFormatException) cause).getPath(); + + if (path != null && !path.isEmpty()) { + // The last reference in the path is typically the field with the error. + return path.get(path.size() - 1).getFieldName(); + } + } + + // If the field name cannot be extracted, return a generic placeholder. + return "unknown"; + } } \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipControllerTest.java index 64ac319e..1d9ca3e9 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1MembershipControllerTest.java @@ -20,6 +20,7 @@ import org.tuna.zoopzoop.backend.testSupport.ControllerTestSupport; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @ActiveProfiles("test") @SpringBootTest @@ -44,7 +45,10 @@ void setUp() { void setUpSpace() { spaceService.createSpace("기존 스페이스 1_forMembershipControllerTest"); spaceService.createSpace("기존 스페이스 2_forMembershipControllerTest"); - + spaceService.createSpace("기존 스페이스 3_forMembershipControllerTest"); + spaceService.createSpace("기존 스페이스 4_forMembershipControllerTest"); + spaceService.createSpace("기존 스페이스 5_forMembershipControllerTest"); + spaceService.createSpace("기존 스페이스 6_forMembershipControllerTest"); } void setUpMember() { @@ -90,17 +94,75 @@ void setUpMembership() { Authority.PENDING ); + + // test1 -> 스페이스 2 가입 (PENDING) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc1111"), + spaceService.findByName("기존 스페이스 2_forMembershipControllerTest"), + Authority.PENDING + ); + // test2 -> 스페이스 2 가입 (PENDING) membershipService.addMemberToSpace( memberService.findByKakaoKey("mc2222"), spaceService.findByName("기존 스페이스 2_forMembershipControllerTest"), Authority.PENDING ); - // test1 -> 스페이스 2 가입 (PENDING) + + // test1 -> 스페이스 3 가입 (ADMIN) membershipService.addMemberToSpace( memberService.findByKakaoKey("mc1111"), - spaceService.findByName("기존 스페이스 2_forMembershipControllerTest"), - Authority.PENDING + spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"), + Authority.ADMIN + ); + + // test2 -> 스페이스 3 가입 (READ_WRITE) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc2222"), + spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"), + Authority.READ_WRITE + ); + + // test3 -> 스페이스 3 가입 (READ_ONLY) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc3333"), + spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"), + Authority.READ_ONLY + ); + + // test1 -> 스페이스 4 가입 (ADMIN) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc1111"), + spaceService.findByName("기존 스페이스 4_forMembershipControllerTest"), + Authority.ADMIN + ); + + // test1 -> 스페이스 5 가입 (ADMIN) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc1111"), + spaceService.findByName("기존 스페이스 5_forMembershipControllerTest"), + Authority.ADMIN + ); + + // test2 -> 스페이스 5 가입 (READ_WRITE) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc2222"), + spaceService.findByName("기존 스페이스 5_forMembershipControllerTest"), + Authority.READ_WRITE + ); + + // test1 -> 스페이스 6 가입 (ADMIN) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc1111"), + spaceService.findByName("기존 스페이스 6_forMembershipControllerTest"), + Authority.ADMIN + ); + + // test2 -> 스페이스 6 가입 (ADMIN) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("mc2222"), + spaceService.findByName("기존 스페이스 6_forMembershipControllerTest"), + Authority.ADMIN ); } @@ -165,4 +227,710 @@ void listPendingInvites_Fail_NotExistSpace() throws Exception { expectNotFound(resultActions, "존재하지 않는 스페이스입니다."); } + // ============================= LIST Space Members ============================= // + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 목록 조회 - 성공") + void listSpaceMembers_Success() throws Exception { + // given + var member1 = memberService.findByKakaoKey("mc1111"); + var member2 = memberService.findByKakaoKey("mc2222"); + var member3 = memberService.findByKakaoKey("mc3333"); + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + + // when + ResultActions resultActions = performGet(url); + + // then + expectOk(resultActions, "스페이스 멤버 목록을 조회했습니다."); + + resultActions + .andExpect(jsonPath("$.data.members.length()").value(3)) + .andExpect(jsonPath("$.data.spaceId").value(space.getId())) + .andExpect(jsonPath("$.data.spaceName").value(space.getName())) + .andExpect(jsonPath("$.data.members[0].id").exists()) + .andExpect(jsonPath("$.data.members[0].name").value(member1.getName())) + .andExpect(jsonPath("$.data.members[0].profileUrl").value(member1.getProfileImageUrl())) + .andExpect(jsonPath("$.data.members[0].authority").value("ADMIN")) + .andExpect(jsonPath("$.data.members[1].id").exists()) + .andExpect(jsonPath("$.data.members[1].name").value(member2.getName())) + .andExpect(jsonPath("$.data.members[1].profileUrl").value(member2.getProfileImageUrl())) + .andExpect(jsonPath("$.data.members[1].authority").value("READ_WRITE")) + .andExpect(jsonPath("$.data.members[2].id").exists()) + .andExpect(jsonPath("$.data.members[2].name").value(member3.getName())) + .andExpect(jsonPath("$.data.members[2].profileUrl").value(member3.getProfileImageUrl())) + .andExpect(jsonPath("$.data.members[2].authority").value("READ_ONLY")); + } + + @Test + @WithUserDetails(value = "KAKAO:mc2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 목록 조회 - 실패 : 스페이스 멤버가 아님") + void listSpaceMembers_Fail_NotSpaceMember() throws Exception { + // given + var space = spaceService.findByName("기존 스페이스 1_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + + // when + ResultActions resultActions = performGet(url); + + // then + expectForbidden(resultActions, "액세스가 거부되었습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 목록 조회 - 실패 : 스페이스가 존재하지 않음") + void listSpaceMembers_Fail_NotExistSpace() throws Exception { + // given + Integer spaceId = 9999; + String url = "/api/v1/space/member/%d".formatted(spaceId); + + // when + ResultActions resultActions = performGet(url); + + // then + expectNotFound(resultActions, "존재하지 않는 스페이스입니다."); + } + + // ============================= CHANGE MEMBER AUTHORITY ============================= // + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 권한 변경 - 성공") + void changeMemberAuthority_Success() throws Exception { + // given + var member2 = memberService.findByKakaoKey("mc2222"); + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "newAuthority": "READ_ONLY", + "memberId": %d + } + """.formatted(member2.getId()); + + // when + ResultActions resultActions = performPut(url, requestBody); + + // then + expectOk(resultActions, "멤버 권한을 변경했습니다."); + + resultActions + .andExpect(jsonPath("$.data.spaceId").value(space.getId())) + .andExpect(jsonPath("$.data.spaceName").value(space.getName())) + .andExpect(jsonPath("$.data.member.id").value(member2.getId())) + .andExpect(jsonPath("$.data.member.name").value(member2.getName())) + .andExpect(jsonPath("$.data.member.profileUrl").value(member2.getProfileImageUrl())) + .andExpect(jsonPath("$.data.member.authority").value("READ_ONLY")); + } + + @Test + @WithUserDetails(value = "KAKAO:mc2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 권한 변경 - 실패 : 어드민 권한 없음") + void changeMemberAuthority_Fail_NoAuthority() throws Exception { + // given + var member3 = memberService.findByKakaoKey("mc3333"); + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "newAuthority": "READ_WRITE", + "memberId": %d + } + """.formatted(member3.getId()); + + // when + ResultActions resultActions = performPut(url, requestBody); + + // then + expectForbidden(resultActions, "액세스가 거부되었습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 권한 변경 - 실패 : 스페이스가 존재하지 않음") + void changeMemberAuthority_Fail_NotExistSpace() throws Exception { + // given + var member2 = memberService.findByKakaoKey("mc2222"); + Integer spaceId = 9999; + String url = "/api/v1/space/member/%d".formatted(spaceId); + String requestBody = """ + { + "newAuthority": "READ_ONLY", + "memberId": %d + } + """.formatted(member2.getId()); + + // when + ResultActions resultActions = performPut(url, requestBody); + + // then + expectNotFound(resultActions, "존재하지 않는 스페이스입니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 권한 변경 - 실패 : 멤버가 존재하지 않음") + void changeMemberAuthority_Fail_NotExistMember() throws Exception { + // given + Integer memberId = 9999; + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "newAuthority": "READ_ONLY", + "memberId": %d + } + """.formatted(memberId); + + // when + ResultActions resultActions = performPut(url, requestBody); + + // then + expectNotFound(resultActions, "9999 id를 가진 사용자를 찾을 수 없습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 권한 변경 - 실패 : 멤버가 스페이스에 속해있지 않음") + void changeMemberAuthority_Fail_MemberNotInSpace() throws Exception { + // given + var member3 = memberService.findByKakaoKey("mc3333"); + var space = spaceService.findByName("기존 스페이스 4_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "newAuthority": "READ_ONLY", + "memberId": %d + } + """.formatted(member3.getId()); + + // when + ResultActions resultActions = performPut(url, requestBody); + + // then + expectNotFound(resultActions, "해당 멤버는 스페이스에 속해있지 않습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 권한 변경 - 실패 : 잘못된 권한") + void changeMemberAuthority_Fail_WrongAuthority() throws Exception { + // given + var member2 = memberService.findByKakaoKey("mc2222"); + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "newAuthority": "WRONG_AUTHORITY", + "memberId": %d + } + """.formatted(member2.getId()); + + // when + ResultActions resultActions = performPut(url, requestBody); + + // then + expectBadRequest(resultActions, "Invalid value provided for field 'newAuthority'. Please check the allowed values."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 권한 변경 - 실패 : 본인의 권한을 변경하려고 함") + void changeMemberAuthority_Fail_ChangeOwnAuthority() throws Exception { + // given + var member1 = memberService.findByKakaoKey("mc1111"); + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "newAuthority": "READ_ONLY", + "memberId": %d + } + """.formatted(member1.getId()); + + // when + ResultActions resultActions = performPut(url, requestBody); + + // then + expectBadRequest(resultActions, "본인의 권한은 변경할 수 없습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 권한 변경 - 실패 : 이미 동일한 권한을 가지고 있음") + void changeMemberAuthority_Fail_SameAuthority() throws Exception { + // given + var member2 = memberService.findByKakaoKey("mc2222"); + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "newAuthority": "READ_WRITE", + "memberId": %d + } + """.formatted(member2.getId()); + + // when + ResultActions resultActions = performPut(url, requestBody); + + // then + resultActions.andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value("409")) + .andExpect(jsonPath("$.msg").value("이미 요청된 권한을 가지고 있습니다.")); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 권한 변경 - 실패 : 멤버Id 누락") + void changeMemberAuthority_Fail_MemberIdMissing() throws Exception { + // given + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "newAuthority": "READ_ONLY" + } + """; + + // when + ResultActions resultActions = performPut(url, requestBody); + + // then + expectBadRequest(resultActions, "memberId-NotNull-must not be null"); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 권한 변경 - 실패 : 권한 누락") + void changeMemberAuthority_Fail_AuthorityMissing() throws Exception { + // given + var member2 = memberService.findByKakaoKey("mc2222"); + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "memberId": %d + } + """.formatted(member2.getId()); + + // when + ResultActions resultActions = performPut(url, requestBody); + + // then + expectBadRequest(resultActions, "newAuthority-NotNull-must not be null"); + } + + // ============================= INVITE MEMBERS ============================= // + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 단건 초대 - 성공") + void inviteMember_Success() throws Exception { + // given + var member3 = memberService.findByKakaoKey("mc3333"); + var space = spaceService.findByName("기존 스페이스 4_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "memberNames": [ "%s" ] + } + """.formatted(member3.getName()); + + // when + ResultActions resultActions = performPost(url, requestBody); + + // then + expectOk(resultActions, "사용자를 스페이스에 초대했습니다."); + + resultActions + .andExpect(jsonPath("$.data.spaceId").value(space.getId())) + .andExpect(jsonPath("$.data.spaceName").value(space.getName())) + .andExpect(jsonPath("$.data.invitedUsers.length()").value(1)) + .andExpect(jsonPath("$.data.invitedUsers[0].id").value(member3.getId())) + .andExpect(jsonPath("$.data.invitedUsers[0].name").value(member3.getName())); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 다건 초대 - 성공") + void inviteMembers_Success() throws Exception { + // given + var member2 = memberService.findByKakaoKey("mc2222"); + var member3 = memberService.findByKakaoKey("mc3333"); + var space = spaceService.findByName("기존 스페이스 4_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "memberNames": [ "%s", "%s" ] + } + """.formatted(member2.getName(), member3.getName()); + + // when + ResultActions resultActions = performPost(url, requestBody); + + // then + expectOk(resultActions, "사용자를 스페이스에 초대했습니다."); + + resultActions + .andExpect(jsonPath("$.data.spaceId").value(space.getId())) + .andExpect(jsonPath("$.data.spaceName").value(space.getName())) + .andExpect(jsonPath("$.data.invitedUsers.length()").value(2)) + .andExpect(jsonPath("$.data.invitedUsers[0].id").value(member2.getId())) + .andExpect(jsonPath("$.data.invitedUsers[0].name").value(member2.getName())) + .andExpect(jsonPath("$.data.invitedUsers[1].id").value(member3.getId())) + .andExpect(jsonPath("$.data.invitedUsers[1].name").value(member3.getName())); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 초대 - 성공 : 일부만 초대됨") + void inviteMember_Success_PartialInvite() throws Exception { + // given + var member2 = memberService.findByKakaoKey("mc2222"); + var member3 = memberService.findByKakaoKey("mc3333"); + var space = spaceService.findByName("기존 스페이스 5_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "memberNames": [ "%s", "%s" ] + } + """.formatted(member2.getName(), member3.getName()); + + // when + ResultActions resultActions = performPost(url, requestBody); + + // then + expectOk(resultActions, "사용자를 스페이스에 초대했습니다."); + + resultActions + .andExpect(jsonPath("$.data.spaceId").value(space.getId())) + .andExpect(jsonPath("$.data.spaceName").value(space.getName())) + .andExpect(jsonPath("$.data.invitedUsers.length()").value(1)) + .andExpect(jsonPath("$.data.invitedUsers[0].id").value(member3.getId())) + .andExpect(jsonPath("$.data.invitedUsers[0].name").value(member3.getName())); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 초대 - 실패 : 스페이스가 존재하지 않음") + void inviteMember_Fail_NotExistSpace() throws Exception { + // given + var member3 = memberService.findByKakaoKey("mc3333"); + Integer spaceId = 9999; + String url = "/api/v1/space/member/%d".formatted(spaceId); + String requestBody = """ + { + "memberNames": [ "%s" ] + } + """.formatted(member3.getName()); + + // when + ResultActions resultActions = performPost(url, requestBody); + + // then + expectNotFound(resultActions, "존재하지 않는 스페이스입니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 초대 - 실패 : 어드민 권한 없음") + void inviteMember_Fail_NoAuthority() throws Exception { + // given + var member3 = memberService.findByKakaoKey("mc3333"); + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "memberNames": [ "%s" ] + } + """.formatted(member3.getName()); + + // when + ResultActions resultActions = performPost(url, requestBody); + + // then + expectForbidden(resultActions, "액세스가 거부되었습니다."); + } + + // ============================= EXPEL MEMBER ============================= // + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 퇴출 - 성공") + void deleteMember_Success() throws Exception { + // given + var member2 = memberService.findByKakaoKey("mc2222"); + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "memberId": %d + } + """.formatted(member2.getId()); + + // when + ResultActions resultActions = performDelete(url, requestBody); + + // then + expectOk(resultActions, "멤버를 스페이스에서 퇴출했습니다."); + + resultActions + .andExpect(jsonPath("$.data.spaceId").value(space.getId())) + .andExpect(jsonPath("$.data.spaceName").value(space.getName())) + .andExpect(jsonPath("$.data.expelledMemberInfo.id").value(member2.getId())) + .andExpect(jsonPath("$.data.expelledMemberInfo.name").value(member2.getName())) + .andExpect(jsonPath("$.data.expelledMemberInfo.profileUrl").value(member2.getProfileImageUrl())); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 퇴출 - 성공 : PENDING 멤버 퇴출") + void deleteMember_Success_PendingMember() throws Exception { + // given + var member2 = memberService.findByKakaoKey("mc2222"); + var space = spaceService.findByName("기존 스페이스 1_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "memberId": %d + } + """.formatted(member2.getId()); + + // when + ResultActions resultActions = performDelete(url, requestBody); + + // then + expectOk(resultActions, "멤버를 스페이스에서 퇴출했습니다."); + + resultActions + .andExpect(jsonPath("$.data.spaceId").value(space.getId())) + .andExpect(jsonPath("$.data.spaceName").value(space.getName())) + .andExpect(jsonPath("$.data.expelledMemberInfo.id").value(member2.getId())) + .andExpect(jsonPath("$.data.expelledMemberInfo.name").value(member2.getName())) + .andExpect(jsonPath("$.data.expelledMemberInfo.profileUrl").value(member2.getProfileImageUrl())); + } + + @Test + @WithUserDetails(value = "KAKAO:mc2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 퇴출 - 실패 : 어드민 권한 없음") + void deleteMember_Fail_NoAuthority() throws Exception { + // given + var member3 = memberService.findByKakaoKey("mc3333"); + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "memberId": %d + } + """.formatted(member3.getId()); + + // when + ResultActions resultActions = performDelete(url, requestBody); + + // then + expectForbidden(resultActions, "액세스가 거부되었습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 퇴출 - 실패 : 스페이스가 존재하지 않음") + void deleteMember_Fail_NotExistSpace() throws Exception { + // given + var member2 = memberService.findByKakaoKey("mc2222"); + Integer spaceId = 9999; + String url = "/api/v1/space/member/%d".formatted(spaceId); + String requestBody = """ + { + "memberId": %d + } + """.formatted(member2.getId()); + + // when + ResultActions resultActions = performDelete(url, requestBody); + + // then + expectNotFound(resultActions, "존재하지 않는 스페이스입니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 퇴출 - 실패 : 멤버가 존재하지 않음") + void deleteMember_Fail_NotExistMember() throws Exception { + // given + Integer memberId = 9999; + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "memberId": %d + } + """.formatted(memberId); + + // when + ResultActions resultActions = performDelete(url, requestBody); + + // then + expectNotFound(resultActions, "9999 id를 가진 사용자를 찾을 수 없습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 퇴출 - 실패 : 멤버가 스페이스에 속해있지 않음") + void deleteMember_Fail_MemberNotInSpace() throws Exception { + // given + var member3 = memberService.findByKakaoKey("mc3333"); + var space = spaceService.findByName("기존 스페이스 4_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "memberId": %d + } + """.formatted(member3.getId()); + + // when + ResultActions resultActions = performDelete(url, requestBody); + + // then + expectNotFound(resultActions, "해당 멤버는 스페이스에 속해있지 않습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 멤버 퇴출 - 실패 : 본인을 퇴출하려고 함") + void deleteMember_Fail_DeleteOwnMembership() throws Exception { + // given + var member1 = memberService.findByKakaoKey("mc1111"); + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/%d".formatted(space.getId()); + String requestBody = """ + { + "memberId": %d + } + """.formatted(member1.getId()); + + // when + ResultActions resultActions = performDelete(url, requestBody); + + // then + expectForbidden(resultActions, "본인은 강퇴할 수 없습니다."); + } + + // ============================= LEAVE SPACE ============================= // + + @Test + @WithUserDetails(value = "KAKAO:mc2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 탈퇴 - 성공") + void leaveSpace_Success() throws Exception { + // given + var space = spaceService.findByName("기존 스페이스 3_forMembershipControllerTest"); + String url = "/api/v1/space/member/me/%d".formatted(space.getId()); + + // when + ResultActions resultActions = performDelete(url); + + // then + expectOk(resultActions, "스페이스에서 탈퇴했습니다."); + + resultActions + .andExpect(jsonPath("$.data.id").value(space.getId())) + .andExpect(jsonPath("$.data.name").value(space.getName())); + } + + @Test + @WithUserDetails(value = "KAKAO:mc2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 탈퇴 - 성공 : 유일한 어드민이 아닌 경우") + void leaveSpace_Success_OnlyNonAdmin() throws Exception { + // given + var space = spaceService.findByName("기존 스페이스 6_forMembershipControllerTest"); + String url = "/api/v1/space/member/me/%d".formatted(space.getId()); + + // when + ResultActions resultActions = performDelete(url); + + // then + expectOk(resultActions, "스페이스에서 탈퇴했습니다."); + + resultActions + .andExpect(jsonPath("$.data.id").value(space.getId())) + .andExpect(jsonPath("$.data.name").value(space.getName())); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 탈퇴 - 실패 : 혼자인 경우(어드민이자 유일한 멤버)") + void leaveSpace_Fail_AloneAdmin() throws Exception { + // given + var space = spaceService.findByName("기존 스페이스 4_forMembershipControllerTest"); + String url = "/api/v1/space/member/me/%d".formatted(space.getId()); + + // when + ResultActions resultActions = performDelete(url); + + // then + expectBadRequest(resultActions, "유일한 어드민은 탈퇴할 수 없습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 탈퇴 - 실패 : 유일한 어드민인 경우") + void leaveSpace_Fail_OnlyAdmin() throws Exception { + // given + var space = spaceService.findByName("기존 스페이스 5_forMembershipControllerTest"); + String url = "/api/v1/space/member/me/%d".formatted(space.getId()); + + // when + ResultActions resultActions = performDelete(url); + + // then + expectBadRequest(resultActions, "유일한 어드민은 탈퇴할 수 없습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 탈퇴 - 실패 : 스페이스가 존재하지 않음") + void leaveSpace_Fail_NotExistSpace() throws Exception { + // given + Integer spaceId = 9999; + String url = "/api/v1/space/member/me/%d".formatted(spaceId); + + // when + ResultActions resultActions = performDelete(url); + + // then + expectNotFound(resultActions, "존재하지 않는 스페이스입니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 탈퇴 - 실패 : 스페이스 멤버가 아님(초대된 상태)") + void leaveSpace_Fail_NotSpaceMember() throws Exception { + // given + var space = spaceService.findByName("기존 스페이스 2_forMembershipControllerTest"); + String url = "/api/v1/space/member/me/%d".formatted(space.getId()); + + // when + ResultActions resultActions = performDelete(url); + + // then + expectNotFound(resultActions, "해당 멤버는 스페이스에 속해있지 않습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:mc2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 탈퇴 - 실패 : 스페이스 멤버가 아님(처음부터 속해있지 않음)") + void leaveSpace_Fail_NotSpaceMember_2() throws Exception { + // given + var space = spaceService.findByName("기존 스페이스 4_forMembershipControllerTest"); + String url = "/api/v1/space/member/me/%d".formatted(space.getId()); + + // when + ResultActions resultActions = performDelete(url); + + // then + expectNotFound(resultActions, "해당 멤버는 스페이스에 속해있지 않습니다."); + } + } \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java index 6b027638..01eac5e1 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipServiceTest.java @@ -10,20 +10,25 @@ import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.enums.Provider; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; import org.tuna.zoopzoop.backend.domain.space.membership.repository.MembershipRepository; import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; import java.nio.file.AccessDeniedException; +import java.util.List; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.junit.jupiter.api.Assertions.*; @ActiveProfiles("test") @SpringBootTest @Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) class MembershipServiceTest { @Autowired private SpaceService spaceService; @@ -36,7 +41,7 @@ class MembershipServiceTest { @Autowired private MemberRepository memberRepository; - @BeforeEach + @BeforeAll void setUp() { membershipRepository.deleteAll(); setUpMember(); @@ -47,9 +52,9 @@ void setUp() { void setUpSpace() { spaceService.createSpace("기존 스페이스 1_forMembershipServiceTest"); spaceService.createSpace("기존 스페이스 2_forMembershipServiceTest"); + spaceService.createSpace("기존 스페이스 3_forMembershipServiceTest"); } - void setUpMember() { memberService.createMember( "tester1_forMembershipServiceTest", @@ -76,12 +81,22 @@ void setUpMembership() { var member2 = memberService.findByKakaoKey("ms2222"); var space1 = spaceService.findByName("기존 스페이스 1_forMembershipServiceTest"); var space2 = spaceService.findByName("기존 스페이스 2_forMembershipServiceTest"); + var space3 = spaceService.findByName("기존 스페이스 3_forMembershipServiceTest"); + // member1 -> space1 (ADMIN) membershipService.addMemberToSpace(member1, space1, Authority.ADMIN); - membershipService.addMemberToSpace(member1, space2, Authority.PENDING); + + // member2 -> space1 (READ_ONLY) membershipService.addMemberToSpace(member2, space1, Authority.READ_ONLY); + + // member1 -> space2 (PENDING) + membershipService.addMemberToSpace(member1, space2, Authority.PENDING); + + // member2 -> space2 (ADMIN) membershipService.addMemberToSpace(member2, space2, Authority.ADMIN); + // member1 -> space3 (ADMIN) + membershipService.addMemberToSpace(member1, space3, Authority.ADMIN); } // ============================= ADD MEMBER TO SPACE ============================= // @@ -229,4 +244,123 @@ void changeAuthority_Success() { // Then assertEquals(Authority.ADMIN, updatedMembership.getAuthority()); } + + // ============================= INVITE MEMBERS ============================= // + + @Test + @DisplayName("멤버 단건 초대 - 성공") + void inviteMemberToSpace_Success() { + // Given + Member member3 = memberService.findByKakaoKey("ms3333"); + List targetMembers = List.of(member3.getName()); + var space = spaceService.findByName("기존 스페이스 3_forMembershipServiceTest"); + + // When + List results = membershipService.inviteMembersToSpace(space, targetMembers); + + // Then + assertThat(results.size()).isEqualTo(1); + assertThat(results.get(0).getMember().getName()).isEqualTo(member3.getName()); + assertThat(results.get(0).getAuthority()).isEqualTo(Authority.PENDING); + assertThat(results.get(0).getSpace().getId()).isEqualTo(space.getId()); + + Membership membership3 = membershipRepository.findByMemberAndSpace(member3, space).orElseThrow(); + + assertThat(membership3.getAuthority()).isEqualTo(Authority.PENDING); + } + + @Test + @DisplayName("멤버 다건 초대 - 성공") + void inviteMultipleMembersToSpace_Success() { + // Given + Member member2 = memberService.findByKakaoKey("ms2222"); + Member member3 = memberService.findByKakaoKey("ms3333"); + + List targetMembers = List.of(member2.getName(), member3.getName()); + var space = spaceService.findByName("기존 스페이스 3_forMembershipServiceTest"); + + // When + List results = membershipService.inviteMembersToSpace(space, targetMembers); + + // Then + assertThat(results.size()).isEqualTo(2); + assertThat(results.get(0).getAuthority()).isEqualTo(Authority.PENDING); + assertThat(results.get(1).getAuthority()).isEqualTo(Authority.PENDING); + assertThat(results.get(0).getSpace().getId()).isEqualTo(space.getId()); + assertThat(results.get(1).getSpace().getId()).isEqualTo(space.getId()); + assertThat(results.get(0).getMember().getId()).isIn(member2.getId(), member3.getId()); + assertThat(results.get(1).getMember().getId()).isIn(member2.getId(), member3.getId()); + + Membership membership2 = membershipRepository.findByMemberAndSpace(member2, space).orElseThrow(); + Membership membership3 = membershipRepository.findByMemberAndSpace(member3, space).orElseThrow(); + + assertThat(membership2.getAuthority()).isEqualTo(Authority.PENDING); + assertThat(membership3.getAuthority()).isEqualTo(Authority.PENDING); + } + + @Test + @DisplayName("멤버 초대 - 성공 : 일부만 초대 (이미 멤버인 경우 제외)") + void inviteMembersToSpace_PartialSuccess_AlreadyMember() { + // Given + Member member1 = memberService.findByKakaoKey("ms1111"); + Member member3 = memberService.findByKakaoKey("ms3333"); + + List targetMembers = List.of(member1.getName(), member3.getName()); + var space = spaceService.findByName("기존 스페이스 1_forMembershipServiceTest"); + + // When + List results = membershipService.inviteMembersToSpace(space, targetMembers); + + // Then + assertThat(results.size()).isEqualTo(1); + assertThat(results.get(0).getMember().getId()).isEqualTo(member3.getId()); + assertThat(results.get(0).getAuthority()).isEqualTo(Authority.PENDING); + assertThat(results.get(0).getSpace().getId()).isEqualTo(space.getId()); + + Membership membership3 = membershipRepository.findByMemberAndSpace(member3, space).orElseThrow(); + assertThat(membership3.getAuthority()).isEqualTo(Authority.PENDING); + } + + @Test + @DisplayName("멤버 초대 - 성공 : 일부만 초대 (없는 아이디 제외)") + void inviteMembersToSpace_PartialSuccess_NonExistentMember() { + // Given + Member member2 = memberService.findByKakaoKey("ms2222"); + String nonExistentName = "nonExistentMember_forMembershipServiceTest"; + + List targetMembers = List.of(member2.getName(), nonExistentName); + var space = spaceService.findByName("기존 스페이스 3_forMembershipServiceTest"); + + // When + List results = membershipService.inviteMembersToSpace(space, targetMembers); + + // Then + assertThat(results.size()).isEqualTo(1); + assertThat(results.get(0).getMember().getId()).isEqualTo(member2.getId()); + assertThat(results.get(0).getAuthority()).isEqualTo(Authority.PENDING); + assertThat(results.get(0).getSpace().getId()).isEqualTo(space.getId()); + + Membership membership2 = membershipRepository.findByMemberAndSpace(member2, space).orElseThrow(); + assertThat(membership2.getAuthority()).isEqualTo(Authority.PENDING); + } + + + // ============================ EXPEL MEMBERS ============================= // + + @Test + @DisplayName("멤버 퇴출 - 성공") + void expelMemberFromSpace_Success() throws AccessDeniedException { + // Given + Member member2 = memberService.findByKakaoKey("ms2222"); + var space = spaceService.findByName("기존 스페이스 1_forMembershipServiceTest"); + Membership membershipToExpel = membershipService.findByMemberAndSpace(member2, space); + + // When + membershipService.expelMemberFromSpace(member2, space); + + // Then + assertThrows(NoResultException.class, () -> { + membershipService.findById(membershipToExpel.getId()); + }); + } } \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java index 7430ce8e..57d8db11 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java @@ -45,7 +45,6 @@ void setUp() { void setUpSpace() { spaceService.createSpace("기존 스페이스 1_forSpaceControllerTest"); spaceService.createSpace("기존 스페이스 2_forSpaceControllerTest"); - } void setUpMember() { @@ -235,6 +234,7 @@ void deleteSpace_Fail_NoAdminAuthority() throws Exception { // ======================= Modify ======================== // + @Test @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) @DisplayName("스페이스 이름 변경 - 성공") diff --git a/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java b/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java index 73663db0..f0d64e8a 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java +++ b/src/test/java/org/tuna/zoopzoop/backend/testSupport/ControllerTestSupport.java @@ -112,6 +112,20 @@ protected ResultActions performDelete(String url) throws Exception { .andDo(print()); } + /** + * DELETE 요청을 수행하는 헬퍼 메서드 (바디 있는 경우) + * @param url - 요청할 URL + * @param body - 요청 바디 (객체 형태) + * @return ResultActions - MockMvc의 ResultActions 객체 + * @throws Exception - 예외 발생 시 던짐 + */ + protected ResultActions performDelete(String url, String body) throws Exception { + return mvc.perform(delete(url) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andDo(print()); + } + // ====================== COMMON ASSERTIONS (Response) ======================= // /** From 94abcdffaaa15544783bbf847e946d0c2a292b58 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:24:33 +0900 Subject: [PATCH 032/132] =?UTF-8?q?refactor/OPS-308=20:=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=B6=84=EB=A6=AC=20&=20CI=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=EC=97=90=20application-sec?= =?UTF-8?q?rets-server.yml=20=EC=B6=94=EA=B0=80.=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test-server.yml | 17 ++++++++++++----- .../domain/home/controller/HomeController.java | 6 +++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test-server.yml b/.github/workflows/test-server.yml index cc23696d..da23340d 100644 --- a/.github/workflows/test-server.yml +++ b/.github/workflows/test-server.yml @@ -55,11 +55,18 @@ jobs: echo "${{ secrets.APPLICATION_SECRET_YML }}" > src/main/resources/application-secrets.yml echo "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" >> src/main/resources/application-secrets.yml - # 6. Gradle 테스트 실행 + # 6. application-secrets-server.yml 생성 + - name: Generate application-secrets-server.yml + run: | + mkdir -p src/main/resources + echo "${{ secrets.APPLICATION_SECRET_SERVER_YML }}" > src/main/resources/application-secrets-server.yml + echo "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" >> src/main/resources/application-secrets-server.yml + + # 7. Gradle 테스트 실행 - name: Test with Gradle run: ./gradlew test - # 7. 테스트 결과 요약 출력 + # 8. 테스트 결과 요약 출력 - name: Show test results run: | echo "==== Test Results ====" @@ -76,11 +83,11 @@ jobs: echo "No test results found." fi - # 8. Gradle 빌드 실행 (테스트 성공 시) + # 9. Gradle 빌드 실행 (테스트 성공 시) - name: Build with Gradle run: ./gradlew build - # 9. GHCR 로그인 + # 10. GHCR 로그인 - name: Log in to GHCR uses: docker/login-action@v2 with: @@ -88,7 +95,7 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - # 10. Docker 이미지 빌드 & 푸시 + # 11. Docker 이미지 빌드 & 푸시 - name: Build & Push Docker Image run: | IMAGE_NAME=ghcr.io/${{ github.repository }}/zoopzoop diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java index 90c94ed7..4930ded8 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java @@ -30,9 +30,9 @@ public class HomeController { public String main() { InetAddress localHost = getLocalHost(); - String kakaoLoginUrl = "http://localhost:8080/oauth2/authorization/kakao"; - String googleLoginUrl = "http://localhost:8080/oauth2/authorization/google"; - String logoutUrl = "http://localhost:8080/api/v1/auth/logout"; + String kakaoLoginUrl = "/oauth2/authorization/kakao"; + String googleLoginUrl = "/oauth2/authorization/google"; + String logoutUrl = "/api/v1/auth/logout"; return """

API 서버

From 02f631058d8135cfd025834619def18e8f9efdfa Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Thu, 25 Sep 2025 14:01:01 +0900 Subject: [PATCH 033/132] =?UTF-8?q?Ops=20255=20be=20feat=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=88=98=EC=A0=95=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-283 : datasource 엔티티 수정 * [Feat/OPS-186] 스페이스 초대 관리 (#38) * new/OPS-189 : 초대 관리 Controller 및 테스트 클래스 생성 * feat/OPS-189 : 초대 수락 관련 테스트 코드 작성 * feat : 멤버 권한 변경 함수 생성 * feat/OPS-189 : 초대 수락 구현 * feat/OPS-190 : 초대 거절 테스트 케이스 작성 * feat/OPS-190 : 초대 거절 엔드포인트 생성 * feat/OPS-190 : membershipService 관련 단위 테스트 추가 * refactor : 코드 간단한 위치 조정 * feat/OPS-188 : 스페이스 초대 목록 조회 완료 * fix/OPS-188 : dto 네이밍 수정 * refactor/OPS-188 : 스페이스에 초대된 유저 목록 조회의 controller 위치 변경 * feat : 사용자에게 온 스페이스 초대 목록 조회 엔드포인트 생성 * feat : 사용자에게 온 스페이스 초대 목록 조회 테스트 케이스 작성 --------- Co-authored-by: EpicFn * refactor/OPS-285 : datasource 엔티티 thumbnailUrl 이름 변경 (#40) * refactor/OPS-285 : datasource 엔티티 thumbnailUrl 이름 변경 * refactor/OPS-285 : datasource 엔티티 thumbnailUrl 이름 변경 * feat/OPS-226 : 자료 등록 구현 * feat/OPS-215 : 자료 삭제 구현 * feat/OPS-255 : 자료 이동 및 수정 구현 --------- Co-authored-by: Hyeok Jin Kim <71928299+EpicFn@users.noreply.github.com> Co-authored-by: EpicFn --- .../folder/repository/FolderRepository.java | 22 +- .../controller/DatasourceController.java | 73 ++++- .../dto/reqBodyForMoveDataSource.java | 2 +- .../datasource/dto/reqBodyForMoveMany.java | 2 +- .../dto/reqBodyForUpdateDataSource.java | 8 + .../dto/resBodyForUpdateDataSource.java | 5 + .../repository/DataSourceRepository.java | 3 +- .../datasource/service/DataSourceService.java | 116 +++++++- .../controller/DatasourceControllerTest.java | 160 ++++++++++- .../service/DataSourceServiceTest.java | 262 +++++++++++++++++- 10 files changed, 640 insertions(+), 13 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/resBodyForUpdateDataSource.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java index 647272af..cd3b30b6 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java @@ -23,10 +23,26 @@ public interface FolderRepository extends JpaRepository{ and f.name < :filenameEnd """) List findNamesForConflictCheck(Integer archiveId, String filename, String filenameEnd); - + // 개인 아카이브의 폴더 조회 List findByArchive(Archive archive); - Optional findByName(String name); - + /** + * 아카이브 Id로 default 폴더 조회 + * @param archiveId 조회할 archive Id + */ Optional findByArchiveIdAndIsDefaultTrue(Integer archiveId); + + /** + * 회원 Id로 default 폴더 조회 + * @param memberId 조회할 회원 Id + */ + @Query(""" + select f + from Folder f + join f.archive a + join PersonalArchive pa on pa.archive = a + where pa.member.id = :memberId + and f.isDefault = true + """) + Optional findDefaultFolderByMemberId(Integer memberId); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java index 5727130f..a7d3296a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java @@ -8,6 +8,7 @@ import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; import org.tuna.zoopzoop.backend.global.security.StubAuthUtil; +import java.util.HashMap; import java.util.Map; @RestController @@ -58,7 +59,7 @@ public ResponseEntity> deleteMany( ) { dataSourceService.deleteMany(body.dataSourceId()); - // ✅ Map.of 는 null 불가 → LinkedHashMap 사용 + // Map.of 는 null 불가 → LinkedHashMap 사용 Map res = new java.util.LinkedHashMap<>(); res.put("status", 200); res.put("msg", "복수개의 자료가 삭제됐습니다."); @@ -67,5 +68,75 @@ public ResponseEntity> deleteMany( return ResponseEntity.ok(res); } + /** + * 자료 단건 이동 + * folderId=null 이면 default 폴더 + */ + @PatchMapping("/{dataSourceId}/move") + public ResponseEntity moveDataSource( + @PathVariable Integer dataSourceId, + @Valid @RequestBody reqBodyForMoveDataSource rq + ) { + Integer currentMemberId = StubAuthUtil.currentMemberId(); + + DataSourceService.MoveResult result = + dataSourceService.moveDataSource(currentMemberId, dataSourceId, rq.folderId()); + resBodyForMoveDataSource body = + new resBodyForMoveDataSource(result.datasourceId(), result.folderId()); + String msg = body.dataSourceId() + "번 자료가 " + body.folderId() + "번 폴더로 이동했습니다."; + + return ResponseEntity.ok( + Map.of( + "status", 200, + "msg", msg, + "data", java.util.Map.of( + "folderId", body.folderId(), + "dataSourceId", body.dataSourceId() + ) + ) + ); + } + + /** + * 자료 다건 이동 + */ + @PatchMapping("/move") + public ResponseEntity moveMany(@Valid @RequestBody reqBodyForMoveMany rq) { + Integer currentMemberId = StubAuthUtil.currentMemberId(); + + dataSourceService.moveDataSources(currentMemberId, rq.folderId(), rq.dataSourceId()); + + Map res = new HashMap<>(); + res.put("status", 200); + res.put("msg", "복수 개의 자료를 이동했습니다."); + res.put("data", null); + + return ResponseEntity.ok(res); + } + + /** + * 파일 수정 + * @param dataSourceId 수정할 파일 Id + * @param body 수정할 내용 + */ + @PatchMapping("/{dataSourceId}") + public ResponseEntity updateDataSource( + @PathVariable Integer dataSourceId, + @Valid @RequestBody reqBodyForUpdateDataSource body + ) { + // title, summary 둘 다 비어있으면 의미 없는 요청 → 400 + boolean noTitle = (body.title() == null || body.title().isBlank()); + boolean noSummary = (body.summary() == null || body.summary().isBlank()); + if (noTitle && noSummary) { + throw new IllegalArgumentException("변경할 값이 없습니다. title 또는 summary 중 하나 이상을 전달하세요."); + } + + Integer updatedId = dataSourceService.updateDataSource(dataSourceId, body.title(), body.summary()); + String msg = updatedId + "번 자료가 수정됐습니다."; + return ResponseEntity.ok( + new ApiResponse<>(200, msg, new resBodyForUpdateDataSource(updatedId)) + ); + } + record ApiResponse(int status, String msg, T data) {} } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveDataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveDataSource.java index 10b8ccdc..cb5cc43c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveDataSource.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveDataSource.java @@ -3,5 +3,5 @@ import jakarta.validation.constraints.NotNull; public record reqBodyForMoveDataSource( - @NotNull Integer folderId + Integer folderId ) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveMany.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveMany.java index e98ebf6d..0479b1a3 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveMany.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForMoveMany.java @@ -6,6 +6,6 @@ import java.util.List; public record reqBodyForMoveMany( - @NotNull Integer folderId, + Integer folderId, @NotEmpty List dataSourceId ) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java new file mode 100644 index 00000000..9a848ba5 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/reqBodyForUpdateDataSource.java @@ -0,0 +1,8 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; + +import jakarta.validation.constraints.NotNull; + +public record reqBodyForUpdateDataSource( + @NotNull String title, + @NotNull String summary +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/resBodyForUpdateDataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/resBodyForUpdateDataSource.java new file mode 100644 index 00000000..7c97a26a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/resBodyForUpdateDataSource.java @@ -0,0 +1,5 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; + +public record resBodyForUpdateDataSource( + Integer dataSourceId +) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java index 85647acb..2a4156b6 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java @@ -2,6 +2,7 @@ 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; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; @@ -16,6 +17,6 @@ public interface DataSourceRepository extends JpaRepository @Query("select d.id from DataSource d where d.id in ?1") java.util.List findExistingIds(Collection ids); - boolean existsByFolder_IdAndTitle(Integer folderId, String title); + List findAllByIdIn(Collection ids); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java index 75a0f96b..3474eb10 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java @@ -8,7 +8,6 @@ import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; -import org.tuna.zoopzoop.backend.domain.datasource.dto.resBodyForMoveDataSource; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; @@ -116,4 +115,119 @@ public void deleteMany(List ids) { dataSourceRepository.deleteAllByIdInBatch(ids); } + + /** + * 자료 위치 단건 이동 + */ + @Transactional + public MoveResult moveDataSource(Integer currentMemberId, Integer dataSourceId, Integer targetFolderId) { + + // 자료 확인 + DataSource ds = dataSourceRepository.findById(dataSourceId) + .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); + + Folder targetFolder = resolveTargetFolder(currentMemberId, targetFolderId); + + // 동일 폴더로 이동 요청 -> 통과 + if (ds.getFolder().getId() == targetFolder.getId()) + return new MoveResult(ds.getId(), targetFolder.getId()); + + // 목적지 폴더 내 파일명 중복 확인 +// if (dataSourceRepository.existsByFolder_IdAndTitle(targetFolderId, ds.getTitle())) +// throw new IllegalStateException("해당 폴더에 동일한 제목의 자료가 이미 존재합니다."); + + ds.setFolder(targetFolder); + + return new MoveResult(ds.getId(), targetFolder.getId()); + } + + + + /** + * 자료 위치 다건 이동 + */ + @Transactional + public void moveDataSources(Integer currentMemberId, Integer targetFolderId, List dataSourceIds) { + // 1) 요소 null 검증 (서비스 방어) + if (dataSourceIds.stream().anyMatch(Objects::isNull)) + throw new IllegalArgumentException("자료 id 목록에 null이 포함되어 있습니다."); + + // 자료 Id 중복 확인 + Map counts = dataSourceIds.stream() + .collect(Collectors.groupingBy(id -> id, Collectors.counting())); + List duplicates = counts.entrySet().stream() + .filter(e -> e.getValue() > 1) + .map(Map.Entry::getKey) + .sorted() + .toList(); + if (!duplicates.isEmpty()) { + throw new IllegalArgumentException("같은 자료를 두 번 선택했습니다: " + duplicates); + } + + // 목적지 폴더 확인 + Folder targetFolder = resolveTargetFolder(currentMemberId, targetFolderId); + + // 목록의 각 자료 확인 + List list = dataSourceRepository.findAllByIdIn(dataSourceIds); + if (list.size() != dataSourceIds.size()) + throw new NoResultException("요청한 자료 중 존재하지 않는 항목이 있습니다."); + + // 목적지 폴더 추출 + List needMove = list.stream() + .filter(ds -> !Objects.equals(ds.getFolder().getId(), targetFolder.getId())) + .toList(); + + // 이미 모두 이동한 경우 + if (needMove.isEmpty()) + return; + + // 같은 이름의 자료 여러 개 이동 시 충돌 + /* + Map reqTitleCount = needMove.stream() + .collect(Collectors.groupingBy(DataSource::getTitle, Collectors.counting())); + List internalDup = reqTitleCount.entrySet().stream() + .filter(e -> e.getValue() > 1) + .map(Map.Entry::getKey) + .toList(); + if (!internalDup.isEmpty()) { + throw new IllegalStateException("요청 목록 내부에 중복 제목이 포함되어 있습니다: " + internalDup); + } + + 이동할 폴더에 이미 같은 제목이 존재하는지 확인 + List titles = needMove.stream().map(DataSource::getTitle).toList(); + List conflicts = titles.isEmpty() + ? List.of() + : dataSourceRepository.findExistingTitlesInFolder(targetFolderId, titles); + + if (!conflicts.isEmpty()) { + throw new IllegalStateException("대상 폴더에 이미 존재하는 제목이 있어 이동할 수 없습니다: " + conflicts); + } + */ + needMove.forEach(ds -> ds.setFolder(targetFolder)); + } + + // 대상 폴더 해석 + private Folder resolveTargetFolder(Integer currentMemberId, Integer targetFolderId) { + if (targetFolderId == null) { + return folderRepository.findDefaultFolderByMemberId(currentMemberId) + .orElseThrow(() -> new NoResultException("기본 폴더가 존재하지 않습니다.")); + } + return folderRepository.findById(targetFolderId) + .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); + } + + public Integer updateDataSource(Integer dataSourceId, String newTitle, String newSummary) { + DataSource ds = dataSourceRepository.findById(dataSourceId) + .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); + + if (newTitle != null && !newTitle.isBlank()) + ds.setTitle(newTitle); + + if (newSummary != null && !newSummary.isBlank()) + ds.setSummary(newSummary); + + return ds.getId(); + } + + public record MoveResult(Integer datasourceId, Integer folderId) {} } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java index 92fbf4a2..3fc3bc2f 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java @@ -11,10 +11,7 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForCreateDataSource; -import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForDeleteMany; -import org.tuna.zoopzoop.backend.domain.datasource.dto.reqBodyForMoveDataSource; -import org.tuna.zoopzoop.backend.domain.datasource.dto.resBodyForMoveDataSource; +import org.tuna.zoopzoop.backend.domain.datasource.dto.*; import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; import org.tuna.zoopzoop.backend.global.exception.GlobalExceptionHandler; @@ -152,4 +149,159 @@ void deleteMany_partialMissing() throws Exception { .andExpect(jsonPath("$.status").value("404")) .andExpect(jsonPath("$.msg").value("존재하지 않는 자료 ID 포함: [2]")); } + + // 자료 단건 이동 + @Test + @DisplayName("단건 이동 성공 -> 200") + void moveOne_ok() throws Exception { + // given + when(dataSourceService.moveDataSource(anyInt(), eq(1), eq(200))) + .thenReturn(new DataSourceService.MoveResult(1, 200)); + + String body = om.writeValueAsString(new reqBodyForMoveDataSource(200)); + + // expect + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.dataSourceId").value(1)) + .andExpect(jsonPath("$.data.folderId").value(200)); + } + + @Test + @DisplayName(" 단건 이동 성공: default 폴더(null) -> 200") + void moveOne_default_ok() throws Exception { + when(dataSourceService.moveDataSource(anyInt(), eq(1), isNull())) + .thenReturn(new DataSourceService.MoveResult(1, 999)); // default folder id + + String body = om.writeValueAsString(new reqBodyForMoveDataSource(null)); + + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.folderId").value(999)); + } + + @Test + @DisplayName("단건 이동 실패: 자료 없음 -> 400") + void moveOne_notFound_data() throws Exception { + when(dataSourceService.moveDataSource(anyInt(), eq(1), eq(200))) + .thenThrow(new NoResultException("존재하지 않는 자료입니다.")); + + String body = om.writeValueAsString(new reqBodyForMoveDataSource(200)); + + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("단건 이동 실패: 폴더 없음 -> 404") + void moveOne_notFound_folder() throws Exception { + when(dataSourceService.moveDataSource(anyInt(), eq(1), eq(200))) + .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); + + String body = om.writeValueAsString(new reqBodyForMoveDataSource(200)); + + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNotFound()); + } + + // 자료 다건 이동 + @Test + @DisplayName("다건 이동 성공: 지정 폴더 -> 200") + void moveMany_specific_ok() throws Exception { + String body = "{\"folderId\":200,\"dataSourceId\":[1,2,3]}"; + + mockMvc.perform(patch("/api/v1/archive/move") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("복수 개의 자료를 이동했습니다.")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + @Test + @DisplayName("다건 이동 성공: 기본 폴더(null) -> 200") + void moveMany_default_ok() throws Exception { + // 서비스는 void 리턴이라 스텁 불필요 (예외만 없으면 200) + String body = "{\"folderId\":null,\"dataSourceId\":[1,2,3]}"; + + mockMvc.perform(patch("/api/v1/archive/move") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("복수 개의 자료를 이동했습니다.")); + } + + @Test + @DisplayName("다건 이동 실패: 기본 폴더 없음 -> 404") + void moveMany_default_missing() throws Exception { + String body = "{\"folderId\":null,\"dataSourceId\":[1,2]}"; + + doThrow(new NoResultException("기본 폴더가 존재하지 않습니다.")) + .when(dataSourceService).moveDataSources(anyInt(), isNull(), eq(List.of(1,2))); + + mockMvc.perform(patch("/api/v1/archive/move") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isNotFound()); + } + + // 자료 수정 + @Test + @DisplayName("자료 수정 성공 -> 200") + void update_ok() throws Exception { + int id = 10; + when(dataSourceService.updateDataSource(eq(id), eq("새 제목"), eq("짧은 요약"))) + .thenReturn(id); + + var body = new reqBodyForUpdateDataSource("새 제목", "짧은 요약"); + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value(id + "번 자료가 수정됐습니다.")) + .andExpect(jsonPath("$.data.dataSourceId").value(id)); + } + + @Test + @DisplayName("자료 수정 실패: 요청 바디가 모두 공백 -> 400") + void update_badRequest_whenEmpty() throws Exception { + var body = new reqBodyForUpdateDataSource(" ", null); + + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", 1) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(body))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.msg").exists()); + } + + @Test + @DisplayName("자료 수정 실패: 존재하지 않는 자료 -> 404") + void update_notFound() throws Exception { + int id = 999; + when(dataSourceService.updateDataSource(eq(id), any(), any())) + .thenThrow(new NoResultException("존재하지 않는 자료입니다.")); + + var body = new reqBodyForUpdateDataSource("제목", "요약"); + + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(body))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.msg").value("존재하지 않는 자료입니다.")); + } + + } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java index 77ffa13c..f19f9d27 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java @@ -12,12 +12,12 @@ import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; -import org.tuna.zoopzoop.backend.domain.datasource.dto.resBodyForMoveDataSource; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -187,4 +187,264 @@ void deleteMany_partialMissing() { verify(dataSourceRepository, never()).deleteAllByIdInBatch(any()); } + + + // 자료 단건 이동 + @Test + @DisplayName("단건 이동 성공: 지정 폴더로 이동") + void moveOne_ok() { + Integer memberId = 1, dsId = 10, fromId = 100, toId = 200; + + Folder from = new Folder(); ReflectionTestUtils.setField(from, "id", fromId); + Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); + + DataSource ds = new DataSource(); + ReflectionTestUtils.setField(ds, "id", dsId); + ds.setTitle("A"); ds.setFolder(from); + + when(dataSourceRepository.findById(dsId)).thenReturn(Optional.of(ds)); + when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); + + DataSourceService.MoveResult rs = dataSourceService.moveDataSource(memberId, dsId, toId); + + assertThat(rs.datasourceId()).isEqualTo(dsId); + assertThat(rs.folderId()).isEqualTo(toId); + assertThat(ds.getFolder().getId()).isEqualTo(toId); + } + + @Test + @DisplayName("단건이동 성공: 기본 폴더(null)로 이동") + void moveOne_default_ok() { + Integer memberId = 7, dsId = 1, fromId = 100, defaultId = 999; + + Folder from = new Folder(); ReflectionTestUtils.setField(from, "id", fromId); + Folder defaultFolder = new Folder(); ReflectionTestUtils.setField(defaultFolder, "id", defaultId); + + DataSource ds = new DataSource(); + ReflectionTestUtils.setField(ds, "id", dsId); + ds.setTitle("문서A"); ds.setFolder(from); + + when(dataSourceRepository.findById(dsId)).thenReturn(Optional.of(ds)); + when(folderRepository.findDefaultFolderByMemberId(memberId)) + .thenReturn(Optional.of(defaultFolder)); + + DataSourceService.MoveResult rs = dataSourceService.moveDataSource(memberId, dsId, null); + + assertThat(rs.folderId()).isEqualTo(defaultId); + assertThat(ds.getFolder().getId()).isEqualTo(defaultId); + verify(folderRepository).findDefaultFolderByMemberId(memberId); + } + + @Test + @DisplayName("단건 이동 성공: 동일 폴더(멱등)") + void moveOne_idempotent() { + Integer memberId = 1, dsId = 10, folderId = 100; + + Folder same = new Folder(); ReflectionTestUtils.setField(same, "id", folderId); + + DataSource ds = new DataSource(); + ReflectionTestUtils.setField(ds, "id", dsId); + ds.setTitle("A"); ds.setFolder(same); + + when(dataSourceRepository.findById(dsId)).thenReturn(Optional.of(ds)); + when(folderRepository.findById(folderId)).thenReturn(Optional.of(same)); + + DataSourceService.MoveResult rs = dataSourceService.moveDataSource(memberId, dsId, folderId); + + assertThat(rs.folderId()).isEqualTo(folderId); + assertThat(ds.getFolder().getId()).isEqualTo(folderId); + } + + @Test + @DisplayName("단건 이동 실패: 자료 없음 → NoResultException") + void moveOne_notFound_data() { + when(dataSourceRepository.findById(1)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> dataSourceService.moveDataSource(1, 1, 200)) + .isInstanceOf(NoResultException.class) + .hasMessageContaining("존재하지 않는 자료"); + } + + @Test + @DisplayName("단건 이동 실패: 폴더 없음 → NoResultException") + void moveOne_notFound_folder() { + Folder from = new Folder(); ReflectionTestUtils.setField(from, "id", 100); + + DataSource ds = new DataSource(); + ReflectionTestUtils.setField(ds, "id", 1); + ds.setTitle("A"); ds.setFolder(from); + + when(dataSourceRepository.findById(1)).thenReturn(Optional.of(ds)); + when(folderRepository.findById(200)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> dataSourceService.moveDataSource(1, 1, 200)) + .isInstanceOf(NoResultException.class) + .hasMessageContaining("존재하지 않는 폴더"); + } + + // 자료 다건 이동 + @Test + @DisplayName("다건: folderId=null → 기본 폴더로 이동") + void moveMany_default_ok() { + Integer memberId = 7, defaultId = 999; + + Folder from = new Folder(); ReflectionTestUtils.setField(from, "id", 100); + Folder defaultFolder = new Folder(); ReflectionTestUtils.setField(defaultFolder, "id", defaultId); + + DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(from); + DataSource b = new DataSource(); ReflectionTestUtils.setField(b, "id", 2); b.setTitle("B"); b.setFolder(from); + + when(folderRepository.findDefaultFolderByMemberId(memberId)).thenReturn(Optional.of(defaultFolder)); + when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a,b)); + + dataSourceService.moveDataSources(memberId, null, List.of(1,2)); + + assertThat(a.getFolder().getId()).isEqualTo(defaultId); + assertThat(b.getFolder().getId()).isEqualTo(defaultId); + verify(folderRepository).findDefaultFolderByMemberId(memberId); + } + + @Test + @DisplayName("다건: folderId=null & 기본 폴더 없음 → NoResultException") + void moveMany_default_missing() { + when(folderRepository.findDefaultFolderByMemberId(7)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> dataSourceService.moveDataSources(7, null, List.of(1))) + .isInstanceOf(NoResultException.class) + .hasMessageContaining("기본 폴더"); + } + + @Test + @DisplayName("다건: 지정 폴더로 이동") + void moveMany_ok() { + Integer toId = 200; + Folder from = new Folder(); ReflectionTestUtils.setField(from, "id", 100); + Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); + + DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(from); + DataSource b = new DataSource(); ReflectionTestUtils.setField(b, "id", 2); b.setTitle("B"); b.setFolder(from); + + when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); + when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a,b)); + + dataSourceService.moveDataSources(1, toId, List.of(1,2)); + + assertThat(a.getFolder().getId()).isEqualTo(toId); + assertThat(b.getFolder().getId()).isEqualTo(toId); + } + + @Test + @DisplayName("다건: 모두 동일 폴더 → 멱등") + void moveMany_idempotent() { + Integer toId = 200; + Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); + + DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(to); + DataSource b = new DataSource(); ReflectionTestUtils.setField(b, "id", 2); b.setTitle("B"); b.setFolder(to); + + when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); + when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a,b)); + + dataSourceService.moveDataSources(1, toId, List.of(1,2)); + + verify(folderRepository).findById(toId); + verify(dataSourceRepository).findAllByIdIn(List.of(1,2)); + verifyNoMoreInteractions(folderRepository, dataSourceRepository); + } + + @Test + @DisplayName("다건: 일부 미존재 → NoResultException") + void moveMany_someNotFound() { + Integer toId = 200; + Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); + + DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(new Folder()); + + when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); + when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a)); // 2 없음 + + assertThatThrownBy(() -> dataSourceService.moveDataSources(1, toId, List.of(1,2))) + .isInstanceOf(NoResultException.class) + .hasMessageContaining("존재하지 않는 항목"); + } + + @Test + @DisplayName("다건: 폴더 없음 → NoResultException") + void moveMany_notFound_folder() { + when(folderRepository.findById(200)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> dataSourceService.moveDataSources(1, 200, List.of(1,2))) + .isInstanceOf(NoResultException.class) + .hasMessageContaining("존재하지 않는 폴더"); + } + + @Test + @DisplayName("다건: 요소 null → IllegalArgumentException") + void moveMany_elementNull() { + List ids = Arrays.asList(1, null, 3); // ← null 허용 + + assertThatThrownBy(() -> dataSourceService.moveDataSources(1, 200, ids)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("null"); + } + + @Test + @DisplayName("다건: 요청에 중복된 자료 ID 포함 → IllegalArgumentException") + void moveMany_duplicatedIds_illegalArgument() { + // given + List ids = List.of(1, 2, 2, 3); // 2가 중복 + + // when & then + assertThatThrownBy(() -> dataSourceService.moveDataSources(7, 200, ids)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("같은 자료를 두 번 선택했습니다") + .hasMessageContaining("2"); + + // 리포지토리 호출 전 단계에서 막혀야 함 + verifyNoInteractions(folderRepository, dataSourceRepository); + } + + @Test + @DisplayName("다건: folderId=null + 중복된 자료 ID 포함 → IllegalArgumentException (default 조회 전 차단)") + void moveMany_default_withDuplicatedIds_illegalArgument() { + // given + List ids = List.of(5, 5); // 중복 + // when & then + assertThatThrownBy(() -> dataSourceService.moveDataSources(7, null, ids)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("같은 자료를 두 번 선택했습니다") + .hasMessageContaining("5"); + + verifyNoInteractions(folderRepository, dataSourceRepository); + } + + // 자료 수정 + @Test + @DisplayName("수정 성공: 제목과 요약 일부/전체 변경") + void update_ok() { + DataSource ds = new DataSource(); + ReflectionTestUtils.setField(ds, "id", 7); + ds.setTitle("old"); + ds.setSummary("old sum"); + + when(dataSourceRepository.findById(anyInt())) + .thenReturn(Optional.of(ds)); + + Integer id = dataSourceService.updateDataSource(7, "new", null); + + assertThat(id).isEqualTo(7); + assertThat(ds.getTitle()).isEqualTo("new"); + assertThat(ds.getSummary()).isEqualTo("old sum"); // summary 미전달 → 유지 + } + + @Test + @DisplayName("수정 실패: 존재하지 않는 자료") + void update_notFound() { + when(dataSourceRepository.findById(anyInt())) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> dataSourceService.updateDataSource(1, "t", "s")) + .isInstanceOf(NoResultException.class) + .hasMessageContaining("존재하지 않는 자료"); + } } From 2edf688113480c05a21b44be1fbf61a36568c7d9 Mon Sep 17 00:00:00 2001 From: osh5030 <72571931+ohsoohyuk@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:07:52 +0900 Subject: [PATCH 034/132] =?UTF-8?q?feat/OPS-289=20:=20dataprocessor=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=83=9D=EC=84=B1=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../datasource/ai/dto/AiExtractorDto.java | 12 ++++ .../domain/datasource/ai/prompt/AiPrompt.java | 25 +++++++ .../datasource/ai/service/AiService.java | 71 ++----------------- .../controller/CrawlerTestController.java | 4 +- .../datasource/crawler/dto/CrawlerResult.java | 10 +++ .../crawler/dto/SpecificSiteDto.java | 12 ++++ .../crawler/dto/UnspecificSiteDto.java | 6 ++ .../datasource/crawler/service/Crawler.java | 4 +- .../service/CrawlerManagerService.java | 4 +- .../crawler/service/GenericCrawler.java | 16 ++++- .../crawler/service/NaverNewsCrawler.java | 10 ++- .../service/DataProcessorService.java | 48 +++++++++++++ .../domain/datasource/dto/ArticleData.java | 3 +- .../backend/global/initData/BaseInitData.java | 8 --- .../service/CrawlerManagerServiceTest.java | 27 +++++-- 15 files changed, 170 insertions(+), 90 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AiExtractorDto.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/CrawlerResult.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/SpecificSiteDto.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/UnspecificSiteDto.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AiExtractorDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AiExtractorDto.java new file mode 100644 index 00000000..28959422 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AiExtractorDto.java @@ -0,0 +1,12 @@ +package org.tuna.zoopzoop.backend.domain.datasource.ai.dto; + +import java.time.LocalDate; + +public record AiExtractorDto( + String title, // 제목 + LocalDate dataCreatedDate, // 작성일자 + String content, // ai한테 줘야할 내용 + String imageUrl, // 썸네일 이미지 url + String sources // 출처 +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java new file mode 100644 index 00000000..bbdadb5b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java @@ -0,0 +1,25 @@ +package org.tuna.zoopzoop.backend.domain.datasource.ai.prompt; + +public class AiPrompt { + public static final String EXTRACTION = """ + 아래 HTML 전문에서 필요한 정보를 JSON 형식으로 추출해 주세요. + 반환 JSON 구조: + { + "title": "제목", + "datacreatedDate": "작성일자 (YYYY-MM-DD)", + "content": "본문 내용", + "imageUrl": "썸네일 이미지 URL", + "sources": "출판사 이름 or 서비스 이름 or 도메인 이름" + } + + HTML 전문: + %s + + - 반드시 JSON 형식으로만 출력해 주세요. + - 해당정보가 없으면 반드시 빈 문자열로 출력해 주세요. + """; + + public static final String SUMMARY_TAG_CATEGORY = """ + 내용 요약, 태그 요약, 카테고리 선정 프롬프트 + """; +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java index 2632b9c6..5578ff48 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java @@ -1,79 +1,22 @@ package org.tuna.zoopzoop.backend.domain.datasource.ai.service; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.ChatClient; import org.springframework.stereotype.Service; -import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; - -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AiExtractorDto; +import org.tuna.zoopzoop.backend.domain.datasource.ai.prompt.AiPrompt; @Service @RequiredArgsConstructor public class AiService { private final ChatClient chatClient; - private final TagRepository tagRepository; - - public Set duplicateTag() { - Set existingTags = new HashSet<>(); - existingTags.addAll(tagRepository.findAllTagNames()); - return existingTags; - } - - public Map summarizeAndTag(String text) { - Set existingTags = duplicateTag(); - String tagsForPrompt = String.join(", ", existingTags); - String prompt = """ - 본문 요약 프롬프트: - 아래 본문을 무조건 50자 이상, 100자 이하로 요약해주세요. - - 핵심 태그 프롬프트: - 이미 존재하는 태그 목록은 다음과 같습니다: - [%s] - - 본문을 요약하고, 해당 본문과 관련된 태그 3~5개를 생성하세요. - - 태그는 반드시 본문과 관련된 것만 선택하세요. - - 기존 태그 중 본문과 관련 없는 것은 포함하지 마세요. - - 새로운 태그는 본문에 꼭 필요한 경우에만 생성하세요. - - 결과는 JSON 형식으로만 출력하세요. - - 본문: - %s - - 예시 출력: - { - "summary": "...", - "tags": ["...", "..."] - } - """.formatted(tagsForPrompt, text); - - String response = chatClient.prompt() - .user(prompt) + public AiExtractorDto extract(String rawHtml) { + AiExtractorDto response = chatClient.prompt() + .user(AiPrompt.EXTRACTION.formatted(rawHtml)) .call() - .content(); - - // JSON 시작/끝만 추출 - int start = response.indexOf("{"); - int end = response.lastIndexOf("}") + 1; - if (start >= 0 && end > start) { - response = response.substring(start, end); - } - - try { - ObjectMapper mapper = new ObjectMapper(); - Map map = mapper.readValue(response, new TypeReference>() {}); - - String summary = (String) map.get("summary"); - List tags = (List) map.get("tags"); + .entity(AiExtractorDto.class); - return map; - } catch (Exception e) { - throw new RuntimeException("AI 응답 파싱 실패: " + response, e); - } + return response; } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java index 0eee5692..6555fd06 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.CrawlerManagerService; +import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService; import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; @RestController @@ -13,9 +14,10 @@ @RequiredArgsConstructor public class CrawlerTestController { private final CrawlerManagerService crawlerManagerService; + private final DataProcessorService dataProcessorService; @GetMapping("/crawl") public ArticleData crawl(@RequestParam String url) throws Exception { - return crawlerManagerService.extractContent(url); + return dataProcessorService.process(url); } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/CrawlerResult.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/CrawlerResult.java new file mode 100644 index 00000000..c65a760b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/CrawlerResult.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.dto; + +public record CrawlerResult( + CrawlerType type, // SPECIFIC or UNSPECIFIC + T data +) { + public enum CrawlerType { + SPECIFIC, UNSPECIFIC + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/SpecificSiteDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/SpecificSiteDto.java new file mode 100644 index 00000000..bfb5fd37 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/SpecificSiteDto.java @@ -0,0 +1,12 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.dto; + +import java.time.LocalDate; + +public record SpecificSiteDto( + String title, // 제목 + LocalDate dataCreatedDate, // 작성일자 + String content, // ai한테 줘야할 내용 + String imageUrl, // 썸네일 이미지 url + String sources // 출처 +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/UnspecificSiteDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/UnspecificSiteDto.java new file mode 100644 index 00000000..0f4200e0 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/UnspecificSiteDto.java @@ -0,0 +1,6 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.dto; + +public record UnspecificSiteDto( + String rawHtml // 불특정 사이트의 html 전문 +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java index d61b8331..e8eb831a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java @@ -1,12 +1,12 @@ package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; import org.jsoup.nodes.Document; -import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; import java.time.LocalDate; public interface Crawler { boolean supports(String domain); - ArticleData extract(Document doc); + CrawlerResult extract(Document doc); LocalDate transLocalDate(String rawDate); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java index 38d6bcef..5a511c09 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java @@ -4,7 +4,7 @@ import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.springframework.stereotype.Service; -import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; import java.io.IOException; import java.util.List; @@ -14,7 +14,7 @@ public class CrawlerManagerService { private final List crawlers; - public ArticleData extractContent(String url) throws IOException { + public CrawlerResult extractContent(String url) throws IOException { Document doc = Jsoup.connect(url) .userAgent("Mozilla/5.0") .timeout(10000) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java index 181d0f10..8efd1c49 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java @@ -4,7 +4,8 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; -import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.UnspecificSiteDto; import java.time.LocalDate; @@ -17,8 +18,17 @@ public boolean supports(String url) { } @Override - public ArticleData extract(Document doc) { - return new ArticleData(null, null, null, null, null, doc.outerHtml()); + public CrawlerResult extract(Document doc) { + // 불필요한 태그 제거 + doc.select("script, style, noscript, iframe, nav, header, footer, form, aside, meta, link").remove(); + + // 본문만 가져오기 (HTML) + String cleanHtml = doc.body().html(); + + return new CrawlerResult<>( + CrawlerResult.CrawlerType.UNSPECIFIC, + new UnspecificSiteDto(cleanHtml) + ); } @Override diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java index 00b3ba73..d7619181 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java @@ -4,7 +4,8 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; -import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.SpecificSiteDto; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -22,7 +23,7 @@ public boolean supports(String domain) { } @Override - public ArticleData extract(Document doc) { + public CrawlerResult extract(Document doc) { // 제목 String title = doc.selectFirst("h2#title_area").text(); @@ -41,7 +42,10 @@ public ArticleData extract(Document doc) { // 출처 String sources = doc.selectFirst("span.media_end_head_top_logo_text").text(); - return new ArticleData(title, dataCreatedDate, content, imageUrl, sources, null); + return new CrawlerResult<>( + CrawlerResult.CrawlerType.SPECIFIC, + new SpecificSiteDto(title, dataCreatedDate, content, imageUrl, sources) + ); } @Override diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java new file mode 100644 index 00000000..64538b8b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java @@ -0,0 +1,48 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AiExtractorDto; +import org.tuna.zoopzoop.backend.domain.datasource.ai.service.AiService; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.SpecificSiteDto; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.UnspecificSiteDto; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.CrawlerManagerService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; + +import java.io.IOException; + +@Service +@RequiredArgsConstructor +public class DataProcessorService { + public final CrawlerManagerService crawlerManagerService; + public final AiService aiService; + + public ArticleData process(String url) throws IOException { + CrawlerResult result = crawlerManagerService.extractContent(url); + + return switch (result.type()) { + case SPECIFIC -> { + SpecificSiteDto specificSiteDto = (SpecificSiteDto) result.data(); + yield new ArticleData( + specificSiteDto.title(), + specificSiteDto.dataCreatedDate(), + specificSiteDto.content(), + specificSiteDto.imageUrl(), + specificSiteDto.sources() + ); + } + case UNSPECIFIC -> { + UnspecificSiteDto unspecificSiteDto = (UnspecificSiteDto) result.data(); + AiExtractorDto aiExtractorDto = aiService.extract(unspecificSiteDto.rawHtml()); + yield new ArticleData( + aiExtractorDto.title(), + aiExtractorDto.dataCreatedDate(), + aiExtractorDto.content(), + aiExtractorDto.imageUrl(), + aiExtractorDto.sources() + ); + } + }; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java index cf608b9f..23521502 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java @@ -7,7 +7,6 @@ public record ArticleData( LocalDate dataCreatedDate, // 작성일자 String content, // ai한테 줘야할 내용 String imageUrl, // 이미지 url - String sources, // 출처 - String rawHtml // GenericCrawler 용 + String sources // 출처 ) { } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java index 1dfc9f5a..8b60296f 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java @@ -54,13 +54,5 @@ public void initTagData() { tagRepository.save(tag1); tagRepository.save(tag2); tagRepository.save(tag3); - - aiService.summarizeAndTag("안녕 내 이름은 오수혁이야"); - aiService.summarizeAndTag("D2Coding 1.3.2 버전을 릴리즈 합니다. ligature 관련 이슈를 수정하여, ligature 적용/미적용 폰트를 구분하여 배포합니다.\n" + - "\n" + - "기존 버전은 반드시 삭제후 설치 바랍니다.\n" + - "\n"); - - aiService.summarizeAndTag("Spring AI는 예외 발생 시 AiClientException, RetryableException 등으로 예외를 포장합니다. Spring의 기본 예외 처리 방식을 활용하면 에러 핸들링을 일관성 있게 구성할 수 있습니다."); } } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java index 98265680..60e520ca 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java @@ -10,10 +10,12 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.SpecificSiteDto; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.UnspecificSiteDto; import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.CrawlerManagerService; import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.GenericCrawler; import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.NaverNewsCrawler; -import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; import java.io.IOException; import java.time.LocalDate; @@ -112,9 +114,11 @@ void NaverCrawlerTest() throws IOException { given(naverNewsCrawler.transLocalDate(any(String.class))).willCallRealMethod(); // when - ArticleData naverDoc = crawlerManagerService.extractContent(url); + CrawlerResult result = crawlerManagerService.extractContent(url); + SpecificSiteDto naverDoc = (SpecificSiteDto) result.data(); // then + assertThat(result.type()).isEqualTo(CrawlerResult.CrawlerType.SPECIFIC); assertThat(naverDoc.title()).isEqualTo(title); assertThat(naverDoc.content()).isEqualTo(content); assertThat(naverDoc.dataCreatedDate()).isEqualTo(dataCreatedDate); @@ -125,15 +129,28 @@ void NaverCrawlerTest() throws IOException { @Test void GenericCrawlerTest() throws IOException { // given - String url = "https://www.slog.gg/p/14006"; // 원하는 URL 넣기 + String url = "https://bcuts.tistory.com/421"; // 원하는 URL 넣기 + + Document doc = Jsoup.connect(url) + .userAgent("Mozilla/5.0") // 크롤링 차단 방지를 위해 user-agent 설정 권장 + .timeout(10 * 1000) // 타임아웃 (10초) + .get(); + + doc.select("script, style, noscript, iframe, nav, header, footer, form, aside, meta, link").remove(); + + String cleanHtml = doc.body().html(); when(genericCrawler.supports(url)).thenReturn(true); given(genericCrawler.extract(any(Document.class))).willCallRealMethod(); // 실제 메소드 실행 // when - String genericDoc = crawlerManagerService.extractContent(url).rawHtml(); + CrawlerResult result = crawlerManagerService.extractContent(url); + UnspecificSiteDto genericDoc = (UnspecificSiteDto) result.data(); + + System.out.println(genericDoc.rawHtml()); // then - assertThat(genericDoc).contains(""); } } From d6098e5cd073781da781d71b82c0a582e61b6a8f Mon Sep 17 00:00:00 2001 From: taekkong <141305946+taekkong@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:17:30 +0900 Subject: [PATCH 035/132] =?UTF-8?q?fix/OPS-318=20=20:=20proxy=20host=20?= =?UTF-8?q?=EC=8A=A4=EC=9C=84=EC=B9=AD=C3=AC=C2=8B=20ssl=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EC=84=9C=20=EC=9C=A0=EC=A7=80=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/prod-server.yml | 10 ++++++---- .github/workflows/test-server.yml | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/prod-server.yml b/.github/workflows/prod-server.yml index 782d4571..7b997a73 100644 --- a/.github/workflows/prod-server.yml +++ b/.github/workflows/prod-server.yml @@ -60,6 +60,7 @@ jobs: echo "📋 Checking current NPM configuration... 📋" CURRENT_CONFIG=$(curl -s -H "Authorization: Bearer $TOKEN" \ "http://${NPM_HOST}/api/nginx/proxy-hosts/${PROXY_HOST_ID}") + echo "Current Config: $CURRENT_CONFIG" CURRENT_TARGET=$(echo $CURRENT_CONFIG | jq -r '.[0].forward_host // .forward_host') CURRENT_PORT=$(echo $CURRENT_CONFIG | jq -r '.[0].forward_port // .forward_port') @@ -109,6 +110,7 @@ jobs: # NPM에서 트래픽 스위칭 echo "🔄 Switching traffic in Nginx Proxy Manager..." DOMAIN_NAME=$(echo $CURRENT_CONFIG | jq -r '.domain_names[0]') + CERT_ID=$(echo "$CURRENT_CONFIG" | jq -r '.certificate_id') SWITCH_RESPONSE=$(curl -s -w "%{http_code}" -X PUT "http://${NPM_HOST}/api/nginx/proxy-hosts/${PROXY_HOST_ID}" \ -H "Authorization: Bearer $TOKEN" \ @@ -122,10 +124,10 @@ jobs: \"block_exploits\": true, \"advanced_config\": \"\", \"locations\": [], - \"certificate_id\": 0, - \"ssl_forced\": 0, - \"hsts_enabled\": 0, - \"hsts_subdomains\": 0 + \"certificate_id\": $CERT_ID, + \"ssl_forced\": 1, + \"hsts_enabled\": 1, + \"hsts_subdomains\": 1 }") HTTP_CODE=${SWITCH_RESPONSE: -3} diff --git a/.github/workflows/test-server.yml b/.github/workflows/test-server.yml index da23340d..4b74993a 100644 --- a/.github/workflows/test-server.yml +++ b/.github/workflows/test-server.yml @@ -201,6 +201,7 @@ jobs: # NPM에서 트래픽 스위칭 echo "🔄 Switching traffic in Nginx Proxy Manager..." DOMAIN_NAME=$(echo $CURRENT_CONFIG | jq -r '.domain_names[0]') + CERT_ID=$(echo "$CURRENT_CONFIG" | jq -r '.certificate_id') SWITCH_RESPONSE=$(curl -s -w "%{http_code}" -X PUT "http://${NPM_HOST}/api/nginx/proxy-hosts/${PROXY_HOST_ID}" \ -H "Authorization: Bearer $TOKEN" \ @@ -214,10 +215,10 @@ jobs: \"block_exploits\": true, \"advanced_config\": \"\", \"locations\": [], - \"certificate_id\": 0, - \"ssl_forced\": 0, - \"hsts_enabled\": 0, - \"hsts_subdomains\": 0 + \"certificate_id\": $CERT_ID, + \"ssl_forced\": 1, + \"hsts_enabled\": 1, + \"hsts_subdomains\": 1 }") HTTP_CODE=${SWITCH_RESPONSE: -3} From 0ccb44675ada13e69f3eb5bb597552ebd0524b8e Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:20:29 +0900 Subject: [PATCH 036/132] =?UTF-8?q?[refactor/OPS-310]=20redirect=20url=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-310 : redirect-url 변경. * refactor/OPS-310 : 작은 수정. * refactor/OPS-310 : 또 하나의 작은 수정. * refactor/OPS-310 : 큰 수정. * refactor/OPS-310 : 테스트 * refactor/OPS-310 : 요청 사항 반영. * refactor/OPS-310 : 요청 사항 반영 #2 --- .../auth/handler/OAuth2FailureHandler.java | 36 +++++++++ .../auth/handler/OAuth2SuccessHandler.java | 75 ++++++++++++------- .../security/jwt/JwtAuthenticationFilter.java | 6 ++ 3 files changed, 92 insertions(+), 25 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2FailureHandler.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2FailureHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2FailureHandler.java new file mode 100644 index 00000000..58197968 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2FailureHandler.java @@ -0,0 +1,36 @@ +package org.tuna.zoopzoop.backend.domain.auth.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; + +@Component +@RequiredArgsConstructor +public class OAuth2FailureHandler implements AuthenticationFailureHandler { + @Value("${front.redirect_domain}") + private String redirect_domain; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + // 프론트로 리다이렉트 + // 필요하면 쿼리 파라미터로 에러 정보 전달 + + String redirectUrl = + redirect_domain + "/auth/callback" + + "?success=false" + + "&error=" + URLEncoder.encode(exception.getMessage(), "UTF-8"); + + response.sendRedirect(redirectUrl); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java index 1a6e4e7a..ec8d6048 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; @@ -17,6 +18,7 @@ import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; import java.io.IOException; +import java.net.URLEncoder; @Component @RequiredArgsConstructor @@ -27,6 +29,12 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final MemberRepository memberRepository; private final MemberService memberService; + @Value("${front.redirect_domain}") + private String redirect_domain; + + @Value("${spring.profiles.active:dev}") + private String activeProfile; + @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { @@ -53,32 +61,49 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo String accessToken = jwtUtil.generateToken(member); String refreshToken = jwtUtil.generateRefreshToken(member); - ResponseCookie accessCookie = ResponseCookie.from("accessToken", accessToken) - .httpOnly(true) - .path("/") - .maxAge(jwtProperties.getAccessTokenValidity() / 1000) - // .domain() // 프론트엔드 & 백엔드 상위 도메인 - // .secure(true) // https 필수 설정. - .sameSite("Lax") - .build(); - - ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken) - .httpOnly(true) - .path("/") - .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) - // .domain() // 프론트엔드 & 백엔드 상위 도메인 - // .secure(true) // https 필수 설정. - .sameSite("Lax") - .build(); - - // HTTP 응답에서 쿠키 값 추가. - response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); - - // 로그인 성공 후 리다이렉트. - // 배포 시에 프론트엔드와 조율이 필요한 부분일 듯 함. - response.sendRedirect("/login-success"); + if ("server".equals(activeProfile)) { + // server 환경일 때: URL 파라미터로 토큰 전달 + String redirectUrl = redirect_domain + "/auth/callback" + + "?success=true" + + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") + + "&refreshToken=" + URLEncoder.encode(refreshToken, "UTF-8"); + response.sendRedirect(redirectUrl); + } else { + ResponseCookie accessCookie = ResponseCookie.from("accessToken", accessToken) + .httpOnly(true) + .path("/") + .maxAge(jwtProperties.getAccessTokenValidity() / 1000) + // .domain() // 프론트엔드 & 백엔드 상위 도메인 + // .secure(true) // https 필수 설정. + .domain(redirect_domain) + .secure(true) + .sameSite("None") + .build(); + + ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .path("/") + .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) + // .domain() // 프론트엔드 & 백엔드 상위 도메인 + // .secure(true) // https 필수 설정. + .domain(redirect_domain) + .secure(true) + .sameSite("None") + .build(); + // HTTP 응답에서 쿠키 값 추가. + response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + + String redirectUrl = redirect_domain + "/auth/callback" + + "?success=true" + + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") + + "&refreshToken=" + URLEncoder.encode(refreshToken, "UTF-8"); + + // 로그인 성공 후 리다이렉트. + // 배포 시에 프론트엔드와 조율이 필요한 부분일 듯 함. + response.sendRedirect(redirect_domain + "/auth/callback"); + } // 보안을 좀 더 강화하고자 한다면 CSRF 토큰 같은 걸 생각해볼 수 있겠으나, // 일단은 구현하지 않음.(개발 과정 중에 번거로워질 수 있을 듯 함.) } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java index 1bd15117..f3e362d3 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java @@ -67,6 +67,12 @@ private String getTokenFromRequest(HttpServletRequest request) { // Authorizatio } } } + + String accessTokenParam = request.getParameter("accessToken"); + if (StringUtils.hasText(accessTokenParam)) { + return accessTokenParam; + } + return null; } } From cc849d2c81dc391cb2a530d78121759163132476 Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Thu, 25 Sep 2025 15:39:01 +0900 Subject: [PATCH 037/132] =?UTF-8?q?refactor/OPS-255=20:=20datasource=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20sources=20=EC=B9=BC=EB=9F=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zoopzoop/backend/domain/datasource/entity/DataSource.java | 3 +++ .../backend/domain/datasource/service/DataSourceService.java | 1 + 2 files changed, 4 insertions(+) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java index 8d9c3f7b..95db499b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java @@ -53,6 +53,9 @@ public class DataSource extends BaseEntity { @Column private String imageUrl; + // 자료 출처 URL + private String sources; + // 태그 목록 @OneToMany(mappedBy = "dataSource", cascade = CascadeType.ALL, orphanRemoval = true) private List tags = new ArrayList<>(); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java index 3474eb10..05c92e55 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java @@ -55,6 +55,7 @@ private DataSource buildDataSource(String sourceUrl, Folder folder) { ds.setFolder(folder); ds.setSourceUrl(sourceUrl); ds.setTitle("자료 제목"); + ds.setSources("www.examplesource.com"); ds.setSummary("설명"); ds.setImageUrl("www.example.com/img"); ds.setDataCreatedDate(LocalDate.now()); From e053a26442674d7c377c4affb4e26e4f62dcdd29 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Thu, 25 Sep 2025 17:12:10 +0900 Subject: [PATCH 038/132] =?UTF-8?q?[refactor/OPS-323]=20News=20API=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95.?= =?UTF-8?q?=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 배포 테스트 #2 * 로직 수정 테스트 * 로직 수정 테스트 #2 --- .../auth/controller/ApiV1AuthController.java | 1 + .../news/service/NewsSearchService.java | 73 +++++++++++++------ 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java index 227ec21c..94f534fb 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java @@ -63,6 +63,7 @@ public ResponseEntity> logout(HttpServletResponse response) { * @param refreshToken 쿠키에 포함된 현재 로그인한 사용자의 refreshToken * @param response Servlet 기반 웹에서 server -> client로 http 응답을 보내기 위한 객체, 자동 주입. */ + @PostMapping("/refresh") @Operation(summary = "사용자 액세스 토큰 재발급 (리프레시 토큰이 유효할 경우)") public ResponseEntity> refreshToken(@CookieValue(name = "refreshToken", required = false) String refreshToken, diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java index 1dad2c56..611b8672 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java @@ -4,9 +4,11 @@ import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; -import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.ArrayList; +import java.util.List; + @Service public class NewsSearchService { private final WebClient webClient; @@ -61,29 +63,52 @@ public Mono searchNews(String query, String sort) { int finalDisplay = 100; String finalSort = (sort == null || (!sort.equals("sim") && !sort.equals("date"))) ? "sim" : sort; - return Flux - .range(0, 10000 / finalDisplay) - .concatMap(page -> webClient.get() - .uri(uriBuilder -> uriBuilder - .path("v1/search/news.json") - .queryParam("query", query) - .queryParam("display", finalDisplay) - .queryParam("start", page * finalDisplay + 1) - .queryParam("sort", finalSort) - .build()) - .header("X-Naver-Client-Id", client_id) - .header("X-Naver-Client-Secret", client_secret) - .retrieve() - .bodyToMono(ResBodyForNaverNews.class) - .flatMapMany(res -> Flux.fromIterable(res.items())) - .filter(item -> item.link().startsWith("https://n.news.naver.com/")) + List collected = new ArrayList<>(); + + return fetchPageRecursively(query, finalSort, finalDisplay, 0, collected) + .map(items -> { + List limited = + items.size() > 100 ? items.subList(0, 100) : items; + return new ResBodyForNaverNews(limited.size(), limited); + }); + } + + private Mono> fetchPageRecursively( + String query, + String sort, + int display, + int page, + List collected + ) { + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path("v1/search/news.json") + .queryParam("query", query) + .queryParam("display", display) + .queryParam("start", page * display + 1) + .queryParam("sort", sort) + .build()) + .header("X-Naver-Client-Id", client_id) + .header("X-Naver-Client-Secret", client_secret) + .retrieve() + .bodyToMono(ResBodyForNaverNews.class) + .map(res -> res.items().stream() + .filter(item -> item.link().startsWith("https://n.news.naver.com/")) // 네이버 뉴스만 + .filter(item -> collected.stream().noneMatch(i -> i.link().equals(item.link()))) // 중복 제거 + .toList() ) - .distinct(ResBodyForNaverNews.NewsItem::link) - .take(100) - .collectList() - .map(items -> new ResBodyForNaverNews( - items.size(), - items - )); + .flatMap(filtered -> { + if (filtered.isEmpty()) { + return Mono.just(collected); + } + + collected.addAll(filtered); + + if (collected.size() >= 100 || page >= 100) { + return Mono.just(collected); + } else { + return fetchPageRecursively(query, sort, display, page + 1, collected); + } + }); } } From caeaa5321a686c517a82c4f6dec5e7d6e4a2418f Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Thu, 25 Sep 2025 17:12:15 +0900 Subject: [PATCH 039/132] =?UTF-8?q?refactor/OPS-322=20:=20source=20?= =?UTF-8?q?=EC=B9=BC=EB=9F=BC=EB=AA=85=20=EC=88=98=EC=A0=95=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-322 : source 칼럼명 수정 * refactor/OPS-322 : source 칼럼명 수정 --- .../zoopzoop/backend/domain/datasource/entity/DataSource.java | 4 ++-- .../backend/domain/datasource/service/DataSourceService.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java index 95db499b..677b2f26 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/entity/DataSource.java @@ -53,8 +53,8 @@ public class DataSource extends BaseEntity { @Column private String imageUrl; - // 자료 출처 URL - private String sources; + // 자료 출처 (동아일보, Tstory 등등) + private String source; // 태그 목록 @OneToMany(mappedBy = "dataSource", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java index 05c92e55..796079eb 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java @@ -55,7 +55,7 @@ private DataSource buildDataSource(String sourceUrl, Folder folder) { ds.setFolder(folder); ds.setSourceUrl(sourceUrl); ds.setTitle("자료 제목"); - ds.setSources("www.examplesource.com"); + ds.setSource("www.examplesource.com"); ds.setSummary("설명"); ds.setImageUrl("www.example.com/img"); ds.setDataCreatedDate(LocalDate.now()); From 865450770cdb18e3fb4e1cc4288e83d02ccdf4da Mon Sep 17 00:00:00 2001 From: osh5030 <72571931+ohsoohyuk@users.noreply.github.com> Date: Thu, 25 Sep 2025 17:45:17 +0900 Subject: [PATCH 040/132] =?UTF-8?q?feat/OPS-321=20:=20dataProcessorService?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9A=94=EC=95=BD=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=99=84=EC=84=B1=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../datasource/ai/dto/AiExtractorDto.java | 2 +- .../datasource/ai/dto/AnalyzeContentDto.java | 12 +++++++ .../domain/datasource/ai/prompt/AiPrompt.java | 36 +++++++++++++++++-- .../datasource/ai/service/AiService.java | 28 +++++++++++++++ .../controller/CrawlerTestController.java | 4 +-- .../crawler/dto/SpecificSiteDto.java | 2 +- .../crawler/service/NaverNewsCrawler.java | 4 +-- .../service/DataProcessorService.java | 27 ++++++++++---- .../domain/datasource/dto/ArticleData.java | 6 ++-- .../domain/datasource/dto/DataSourceDto.java | 18 ++++++++++ .../service/CrawlerManagerServiceTest.java | 2 +- 11 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AnalyzeContentDto.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/DataSourceDto.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AiExtractorDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AiExtractorDto.java index 28959422..3d3fd308 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AiExtractorDto.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AiExtractorDto.java @@ -7,6 +7,6 @@ public record AiExtractorDto( LocalDate dataCreatedDate, // 작성일자 String content, // ai한테 줘야할 내용 String imageUrl, // 썸네일 이미지 url - String sources // 출처 + String source // 출처 ) { } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AnalyzeContentDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AnalyzeContentDto.java new file mode 100644 index 00000000..f56f1fc4 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/dto/AnalyzeContentDto.java @@ -0,0 +1,12 @@ +package org.tuna.zoopzoop.backend.domain.datasource.ai.dto; + +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; + +import java.util.List; + +public record AnalyzeContentDto( + String summary, + Category category, // ENUM 그대로 매핑 (AI 출력도 ENUM 이름으로) + List tags +) { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java index bbdadb5b..0f5fb194 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java @@ -1,6 +1,7 @@ package org.tuna.zoopzoop.backend.domain.datasource.ai.prompt; public class AiPrompt { + // 불특정 사이트 메타데이터 추출 프롬프트 public static final String EXTRACTION = """ 아래 HTML 전문에서 필요한 정보를 JSON 형식으로 추출해 주세요. 반환 JSON 구조: @@ -9,7 +10,7 @@ public class AiPrompt { "datacreatedDate": "작성일자 (YYYY-MM-DD)", "content": "본문 내용", "imageUrl": "썸네일 이미지 URL", - "sources": "출판사 이름 or 서비스 이름 or 도메인 이름" + "source": "출판사 이름 or 서비스 이름 or 도메인 이름" } HTML 전문: @@ -19,7 +20,38 @@ public class AiPrompt { - 해당정보가 없으면 반드시 빈 문자열로 출력해 주세요. """; + // 내용 요약, 태그 추출, 카테고리 선정 프롬프트 public static final String SUMMARY_TAG_CATEGORY = """ - 내용 요약, 태그 요약, 카테고리 선정 프롬프트 + 너는 뉴스, 블로그 등 내용 요약 및 분류 AI야. 아래의 규칙에 따라 답변해. + + [규칙] + 1. 주어진 content를 50자 이상 100자 이하로 간단히 요약해라. + 2. 아래 Category 목록 중에서 content와 가장 적절한 카테고리 하나를 정확히 선택해라. + - POLITICS("정치") + - ECONOMY("경제") + - SOCIETY("사회") + - IT("IT") + - SCIENCE("과학") + - CULTURE("문화") + - SPORTS("스포츠") + - ENVIRONMENT("환경") + - HISTORY("역사") + - WORLD("세계") + 3. 내가 제공하는 태그 목록을 참고해서, content와 관련된 태그를 3~5개 생성해라. + - 제공된 태그와 중복 가능하다. + - 필요하면 새로운 태그를 만들어도 된다. + 4. 출력은 반드시 아래 JSON 형식으로 해라. Markdown 문법(```)은 쓰지 마라. + - 해당 정보가 없으면 null말고 무조건 빈 문자열로 출력해줘라. + + [출력 JSON 형식] + { + "summary": "내용 요약 (50~100자)", + "category": "선택된 카테고리 ENUM 이름", + "tags": ["태그1", "태그2", "태그3", ...] + } + + [입력 데이터] + content: %s + existingTags: %s """; } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java index 5578ff48..7c3a820b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java @@ -4,12 +4,20 @@ import org.springframework.ai.chat.client.ChatClient; import org.springframework.stereotype.Service; import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AiExtractorDto; +import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AnalyzeContentDto; import org.tuna.zoopzoop.backend.domain.datasource.ai.prompt.AiPrompt; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class AiService { private final ChatClient chatClient; + private final TagRepository tagRepository; public AiExtractorDto extract(String rawHtml) { AiExtractorDto response = chatClient.prompt() @@ -19,4 +27,24 @@ public AiExtractorDto extract(String rawHtml) { return response; } + + public AnalyzeContentDto analyzeContent(String content) { + // 모든 태그 가져오기 + List allTags = tagRepository.findAllTagNames(); + + // 중복 제거 (Set → 다시 List or String) + Set uniqueTags = new HashSet<>(allTags); + + // JSON 배열 문자열로 변환 + String tags = uniqueTags.stream() + .map(tag -> "\"" + tag + "\"") // "tagName" + .collect(Collectors.joining(", ", "[", "]")); // ["tag1", "tag2"] + + AnalyzeContentDto response = chatClient.prompt() + .user(AiPrompt.SUMMARY_TAG_CATEGORY.formatted(content, tags)) + .call() + .entity(AnalyzeContentDto.class); + + return response; + } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java index 6555fd06..a9088769 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java @@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.RestController; import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.CrawlerManagerService; import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService; -import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; +import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto; @RestController @RequestMapping("api/v1") @@ -17,7 +17,7 @@ public class CrawlerTestController { private final DataProcessorService dataProcessorService; @GetMapping("/crawl") - public ArticleData crawl(@RequestParam String url) throws Exception { + public DataSourceDto crawl(@RequestParam String url) throws Exception { return dataProcessorService.process(url); } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/SpecificSiteDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/SpecificSiteDto.java index bfb5fd37..2b6ed411 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/SpecificSiteDto.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/dto/SpecificSiteDto.java @@ -7,6 +7,6 @@ public record SpecificSiteDto( LocalDate dataCreatedDate, // 작성일자 String content, // ai한테 줘야할 내용 String imageUrl, // 썸네일 이미지 url - String sources // 출처 + String source // 출처 ) { } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java index d7619181..5f39b095 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverNewsCrawler.java @@ -40,11 +40,11 @@ public CrawlerResult extract(Document doc) { String imageUrl = doc.selectFirst("img#img1._LAZY_LOADING._LAZY_LOADING_INIT_HIDE").attr("data-src"); // 출처 - String sources = doc.selectFirst("span.media_end_head_top_logo_text").text(); + String source = doc.selectFirst("span.media_end_head_top_logo_text").text(); return new CrawlerResult<>( CrawlerResult.CrawlerType.SPECIFIC, - new SpecificSiteDto(title, dataCreatedDate, content, imageUrl, sources) + new SpecificSiteDto(title, dataCreatedDate, content, imageUrl, source) ); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java index 64538b8b..b9354a6d 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java @@ -3,12 +3,14 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AiExtractorDto; +import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AnalyzeContentDto; import org.tuna.zoopzoop.backend.domain.datasource.ai.service.AiService; import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.SpecificSiteDto; import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.UnspecificSiteDto; import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.CrawlerManagerService; import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; +import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto; import java.io.IOException; @@ -18,18 +20,18 @@ public class DataProcessorService { public final CrawlerManagerService crawlerManagerService; public final AiService aiService; - public ArticleData process(String url) throws IOException { + public DataSourceDto process(String url) throws IOException { CrawlerResult result = crawlerManagerService.extractContent(url); - return switch (result.type()) { + ArticleData articleData = switch (result.type()) { case SPECIFIC -> { SpecificSiteDto specificSiteDto = (SpecificSiteDto) result.data(); yield new ArticleData( specificSiteDto.title(), - specificSiteDto.dataCreatedDate(), specificSiteDto.content(), + specificSiteDto.dataCreatedDate(), specificSiteDto.imageUrl(), - specificSiteDto.sources() + specificSiteDto.source() ); } case UNSPECIFIC -> { @@ -37,12 +39,25 @@ yield new ArticleData( AiExtractorDto aiExtractorDto = aiService.extract(unspecificSiteDto.rawHtml()); yield new ArticleData( aiExtractorDto.title(), - aiExtractorDto.dataCreatedDate(), aiExtractorDto.content(), + aiExtractorDto.dataCreatedDate(), aiExtractorDto.imageUrl(), - aiExtractorDto.sources() + aiExtractorDto.source() ); } }; + + AnalyzeContentDto analyzeContentDto = aiService.analyzeContent(articleData.content()); + + return new DataSourceDto( + articleData.title(), + analyzeContentDto.summary(), + articleData.dataCreatedDate(), + url, + articleData.imageUrl(), + articleData.source(), + analyzeContentDto.category(), + analyzeContentDto.tags() + ); } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java index 23521502..09e986ba 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/ArticleData.java @@ -4,9 +4,9 @@ public record ArticleData( String title, // 제목 + String content, // 내용 LocalDate dataCreatedDate, // 작성일자 - String content, // ai한테 줘야할 내용 - String imageUrl, // 이미지 url - String sources // 출처 + String imageUrl, // 썸네일 이미지 url + String source // 출처 ) { } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/DataSourceDto.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/DataSourceDto.java new file mode 100644 index 00000000..13791aa0 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/DataSourceDto.java @@ -0,0 +1,18 @@ +package org.tuna.zoopzoop.backend.domain.datasource.dto; + +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; + +import java.time.LocalDate; +import java.util.List; + +public record DataSourceDto( + String title, // 제목 + String summary, // 요약내용 + LocalDate dataCreatedDate, // 작성일자 + String sourceUrl, // 소스 데이터 URL + String imageUrl, // 썸네일 이미지 url + String source, // 출처 + Category category, // 대분류 카테고리 + List tags // 태그 목록 +) { +} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java index 60e520ca..9617fd0f 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java @@ -123,7 +123,7 @@ void NaverCrawlerTest() throws IOException { assertThat(naverDoc.content()).isEqualTo(content); assertThat(naverDoc.dataCreatedDate()).isEqualTo(dataCreatedDate); assertThat(naverDoc.imageUrl()).isEqualTo(imageUrl); - assertThat(naverDoc.sources()).isEqualTo(sources); + assertThat(naverDoc.source()).isEqualTo(sources); } @Test From b9d768169bd693aba9d03cf6cae90f4e888e0d9c Mon Sep 17 00:00:00 2001 From: taekkong <141305946+taekkong@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:47:33 +0900 Subject: [PATCH 041/132] =?UTF-8?q?[fix/OPS-317]=20CI/CD=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : prod-server nginx 정보 출력 제거 * fix : prod-server CD 워크플로우 main에 push될 때 실행되도록 수정 * fix : test-server CI는 pr시 develop에 실행, CD는 push될 때 실행 * fix : test-server CI 워크플로우에서 CD 부분 제거 --- .github/workflows/prod-server.yml | 5 +- .github/workflows/test-server-cd.yml | 116 +++++++++++++ .github/workflows/test-server-ci.yml | 108 ++++++++++++ .github/workflows/test-server.yml | 249 --------------------------- 4 files changed, 226 insertions(+), 252 deletions(-) create mode 100644 .github/workflows/test-server-cd.yml create mode 100644 .github/workflows/test-server-ci.yml delete mode 100644 .github/workflows/test-server.yml diff --git a/.github/workflows/prod-server.yml b/.github/workflows/prod-server.yml index 7b997a73..8e21f83f 100644 --- a/.github/workflows/prod-server.yml +++ b/.github/workflows/prod-server.yml @@ -3,7 +3,7 @@ name: Spring CD (Production) # main 브랜치 PR에서만 실행 (이미 빌드된 Docker 이미지 사용) on: - pull_request: + push: branches: - main paths: @@ -18,7 +18,7 @@ jobs: # ================================== # CD: Deploy to Production Environment # ================================== - cd-test: + cd-prod: runs-on: ubuntu-latest steps: @@ -60,7 +60,6 @@ jobs: echo "📋 Checking current NPM configuration... 📋" CURRENT_CONFIG=$(curl -s -H "Authorization: Bearer $TOKEN" \ "http://${NPM_HOST}/api/nginx/proxy-hosts/${PROXY_HOST_ID}") - echo "Current Config: $CURRENT_CONFIG" CURRENT_TARGET=$(echo $CURRENT_CONFIG | jq -r '.[0].forward_host // .forward_host') CURRENT_PORT=$(echo $CURRENT_CONFIG | jq -r '.[0].forward_port // .forward_port') diff --git a/.github/workflows/test-server-cd.yml b/.github/workflows/test-server-cd.yml new file mode 100644 index 00000000..9f32a143 --- /dev/null +++ b/.github/workflows/test-server-cd.yml @@ -0,0 +1,116 @@ +name: Spring CD (Test Server) + +on: + push: + branches: + - develop + +jobs: + cd-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Deploy to Test Environment + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.TEST_SERVER_HOST }} + username: ec2-user + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + # GHCR 로그인 + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.repository_owner }}" --password-stdin + docker pull ghcr.io/${{ github.repository }}/zoopzoop:latest + + NPM_HOST="localhost:81" + NPM_EMAIL="${{ secrets.NPM_ADMIN_EMAIL }}" + NPM_PASSWORD="${{ secrets.NPM_ADMIN_PASSWORD }}" + PROXY_HOST_ID="${{ secrets.NPM_PROXY_HOST_ID }}" + + # NPM 토큰 + TOKEN=$(curl -s -X POST "http://${NPM_HOST}/api/tokens" \ + -H "Content-Type: application/json" \ + -d "{\"identity\":\"${NPM_EMAIL}\",\"secret\":\"${NPM_PASSWORD}\"}" | jq -r '.token') + if [ -z "$TOKEN" ] || [ "$TOKEN" == "null" ]; then + echo "❌ Failed to get NPM API token" + exit 1 + fi + + CURRENT_CONFIG=$(curl -s -H "Authorization: Bearer $TOKEN" \ + "http://${NPM_HOST}/api/nginx/proxy-hosts/${PROXY_HOST_ID}") + + CURRENT_TARGET=$(echo $CURRENT_CONFIG | jq -r '.[0].forward_host // .forward_host') + CURRENT_PORT=$(echo $CURRENT_CONFIG | jq -r '.[0].forward_port // .forward_port') + echo "Current NPM target: $CURRENT_TARGET:$CURRENT_PORT" + + if [ "$(docker ps -q -f name=zoopzoop-blue)" ]; then + NEW_CONTAINER=zoopzoop-green + OLD_CONTAINER=zoopzoop-blue + NEW_PORT=8082 + else + NEW_CONTAINER=zoopzoop-blue + OLD_CONTAINER=zoopzoop-green + NEW_PORT=8081 + fi + + docker run -d --restart unless-stopped \ + -p $NEW_PORT:8080 \ + --name $NEW_CONTAINER \ + --network common \ + -e SPRING_DATASOURCE_URL="${{secrets.TEST_DB_URL}}" \ + -e SPRING_DATASOURCE_USERNAME="${{secrets.TEST_DB_USERNAME}}" \ + -e SPRING_DATASOURCE_PASSWORD="${{secrets.TEST_DB_PASSWORD}}" \ + ghcr.io/${{ github.repository }}/zoopzoop:latest + + # 헬스체크 + for i in {1..30}; do + if curl -s http://localhost:$NEW_PORT/actuator/health | grep -q '"status":"UP"'; then + echo "✅ New container is healthy!" + break + else + echo "Waiting for new container to be healthy..." + sleep 5 + fi + if [ $i -eq 30 ]; then + echo "❌ Health check failed. Rolling back..." + docker stop $NEW_CONTAINER || true + docker rm $NEW_CONTAINER || true + exit 1 + fi + done + + # NPM 트래픽 스위칭 + DOMAIN_NAME=$(echo $CURRENT_CONFIG | jq -r '.domain_names[0]') + CERT_ID=$(echo "$CURRENT_CONFIG" | jq -r '.certificate_id') + + SWITCH_RESPONSE=$(curl -s -w "%{http_code}" -X PUT "http://${NPM_HOST}/api/nginx/proxy-hosts/${PROXY_HOST_ID}" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"domain_names\": [\"$DOMAIN_NAME\"], + \"forward_scheme\": \"http\", + \"forward_host\": \"$NEW_CONTAINER\", + \"forward_port\": 8080, + \"caching_enabled\": false, + \"block_exploits\": true, + \"advanced_config\": \"\", + \"locations\": [], + \"certificate_id\": $CERT_ID, + \"ssl_forced\": 1, + \"hsts_enabled\": 1, + \"hsts_subdomains\": 1 + }") + + HTTP_CODE=${SWITCH_RESPONSE: -3} + if [ "$HTTP_CODE" -ne 200 ] && [ "$HTTP_CODE" -ne 201 ]; then + echo "❌ Traffic switching failed! HTTP Code: $HTTP_CODE" + echo "Response: ${SWITCH_RESPONSE%???}" + docker stop $NEW_CONTAINER || true + docker rm $NEW_CONTAINER || true + exit 1 + fi + + docker stop $OLD_CONTAINER || true + docker rm $OLD_CONTAINER || true diff --git a/.github/workflows/test-server-ci.yml b/.github/workflows/test-server-ci.yml new file mode 100644 index 00000000..c7c0e445 --- /dev/null +++ b/.github/workflows/test-server-ci.yml @@ -0,0 +1,108 @@ +# 워크플로우 이름 +name: Spring CI/CD Pipeline (Develop) + +# develop 브랜치 PR에서만 실행 +on: + pull_request: + branches: + - develop + paths: + - 'src/**' + - 'build.gradle*' + - 'settings.gradle*' + - 'gradle/**' + - 'Dockerfile' + - '.github/workflows/**' + +jobs: + # ================================== + # CI: Test and Build and Push Docker Image + # ================================== + ci: + runs-on: ubuntu-latest + + steps: + # 1. 소스 코드 체크아웃 + - name: Checkout source code + uses: actions/checkout@v4 + + # 2. JDK 21 설치 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + # 3. Gradle 캐시 설정 + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # 4. gradlew 실행 권한 부여 + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # 5. application-secrets.yml 생성 + - name: Generate application-secrets.yml + run: | + mkdir -p src/main/resources + echo "${{ secrets.APPLICATION_SECRET_YML }}" > src/main/resources/application-secrets.yml + echo "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" >> src/main/resources/application-secrets.yml + + # 6. application-secrets-server.yml 생성 + - name: Generate application-secrets-server.yml + run: | + mkdir -p src/main/resources + echo "${{ secrets.APPLICATION_SECRET_SERVER_YML }}" > src/main/resources/application-secrets-server.yml + echo "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" >> src/main/resources/application-secrets-server.yml + + # 7. Gradle 테스트 실행 + - name: Test with Gradle + run: ./gradlew test + + # 8. 테스트 결과 요약 출력 + - name: Show test results + run: | + echo "==== Test Results ====" + if compgen -G "build/test-results/test/TEST-*.xml" > /dev/null; then + total=$(grep ' src/main/resources/application-secrets.yml - echo "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" >> src/main/resources/application-secrets.yml - - # 6. application-secrets-server.yml 생성 - - name: Generate application-secrets-server.yml - run: | - mkdir -p src/main/resources - echo "${{ secrets.APPLICATION_SECRET_SERVER_YML }}" > src/main/resources/application-secrets-server.yml - echo "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" >> src/main/resources/application-secrets-server.yml - - # 7. Gradle 테스트 실행 - - name: Test with Gradle - run: ./gradlew test - - # 8. 테스트 결과 요약 출력 - - name: Show test results - run: | - echo "==== Test Results ====" - if compgen -G "build/test-results/test/TEST-*.xml" > /dev/null; then - total=$(grep ' Date: Fri, 26 Sep 2025 11:23:25 +0900 Subject: [PATCH 042/132] =?UTF-8?q?chore=20:=20=EB=B9=8C=EB=93=9C=EC=8B=9C?= =?UTF-8?q?=20aws=EA=B4=80=EB=A0=A8=20=EC=A0=95=EB=B3=B4=20application-sec?= =?UTF-8?q?ret.yml=EC=97=90=20=EB=93=A4=EC=96=B4=EA=B0=80=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=84=A4=EC=A0=95=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : S3 사용을 위해 빌드시 AWS 정보 application-secrets.yml에 추가되도록 설정 * chore : 역할에 S3 접근 정책 부착 하도록 설정 * chore : ãyml에 추가정보 넣도록 설정 --- .github/workflows/test-server-ci.yml | 5 +++++ infra/terraform/modules/iam/main.tf | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-server-ci.yml b/.github/workflows/test-server-ci.yml index c7c0e445..52ea07bf 100644 --- a/.github/workflows/test-server-ci.yml +++ b/.github/workflows/test-server-ci.yml @@ -55,6 +55,11 @@ jobs: echo "${{ secrets.APPLICATION_SECRET_YML }}" > src/main/resources/application-secrets.yml echo "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" >> src/main/resources/application-secrets.yml + echo "spring.cloud.aws.region.static: ${{ secrets.AWS_REGION }}" >> src/main/resources/application-secrets.yml + echo "spring.cloud.aws.credentials.access-key: ${{ secrets.AWS_ACCESS_KEY_ID }}" >> src/main/resources/application-secrets.yml + echo "spring.cloud.aws.credentials.secret-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}" >> src/main/resources/application-secrets.yml + echo "spring.cloud.aws.s3.bucket: ${{ secrets.AWS_S3_BUCKET_NAME }}" >> src/main/resources/application-secrets.yml + echo "spring.cloud.aws.stack : false" >> src/main/resources/application-secrets.yml # 6. application-secrets-server.yml 생성 - name: Generate application-secrets-server.yml run: | diff --git a/infra/terraform/modules/iam/main.tf b/infra/terraform/modules/iam/main.tf index 190e2e3e..202b41d5 100644 --- a/infra/terraform/modules/iam/main.tf +++ b/infra/terraform/modules/iam/main.tf @@ -13,10 +13,10 @@ resource "aws_iam_role" "ec2_role" { } # 역할에 S3 접근 정책 부착 (사용하지 않을 경우 주석 처리) -# resource "aws_iam_role_policy_attachment" "s3_full" { -# role = aws_iam_role.ec2_role.name -# policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess" -# } +resource "aws_iam_role_policy_attachment" "s3_full" { + role = aws_iam_role.ec2_role.name + policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess" +} # 역할에 SSM 접근 정책 부착 (AWS Systems Manager) resource "aws_iam_role_policy_attachment" "ssm" { From 9264042f480932995239f40f96c8f2e087371018 Mon Sep 17 00:00:00 2001 From: Hyeok Jin Kim <71928299+EpicFn@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:32:31 +0900 Subject: [PATCH 043/132] =?UTF-8?q?[Feat/OPS-275]=20=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=A0=80=EC=9E=A5=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore : S3 환경 설정 * feat : S3Service 구성 * chore : multipart 데이터 크기 설정 * feat : S3 업로드 테스트용 엔드포인트 생성 * feat : S3 연동 완료 * feat : space entity에 imageUrl 항목 추가 * feat : 썸네일 캡처 테스트용 * feat : 썸네일 촬영 테스트 엔드포인트 작성 * feat : 스페이스 썸네일 갱신 API 생성 * feat : 스페이스 이미지 갱신 기능 완성 * feat : 스페이스 목록 반환 시 썸네일 url 같이 반환 * feat : 스페이스 단건 조회 테스트 케이스 작성 * feat : 스페이스 단건 조회 구현 * feat : 스페이스 목록 조회 페이징 기능 추가 --------- Co-authored-by: EpicFn --- build.gradle | 7 +- .../home/controller/HomeController.java | 21 ++++- .../controller/ApiV1InviteController.java | 3 +- .../repository/MembershipRepository.java | 8 +- .../membership/service/MembershipService.java | 18 ++++ .../controller/ApiV1SpaceController.java | 91 +++++++++++++++---- .../space/dto/etc/SpaceMembershipInfo.java | 1 + .../SpaceMembershipInfoWithoutAuthority.java | 3 +- .../space/dto/res/ResBodyForSpaceInfo.java | 10 ++ .../dto/res/ResBodyForSpaceListPage.java | 26 ++++++ .../domain/space/space/entity/Space.java | 8 +- .../space/space/service/SpaceService.java | 80 ++++++++++++++++ .../backend/global/aws/S3Service.java | 70 ++++++++++++++ .../backend/global/aws/S3TestController.java | 42 +++++++++ .../TestThumbnailController.java | 21 +++++ .../ThumbnailGeneratorService.java | 50 ++++++++++ .../backend/global/initData/BaseInitData.java | 1 + .../global/security/SecurityConfig.java | 1 + .../application-secrets.yml.template | 11 +++ src/main/resources/application.yml | 4 + .../controller/ApiV1InviteControllerTest.java | 9 +- .../controller/ApiV1SpaceControllerTest.java | 80 ++++++++++++++-- 22 files changed, 527 insertions(+), 38 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInfo.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceListPage.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/aws/S3Service.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/aws/S3TestController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/TestThumbnailController.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/ThumbnailGeneratorService.java diff --git a/build.gradle b/build.gradle index f0e18d8e..8a4623d3 100644 --- a/build.gradle +++ b/build.gradle @@ -77,7 +77,6 @@ dependencies { // Spring AI implementation "org.springframework.ai:spring-ai-starter-model-openai" - // 크롤링 implementation("org.jsoup:jsoup:1.21.2") @@ -87,6 +86,12 @@ dependencies { // Mysql driver implementation 'mysql:mysql-connector-java:8.0.33' + // AWS SDK for S3 + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.4.0' + + // Playwright for Java + implementation 'com.microsoft.playwright:playwright:1.54.0' + } dependencyManagement { diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java index 4930ded8..0ab283d2 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java @@ -33,6 +33,8 @@ public String main() { String kakaoLoginUrl = "/oauth2/authorization/kakao"; String googleLoginUrl = "/oauth2/authorization/google"; String logoutUrl = "/api/v1/auth/logout"; + String testS3UploadUrl = "/test/upload-file"; + String testThumbnailUrl = "/test/generate-thumbnail"; return """

API 서버

@@ -56,6 +58,23 @@ public String main() { - """.formatted(localHost.getHostName(), localHost.getHostAddress(), kakaoLoginUrl, googleLoginUrl, logoutUrl); + +

S3 파일 업로드 테스트

+
+ +

+ +

+ +
+ +
+

썸네일 생성 테스트

+ + """.formatted(localHost.getHostName(), localHost.getHostAddress(), kakaoLoginUrl, googleLoginUrl, logoutUrl, testS3UploadUrl, testThumbnailUrl); } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java index 8944a7fa..5309da40 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteController.java @@ -84,7 +84,8 @@ public RsData getMyInvites( List invitationInfos = invitations.stream() .map(membership -> new SpaceMembershipInfoWithoutAuthority( membership.getSpace().getId(), - membership.getSpace().getName() + membership.getSpace().getName(), + membership.getSpace().getThumbnailUrl() )) .toList(); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java index a39a6b09..9d5b9bc3 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/repository/MembershipRepository.java @@ -1,5 +1,7 @@ package org.tuna.zoopzoop.backend.domain.space.membership.repository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; @@ -12,10 +14,12 @@ public interface MembershipRepository extends JpaRepository { boolean existsByMemberAndSpace(Member member, Space space); - List findAllByMemberAndAuthority(Member member, Authority authority); + Page findAllByMemberAndAuthority(Member member, Authority authority, Pageable pageable); + Page findAllByMemberAndAuthorityIsNot(Member member, Authority authority, Pageable pageable); + Page findAllByMember(Member member, Pageable pageable); + List findAllByMemberAndAuthority(Member member, Authority authority); List findAllByMemberAndAuthorityIsNot(Member member, Authority authority); - List findAllByMember(Member member); boolean existsByMemberAndSpaceAndAuthorityIsNot(Member member, Space space, Authority authority); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java index 0d646f5c..d296384a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/membership/service/MembershipService.java @@ -5,6 +5,8 @@ import jakarta.validation.constraints.PositiveOrZero; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; @@ -50,6 +52,22 @@ public Membership findByMemberAndSpace(Member member, Space space) { .orElseThrow(() -> new NoResultException("해당 멤버는 스페이스에 속해있지 않습니다.")); } + /** + * 멤버가 속한 스페이스 목록 조회 + * @param member 조회할 멤버 + * @param state 멤버의 가입 상태로 필터링 (PENDING, JOINED, ALL) + * @return 멤버가 속한 스페이스 목록 + */ + public Page findByMember(Member member, String state, Pageable pageable) { + if (state.equalsIgnoreCase("PENDING")) { + return membershipRepository.findAllByMemberAndAuthority(member, Authority.PENDING, pageable); + } else if (state.equalsIgnoreCase("JOINED")) { + return membershipRepository.findAllByMemberAndAuthorityIsNot(member, Authority.PENDING, pageable); + } else { + return membershipRepository.findAllByMember(member, pageable); + } + } + /** * 멤버가 속한 스페이스 목록 조회 * @param member 조회할 멤버 diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java index 0057a3e8..7e00a289 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java @@ -4,16 +4,24 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; import org.tuna.zoopzoop.backend.domain.space.membership.enums.JoinState; import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; import org.tuna.zoopzoop.backend.domain.space.space.dto.req.ReqBodyForSpaceSave; +import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceInfo; import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceList; import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfo; +import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceListPage; import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceSave; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; @@ -22,6 +30,7 @@ import java.nio.file.AccessDeniedException; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @RestController @@ -99,33 +108,49 @@ public RsData updateSpaceName( ); } + @PutMapping(path = "/thumbnail/{spaceId}", consumes = {"multipart/form-data"}) + @Operation(summary = "스페이스 썸네일 이미지 갱신") + public RsData updateSpaceThumbnail( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId, + @RequestPart(value = "image", required = false) MultipartFile image + ) { + Member member = userDetails.getMember(); + + spaceService.updateSpaceThumbnail(spaceId, member, image); + + return new RsData<>( + "200", + "스페이스 썸네일 이미지가 갱신됐습니다.", + null + ); + } + @GetMapping - @Operation(summary = "스페이스 목록 조회") - public RsData getAllSpaces( + @Operation(summary = "나의 스페이스 목록 조회") + public RsData getAllSpaces( @AuthenticationPrincipal CustomUserDetails userDetails, - @RequestParam(required = false) JoinState state + @RequestParam(required = false) JoinState state, + @PageableDefault(size = 10, sort = "createDate", direction = Sort.Direction.DESC) Pageable pageable ) { // 현재 로그인한 사용자 정보 가져오기 Member member = userDetails.getMember(); // 멤버가 속한 스페이스 목록 조회 - List memberships; - if (state == null) { - memberships = membershipService.findByMember(member, "ALL"); - } - else { - memberships = membershipService.findByMember(member, state.name()); - } - - // 반환 값 생성 - List spaceInfos = memberships.stream() - .map(membership -> new SpaceMembershipInfo( - membership.getSpace().getId(), - membership.getSpace().getName(), - membership.getAuthority() - )) - .collect(Collectors.toList()); - ResBodyForSpaceList resBody = new ResBodyForSpaceList(spaceInfos); + String stateStr = (state == null) ? "ALL" : state.name(); + Page membershipsPage = membershipService.findByMember(member, stateStr, pageable); + + // Page를 Page로 변환 + // Page 객체의 map() 메서드를 사용하면 페이징 정보는 그대로 유지하면서 내용물만 쉽게 바꿀 수 있습니다. + Page spaceInfosPage = membershipsPage.map(membership -> new SpaceMembershipInfo( + membership.getSpace().getId(), + membership.getSpace().getName(), + membership.getSpace().getThumbnailUrl(), + membership.getAuthority() + )); + + // 새로운 응답 DTO 생성 + ResBodyForSpaceListPage resBody = new ResBodyForSpaceListPage(spaceInfosPage); return new RsData<>( "200", @@ -134,6 +159,32 @@ public RsData getAllSpaces( ); } + @GetMapping("/{spaceId}") + @Operation(summary = "스페이스 단건 조회") + public RsData getSpace( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer spaceId + ) { + Member member = userDetails.getMember(); + Space space = spaceService.findById(spaceId); + + // 해당 스페이스에 속한 멤버인지 확인 + Membership membership = membershipService.findByMemberAndSpace(member, space); + + ResBodyForSpaceInfo resBody = new ResBodyForSpaceInfo( + space.getId(), + space.getName(), + space.getThumbnailUrl(), + membership.getAuthority().name(), + space.getSharingArchive().getId() + ); + + return new RsData<>( + "200", + String.format("%s - 스페이스가 조회됐습니다.", space.getName()), + resBody + ); + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfo.java index 83de0386..1ee8ec16 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfo.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfo.java @@ -5,6 +5,7 @@ public record SpaceMembershipInfo( Integer id, String name, + String thumbnailUrl, Authority authority ) { } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfoWithoutAuthority.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfoWithoutAuthority.java index a27b2624..f5452b40 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfoWithoutAuthority.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/etc/SpaceMembershipInfoWithoutAuthority.java @@ -2,6 +2,7 @@ public record SpaceMembershipInfoWithoutAuthority( Integer id, - String name + String name, + String thumbnailUrl ) { } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInfo.java new file mode 100644 index 00000000..c143b926 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInfo.java @@ -0,0 +1,10 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto.res; + +public record ResBodyForSpaceInfo ( + Integer spaceId, + String spaceName, + String thumbnailUrl, + String userAuthority, + Integer sharingArchiveId +){ +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceListPage.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceListPage.java new file mode 100644 index 00000000..4e3f1dd4 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceListPage.java @@ -0,0 +1,26 @@ +package org.tuna.zoopzoop.backend.domain.space.space.dto.res; + +import lombok.Getter; +import org.springframework.data.domain.Page; +import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfo; + +import java.util.List; + +@Getter +public class ResBodyForSpaceListPage { + private final List spaces; // 현재 페이지의 데이터 + private final int page; // 현재 페이지 번호 (0부터 시작) + private final int size; // 페이지 크기 + private final long totalElements; // 전체 요소 수 + private final int totalPages; // 전체 페이지 수 + private final boolean isLast; // 마지막 페이지 여부 + + public ResBodyForSpaceListPage(Page page) { + this.spaces = page.getContent(); + this.page = page.getNumber(); + this.size = page.getSize(); + this.totalElements = page.getTotalElements(); + this.totalPages = page.getTotalPages(); + this.isLast = page.isLast(); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java index 1b03349a..5d0db6ea 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java @@ -26,6 +26,9 @@ public class Space extends BaseEntity { @OneToOne(mappedBy = "space", cascade = CascadeType.ALL, orphanRemoval = true) private SharingArchive sharingArchive; + @Column(nullable = true) + private String thumbnailUrl; + //연결된 MemberShip //Space 삭제시 cascade.all @OneToMany(mappedBy = "space", cascade = CascadeType.ALL, orphanRemoval = true) @@ -36,11 +39,12 @@ public Space() { } @Builder - public Space(String name, Boolean active) { + public Space(String name, Boolean active, String thumbnailUrl) { this.name = name; - if (active != null) this.active = active; + if( thumbnailUrl != null) + this.thumbnailUrl = thumbnailUrl; this.sharingArchive = new SharingArchive(this); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java index 0073949f..35d2658a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java @@ -3,17 +3,25 @@ import jakarta.persistence.NoResultException; import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; +import org.hibernate.service.spi.ServiceException; import org.hibernate.validator.constraints.Length; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.exception.DuplicateSpaceNameException; import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository; +import org.tuna.zoopzoop.backend.global.aws.S3Service; @Service @RequiredArgsConstructor public class SpaceService { private final SpaceRepository spaceRepository; + private final S3Service s3Service; + private final MembershipService membershipService; // ======================== 스페이스 조회 ======================== // @@ -23,6 +31,7 @@ public class SpaceService { * @return 조회된 스페이스 * @throws NoResultException 스페이스가 존재하지 않을 경우 */ + @Transactional(readOnly = true) public Space findById(Integer spaceId) { return spaceRepository.findById(spaceId) .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); @@ -34,6 +43,7 @@ public Space findById(Integer spaceId) { * @return 조회된 스페이스 * @throws NoResultException 스페이스가 존재하지 않을 경우 */ + @Transactional(readOnly = true) public Space findByName(String name) { return spaceRepository.findByName(name) .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); @@ -46,6 +56,7 @@ public Space findByName(String name) { * @param name 스페이스 이름 * @return 생성된 스페이스 */ + @Transactional public Space createSpace(@NotBlank @Length(max = 50) String name) { Space newSpace = Space.builder() .name(name) @@ -60,12 +71,35 @@ public Space createSpace(@NotBlank @Length(max = 50) String name) { } } + /** + * 스페이스 생성 + * @param name 스페이스 이름 + * @param thumbnailUrl 스페이스 썸네일 이미지 URL + * @return 생성된 스페이스 + */ + @Transactional + public Space createSpace(@NotBlank @Length(max = 50) String name, String thumbnailUrl) { + Space newSpace = Space.builder() + .name(name) + .thumbnailUrl(thumbnailUrl) + .build(); + + try{ + return spaceRepository.save(newSpace); + }catch (DataIntegrityViolationException e) { + throw new DuplicateSpaceNameException("이미 존재하는 스페이스 이름입니다."); + } catch (Exception e) { + throw e; + } + } + /** * 스페이스 삭제 (hard delete) * @param spaceId 스페이스 ID * @return 삭제된 스페이스 이름 * @throws IllegalArgumentException 스페이스가 존재하지 않을 경우 */ + @Transactional public String deleteSpace(Integer spaceId) { Space space = spaceRepository.findById(spaceId) .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); @@ -84,6 +118,7 @@ public String deleteSpace(Integer spaceId) { * @throws IllegalArgumentException 스페이스가 존재하지 않을 경우 * @throws DuplicateSpaceNameException 새로운 스페이스 이름이 중복될 경우 */ + @Transactional public Space updateSpaceName(Integer spaceId, @NotBlank @Length(max = 50) String name) { Space space = spaceRepository.findById(spaceId) .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); @@ -98,4 +133,49 @@ public Space updateSpaceName(Integer spaceId, @NotBlank @Length(max = 50) String throw e; } } + + /** + * 스페이스 썸네일 이미지 변경 + * @param spaceId 스페이스 ID + * @param image 새로운 썸네일 이미지 + * @throws IllegalArgumentException 스페이스가 존재하지 않을 경우 + */ + @Transactional + public void updateSpaceThumbnail(Integer spaceId, Member requester, MultipartFile image) { + // 이미지가 null이거나 비어있는 경우 예외 처리 + if(image == null || image.isEmpty()) { + return; + } + + // 파일 크기 제한 (5MB) + if (image.getSize() > (5 * 1024 * 1024)) // 5MB + throw new IllegalArgumentException("이미지 파일 크기는 5MB를 초과할 수 없습니다."); + + Space space = spaceRepository.findById(spaceId) + .orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다.")); + + if (requester == null) { + throw new IllegalArgumentException("사용자 정보가 없습니다."); + } + + if (!membershipService.isMemberJoinedSpace(requester, space)) { + throw new IllegalArgumentException("스페이스의 구성원이 아닙니다."); + } + + try { + //String fileName = "space/" + spaceId + "/thumbnail/" + System.currentTimeMillis() + "_" + + // S3 저장 시 파일 이름 고정 (덮어쓰기) + String fileName = "space-thumbnail/space_" + spaceId ; + String baseImageUrl = s3Service.upload(image, fileName); + + // DB 용으로 현재 시간을 쿼리 파라미터에 추가 (캐시 무효화) + String finalImageUrl = baseImageUrl + "?v=" + System.currentTimeMillis(); + + // DB 갱신 + space.setThumbnailUrl(finalImageUrl); + spaceRepository.save(space); + } catch (Exception e) { + throw new RuntimeException("스페이스 썸네일 이미지 업로드에 실패했습니다."); + } + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3Service.java b/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3Service.java new file mode 100644 index 00000000..c372609b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3Service.java @@ -0,0 +1,70 @@ +package org.tuna.zoopzoop.backend.global.aws; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; + +@Service +@RequiredArgsConstructor +public class S3Service { + private final S3Client s3Client; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + + /** + * S3에 파일 업로드 메서드 + * @param multipartFile 업로드할 파일 + * @param fileName S3에 저장될 파일 이름 + * @return 업로드된 파일의 URL 주소 + * @throws IOException 파일 처리 중 발생할 수 있는 예외 + */ + public String upload(MultipartFile multipartFile, String fileName) throws IOException { + // 1. PutObjectRequest 객체 생성 (빌더 패턴 사용) + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(multipartFile.getContentType()) + .build(); + + // 2. S3에 파일 업로드 (InputStream을 직접 사용) + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(multipartFile.getInputStream(), multipartFile.getSize())); + + // 3. 업로드된 파일의 URL 주소 반환 + return s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(fileName)).toString(); + } + + /** + * S3에 파일 업로드 (byte[])💡 + * @param bytes 업로드할 파일의 바이트 배열 + * @param fileName S3에 저장될 파일 이름 + * @param contentType 파일의 MIME 타입 (e.g., "image/png") + * @return 업로드된 파일의 URL + */ + public String upload(byte[] bytes, String fileName, String contentType) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(contentType) + .contentLength((long) bytes.length) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes)); + + return s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(fileName)).toString(); + } + + /** + * S3에서 파일 삭제 메서드 + * @param fileName 삭제할 파일 이름 + */ + public void delete(String fileName) { + s3Client.deleteObject(builder -> builder.bucket(bucket).key(fileName).build()); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3TestController.java b/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3TestController.java new file mode 100644 index 00000000..f37b241a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/aws/S3TestController.java @@ -0,0 +1,42 @@ +package org.tuna.zoopzoop.backend.global.aws; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +import static org.springframework.http.MediaType.TEXT_HTML_VALUE; + +@RestController +@RequiredArgsConstructor +public class S3TestController { + private final S3Service s3Service; + + @PostMapping(value = "/test/upload-file", produces = TEXT_HTML_VALUE) + @Operation(summary = "S3 파일 업로드 테스트") + public String uploadFile(@RequestParam("fileName") String fileName, + @RequestParam("file") MultipartFile file) { + try { + String fileUrl = s3Service.upload(file, fileName); + return """ +

업로드 성공! ✅

+

파일명: %s

+

업로드된 URL: %s

+
+ 메인으로 돌아가기 + """.formatted(fileName, fileUrl, fileUrl); + } catch (IOException e) { + // e.printStackTrace(); // 실제 운영 환경에서는 로그를 남기는 것이 좋습니다. + return """ +

업로드 실패 ❌

+

오류: %s

+
+ 메인으로 돌아가기 + """.formatted(e.getMessage()); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/TestThumbnailController.java b/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/TestThumbnailController.java new file mode 100644 index 00000000..b573bbc6 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/TestThumbnailController.java @@ -0,0 +1,21 @@ +package org.tuna.zoopzoop.backend.global.headlessBrowser; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/test") // "/test" 라는 공통 경로를 사용 +@RequiredArgsConstructor +public class TestThumbnailController { + private final ThumbnailGeneratorService thumbnailGeneratorService; + + @GetMapping("/generate-thumbnail") // GET /test/generate-thumbnail 요청을 처리 + public String testGenerateThumbnail() { + // 테스트 목적으로 workspaceId는 임의의 값(예: 1)을 사용합니다. + thumbnailGeneratorService.generateAndUploadThumbnail(1); + + return "썸네일 생성 및 업로드 요청을 보냈습니다. 서버 로그와 S3를 확인해주세요."; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/ThumbnailGeneratorService.java b/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/ThumbnailGeneratorService.java new file mode 100644 index 00000000..94ddd79d --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/headlessBrowser/ThumbnailGeneratorService.java @@ -0,0 +1,50 @@ +package org.tuna.zoopzoop.backend.global.headlessBrowser; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.global.aws.S3Service; + +@Service +@RequiredArgsConstructor +public class ThumbnailGeneratorService { + private final S3Service s3Service; + + @Async // 비동기 실행 + public void generateAndUploadThumbnail(Integer workspaceId) { + try (Playwright playwright = Playwright.create()) { + Browser browser = playwright.chromium().launch(); // Chromium 브라우저 실행 + Page page = browser.newPage(); + + // 1. 썸네일 생성용 내부 URL로 이동 + // (인증을 위한 임시 토큰 등을 쿼리 파라미터로 추가할 수 있습니다.) + //String thumbnailUrl = "http://localhost:8080/internal/render/workspace/" + workspaceId + "?auth_token=TEMP_TOKEN"; + String thumbnailUrl = "https://www.naver.com"; // 테스트용 URL + page.navigate(thumbnailUrl); + + // 2. 대시보드 컨텐츠가 모두 로드될 때까지 대기 + //page.waitForSelector("#dashboard-container"); // 대시보드 컨테이너의 CSS 선택자 + page.waitForSelector("#header"); + + // 3. 특정 요소만 스크린샷으로 찍기 + //Locator dashboardElement = page.locator("#dashboard- + Locator dashboardElement = page.locator("#header"); + byte[] screenshotBytes = dashboardElement.screenshot(); + + // 4. S3에 업로드 + // 파일 이름은 유니크하게 설정 (e.g., workspace_1_thumbnail.png) + //String fileName = "thumbnails/workspace_" + workspaceId + ".png"; + String fileName = "thumbnails/test_thumbnail.png"; // 테스트용 파일 이름 + String s3Url = s3Service.upload(screenshotBytes, fileName, "image/png"); + + browser.close(); + } catch (Exception e) { + // 에러 처리 로직 + e.printStackTrace(); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java index 8b60296f..8e4e08a8 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java @@ -12,6 +12,7 @@ import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; import org.tuna.zoopzoop.backend.domain.datasource.ai.service.AiService; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository; @Configuration diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java index 93eb25bc..29d81c91 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java @@ -39,6 +39,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/oauth/**", "/webjars/**", "/api/v1/**", // API 테스트용으로 모두 허용. 차후 필수로 변경 필요. + "/test/**", // 테스트용으로 모두 허용. 차후 삭제 필요. "/actuator/health" // health 체크용 ).permitAll() .anyRequest().authenticated() diff --git a/src/main/resources/application-secrets.yml.template b/src/main/resources/application-secrets.yml.template index 040cd1e8..4ff88092 100644 --- a/src/main/resources/application-secrets.yml.template +++ b/src/main/resources/application-secrets.yml.template @@ -28,6 +28,17 @@ spring: token-uri: https://oauth2.googleapis.com/token user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo user-name-attribute: sub + cloud: + aws: + credentials: + access-key: {AWS_ACCESS_KEY} + secret-key: {AWS_SECRET_KEY} + region: + static: {AWS_REGION} + s3: + bucket: {AWS_S3_BUCKET_NAME} + stack: + auto: false naver: client_id: {NAVER_CLIENT_ID} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 60a0d4f7..f6cd5b26 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,10 @@ spring: use_sql_comments: true config: import: optional:classpath:application-secrets.yml + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB ai: openai: base-url: https://api.groq.com/openai # 내부 서버를 groq으로 diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java index 88ffb0f4..2c16db5d 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/membership/controller/ApiV1InviteControllerTest.java @@ -44,9 +44,8 @@ void setUp() { } void setUpSpace() { - spaceService.createSpace("기존 스페이스 1_forInviteControllerTest"); - spaceService.createSpace("기존 스페이스 2_forInviteControllerTest"); - + Space space1 = spaceService.createSpace("기존 스페이스 1_forInviteControllerTest", "dummyUrl1"); + Space space2 = spaceService.createSpace("기존 스페이스 2_forInviteControllerTest", "dummyUrl2"); } void setUpMember() { @@ -281,8 +280,10 @@ void getMyInvites_Success() throws Exception { .andExpect(jsonPath("$.data.spaces.length()").value(2)) .andExpect(jsonPath("$.data.spaces[0].id").value(space1.getId())) .andExpect(jsonPath("$.data.spaces[0].name").value(space1.getName())) + .andExpect(jsonPath("$.data.spaces[0].thumbnailUrl").value(space1.getThumbnailUrl())) .andExpect(jsonPath("$.data.spaces[1].id").value(space2.getId())) - .andExpect(jsonPath("$.data.spaces[1].name").value(space2.getName())); + .andExpect(jsonPath("$.data.spaces[1].name").value(space2.getName())) + .andExpect(jsonPath("$.data.spaces[1].thumbnailUrl").value(space2.getThumbnailUrl())); } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java index 57d8db11..b270d411 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java @@ -14,6 +14,7 @@ import org.tuna.zoopzoop.backend.domain.member.service.MemberService; import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; import org.tuna.zoopzoop.backend.testSupport.ControllerTestSupport; @@ -43,8 +44,8 @@ void setUp() { } void setUpSpace() { - spaceService.createSpace("기존 스페이스 1_forSpaceControllerTest"); - spaceService.createSpace("기존 스페이스 2_forSpaceControllerTest"); + Space space1 = spaceService.createSpace("기존 스페이스 1_forSpaceControllerTest", "thumbnailUrl1"); + Space space2 = spaceService.createSpace("기존 스페이스 2_forSpaceControllerTest", "thumbnailUrl2"); } void setUpMember() { @@ -375,7 +376,7 @@ void modifySpaceName_Fail_NoAdminAuthority() throws Exception { .andExpect(jsonPath("$.data").value(nullValue())); } - // ======================= Read ======================= // + // ======================= Read List ======================= // @Test @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) @@ -401,9 +402,11 @@ void getMySpaces_Success() throws Exception { .andExpect(jsonPath("$.data.spaces[0].id").isNumber()) .andExpect(jsonPath("$.data.spaces[0].name").value("기존 스페이스 1_forSpaceControllerTest")) .andExpect(jsonPath("$.data.spaces[0].authority").value("ADMIN")) + .andExpect(jsonPath("$.data.spaces[0].thumbnailUrl").value("thumbnailUrl1")) .andExpect(jsonPath("$.data.spaces[1].id").isNumber()) .andExpect(jsonPath("$.data.spaces[1].name").value("기존 스페이스 2_forSpaceControllerTest")) - .andExpect(jsonPath("$.data.spaces[1].authority").value("PENDING")); + .andExpect(jsonPath("$.data.spaces[1].authority").value("PENDING")) + .andExpect(jsonPath("$.data.spaces[1].thumbnailUrl").value("thumbnailUrl2")); } @Test @@ -429,7 +432,8 @@ void getInvitedSpaces_Success() throws Exception { resultActions .andExpect(jsonPath("$.data.spaces[0].id").isNumber()) .andExpect(jsonPath("$.data.spaces[0].name").value("기존 스페이스 2_forSpaceControllerTest")) - .andExpect(jsonPath("$.data.spaces[0].authority").value("PENDING")); + .andExpect(jsonPath("$.data.spaces[0].authority").value("PENDING")) + .andExpect(jsonPath("$.data.spaces[0].thumbnailUrl").value("thumbnailUrl2")); } @Test @@ -455,7 +459,8 @@ void getJoinedSpaces_Success() throws Exception { resultActions .andExpect(jsonPath("$.data.spaces[0].id").isNumber()) .andExpect(jsonPath("$.data.spaces[0].name").value("기존 스페이스 1_forSpaceControllerTest")) - .andExpect(jsonPath("$.data.spaces[0].authority").value("ADMIN")); + .andExpect(jsonPath("$.data.spaces[0].authority").value("ADMIN")) + .andExpect(jsonPath("$.data.spaces[0].thumbnailUrl").value("thumbnailUrl1")); } // TODO : Spring Security 설정 이후 테스트 코드 활성화 @@ -489,6 +494,68 @@ void getMySpaces_Fail_InvalidState() throws Exception { ); } + // ======================= Read ======================= // + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 단건 조회 - 성공") + void getSpace_Success() throws Exception { + // Given + Space space = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest"); + Integer spaceId = space.getId(); + String url = String.format("/api/v1/space/%d", spaceId); + + // When + ResultActions resultActions = performGet(url); + + // Then + expectOk( + resultActions, + "기존 스페이스 1_forSpaceControllerTest - 스페이스가 조회됐습니다." + ); + resultActions + .andExpect(jsonPath("$.data.spaceId").value(spaceId)) + .andExpect(jsonPath("$.data.spaceName").value("기존 스페이스 1_forSpaceControllerTest")) + .andExpect(jsonPath("$.data.thumbnailUrl").value("thumbnailUrl1")) + .andExpect(jsonPath("$.data.userAuthority").value("ADMIN")) + .andExpect(jsonPath("$.data.sharingArchiveId").value(space.getSharingArchive().getId())); + } + + @Test + @WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 단건 조회 - 실패 : 존재하지 않는 스페이스") + void getSpace_Fail_NotFound() throws Exception { + // Given + Integer spaceId = 9999; // 존재하지 않는 스페이스 ID + String url = String.format("/api/v1/space/%d", spaceId); + + // When + ResultActions resultActions = performGet(url); + + // Then + resultActions.andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value("존재하지 않는 스페이스입니다.")) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + @Test + @WithUserDetails(value = "KAKAO:sc3333", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("스페이스 단건 조회 - 실패 : 스페이스 멤버가 아닌 사용자") + void getSpace_Fail_NotMember() throws Exception { + // Given + Space space = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest"); + Integer spaceId = space.getId(); + String url = String.format("/api/v1/space/%d", spaceId); + + // When + ResultActions resultActions = performGet(url); + + // Then + expectNotFound(resultActions, "해당 멤버는 스페이스에 속해있지 않습니다."); + } + + // ======================= TEST DATA FACTORIES ======================== // private String createDefaultSpaceCreateRequestBody() { @@ -500,4 +567,5 @@ private String createDefaultSpaceCreateRequestBody() { } + } \ No newline at end of file From 679e3b8fd05f62f9cf3a79b6317ba85fc5f4f013 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:03:07 +0900 Subject: [PATCH 044/132] =?UTF-8?q?[feat/OPS-324]=20=ED=81=AC=EB=A1=AC=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20=ED=94=84=EB=A1=9C=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20&=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80.=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-324 : 확장 프로그램 분기 및 소셜 로그인 테스트 API 추가. * feat/OPS-324 : 테스트 로직 일부 수정. * feat/OPS-324 : 테스트 로직 일부 수정. #2 * feat/OPS-324 : 테스트 완료, 테스트 코드 제거. * feat/OPS-324 : 로그인 분기 수정. * feat/OPS-324 : state 정보를 저장하는 resolver 추가. * feat/OPS-324 : session 등록을 위한 OAuth2LoginSourceFilter 클래스 추가. * feat/OPS-324 : 해결 --- .../auth/controller/ApiV1AuthController.java | 4 ++ ...tomOAuth2AuthorizationRequestResolver.java | 40 +++++++++++++++++++ .../auth/handler/OAuth2FailureHandler.java | 10 +++++ .../auth/handler/OAuth2SuccessHandler.java | 27 +++++++++---- .../global/security/SecurityConfig.java | 13 +++++- 5 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/global/CustomOAuth2AuthorizationRequestResolver.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java index 94f534fb..60d187ba 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java @@ -9,6 +9,8 @@ import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.reactive.function.client.WebClient; +import org.tuna.zoopzoop.backend.domain.auth.service.KakaoUserInfoService; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; import org.tuna.zoopzoop.backend.global.config.jwt.JwtProperties; @@ -23,6 +25,8 @@ public class ApiV1AuthController { private final JwtUtil jwtUtil; private final MemberService memberService; private final JwtProperties jwtProperties; + private final KakaoUserInfoService kakaoUserInfoService; + private final WebClient webClient; /** * 사용자 로그아웃 API diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/global/CustomOAuth2AuthorizationRequestResolver.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/global/CustomOAuth2AuthorizationRequestResolver.java new file mode 100644 index 00000000..04429882 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/global/CustomOAuth2AuthorizationRequestResolver.java @@ -0,0 +1,40 @@ +package org.tuna.zoopzoop.backend.domain.auth.global; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { + + private final OAuth2AuthorizationRequestResolver defaultResolver; + + public CustomOAuth2AuthorizationRequestResolver(ClientRegistrationRepository repo, String authorizationRequestBaseUri) { + this.defaultResolver = new DefaultOAuth2AuthorizationRequestResolver(repo, authorizationRequestBaseUri); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + return customize(defaultResolver.resolve(request), request); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { + return customize(defaultResolver.resolve(request, clientRegistrationId), request); + } + + private OAuth2AuthorizationRequest customize(OAuth2AuthorizationRequest req, HttpServletRequest request) { + if (req == null) return null; + + String source = request.getParameter("source"); // 로그인 시작 시 전달된 source + + OAuth2AuthorizationRequest.Builder builder = OAuth2AuthorizationRequest.from(req); + + if ("extension".equals(source)) { + // state에 source 정보를 안전하게 포함 + builder.state("source:extension;" + req.getState()); + } + return builder.build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2FailureHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2FailureHandler.java index 58197968..cb8f6dc5 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2FailureHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2FailureHandler.java @@ -26,6 +26,16 @@ public void onAuthenticationFailure(HttpServletRequest request, // 프론트로 리다이렉트 // 필요하면 쿼리 파라미터로 에러 정보 전달 + String source = request.getParameter("source"); + + if("extension".equals(source)){ + String redirectUrl = redirect_domain + "/extension/callback " + + "?success=false" + + "&error=" + URLEncoder.encode(exception.getMessage(), "UTF-8"); + response.sendRedirect(redirectUrl); + return; + } + String redirectUrl = redirect_domain + "/auth/callback" + "?success=false" diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java index ec8d6048..f54de5b6 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; @@ -22,8 +23,8 @@ @Component @RequiredArgsConstructor +@Slf4j public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - private final JwtUtil jwtUtil; private final JwtProperties jwtProperties; private final MemberRepository memberRepository; @@ -61,13 +62,30 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo String accessToken = jwtUtil.generateToken(member); String refreshToken = jwtUtil.generateRefreshToken(member); - if ("server".equals(activeProfile)) { + String source = request.getParameter("source"); + String state = request.getParameter("state"); + log.info("[OAuth2SuccessHandler] Source: {}", source); + log.info("[OAuth2SuccessHandler] State: {}", state); + boolean isExtension = state != null && state.contains("source:extension"); + + // 확장 프로그램에서 로그인 했을 경우. + if(isExtension){ + String redirectUrl = redirect_domain + "/extension/callback " + + "?success=true" + + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") + + "&refreshToken=" + URLEncoder.encode(refreshToken, "UTF-8"); + response.sendRedirect(redirectUrl); + return; + } + + if ("http://localhost:3000".equals(redirect_domain)) { // server 환경일 때: URL 파라미터로 토큰 전달 String redirectUrl = redirect_domain + "/auth/callback" + "?success=true" + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") + "&refreshToken=" + URLEncoder.encode(refreshToken, "UTF-8"); response.sendRedirect(redirectUrl); + } else { ResponseCookie accessCookie = ResponseCookie.from("accessToken", accessToken) .httpOnly(true) @@ -95,11 +113,6 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); - String redirectUrl = redirect_domain + "/auth/callback" - + "?success=true" - + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") - + "&refreshToken=" + URLEncoder.encode(refreshToken, "UTF-8"); - // 로그인 성공 후 리다이렉트. // 배포 시에 프론트엔드와 조율이 필요한 부분일 듯 함. response.sendRedirect(redirect_domain + "/auth/callback"); diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java index 29d81c91..2f0c70e3 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java @@ -4,8 +4,10 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.tuna.zoopzoop.backend.domain.auth.global.CustomOAuth2AuthorizationRequestResolver; import org.tuna.zoopzoop.backend.domain.auth.handler.OAuth2SuccessHandler; import org.tuna.zoopzoop.backend.domain.auth.service.CustomOAuth2UserService; import org.tuna.zoopzoop.backend.global.security.jwt.CustomAuthenticationEntryPoint; @@ -18,6 +20,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomOAuth2UserService customOAuth2UserService; private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final ClientRegistrationRepository clientRegistrationRepository; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -45,6 +48,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(authorization -> authorization + .authorizationRequestResolver( + new CustomOAuth2AuthorizationRequestResolver( + clientRegistrationRepository, + "/oauth2/authorization" + ) + )) .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService) ) @@ -59,7 +69,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .exceptionHandling(ex -> ex .authenticationEntryPoint(customAuthenticationEntryPoint) ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);; + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); } } \ No newline at end of file From 34efe1fcc58c9615a90b88ece598c47f74e012b3 Mon Sep 17 00:00:00 2001 From: Hyeok Jin Kim <71928299+EpicFn@users.noreply.github.com> Date: Fri, 26 Sep 2025 14:20:45 +0900 Subject: [PATCH 045/132] =?UTF-8?q?fix=20:=20ci=20=EC=8B=9C=20secrets=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20S3=20=EA=B4=80=EB=A0=A8=20=EC=8B=9C?= =?UTF-8?q?=ED=81=AC=EB=A6=BF=20=EA=B0=92=20=EC=82=AD=EC=A0=9C=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD(=EB=B2=84=ED=82=B7?= =?UTF-8?q?=EB=AA=85=20=EC=A0=9C=EC=99=B8)=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : ci 시 secrets 에서 S3 관련 시크릿 값 삭제하도록 변경(버킷명 제외) * fix : CI 오타 수정 * fix : build 단계에선 testest를 수행하지 않도록 변경 --------- Co-authored-by: EpicFn --- .github/workflows/test-server-ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-server-ci.yml b/.github/workflows/test-server-ci.yml index 52ea07bf..3e9c06b4 100644 --- a/.github/workflows/test-server-ci.yml +++ b/.github/workflows/test-server-ci.yml @@ -59,7 +59,8 @@ jobs: echo "spring.cloud.aws.credentials.access-key: ${{ secrets.AWS_ACCESS_KEY_ID }}" >> src/main/resources/application-secrets.yml echo "spring.cloud.aws.credentials.secret-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}" >> src/main/resources/application-secrets.yml echo "spring.cloud.aws.s3.bucket: ${{ secrets.AWS_S3_BUCKET_NAME }}" >> src/main/resources/application-secrets.yml - echo "spring.cloud.aws.stack : false" >> src/main/resources/application-secrets.yml + echo "spring.cloud.aws.stack.auto: false" >> src/main/resources/application-secrets.yml + # 6. application-secrets-server.yml 생성 - name: Generate application-secrets-server.yml run: | @@ -88,9 +89,18 @@ jobs: echo "No test results found." fi + # 8-1. S3 자격 증명 제거 (빌드 전에만) + - name: Remove only S3 credentials before building + run: | + CONFIG_FILE="src/main/resources/application-secrets.yml" + sed -i '/spring.cloud.aws.credentials.access-key/d' $CONFIG_FILE + sed -i '/spring.cloud.aws.credentials.secret-key/d' $CONFIG_FILE + sed -i '/spring.cloud.aws.region.static/d' $CONFIG_FILE + sed -i '/spring.cloud.aws.stack.auto/d' $CONFIG_FILE + # 9. Gradle 빌드 실행 (테스트 성공 시) - name: Build with Gradle - run: ./gradlew build + run: ./gradlew build -x test # 10. GHCR 로그인 - name: Log in to GHCR From 657365ba27f129b01cf9eca17a0f492942eb5082 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:43:19 +0900 Subject: [PATCH 046/132] =?UTF-8?q?[feat/OPS-198]=20=EC=95=84=EC=B9=B4?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EC=A0=95=EB=B3=B4=EB=A1=9C=20=EB=89=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-198 : 아카이브 기반 뉴스 조회 구현. * feat/OPS-198 : 테스트 케이스 추가. --- build.gradle | 4 + .../zoopzoop/backend/BackendApplication.java | 1 - .../auth/handler/OAuth2SuccessHandler.java | 6 +- .../home/controller/HomeController.java | 4 +- .../news/controller/ApiV1NewsController.java | 40 +++++- ...SearchService.java => NewsAPIService.java} | 4 +- .../domain/news/service/NewsService.java | 40 ++++++ .../news/service/NewsAPIServiceTest.java | 37 +++++ .../domain/news/service/NewsServiceTest.java | 127 +++++++++++++++--- 9 files changed, 230 insertions(+), 33 deletions(-) rename src/main/java/org/tuna/zoopzoop/backend/domain/news/service/{NewsSearchService.java => NewsAPIService.java} (98%) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsService.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIServiceTest.java diff --git a/build.gradle b/build.gradle index 8a4623d3..2e173227 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,10 @@ ext { springAiVersion = "1.0.0" } +test { + useJUnitPlatform() +} + dependencies { // Spring Data JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' diff --git a/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java b/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java index ba492291..d74d6a50 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java +++ b/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java @@ -5,7 +5,6 @@ @SpringBootApplication public class BackendApplication { - public static void main(String[] args) { SpringApplication.run(BackendApplication.class, args); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java index f54de5b6..abd7e075 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java @@ -70,7 +70,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo // 확장 프로그램에서 로그인 했을 경우. if(isExtension){ - String redirectUrl = redirect_domain + "/extension/callback " + String redirectUrl = redirect_domain + "/extension/callback" + "?success=true" + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") + "&refreshToken=" + URLEncoder.encode(refreshToken, "UTF-8"); @@ -80,7 +80,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo if ("http://localhost:3000".equals(redirect_domain)) { // server 환경일 때: URL 파라미터로 토큰 전달 - String redirectUrl = redirect_domain + "/auth/callback" + String redirectUrl = redirect_domain + "/api/auth/callback" + "?success=true" + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") + "&refreshToken=" + URLEncoder.encode(refreshToken, "UTF-8"); @@ -115,7 +115,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo // 로그인 성공 후 리다이렉트. // 배포 시에 프론트엔드와 조율이 필요한 부분일 듯 함. - response.sendRedirect(redirect_domain + "/auth/callback"); + response.sendRedirect(redirect_domain + "/api/auth/callback"); } // 보안을 좀 더 강화하고자 한다면 CSRF 토큰 같은 걸 생각해볼 수 있겠으나, // 일단은 구현하지 않음.(개발 과정 중에 번거로워질 수 있을 듯 함.) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java index 0ab283d2..55933ea0 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java @@ -6,7 +6,7 @@ import lombok.SneakyThrows; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import org.tuna.zoopzoop.backend.domain.news.service.NewsSearchService; +import org.tuna.zoopzoop.backend.domain.news.service.NewsAPIService; import java.net.InetAddress; @@ -22,7 +22,7 @@ public class HomeController { // // @Value("${kakao.redirect_uri}") // private String kakaoRedirectUri; - private final NewsSearchService newsSearchService; + private final NewsAPIService newsSearchService; @SneakyThrows @GetMapping(produces = TEXT_HTML_VALUE) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java index 4c812eaf..078ad563 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java @@ -5,19 +5,26 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.news.dto.req.ReqBodyForKeyword; import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; -import org.tuna.zoopzoop.backend.domain.news.service.NewsSearchService; +import org.tuna.zoopzoop.backend.domain.news.service.NewsAPIService; +import org.tuna.zoopzoop.backend.domain.news.service.NewsService; import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; import reactor.core.publisher.Mono; +import java.util.List; + @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/news") @Tag(name = "ApiV1NewsController", description = "뉴스 API 기반 검색 컨트롤러") public class ApiV1NewsController { - private final NewsSearchService newsSearchService; + private final NewsAPIService newsSearchService; + private final NewsService newsService; /** * 최신 뉴스 목록을 조회하는 API @@ -47,7 +54,7 @@ public Mono>> searchRecentNews( * @param dto 키워드를 받아오는 reqDto */ @PostMapping("/keywords") - @Operation(summary = "최신 뉴스 목록 조회") + @Operation(summary = "키워드 기반 뉴스 조회") public Mono>> searchNewsByKeywords( @RequestBody ReqBodyForKeyword dto ) { @@ -64,4 +71,31 @@ public Mono>> searchNewsByKeywords( response ))); } + + /** + * 개인 아카이브의 폴더 내부의 자료를 기반으로 키워드를 추천해서 검색하는 뉴스 API + * HTTP METHOD: GET + * 한번에 100개를 조회 합니다. + * @param userDetails 로그인한 사용자 + * @param folderId 대상 폴더 id + */ + @GetMapping("/recommends/personal/{folderId}") + @Operation(summary = "개인 아카이브 뉴스 추천") + public Mono>> searchNewsByRecommends( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer folderId + ) { + Member member = userDetails.getMember(); + List frequency = newsService.getTagFrequencyFromFiles(member.getId(), folderId); + String query = String.join(" ", frequency); + + return newsSearchService.searchNews(query, "sim") + .map(response -> ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "키워드 기반 뉴스 목록을 조회했습니다.", + response + ))); + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIService.java similarity index 98% rename from src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIService.java index 611b8672..db03e5f9 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIService.java @@ -10,7 +10,7 @@ import java.util.List; @Service -public class NewsSearchService { +public class NewsAPIService { private final WebClient webClient; @Value("${naver.client_id}") @@ -19,7 +19,7 @@ public class NewsSearchService { @Value("${naver.client_secret}") private String client_secret; - public NewsSearchService(WebClient.Builder webClientBuilder) { + public NewsAPIService(WebClient.Builder webClientBuilder) { this.webClient = webClientBuilder.baseUrl("https://openapi.naver.com").build(); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsService.java new file mode 100644 index 00000000..440a3476 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsService.java @@ -0,0 +1,40 @@ +package org.tuna.zoopzoop.backend.domain.news.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FileSummary; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class NewsService { + private final FolderService folderService; + + public List getTagFrequencyFromFiles(Integer memberId, Integer folderId) { + FolderFilesDto folderFilesDto = folderService.getFilesInFolderForPersonal(memberId, folderId); + + List files = folderFilesDto.files(); + + Map tags = files.stream() + .flatMap(file -> file.tags().stream()) + .map(tag -> tag.getTagName()) + .collect(Collectors.groupingBy( + tagName -> tagName, + Collectors.counting() + )); + + List frequency = tags.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .limit(3) + .map(Map.Entry::getKey) + .toList(); + + return frequency; + } +} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIServiceTest.java new file mode 100644 index 00000000..2bbdd7d9 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIServiceTest.java @@ -0,0 +1,37 @@ +package org.tuna.zoopzoop.backend.domain.news.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; +import reactor.core.publisher.Mono; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest +@ActiveProfiles("test") +class NewsAPIServiceTest { + @Autowired + private NewsAPIService newsSearchService; + + @Test + @DisplayName("뉴스 서비스 테스트 - 정상적인 JSON 구조 반환 여부 확인") + void newsJsonStructureTest() { + Mono result = newsSearchService.searchNews("뉴스", "sim"); + + // JSON 구조 확인 + result.doOnNext(res -> { + assertNotNull(res.total()); + assertNotNull(res.items()); + + res.items().forEach(item -> { + assertNotNull(item.title()); + assertNotNull(item.link()); + assertNotNull(item.description()); + assertNotNull(item.pubDate()); + }); + }).block(); // Mono 블로킹. + } +} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java index 1c90cc1f..b75e73d6 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java @@ -1,38 +1,121 @@ package org.tuna.zoopzoop.backend.domain.news.service; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; -import reactor.core.publisher.Mono; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; +import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest @ActiveProfiles("test") -class NewsServiceTest { +@Transactional +public class NewsServiceTest { @Autowired - private NewsSearchService newsSearchService; + private NewsService newsService; + + @Autowired + private NewsAPIService newsAPIService; + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private FolderService folderService; + + @Autowired + private FolderRepository folderRepository; + + @Autowired + private DataSourceService dataSourceService; + + @Autowired + private DataSourceRepository dataSourceRepository; + + private final Map> tags = Map.ofEntries( + Map.entry(1, List.of(new Tag("A"), new Tag("B"), new Tag("E"))), + Map.entry(2, List.of(new Tag("B"), new Tag("E"), new Tag("F"))), + Map.entry(3, List.of(new Tag("E"), new Tag("F"))), + Map.entry(4, List.of(new Tag("A"), new Tag("D"), new Tag("E"))), + Map.entry(5, List.of(new Tag("B"), new Tag("F"))), + Map.entry(6, List.of(new Tag("E"), new Tag("F"), new Tag("B"))), + Map.entry(7, List.of(new Tag("D"), new Tag("E"))), + Map.entry(8, List.of(new Tag("B"), new Tag("E"))), + Map.entry(9, List.of(new Tag("F"), new Tag("E"))), + Map.entry(10, List.of(new Tag("C"), new Tag("E"))) + ); + // A = 2회, B = 5회, C = 1회, D = 2회, E = 9회, F = 5회 + + private DataSource buildDataSource(String title, Folder folder, String sourceUrl, List tags) { + DataSource ds = new DataSource(); + ds.setFolder(folder); + ds.setSourceUrl(sourceUrl); + ds.setTitle(title); + ds.setSource("www.examplesource.com"); + ds.setSummary("설명"); + ds.setImageUrl("www.example.com/img"); + ds.setDataCreatedDate(LocalDate.now()); + ds.setTags(tags); + ds.setCategory(Category.ENVIRONMENT); + ds.setActive(true); + return dataSourceRepository.save(ds); + } + + @AfterEach + void cleanUp() { + memberRepository.deleteAll(); + } + + @BeforeEach + public void setUp() { + Member member = memberService.createMember( + "newsServiceTestMember", + "url", + "newsServiceTestKey", + Provider.KAKAO + ); + + FolderResponse folderResponse = folderService.createFolderForPersonal(member.getId(), "newServiceTestFolder"); + Folder folder = folderRepository.findById(folderResponse.folderId()).orElse(null); + + for(int i = 1; i <= 10; i++) { + buildDataSource(String.valueOf(i), folder, String.valueOf(i), tags.get(i)); + } + } @Test - @DisplayName("뉴스 서비스 테스트 - 정상적인 JSON 구조 반환 여부 확인") - void newsJsonStructureTest() { - Mono result = newsSearchService.searchNews("뉴스", "sim"); - - // JSON 구조 확인 - result.doOnNext(res -> { - assertNotNull(res.total()); - assertNotNull(res.items()); - - res.items().forEach(item -> { - assertNotNull(item.title()); - assertNotNull(item.link()); - assertNotNull(item.description()); - assertNotNull(item.pubDate()); - }); - }).block(); // Mono 블로킹. + @DisplayName("태그 빈도 수 추출 테스트") + void DataSourceExtractTagsTest(){ + Member member = memberService.findByProviderKey("newsServiceTestKey"); + List folderResponses = folderService.getFoldersForPersonal(member.getId()); + List frequency = newsService.getTagFrequencyFromFiles(member.getId(), folderResponses.get(0).folderId()); + + assertEquals("E", frequency.get(0)); + assertEquals("B", frequency.get(1)); + assertEquals("F", frequency.get(2)); } -} \ No newline at end of file +} From 79c2b3cde0fac63806bcc530ae762df10bcecba1 Mon Sep 17 00:00:00 2001 From: osh5030 <72571931+ohsoohyuk@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:09:21 +0900 Subject: [PATCH 047/132] =?UTF-8?q?feat/OPS-265=20:=20LLM-=ED=99=9C?= =?UTF-8?q?=EC=9A=A9-=EB=8D=B0=EC=9D=B4=ED=84=B0-=EC=A0=95=EC=A0=9C?= =?UTF-8?q?=ED=99=94-=EC=8B=9C-=EA=B8=B0=EC=A1=B4-=EC=95=84=EC=B9=B4?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EC=9D=98-=ED=83=9C=EA=B7=B8-=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../datasource/ai/service/AiService.java | 18 ++---- .../controller/CrawlerTestController.java | 6 +- .../service/DataProcessorService.java | 6 +- .../datasource/repository/TagRepository.java | 5 -- .../backend/global/initData/BaseInitData.java | 14 ----- .../datasource/service/AiServiceTest.java | 62 +++++++++++++++++++ 6 files changed, 77 insertions(+), 34 deletions(-) create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiServiceTest.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java index 7c3a820b..1ff5f460 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java @@ -6,11 +6,10 @@ import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AiExtractorDto; import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AnalyzeContentDto; import org.tuna.zoopzoop.backend.domain.datasource.ai.prompt.AiPrompt; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; -import java.util.HashSet; import java.util.List; -import java.util.Set; import java.util.stream.Collectors; @Service @@ -28,17 +27,12 @@ public AiExtractorDto extract(String rawHtml) { return response; } - public AnalyzeContentDto analyzeContent(String content) { - // 모든 태그 가져오기 - List allTags = tagRepository.findAllTagNames(); - - // 중복 제거 (Set → 다시 List or String) - Set uniqueTags = new HashSet<>(allTags); - + public AnalyzeContentDto analyzeContent(String content, List tagList) { // JSON 배열 문자열로 변환 - String tags = uniqueTags.stream() - .map(tag -> "\"" + tag + "\"") // "tagName" - .collect(Collectors.joining(", ", "[", "]")); // ["tag1", "tag2"] + String tags = tagList.stream() + .map(Tag::getTagName) // 태그명만 추출 + .map(tagName -> "\"" + tagName + "\"") + .collect(Collectors.joining(", ", "[", "]")); AnalyzeContentDto response = chatClient.prompt() .user(AiPrompt.SUMMARY_TAG_CATEGORY.formatted(content, tags)) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java index a9088769..e6c28f50 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/controller/CrawlerTestController.java @@ -8,6 +8,9 @@ import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.CrawlerManagerService; import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService; import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; + +import java.util.ArrayList; @RestController @RequestMapping("api/v1") @@ -15,9 +18,10 @@ public class CrawlerTestController { private final CrawlerManagerService crawlerManagerService; private final DataProcessorService dataProcessorService; + private final TagRepository tagRepository; @GetMapping("/crawl") public DataSourceDto crawl(@RequestParam String url) throws Exception { - return dataProcessorService.process(url); + return dataProcessorService.process(url, new ArrayList<>()); } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java index b9354a6d..864f8c03 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dataprocessor/service/DataProcessorService.java @@ -11,8 +11,10 @@ import org.tuna.zoopzoop.backend.domain.datasource.crawler.service.CrawlerManagerService; import org.tuna.zoopzoop.backend.domain.datasource.dto.ArticleData; import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import java.io.IOException; +import java.util.List; @Service @RequiredArgsConstructor @@ -20,7 +22,7 @@ public class DataProcessorService { public final CrawlerManagerService crawlerManagerService; public final AiService aiService; - public DataSourceDto process(String url) throws IOException { + public DataSourceDto process(String url, List tagList) throws IOException { CrawlerResult result = crawlerManagerService.extractContent(url); ArticleData articleData = switch (result.type()) { @@ -47,7 +49,7 @@ yield new ArticleData( } }; - AnalyzeContentDto analyzeContentDto = aiService.analyzeContent(articleData.content()); + AnalyzeContentDto analyzeContentDto = aiService.analyzeContent(articleData.content(), tagList); return new DataSourceDto( articleData.title(), diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java index 72a88027..24cadb08 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java @@ -1,14 +1,9 @@ package org.tuna.zoopzoop.backend.domain.datasource.repository; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; -import java.util.List; - @Repository public interface TagRepository extends JpaRepository { - @Query("select t.tagName from Tag t") - List findAllTagNames(); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java index 8e4e08a8..df82e460 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/initData/BaseInitData.java @@ -8,9 +8,7 @@ import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; import org.springframework.transaction.annotation.Transactional; -import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; -import org.tuna.zoopzoop.backend.domain.datasource.ai.service.AiService; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository; @@ -40,20 +38,8 @@ public void initalizeData() { } - private final AiService aiService; - @Transactional public void initTagData() { - if (tagRepository.count() > 0) { - return; - } - - Tag tag1 = new Tag(null,"IT"); - Tag tag2 = new Tag(null, "자기소개"); - Tag tag3 = new Tag(null, "이름"); - tagRepository.save(tag1); - tagRepository.save(tag2); - tagRepository.save(tag3); } } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiServiceTest.java new file mode 100644 index 00000000..ebefee04 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiServiceTest.java @@ -0,0 +1,62 @@ +package org.tuna.zoopzoop.backend.domain.datasource.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.client.ChatClient; +import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AnalyzeContentDto; +import org.tuna.zoopzoop.backend.domain.datasource.ai.service.AiService; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AiServiceTest { + @Mock + private ChatClient chatClient; + + @Mock + private ChatClient.ChatClientRequestSpec requestSpec; + + @Mock + private ChatClient.CallResponseSpec responseSpec; + + + @InjectMocks + private AiService aiService; + + @Test + void analyzeContent_ShouldReturnMockedResponse() { + // given + String content = "테스트 본문"; + List tagList = List.of(new Tag("Java"), new Tag("Spring")); + + AnalyzeContentDto mockResponse = new AnalyzeContentDto( + "요약", + Category.IT, + List.of("Java", "Spring") + ); + + // 체인 mock 세팅 + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.user(anyString())).thenReturn(requestSpec); + when(requestSpec.call()).thenReturn(responseSpec); + when(responseSpec.entity(AnalyzeContentDto.class)).thenReturn(mockResponse); + + // when + AnalyzeContentDto result = aiService.analyzeContent(content, tagList); + + // then + assertThat(result).isNotNull(); + assertThat(result.summary()).isEqualTo("요약"); + assertThat(result.tags()).containsExactly("Java", "Spring"); + assertThat(result.category()).isEqualTo(Category.IT); + } +} \ No newline at end of file From cd43e9aece4b61fc7b3d96e933f52c8b3622ed7e Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:28:08 +0900 Subject: [PATCH 048/132] =?UTF-8?q?refactor/OPS-319:=20=EC=95=84=EC=B9=B4?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-255 : datasource 테이블 sources 칼럼 추가 * refactor/OPS-319 : 아카이브 로그인 연동 * refactor/OPS-319 : 아카이브 로그인 연동 --- .../folder/controller/FolderController.java | 52 +-- .../folder/repository/FolderRepository.java | 13 + .../archive/folder/service/FolderService.java | 19 +- .../controller/DatasourceController.java | 49 ++- .../domain/datasource/dto/FileSummary.java | 7 +- .../repository/DataSourceRepository.java | 27 +- .../datasource/service/DataSourceService.java | 94 ++--- .../backend/global/security/StubAuthUtil.java | 30 +- .../controller/FolderControllerTest.java | 282 ++++++++------- .../folder/service/FolderServiceTest.java | 75 ++-- .../controller/DatasourceControllerTest.java | 332 +++++++++++------- .../service/DataSourceServiceTest.java | 155 ++++---- 12 files changed, 627 insertions(+), 508 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java index fe70cc3c..1897bf3b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderController.java @@ -3,14 +3,16 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.reqBodyForCreateFolder; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.resBodyForCreateFolder; import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.global.rsData.RsData; -import org.tuna.zoopzoop.backend.global.security.StubAuthUtil; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; import java.util.HashMap; import java.util.List; @@ -28,13 +30,13 @@ public class FolderController { * @param rq reqBodyForCreateFolder * @return resBodyForCreateFolder */ - @PostMapping("") + @PostMapping public RsData createFolder( - @Valid @RequestBody reqBodyForCreateFolder rq + @Valid @RequestBody reqBodyForCreateFolder rq, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - // 임시 인증 정보 - Integer currentMemberId = StubAuthUtil.currentMemberId(); - FolderResponse createFile = folderService.createFolderForPersonal(currentMemberId, rq.folderName()); + Member member = userDetails.getMember(); + FolderResponse createFile = folderService.createFolderForPersonal(member.getId(), rq.folderName()); resBodyForCreateFolder rs = new resBodyForCreateFolder(createFile.folderName(), createFile.folderId()); @@ -43,7 +45,6 @@ public RsData createFolder( rq.folderName() + " 폴더가 생성됐습니다.", rs ); - } /** @@ -51,8 +52,12 @@ public RsData createFolder( * @param folderId 삭제할 folderId */ @DeleteMapping("/{folderId}") - public ResponseEntity> deleteFolder(@PathVariable Integer folderId) { - String deletedFolderName = folderService.deleteFolder(folderId); + public ResponseEntity> deleteFolder( + @PathVariable Integer folderId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + String deletedFolderName = folderService.deleteFolder(member.getId(), folderId); Map body = new HashMap<>(); body.put("status", 200); @@ -70,10 +75,12 @@ public ResponseEntity> deleteFolder(@PathVariable Integer fo @PatchMapping("/{folderId}") public ResponseEntity> updateFolderName( @PathVariable Integer folderId, - @RequestBody Map body + @RequestBody Map body, + @AuthenticationPrincipal CustomUserDetails userDetails ) { + Member member = userDetails.getMember(); String newName = body.get("folderName"); - String updatedName = folderService.updateFolderName(folderId, newName); + String updatedName = folderService.updateFolderName(member.getId(), folderId, newName); Map response = new HashMap<>(); response.put("status", 200); @@ -87,13 +94,12 @@ public ResponseEntity> updateFolderName( * 개인 아카이브의 폴더 이름 전부 조회 * "default", "폴더1", "폴더2" */ - @GetMapping("") - public ResponseEntity getFolders() { - // 로그인된 멤버 ID 가져오기 - Integer currentMemberId = StubAuthUtil.currentMemberId(); - - // 내 personal archive 안의 폴더 조회 - List folders = folderService.getFoldersForPersonal(currentMemberId); + @GetMapping + public ResponseEntity getFolders( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + List folders = folderService.getFoldersForPersonal(member.getId()); return ResponseEntity.ok( Map.of( @@ -108,10 +114,12 @@ public ResponseEntity getFolders() { * 폴더(내 PersonalArchive 소속) 안의 파일 목록 조회 */ @GetMapping("/{folderId}/files") - public ResponseEntity getFilesInFolder(@PathVariable Integer folderId) { - Integer currentMemberId = StubAuthUtil.currentMemberId(); - - FolderFilesDto rs = folderService.getFilesInFolderForPersonal(currentMemberId, folderId); + public ResponseEntity getFilesInFolder( + @PathVariable Integer folderId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + FolderFilesDto rs = folderService.getFilesInFolderForPersonal(member.getId(), folderId); return ResponseEntity.ok( Map.of( diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java index cd3b30b6..9f5048c1 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java @@ -2,6 +2,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; @@ -45,4 +46,16 @@ public interface FolderRepository extends JpaRepository{ and f.isDefault = true """) Optional findDefaultFolderByMemberId(Integer memberId); + + // 한 번의 조인으로 존재 + 소유권(memberId) 검증 + @Query(""" + select f + from Folder f + join f.archive a + join PersonalArchive pa on pa.archive = a + where f.id = :folderId + and pa.member.id = :memberId + """) + Optional findByIdAndMemberId(@Param("folderId") Integer folderId, + @Param("memberId") Integer memberId); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java index 849ace79..09c8aebe 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderService.java @@ -38,9 +38,8 @@ public class FolderService { */ @Transactional public FolderResponse createFolderForPersonal(Integer currentMemberId, String folderName) { - if (folderName == null || folderName.trim().isEmpty()) { + if (folderName == null || folderName.trim().isEmpty()) throw new IllegalArgumentException("폴더 이름은 비어 있을 수 없습니다."); - } Member member = memberRepository.findById(currentMemberId) .orElseThrow(() -> new IllegalArgumentException("멤버를 찾을 수 없습니다.")); @@ -115,8 +114,9 @@ private static String pickNextAvailable(String file, List existing) { * soft delete 아직 구현 X */ @Transactional - public String deleteFolder(Integer folderId) { - Folder folder = folderRepository.findById(folderId) + public String deleteFolder(Integer currentId, Integer folderId) { + // 공격자에게 리소스 존재 여부를 노출 X (존재하지 않음 / 남의 폴더) + Folder folder = folderRepository.findByIdAndMemberId(folderId, currentId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); if (folder.isDefault()) @@ -131,8 +131,8 @@ public String deleteFolder(Integer folderId) { * folderId에 해당하는 이름 변경 */ @Transactional - public String updateFolderName(Integer folderId, String newName) { - Folder folder = folderRepository.findById(folderId) + public String updateFolderName(Integer currentId, Integer folderId, String newName) { + Folder folder = folderRepository.findByIdAndMemberId(folderId, currentId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); // 같은 아카이브 내에서 중복 폴더 이름 확인 @@ -173,18 +173,19 @@ public List getFoldersForPersonal(Integer memberId) { */ @Transactional(readOnly = true) public FolderFilesDto getFilesInFolderForPersonal(Integer memberId, Integer folderId) { - Folder folder = folderRepository.findById(folderId) + Folder folder = folderRepository.findByIdAndMemberId(folderId, memberId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); var files = dataSourceRepository.findAllByFolder(folder).stream() .map(ds -> new FileSummary( ds.getId(), ds.getTitle(), - ds.getCreateDate(), // LocalDateTime + ds.getDataCreatedDate(), // LocalDate ds.getSummary(), ds.getSourceUrl(), ds.getImageUrl(), - ds.getTags() == null ? List.of() : ds.getTags() + ds.getTags() == null ? List.of() : ds.getTags(), + ds.getCategory() == null ? null : ds.getCategory().toString() )) .toList(); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java index a7d3296a..66bc6ff6 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java @@ -3,10 +3,12 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.tuna.zoopzoop.backend.domain.datasource.dto.*; import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; -import org.tuna.zoopzoop.backend.global.security.StubAuthUtil; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; import java.util.HashMap; import java.util.Map; @@ -24,10 +26,14 @@ public class DatasourceController { * folderId 등록될 폴더 위치(null 이면 default) */ @PostMapping("") - public ResponseEntity createDataSource(@Valid @RequestBody reqBodyForCreateDataSource rq) { + public ResponseEntity createDataSource( + @Valid @RequestBody reqBodyForCreateDataSource rq, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + // 로그인된 멤버 Id 사용 + Member member = userDetails.getMember(); + Integer currentMemberId = member.getId(); - //임시 인증 정보 - Integer currentMemberId = StubAuthUtil.currentMemberId(); int rs = dataSourceService.createDataSource(currentMemberId, rq.sourceUrl(), rq.folderId()); return ResponseEntity.ok() .body( @@ -39,8 +45,12 @@ public ResponseEntity createDataSource(@Valid @RequestBody reqBodyForCreateDa * 자료 단건 삭제 */ @DeleteMapping("/{dataSourceId}") - public ResponseEntity> delete(@PathVariable Integer dataSourceId) { - int deletedId = dataSourceService.deleteById(dataSourceId); + public ResponseEntity> delete( + @PathVariable Integer dataSourceId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + int deletedId = dataSourceService.deleteById(member.getId(), dataSourceId); return ResponseEntity.ok( Map.of( "status", 200, @@ -55,11 +65,12 @@ public ResponseEntity> delete(@PathVariable Integer dataSour */ @PostMapping("/delete") public ResponseEntity> deleteMany( - @Valid @RequestBody reqBodyForDeleteMany body + @Valid @RequestBody reqBodyForDeleteMany body, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - dataSourceService.deleteMany(body.dataSourceId()); + Member member = userDetails.getMember(); + dataSourceService.deleteMany(member.getId(), body.dataSourceId()); - // Map.of 는 null 불가 → LinkedHashMap 사용 Map res = new java.util.LinkedHashMap<>(); res.put("status", 200); res.put("msg", "복수개의 자료가 삭제됐습니다."); @@ -75,9 +86,11 @@ public ResponseEntity> deleteMany( @PatchMapping("/{dataSourceId}/move") public ResponseEntity moveDataSource( @PathVariable Integer dataSourceId, - @Valid @RequestBody reqBodyForMoveDataSource rq + @Valid @RequestBody reqBodyForMoveDataSource rq, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - Integer currentMemberId = StubAuthUtil.currentMemberId(); + Member member = userDetails.getMember(); + Integer currentMemberId = member.getId(); DataSourceService.MoveResult result = dataSourceService.moveDataSource(currentMemberId, dataSourceId, rq.folderId()); @@ -101,8 +114,12 @@ public ResponseEntity moveDataSource( * 자료 다건 이동 */ @PatchMapping("/move") - public ResponseEntity moveMany(@Valid @RequestBody reqBodyForMoveMany rq) { - Integer currentMemberId = StubAuthUtil.currentMemberId(); + public ResponseEntity moveMany( + @Valid @RequestBody reqBodyForMoveMany rq, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Member member = userDetails.getMember(); + Integer currentMemberId = member.getId(); dataSourceService.moveDataSources(currentMemberId, rq.folderId(), rq.dataSourceId()); @@ -122,7 +139,8 @@ public ResponseEntity moveMany(@Valid @RequestBody reqBodyForMoveMany rq) { @PatchMapping("/{dataSourceId}") public ResponseEntity updateDataSource( @PathVariable Integer dataSourceId, - @Valid @RequestBody reqBodyForUpdateDataSource body + @Valid @RequestBody reqBodyForUpdateDataSource body, + @AuthenticationPrincipal CustomUserDetails userDetails ) { // title, summary 둘 다 비어있으면 의미 없는 요청 → 400 boolean noTitle = (body.title() == null || body.title().isBlank()); @@ -131,7 +149,8 @@ public ResponseEntity updateDataSource( throw new IllegalArgumentException("변경할 값이 없습니다. title 또는 summary 중 하나 이상을 전달하세요."); } - Integer updatedId = dataSourceService.updateDataSource(dataSourceId, body.title(), body.summary()); + Member member = userDetails.getMember(); + Integer updatedId = dataSourceService.updateDataSource(member.getId(), dataSourceId, body.title(), body.summary()); // CHANGED String msg = updatedId + "번 자료가 수정됐습니다."; return ResponseEntity.ok( new ApiResponse<>(200, msg, new resBodyForUpdateDataSource(updatedId)) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java index 1efc5084..66c9857a 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/dto/FileSummary.java @@ -2,15 +2,16 @@ import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; public record FileSummary( Integer dataSourceId, String title, - LocalDateTime createdAt, + LocalDate createdAt, String summary, String sourceUrl, String imageUrl, - List tags + List tags, + String category ) {} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java index 2a4156b6..fcbf8a65 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java @@ -9,14 +9,35 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; @Repository public interface DataSourceRepository extends JpaRepository { List findAllByFolder(Folder folder); - @Query("select d.id from DataSource d where d.id in ?1") - java.util.List findExistingIds(Collection ids); - List findAllByIdIn(Collection ids); + + // CHANGED: 특정 멤버(개인 아카이브 소유자) 범위에서 id로 조회 (ownership check) + @Query(""" + select d from DataSource d + join d.folder f + join f.archive a + join PersonalArchive pa on pa.archive = a + where d.id = :id + and pa.member.id = :memberId + """) + Optional findByIdAndMemberId(@Param("id") Integer id, @Param("memberId") Integer memberId); + + // CHANGED: 여러 id 중에서 해당 member 소유인 id만 반환 (다건 삭제/검증용) + @Query(""" + select d.id from DataSource d + join d.folder f + join f.archive a + join PersonalArchive pa on pa.archive = a + where pa.member.id = :memberId + and d.id in :ids + """) + List findExistingIdsInMember(@Param("memberId") Integer memberId, @Param("ids") Collection ids); + } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java index 796079eb..3e6ba728 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java @@ -8,6 +8,7 @@ import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; @@ -24,32 +25,22 @@ public class DataSourceService { /** * 지정한 folder 위치에 자료 생성 - * @param currentMemberId 현재 로그인한 유저 Id - * @param sourceUrl 생성할 자료의 url - * @param folderId 생성될 폴더 위치 Id */ @Transactional public int createDataSource(int currentMemberId, String sourceUrl, Integer folderId) { Folder folder; - // default 폴더에 데이터 넣을 경우 if(folderId == null) folder = findDefaultFolder(currentMemberId); - // Id에 해당하는 폴더에 데이터 넣을 경우 else folder = folderRepository.findById(folderId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); - // 임시 파일 생성 메서드 DataSource ds = buildDataSource(sourceUrl, folder); DataSource saved = dataSourceRepository.save(ds); return saved.getId(); } - /** - * 임시 data build 메서드 - * 추후 title,summary, tag, category, imgUrl 불러올 예정 - */ private DataSource buildDataSource(String sourceUrl, Folder folder) { DataSource ds = new DataSource(); ds.setFolder(folder); @@ -59,59 +50,49 @@ private DataSource buildDataSource(String sourceUrl, Folder folder) { ds.setSummary("설명"); ds.setImageUrl("www.example.com/img"); ds.setDataCreatedDate(LocalDate.now()); + ds.setCategory(Category.IT); ds.setActive(true); return ds; } - /** - * default 폴더에 해당하는 FolderId 반환 - * folder의 isDefault 속성 + 인덱스(archiveId)로 탐색 - */ private Folder findDefaultFolder(int currentMemberId) { - // 현재 로그인 Id 기반 Personal Archive Id 탐색 PersonalArchive pa = personalArchiveRepository.findByMemberId(currentMemberId) .orElseThrow(() -> new NoResultException("개인 아카이브를 찾을 수 없습니다.")); - // 2. PersonalArchive 안에 연결된 Archive 조회 Integer archiveId = pa.getArchive().getId(); - // 3. 해당 Archive 내 default 폴더 조회 return folderRepository.findByArchiveIdAndIsDefaultTrue(archiveId) .orElseThrow(() -> new NoResultException("default 폴더를 찾을 수 없습니다.")); } /** * 자료 단건 삭제 - * soft delete 추후 구현 예정 - * @param dataSourceId 삭제할 자료 Id */ @Transactional - public int deleteById(Integer dataSourceId) { - DataSource ds = dataSourceRepository.findById(dataSourceId) + public int deleteById(Integer memberId, Integer dataSourceId) { + // member 범위에서 자료를 조회하여 소유 확인 + DataSource ds = dataSourceRepository.findByIdAndMemberId(dataSourceId, memberId) .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); - /* 추후 권한 체크 예외 필요 */ - dataSourceRepository.delete(ds); return dataSourceId; } /** * 자료 다건 삭제 - * 모든 자료 id가 존재해야 함 (부분 존재 시 404) */ @Transactional - public void deleteMany(List ids) { + public void deleteMany(Integer memberId, List ids) { if (ids == null || ids.isEmpty()) { throw new IllegalArgumentException("삭제할 자료 id 배열이 비어있습니다."); } - // 존재 여부 검증 (부분 존재 시 누락 ID 명시) - List existing = dataSourceRepository.findExistingIds(ids); + // 해당 멤버가 소유한 id만 조회 + List existing = dataSourceRepository.findExistingIdsInMember(memberId, ids); if (existing.size() != ids.size()) { Set missing = new HashSet<>(ids); missing.removeAll(new HashSet<>(existing)); - throw new NoResultException("존재하지 않는 자료 ID 포함: " + missing); + throw new NoResultException("존재하지 않거나 소유자가 다른 자료 ID 포함: " + missing); } dataSourceRepository.deleteAllByIdInBatch(ids); @@ -123,37 +104,24 @@ public void deleteMany(List ids) { @Transactional public MoveResult moveDataSource(Integer currentMemberId, Integer dataSourceId, Integer targetFolderId) { - // 자료 확인 - DataSource ds = dataSourceRepository.findById(dataSourceId) + DataSource ds = dataSourceRepository.findByIdAndMemberId(dataSourceId, currentMemberId) .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); Folder targetFolder = resolveTargetFolder(currentMemberId, targetFolderId); - // 동일 폴더로 이동 요청 -> 통과 if (ds.getFolder().getId() == targetFolder.getId()) return new MoveResult(ds.getId(), targetFolder.getId()); - // 목적지 폴더 내 파일명 중복 확인 -// if (dataSourceRepository.existsByFolder_IdAndTitle(targetFolderId, ds.getTitle())) -// throw new IllegalStateException("해당 폴더에 동일한 제목의 자료가 이미 존재합니다."); - ds.setFolder(targetFolder); return new MoveResult(ds.getId(), targetFolder.getId()); } - - - /** - * 자료 위치 다건 이동 - */ @Transactional public void moveDataSources(Integer currentMemberId, Integer targetFolderId, List dataSourceIds) { - // 1) 요소 null 검증 (서비스 방어) if (dataSourceIds.stream().anyMatch(Objects::isNull)) throw new IllegalArgumentException("자료 id 목록에 null이 포함되어 있습니다."); - // 자료 Id 중복 확인 Map counts = dataSourceIds.stream() .collect(Collectors.groupingBy(id -> id, Collectors.counting())); List duplicates = counts.entrySet().stream() @@ -165,49 +133,30 @@ public void moveDataSources(Integer currentMemberId, Integer targetFolderId, Lis throw new IllegalArgumentException("같은 자료를 두 번 선택했습니다: " + duplicates); } - // 목적지 폴더 확인 Folder targetFolder = resolveTargetFolder(currentMemberId, targetFolderId); - // 목록의 각 자료 확인 + // 소유 검증: 요청된 id들이 모두 현재 멤버의 소유인지 확인 + List existing = dataSourceRepository.findExistingIdsInMember(currentMemberId, dataSourceIds); + if (existing.size() != dataSourceIds.size()) { + Set missing = new HashSet<>(dataSourceIds); + missing.removeAll(new HashSet<>(existing)); + throw new NoResultException("존재하지 않거나 소유자가 다른 자료 ID 포함: " + missing); + } + List list = dataSourceRepository.findAllByIdIn(dataSourceIds); if (list.size() != dataSourceIds.size()) throw new NoResultException("요청한 자료 중 존재하지 않는 항목이 있습니다."); - // 목적지 폴더 추출 List needMove = list.stream() .filter(ds -> !Objects.equals(ds.getFolder().getId(), targetFolder.getId())) .toList(); - // 이미 모두 이동한 경우 if (needMove.isEmpty()) return; - // 같은 이름의 자료 여러 개 이동 시 충돌 - /* - Map reqTitleCount = needMove.stream() - .collect(Collectors.groupingBy(DataSource::getTitle, Collectors.counting())); - List internalDup = reqTitleCount.entrySet().stream() - .filter(e -> e.getValue() > 1) - .map(Map.Entry::getKey) - .toList(); - if (!internalDup.isEmpty()) { - throw new IllegalStateException("요청 목록 내부에 중복 제목이 포함되어 있습니다: " + internalDup); - } - - 이동할 폴더에 이미 같은 제목이 존재하는지 확인 - List titles = needMove.stream().map(DataSource::getTitle).toList(); - List conflicts = titles.isEmpty() - ? List.of() - : dataSourceRepository.findExistingTitlesInFolder(targetFolderId, titles); - - if (!conflicts.isEmpty()) { - throw new IllegalStateException("대상 폴더에 이미 존재하는 제목이 있어 이동할 수 없습니다: " + conflicts); - } - */ needMove.forEach(ds -> ds.setFolder(targetFolder)); } - // 대상 폴더 해석 private Folder resolveTargetFolder(Integer currentMemberId, Integer targetFolderId) { if (targetFolderId == null) { return folderRepository.findDefaultFolderByMemberId(currentMemberId) @@ -217,8 +166,11 @@ private Folder resolveTargetFolder(Integer currentMemberId, Integer targetFolder .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); } - public Integer updateDataSource(Integer dataSourceId, String newTitle, String newSummary) { - DataSource ds = dataSourceRepository.findById(dataSourceId) + /** + * 자료 수정 + */ + public Integer updateDataSource(Integer memberId, Integer dataSourceId, String newTitle, String newSummary) { + DataSource ds = dataSourceRepository.findByIdAndMemberId(dataSourceId, memberId) .orElseThrow(() -> new NoResultException("존재하지 않는 자료입니다.")); if (newTitle != null && !newTitle.isBlank()) diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java index 2135ee69..787d2509 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/StubAuthUtil.java @@ -1,15 +1,15 @@ -package org.tuna.zoopzoop.backend.global.security; - -/** - * Spring Securiity 구현 전 임시 헬퍼 클래스 - * 추후 Spring Security 연동시 SecurityContext에서 불러오도록 수정 - */ - -public final class StubAuthUtil { - private StubAuthUtil() {} - - public static Integer currentMemberId() { - return 1; - } - -} +//package org.tuna.zoopzoop.backend.global.security; +// +///** +// * Spring Securiity 구현 전 임시 헬퍼 클래스 +// * 추후 Spring Security 연동시 SecurityContext에서 불러오도록 수정 +// */ +// +//public final class StubAuthUtil { +// private StubAuthUtil() {} +// +// public static Integer currentMemberId() { +// return 1; +// } +// +//} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java index f09e2569..95fa1504 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java @@ -1,114 +1,169 @@ package org.tuna.zoopzoop.backend.domain.archive.folder.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.NoResultException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; import org.tuna.zoopzoop.backend.domain.archive.folder.dto.reqBodyForCreateFolder; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; -import org.tuna.zoopzoop.backend.domain.datasource.dto.FileSummary; -import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; -import org.tuna.zoopzoop.backend.global.exception.GlobalExceptionHandler; -import org.tuna.zoopzoop.backend.global.security.StubAuthUtil; +import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; -import java.util.HashMap; +import java.time.LocalDate; import java.util.List; -import java.util.Map; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.when; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -@ExtendWith(MockitoExtension.class) -@Transactional +/** + * FolderController 통합 테스트 (Given / When / Then 주석 유지) + * + * - @SpringBootTest + @AutoConfigureMockMvc 로 전체 컨텍스트에서 테스트 + * - @WithUserDetails 를 사용해 인증 principal 을 주입 + * - 테스트용 멤버는 BeforeAll에서 생성 (UserDetailsService 가 해당 username 으로 로드 가능해야 함) + */ @ActiveProfiles("test") -class FolderControllerTest { +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FolderControllerIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @Autowired private MemberService memberService; + @Autowired private MemberRepository memberRepository; + + @Autowired private FolderService folderService; + @Autowired private FolderRepository folderRepository; + + @Autowired private DataSourceRepository dataSourceRepository; - @Mock private FolderService folderService; + private final String TEST_PROVIDER_KEY = "sc1111"; // WithUserDetails 에서 사용되는 provider key ("KAKAO:sc1111") + private Integer testMemberId; + private Integer docsFolderId; - private MockMvc mockMvc; - private ObjectMapper objectMapper; + @BeforeAll + void beforeAll() { + // WithUserDetails가 SecurityContext 생성 시 DB에서 사용자를 조회하므로 미리 생성 + try { + memberService.createMember("folderTester", "http://example.com/profile.png", TEST_PROVIDER_KEY, Provider.KAKAO); + } catch (Exception ignored) {} + + // 준비된 멤버 ID + testMemberId = memberRepository.findByProviderAndProviderKey(Provider.KAKAO, TEST_PROVIDER_KEY) + .map(m -> m.getId()) + .orElseThrow(); + + // GIVEN: 테스트용 폴더 및 샘플 자료 준비 (docs 폴더 + 2개 자료) + FolderResponse fr = folderService.createFolderForPersonal(testMemberId, "docs"); + docsFolderId = fr.folderId(); + + Folder docsFolder = folderRepository.findById(docsFolderId).orElseThrow(); + + // 자료 2건 생성 — **category는 NOT NULL enum** 이므로 반드시 설정 + DataSource d1 = new DataSource(); + d1.setFolder(docsFolder); + d1.setTitle("spec.pdf"); + d1.setSummary("요약 A"); + d1.setSourceUrl("http://src/a"); + d1.setImageUrl("http://img/a"); + d1.setDataCreatedDate(LocalDate.now()); + d1.setActive(true); + d1.setTags(List.of(new Tag("tag1"), new Tag("tag2"))); + d1.setCategory(Category.IT); // enum 타입 반영 + dataSourceRepository.save(d1); + + DataSource d2 = new DataSource(); + d2.setFolder(docsFolder); + d2.setTitle("notes.txt"); + d2.setSummary("요약 B"); + d2.setSourceUrl("http://src/b"); + d2.setImageUrl("http://img/b"); + d2.setDataCreatedDate(LocalDate.now()); + d2.setActive(true); + d2.setTags(List.of()); + d2.setCategory(Category.SCIENCE); + dataSourceRepository.save(d2); + } - @BeforeEach - void setUp() { - FolderController controller = new FolderController(folderService); - mockMvc = MockMvcBuilders.standaloneSetup(controller) - .setControllerAdvice(new GlobalExceptionHandler()) - .build(); - objectMapper = new ObjectMapper(); + @AfterAll + void afterAll() { + // 테스트용 회원 삭제 (cascade에 따라 연결된 엔티티 정리) + memberRepository.findByProviderAndProviderKey(Provider.KAKAO, TEST_PROVIDER_KEY) + .ifPresent(memberRepository::delete); } // CreateFile @Test @DisplayName("개인 아카이브 폴더 생성 - 성공 시 200과 응답 DTO 반환") + @WithUserDetails("KAKAO:sc1111") void createFolder_ok() throws Exception { - // given - when(folderService.createFolderForPersonal(anyInt(), eq("보고서"))) - .thenReturn(new FolderResponse(123, "보고서")); + // Given var req = new reqBodyForCreateFolder("보고서"); - // when & then + // When & Then mockMvc.perform(post("/api/v1/archive/folder") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("200")) .andExpect(jsonPath("$.msg").value("보고서 폴더가 생성됐습니다.")) - .andExpect(jsonPath("$.data.folderId").value(123)) + .andExpect(jsonPath("$.data.folderId").isNumber()) .andExpect(jsonPath("$.data.folderName").value("보고서")); } @Test @DisplayName("개인 아카이브 폴더 생성 - 폴더 이름 누락 시 400") + @WithUserDetails("KAKAO:sc1111") void createFolder_missingName() throws Exception { - // given + // Given var req = new reqBodyForCreateFolder(null); - // when & then + // When & Then mockMvc.perform(post("/api/v1/archive/folder") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isBadRequest()); } - // DeleteFile @Test @DisplayName("개인 아카이브 폴더 삭제 - 성공 시 200과 삭제 메시지 반환") + @WithUserDetails("KAKAO:sc1111") void deleteFolder_ok() throws Exception { - // given - when(folderService.deleteFolder(7)).thenReturn("보고서"); + // Given: 새 폴더 생성 후 삭제 준비 + FolderResponse fr = folderService.createFolderForPersonal(testMemberId, "todelete"); + Integer idToDelete = fr.folderId(); - // when & then - mockMvc.perform(delete("/api/v1/archive/folder/{folderId}", 7)) + // When & Then + mockMvc.perform(delete("/api/v1/archive/folder/{folderId}", idToDelete)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.msg").value("보고서 폴더가 삭제됐습니다.")); + .andExpect(jsonPath("$.msg").value("todelete 폴더가 삭제됐습니다.")); } @Test @DisplayName("개인 아카이브 폴더 삭제 - 존재하지 않으면 404") + @WithUserDetails("KAKAO:sc1111") void deleteFolder_notFound() throws Exception { - // given - when(folderService.deleteFolder(404)) - .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); - - // when & then - mockMvc.perform(delete("/api/v1/archive/folder/{folderId}", 404)) + // When & Then + mockMvc.perform(delete("/api/v1/archive/folder/{folderId}", 999999)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value("404")) .andExpect(jsonPath("$.msg").value("존재하지 않는 폴더입니다.")); @@ -117,15 +172,17 @@ void deleteFolder_notFound() throws Exception { // UpdateFile @Test @DisplayName("개인 아카이브 폴더 이름 변경 - 성공 시 200과 변경된 이름 반환") + @WithUserDetails("KAKAO:sc1111") void updateFolder_ok() throws Exception { - // given - when(folderService.updateFolderName(10, "회의록")).thenReturn("회의록"); + // Given: rename 대상 폴더 생성 + FolderResponse fr = folderService.createFolderForPersonal(testMemberId, "toRename"); + Integer id = fr.folderId(); - Map body = new HashMap<>(); - body.put("folderName", "회의록"); + var body = new java.util.HashMap(); + body.put("folderName","회의록"); - // when & then - mockMvc.perform(patch("/api/v1/archive/folder/{folderId}", 10) + // When & Then + mockMvc.perform(patch("/api/v1/archive/folder/{folderId}", id) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(body))) .andExpect(status().isOk()) @@ -136,17 +193,14 @@ void updateFolder_ok() throws Exception { @Test @DisplayName("개인 아카이브 폴더 이름 변경 - 존재하지 않는 폴더면 404") + @WithUserDetails("KAKAO:sc1111") void updateFolder_notFound() throws Exception { - // given - when(folderService.updateFolderName(99, "회의록")) - .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); - - - Map body = new HashMap<>(); - body.put("folderName", "회의록"); + // Given + var body = new java.util.HashMap(); + body.put("folderName","회의록"); - // when & then - mockMvc.perform(patch("/api/v1/archive/folder/{folderId}", 99) + // When & Then + mockMvc.perform(patch("/api/v1/archive/folder/{folderId}", 999999) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(body))) .andExpect(status().isNotFound()) @@ -158,82 +212,48 @@ void updateFolder_notFound() throws Exception { // Read: 내 폴더 목록 @Test @DisplayName("개인 아카이브 폴더 목록 조회 - 성공") + @WithUserDetails("KAKAO:sc1111") void getFolders_success() throws Exception { - List folders = List.of( - new FolderResponse(1, "default"), - new FolderResponse(2, "docs") - ); - - try (MockedStatic mocked = mockStatic(StubAuthUtil.class)) { - mocked.when(StubAuthUtil::currentMemberId).thenReturn(100); - when(folderService.getFoldersForPersonal(100)).thenReturn(folders); - - mockMvc.perform(get("/api/v1/archive/folder") - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.msg").value("개인 아카이브의 폴더 목록을 불러왔습니다.")) - .andExpect(jsonPath("$.data.folders.length()").value(2)) - .andExpect(jsonPath("$.data.folders[0].folderId").value(1)) - .andExpect(jsonPath("$.data.folders[0].folderName").value("default")) - .andExpect(jsonPath("$.data.folders[1].folderName").value("docs")); - } + // When & Then + mockMvc.perform(get("/api/v1/archive/folder") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("개인 아카이브의 폴더 목록을 불러왔습니다.")) + .andExpect(jsonPath("$.data.folders").isArray()); } // Read: 폴더 내 파일 목록 @Test @DisplayName("폴더 내 파일 목록 조회 - 성공") + @WithUserDetails("KAKAO:sc1111") void getFilesInFolder_success() throws Exception { - // given - FolderFilesDto rs = new FolderFilesDto( - 2, "docs", - List.of( - new FileSummary(10, "spec.pdf", null, "요약 A", "http://src/a", "http://img/a", - List.of(new Tag("tag1"), new Tag("tag2"))), - new FileSummary(11, "notes.txt", null, "요약 B", "http://src/b", "http://img/b", - List.of()) - ) - ); - - try (MockedStatic mocked = mockStatic(StubAuthUtil.class)) { - mocked.when(StubAuthUtil::currentMemberId).thenReturn(100); - when(folderService.getFilesInFolderForPersonal(100, 2)).thenReturn(rs); - - // when & then - mockMvc.perform(get("/api/v1/archive/folder/{folderId}/files", 2) - .accept(MediaType.APPLICATION_JSON)) - // then - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.msg").value("해당 폴더의 파일 목록을 불러왔습니다.")) - .andExpect(jsonPath("$.data.files").isArray()) - .andExpect(jsonPath("$.data.files.length()").value(2)) - .andExpect(jsonPath("$.data.files[0].dataSourceId").value(10)) - .andExpect(jsonPath("$.data.files[0].title").value("spec.pdf")) - .andExpect(jsonPath("$.data.files[0].summary").value("요약 A")) - .andExpect(jsonPath("$.data.files[0].sourceUrl").value("http://src/a")) - .andExpect(jsonPath("$.data.files[0].imageUrl").value("http://img/a")) - .andExpect(jsonPath("$.data.files[0].tags[0].tagName").value("tag1")); - } + // Given : @BeforeAll: docsFolderId 및 샘플 파일 준비됨 + + // When & Then + mockMvc.perform(get("/api/v1/archive/folder/{folderId}/files", docsFolderId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.msg").value("해당 폴더의 파일 목록을 불러왔습니다.")) + .andExpect(jsonPath("$.data.files").isArray()) + .andExpect(jsonPath("$.data.files.length()").value(greaterThanOrEqualTo(2))) + .andExpect(jsonPath("$.data.files[0].dataSourceId").isNumber()) + .andExpect(jsonPath("$.data.files[0].title").isString()) + .andExpect(jsonPath("$.data.files[0].summary").isString()) + .andExpect(jsonPath("$.data.files[0].sourceUrl").isString()) + .andExpect(jsonPath("$.data.files[0].imageUrl").isString()); } @Test @DisplayName("폴더 내 파일 목록 조회 - 폴더가 없으면 404") + @WithUserDetails("KAKAO:sc1111") void getFilesInFolder_notFound() throws Exception { - // given - try (MockedStatic mocked = mockStatic(StubAuthUtil.class)) { - mocked.when(StubAuthUtil::currentMemberId).thenReturn(100); - when(folderService.getFilesInFolderForPersonal(100, 999)) - .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); - - // when & then - mockMvc.perform(get("/api/v1/archive/folder/{folderId}/files", 999) - .accept(MediaType.APPLICATION_JSON)) - // then - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value("404")) - .andExpect(jsonPath("$.msg").value("존재하지 않는 폴더입니다.")); - } + // When & Then + mockMvc.perform(get("/api/v1/archive/folder/{folderId}/files", 999999) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("404")) + .andExpect(jsonPath("$.msg").value("존재하지 않는 폴더입니다.")); } - } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java index 41d34372..394a939a 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java @@ -17,8 +17,8 @@ import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; -import org.tuna.zoopzoop.backend.domain.datasource.dto.FileSummary; import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FileSummary; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; @@ -33,6 +33,10 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +/** + * FolderService 단위 테스트 + * - memberRepository 스텁은 필요한 테스트에만 선언 + */ @ExtendWith(MockitoExtension.class) @Transactional @ActiveProfiles("test") @@ -51,6 +55,7 @@ class FolderServiceTest { @BeforeEach void setUp() { + // 공통 테스트 데이터 준비 (스텁은 각 테스트에서 선언) this.member = new Member(); ReflectionTestUtils.setField(member, "id", 1); @@ -67,10 +72,11 @@ void setUp() { @Test @DisplayName("폴더 생성 성공(중복 없음)") void createFolder_success() { - when(memberRepository.findById(1)).thenReturn(Optional.of(member)); + // GIVEN + when(memberRepository.findById(1)).thenReturn(Optional.of(member)); // <- 반드시 필요 when(personalArchiveRepository.findByMemberId(1)).thenReturn(Optional.of(personalArchive)); when(folderRepository.findNamesForConflictCheck(eq(archive.getId()), anyString(), anyString())) - .thenReturn(List.of()); // 충돌 없음 + .thenReturn(List.of()); Folder saved = new Folder(); saved.setName("보고서"); @@ -79,8 +85,10 @@ void createFolder_success() { when(folderRepository.save(any(Folder.class))).thenReturn(saved); + // WHEN FolderResponse result = folderService.createFolderForPersonal(1, "보고서"); + // THEN assertThat(result.folderId()).isEqualTo(999); assertThat(result.folderName()).isEqualTo("보고서"); } @@ -88,29 +96,34 @@ void createFolder_success() { @Test @DisplayName("폴더 이름 중복 시 '(1)' 붙여 생성") void createFolder_withConflict() { - when(memberRepository.findById(1)).thenReturn(Optional.of(member)); + // given + when(memberRepository.findById(1)).thenReturn(Optional.of(member)); // <- 반드시 필요 when(personalArchiveRepository.findByMemberId(1)).thenReturn(Optional.of(personalArchive)); when(folderRepository.findNamesForConflictCheck(eq(archive.getId()), eq("보고서"), anyString())) .thenReturn(List.of("보고서")); Folder saved = new Folder(); - saved.setName("보고서(1)"); + saved.setName("보고서 (1)"); saved.setArchive(archive); ReflectionTestUtils.setField(saved, "id", 1000); when(folderRepository.save(any(Folder.class))).thenReturn(saved); + // when FolderResponse result = folderService.createFolderForPersonal(1, "보고서"); - assertThat(result.folderName()).isEqualTo("보고서(1)"); + // then + assertThat(result.folderName()).isEqualTo("보고서 (1)"); assertThat(result.folderId()).isEqualTo(1000); } @Test @DisplayName("멤버가 없으면 예외 발생") void createFolder_memberNotFound() { + // given when(memberRepository.findById(2)).thenReturn(Optional.empty()); + // when & then assertThrows(IllegalArgumentException.class, () -> folderService.createFolderForPersonal(2, "보고서")); } @@ -119,15 +132,18 @@ void createFolder_memberNotFound() { @Test @DisplayName("폴더 삭제 성공") void deleteFolder_success() { + // given Folder folder = new Folder(); folder.setName("보고서"); folder.setArchive(archive); ReflectionTestUtils.setField(folder, "id", 500); - when(folderRepository.findById(500)).thenReturn(Optional.of(folder)); + when(folderRepository.findByIdAndMemberId(500, 1)).thenReturn(Optional.of(folder)); - String deletedName = folderService.deleteFolder(500); + // when + String deletedName = folderService.deleteFolder(1, 500); + // then assertThat(deletedName).isEqualTo("보고서"); verify(folderRepository, times(1)).delete(folder); } @@ -135,21 +151,25 @@ void deleteFolder_success() { @Test @DisplayName("폴더 삭제 실패 - 존재하지 않는 폴더") void deleteFolder_notFound() { - when(folderRepository.findById(999)).thenReturn(Optional.empty()); + // given + when(folderRepository.findByIdAndMemberId(999, 1)).thenReturn(Optional.empty()); - assertThrows(NoResultException.class, () -> folderService.deleteFolder(999)); + // when & then + assertThrows(NoResultException.class, () -> folderService.deleteFolder(1, 999)); verify(folderRepository, never()).delete(any(Folder.class)); } @Test @DisplayName("default 폴더는 삭제할 수 없다") void deleteFolder_default_forbidden() { + // given Folder defaultFolder = new Folder("default"); // isDefault=true ReflectionTestUtils.setField(defaultFolder, "id", 42); - when(folderRepository.findById(42)).thenReturn(Optional.of(defaultFolder)); + when(folderRepository.findByIdAndMemberId(42, 1)).thenReturn(Optional.of(defaultFolder)); - assertThrows(IllegalArgumentException.class, () -> folderService.deleteFolder(42)); + // when & then + assertThrows(IllegalArgumentException.class, () -> folderService.deleteFolder(1, 42)); verify(folderRepository, never()).delete(any()); } @@ -157,16 +177,21 @@ void deleteFolder_default_forbidden() { @Test @DisplayName("폴더 이름 변경 성공") void updateFolderName_success() { + // given Folder folder = new Folder(); folder.setName("기존이름"); folder.setArchive(archive); ReflectionTestUtils.setField(folder, "id", 700); - when(folderRepository.findById(700)).thenReturn(Optional.of(folder)); + when(folderRepository.findByIdAndMemberId(700, 1)).thenReturn(Optional.of(folder)); + when(folderRepository.findNamesForConflictCheck(archive.getId(), "새이름", folder.getName())) + .thenReturn(List.of()); when(folderRepository.save(any(Folder.class))).thenAnswer(invocation -> invocation.getArgument(0)); - String updated = folderService.updateFolderName(700, "새이름"); + // when + String updated = folderService.updateFolderName(1, 700, "새이름"); + // then assertThat(updated).isEqualTo("새이름"); assertThat(folder.getName()).isEqualTo("새이름"); verify(folderRepository, times(1)).save(folder); @@ -175,26 +200,30 @@ void updateFolderName_success() { @Test @DisplayName("폴더 이름 변경 실패 - 존재하지 않음") void updateFolderName_notFound() { - when(folderRepository.findById(701)).thenReturn(Optional.empty()); + // given + when(folderRepository.findByIdAndMemberId(701, 1)).thenReturn(Optional.empty()); - assertThrows(NoResultException.class, () -> folderService.updateFolderName(701, "아무거나")); + // when & then + assertThrows(NoResultException.class, () -> folderService.updateFolderName(1, 701, "아무거나")); verify(folderRepository, never()).save(any(Folder.class)); } @Test @DisplayName("폴더 이름 변경 실패 - 중복 이름 존재") void updateFolderName_conflict() { + // given Folder folder = new Folder(); folder.setName("기존이름"); folder.setArchive(archive); ReflectionTestUtils.setField(folder, "id", 700); - when(folderRepository.findById(700)).thenReturn(Optional.of(folder)); + when(folderRepository.findByIdAndMemberId(700, 1)).thenReturn(Optional.of(folder)); when(folderRepository.findNamesForConflictCheck(archive.getId(), "보고서", "기존이름")) .thenReturn(List.of("보고서")); + // when & then assertThrows(IllegalArgumentException.class, - () -> folderService.updateFolderName(700, "보고서")); + () -> folderService.updateFolderName(1, 700, "보고서")); verify(folderRepository, never()).save(any(Folder.class)); } @@ -232,7 +261,8 @@ void getFilesInFolderForPersonal_success() { folder.setName("docs"); folder.setArchive(archive); ReflectionTestUtils.setField(folder, "id", folderId); - when(folderRepository.findById(folderId)).thenReturn(Optional.of(folder)); + + when(folderRepository.findByIdAndMemberId(folderId, 1)).thenReturn(Optional.of(folder)); DataSource d1 = new DataSource(); ReflectionTestUtils.setField(d1, "id", 10); @@ -242,6 +272,7 @@ void getFilesInFolderForPersonal_success() { d1.setSourceUrl("http://src/a"); d1.setImageUrl("http://img/a"); d1.setTags(List.of(new Tag("tag1"), new Tag("tag2"))); + d1.setCategory(org.tuna.zoopzoop.backend.domain.datasource.entity.Category.IT); DataSource d2 = new DataSource(); ReflectionTestUtils.setField(d2, "id", 11); @@ -251,6 +282,7 @@ void getFilesInFolderForPersonal_success() { d2.setSourceUrl("http://src/b"); d2.setImageUrl("http://img/b"); d2.setTags(List.of()); + d2.setCategory(org.tuna.zoopzoop.backend.domain.datasource.entity.Category.SCIENCE); when(dataSourceRepository.findAllByFolder(folder)).thenReturn(List.of(d1, d2)); @@ -273,14 +305,11 @@ void getFilesInFolderForPersonal_success() { void getFilesInFolderForPersonal_notFound() { // given Integer folderId = 999; - when(folderRepository.findById(folderId)).thenReturn(Optional.empty()); + when(folderRepository.findByIdAndMemberId(folderId, 1)).thenReturn(Optional.empty()); // when & then assertThrows(NoResultException.class, () -> folderService.getFilesInFolderForPersonal(1, folderId)); - - // then(verify) verify(dataSourceRepository, never()).findAllByFolder(any()); } - } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java index 3fc3bc2f..2643b8c0 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java @@ -1,91 +1,179 @@ package org.tuna.zoopzoop.backend.domain.datasource.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.NoResultException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; import org.tuna.zoopzoop.backend.domain.datasource.dto.*; -import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; -import org.tuna.zoopzoop.backend.global.exception.GlobalExceptionHandler; - +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; +import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; + +import java.time.LocalDate; import java.util.List; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.hamcrest.Matchers.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +@ActiveProfiles("test") +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) class DatasourceControllerTest { - @Mock private DataSourceService dataSourceService; - @InjectMocks private DatasourceController datasourceController; - - private MockMvc mockMvc; - private ObjectMapper om; - - @BeforeEach - void setup() { - MockitoAnnotations.openMocks(this); - om = new ObjectMapper(); + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @Autowired private MemberService memberService; + @Autowired private MemberRepository memberRepository; + @Autowired private FolderService folderService; + @Autowired private FolderRepository folderRepository; + @Autowired private DataSourceRepository dataSourceRepository; + + private final String TEST_PROVIDER_KEY = "testUser_sc1111"; // WithUserDetails username -> "KAKAO:testUser_sc1111" + + private Integer testMemberId; + private Integer docsFolderId; + private Integer dataSourceId1; + private Integer dataSourceId2; + + @BeforeAll + void beforeAll() { + try { + memberService.createMember("testUser_sc1111", "http://img", TEST_PROVIDER_KEY, Provider.KAKAO); + } catch (Exception ignored) {} + + var member = memberRepository.findByProviderAndProviderKey(Provider.KAKAO, TEST_PROVIDER_KEY) + .orElseThrow(); + testMemberId = member.getId(); + + // docs 폴더 생성 + FolderResponse fr = folderService.createFolderForPersonal(testMemberId, "docs"); + docsFolderId = fr.folderId(); + + Folder docsFolder = folderRepository.findById(docsFolderId).orElseThrow(); + + Integer archiveId = docsFolder.getArchive().getId(); + folderRepository.findByArchiveIdAndIsDefaultTrue(archiveId) + .orElseGet(() -> { + Folder defaultFolder = new Folder(); + defaultFolder.setArchive(docsFolder.getArchive()); + defaultFolder.setName("default"); + defaultFolder.setDefault(true); + return folderRepository.save(defaultFolder); + }); + + // 자료 2건 생성 + DataSource d1 = new DataSource(); + d1.setFolder(docsFolder); + d1.setTitle("spec.pdf"); + d1.setSummary("요약 A"); + d1.setSourceUrl("http://src/a"); + d1.setImageUrl("http://img/a"); + d1.setDataCreatedDate(LocalDate.now()); + d1.setActive(true); + d1.setTags(List.of(new Tag("tag1"), new Tag("tag2"))); + d1.setCategory(Category.IT); + dataSourceRepository.save(d1); + dataSourceId1 = d1.getId(); + + DataSource d2 = new DataSource(); + d2.setFolder(docsFolder); + d2.setTitle("notes.txt"); + d2.setSummary("요약 B"); + d2.setSourceUrl("http://src/b"); + d2.setImageUrl("http://img/b"); + d2.setDataCreatedDate(LocalDate.now()); + d2.setActive(true); + d2.setTags(List.of()); + d2.setCategory(Category.SCIENCE); + dataSourceRepository.save(d2); + dataSourceId2 = d2.getId(); + } - // ✅ 한 번만 생성 + 전역 예외핸들러 등록 - mockMvc = MockMvcBuilders - .standaloneSetup(datasourceController) - .setControllerAdvice(new GlobalExceptionHandler()) - .build(); + @AfterAll + void afterAll() { + // 생성한 자료/폴더/멤버 삭제 + try { + if (dataSourceId1 != null) dataSourceRepository.findById(dataSourceId1).ifPresent(dataSourceRepository::delete); + } catch (Exception ignored) {} + try { + if (dataSourceId2 != null) dataSourceRepository.findById(dataSourceId2).ifPresent(dataSourceRepository::delete); + } catch (Exception ignored) {} + + try { + if (docsFolderId != null) folderRepository.findById(docsFolderId).ifPresent(folderRepository::delete); + } catch (Exception ignored) {} + + memberRepository.findByProviderAndProviderKey(Provider.KAKAO, TEST_PROVIDER_KEY) + .ifPresent(memberRepository::delete); } // create @Test @DisplayName("자료 생성 성공 - folderId=null → default 폴더에 등록") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void create_defaultFolder() throws Exception { var rq = new reqBodyForCreateDataSource("https://example.com/a", null); - when(dataSourceService.createDataSource(anyInt(), eq(rq.sourceUrl()), isNull())) - .thenReturn(1001); - mockMvc.perform(post("/api/v1/archive") .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(rq))) + .content(objectMapper.writeValueAsString(rq))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.msg").value("새로운 자료가 등록됐습니다.")) - .andExpect(jsonPath("$.data").value(1001)); + .andExpect(jsonPath("$.data").isNumber()); } @Test @DisplayName("자료 생성 성공 - folderId 지정 → 해당 폴더에 등록") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void create_specificFolder() throws Exception { - var rq = new reqBodyForCreateDataSource("https://example.com/b", 55); - - when(dataSourceService.createDataSource(anyInt(), eq(rq.sourceUrl()), eq(rq.folderId()))) - .thenReturn(2002); + var rq = new reqBodyForCreateDataSource("https://example.com/b", docsFolderId); mockMvc.perform(post("/api/v1/archive") .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(rq))) + .content(objectMapper.writeValueAsString(rq))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.msg").value("새로운 자료가 등록됐습니다.")) - .andExpect(jsonPath("$.data").value(2002)); + .andExpect(jsonPath("$.data").isNumber()); } // delete @Test @DisplayName("단건 삭제 성공 -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void delete_success() throws Exception { - // given - int id = 123; - when(dataSourceService.deleteById(id)).thenReturn(id); + DataSource d = new DataSource(); + d.setFolder(folderRepository.findById(docsFolderId).orElseThrow()); + d.setTitle("tmp_delete"); + d.setSummary("tmp"); + d.setSourceUrl("http://s"); + d.setImageUrl("http://i"); + d.setDataCreatedDate(LocalDate.now()); + d.setActive(true); + d.setCategory(Category.IT); + dataSourceRepository.save(d); + Integer id = d.getId(); - // when & then mockMvc.perform(delete("/api/v1/archive/{id}", id)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) @@ -95,12 +183,9 @@ void delete_success() throws Exception { @Test @DisplayName("단건 삭제 실패: 자료 없음 → 404 Not Found") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void delete_notFound() throws Exception { - int id = 999; - when(dataSourceService.deleteById(id)) - .thenThrow(new NoResultException("존재하지 않는 자료입니다.")); - - mockMvc.perform(delete("/api/v1/archive/{id}", id)) + mockMvc.perform(delete("/api/v1/archive/{id}", 999999)) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value("404")) .andExpect(jsonPath("$.msg").value("존재하지 않는 자료입니다.")); @@ -109,129 +194,133 @@ void delete_notFound() throws Exception { // deleteMany @Test @DisplayName("다건 삭제 성공 -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void deleteMany_success() throws Exception { - var body = new reqBodyForDeleteMany(List.of(10, 20, 30)); - doNothing().when(dataSourceService).deleteMany(anyList()); + DataSource a = new DataSource(); a.setFolder(folderRepository.findById(docsFolderId).orElseThrow()); + a.setTitle("tmp_a"); a.setSummary("a"); a.setSourceUrl("a"); a.setImageUrl("a"); a.setDataCreatedDate(LocalDate.now()); a.setActive(true); a.setCategory(Category.IT); + DataSource b = new DataSource(); b.setFolder(folderRepository.findById(docsFolderId).orElseThrow()); + b.setTitle("tmp_b"); b.setSummary("b"); b.setSourceUrl("b"); b.setImageUrl("b"); b.setDataCreatedDate(LocalDate.now()); b.setActive(true); b.setCategory(Category.IT); + dataSourceRepository.save(a); dataSourceRepository.save(b); + + var body = new reqBodyForDeleteMany(List.of(a.getId(), b.getId())); mockMvc.perform(post("/api/v1/archive/delete") .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(body))) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.msg").value("복수개의 자료가 삭제됐습니다.")) - .andExpect(jsonPath("$.data").value(org.hamcrest.Matchers.nullValue())); + .andExpect(jsonPath("$.data").value(nullValue())); } @Test @DisplayName("다건 삭제 실패: 배열 비어있음 → 400 Bad Request") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void deleteMany_empty() throws Exception { - // @NotEmpty로 잡히면 MethodArgumentNotValidException(400), 서비스에서 잡히면 IllegalArgumentException(400) var empty = new reqBodyForDeleteMany(List.of()); mockMvc.perform(post("/api/v1/archive/delete") .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(empty))) + .content(objectMapper.writeValueAsString(empty))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value("400")); } @Test @DisplayName("다건 삭제 실패: 일부 ID 미존재 → 404 Not Found") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void deleteMany_partialMissing() throws Exception { - var body = new reqBodyForDeleteMany(List.of(1, 2, 3)); - doThrow(new NoResultException("존재하지 않는 자료 ID 포함: [2]")) - .when(dataSourceService).deleteMany(anyList()); + var body = new reqBodyForDeleteMany(List.of(dataSourceId1, 999999)); mockMvc.perform(post("/api/v1/archive/delete") .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(body))) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value("404")) - .andExpect(jsonPath("$.msg").value("존재하지 않는 자료 ID 포함: [2]")); + .andExpect(jsonPath("$.status").value("404")); } // 자료 단건 이동 @Test @DisplayName("단건 이동 성공 -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveOne_ok() throws Exception { - // given - when(dataSourceService.moveDataSource(anyInt(), eq(1), eq(200))) - .thenReturn(new DataSourceService.MoveResult(1, 200)); + FolderResponse newFolder = folderService.createFolderForPersonal(testMemberId, "moveTarget"); + Integer toId = newFolder.folderId(); - String body = om.writeValueAsString(new reqBodyForMoveDataSource(200)); + var body = new reqBodyForMoveDataSource(toId); - // expect - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.data.dataSourceId").value(1)) - .andExpect(jsonPath("$.data.folderId").value(200)); + .andExpect(jsonPath("$.data.dataSourceId").value(dataSourceId1)) + .andExpect(jsonPath("$.data.folderId").value(toId)); } @Test - @DisplayName(" 단건 이동 성공: default 폴더(null) -> 200") + @DisplayName("단건 이동 성공: default 폴더(null) -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveOne_default_ok() throws Exception { - when(dataSourceService.moveDataSource(anyInt(), eq(1), isNull())) - .thenReturn(new DataSourceService.MoveResult(1, 999)); // default folder id - - String body = om.writeValueAsString(new reqBodyForMoveDataSource(null)); + var body = new reqBodyForMoveDataSource(null); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.folderId").value(999)); + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.dataSourceId").value(dataSourceId1)) + .andExpect(jsonPath("$.data.folderId").isNumber()); } @Test - @DisplayName("단건 이동 실패: 자료 없음 -> 400") + @DisplayName("단건 이동 실패: 자료 없음 -> 404") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveOne_notFound_data() throws Exception { - when(dataSourceService.moveDataSource(anyInt(), eq(1), eq(200))) - .thenThrow(new NoResultException("존재하지 않는 자료입니다.")); - - String body = om.writeValueAsString(new reqBodyForMoveDataSource(200)); + var body = new reqBodyForMoveDataSource(docsFolderId); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 999999) .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isNotFound()); } @Test @DisplayName("단건 이동 실패: 폴더 없음 -> 404") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveOne_notFound_folder() throws Exception { - when(dataSourceService.moveDataSource(anyInt(), eq(1), eq(200))) - .thenThrow(new NoResultException("존재하지 않는 폴더입니다.")); - - String body = om.writeValueAsString(new reqBodyForMoveDataSource(200)); + // 임의의 존재하지 않는 폴더로 이동 시도 + var body = new reqBodyForMoveDataSource(999999); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", 1) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}/move", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isNotFound()); } - // 자료 다건 이동 + // 자료 다건 이동 (지정 폴더) @Test - @DisplayName("다건 이동 성공: 지정 폴더 -> 200") + @DisplayName("자료 다건 이동 성공: 지정 폴더 -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveMany_specific_ok() throws Exception { - String body = "{\"folderId\":200,\"dataSourceId\":[1,2,3]}"; + FolderResponse newFolder = folderService.createFolderForPersonal(testMemberId, "moveManyTarget"); + Integer toId = newFolder.folderId(); + + String body = String.format("{\"folderId\":%d,\"dataSourceId\":[%d,%d]}", toId, dataSourceId1, dataSourceId2); mockMvc.perform(patch("/api/v1/archive/move") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.msg").value("복수 개의 자료를 이동했습니다.")) - .andExpect(jsonPath("$.data").doesNotExist()); + .andExpect(jsonPath("$.msg").value("복수 개의 자료를 이동했습니다.")); } + @Test - @DisplayName("다건 이동 성공: 기본 폴더(null) -> 200") + @DisplayName("자료 다건 이동 성공: 기본 폴더(null) -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void moveMany_default_ok() throws Exception { - // 서비스는 void 리턴이라 스텁 불필요 (예외만 없으면 200) - String body = "{\"folderId\":null,\"dataSourceId\":[1,2,3]}"; + String body = String.format("{\"folderId\":null,\"dataSourceId\":[%d,%d]}", dataSourceId1, dataSourceId2); mockMvc.perform(patch("/api/v1/archive/move") .contentType(MediaType.APPLICATION_JSON) @@ -241,46 +330,31 @@ void moveMany_default_ok() throws Exception { .andExpect(jsonPath("$.msg").value("복수 개의 자료를 이동했습니다.")); } - @Test - @DisplayName("다건 이동 실패: 기본 폴더 없음 -> 404") - void moveMany_default_missing() throws Exception { - String body = "{\"folderId\":null,\"dataSourceId\":[1,2]}"; - - doThrow(new NoResultException("기본 폴더가 존재하지 않습니다.")) - .when(dataSourceService).moveDataSources(anyInt(), isNull(), eq(List.of(1,2))); - - mockMvc.perform(patch("/api/v1/archive/move") - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isNotFound()); - } - // 자료 수정 @Test @DisplayName("자료 수정 성공 -> 200") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void update_ok() throws Exception { - int id = 10; - when(dataSourceService.updateDataSource(eq(id), eq("새 제목"), eq("짧은 요약"))) - .thenReturn(id); - var body = new reqBodyForUpdateDataSource("새 제목", "짧은 요약"); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", id) + + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(body))) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.msg").value(id + "번 자료가 수정됐습니다.")) - .andExpect(jsonPath("$.data.dataSourceId").value(id)); + .andExpect(jsonPath("$.msg").exists()) + .andExpect(jsonPath("$.data.dataSourceId").value(dataSourceId1)); } @Test @DisplayName("자료 수정 실패: 요청 바디가 모두 공백 -> 400") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void update_badRequest_whenEmpty() throws Exception { var body = new reqBodyForUpdateDataSource(" ", null); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", 1) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", dataSourceId1) .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(body))) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.msg").exists()); @@ -288,20 +362,14 @@ void update_badRequest_whenEmpty() throws Exception { @Test @DisplayName("자료 수정 실패: 존재하지 않는 자료 -> 404") + @WithUserDetails(value = "KAKAO:testUser_sc1111", setupBefore = TestExecutionEvent.TEST_METHOD) void update_notFound() throws Exception { - int id = 999; - when(dataSourceService.updateDataSource(eq(id), any(), any())) - .thenThrow(new NoResultException("존재하지 않는 자료입니다.")); - var body = new reqBodyForUpdateDataSource("제목", "요약"); - mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", id) + mockMvc.perform(patch("/api/v1/archive/{dataSourceId}", 999999) .contentType(MediaType.APPLICATION_JSON) - .content(om.writeValueAsString(body))) + .content(objectMapper.writeValueAsString(body))) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.status").value(404)) - .andExpect(jsonPath("$.msg").value("존재하지 않는 자료입니다.")); + .andExpect(jsonPath("$.status").value(404)); } - - } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java index f19f9d27..2660cb89 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java @@ -46,13 +46,11 @@ void createDataSource_defaultFolder() { // PersonalArchive 생성 시 Archive + default folder 자동 생성됨 Member member = new Member("u1", "k-1", Provider.KAKAO, null); PersonalArchive pa = new PersonalArchive(member); - Integer archiveId = pa.getArchive().getId(); // 실제 id는 없지만, 아래 anyInt()로 받게 스텁함 when(personalArchiveRepository.findByMemberId(eq(currentMemberId))) .thenReturn(Optional.of(pa)); Folder defaultFolder = new Folder("default"); - // 리얼 구현은 archiveId 기준으로 찾으니 시그니처 맞추기 when(folderRepository.findByArchiveIdAndIsDefaultTrue(anyInt())) .thenReturn(Optional.of(defaultFolder)); @@ -67,7 +65,6 @@ void createDataSource_defaultFolder() { assertThat(id).isEqualTo(123); } - @Test @DisplayName("폴더 생성 성공- folderId가 주어지면 해당 폴더에 자료 생성") void createDataSource_specificFolder() { @@ -77,16 +74,14 @@ void createDataSource_specificFolder() { Integer folderId = 77; Folder target = new Folder("target"); - // BaseEntity.id 는 protected setter → 리플렉션으로 주입 - org.springframework.test.util.ReflectionTestUtils.setField(target, "id", folderId); + ReflectionTestUtils.setField(target, "id", folderId); when(folderRepository.findById(eq(folderId))).thenReturn(Optional.of(target)); - // save(...) 시에 PK가 채워진 것처럼 반환 when(dataSourceRepository.save(any(DataSource.class))) .thenAnswer(inv -> { DataSource ds = inv.getArgument(0); - org.springframework.test.util.ReflectionTestUtils.setField(ds, "id", 456); + ReflectionTestUtils.setField(ds, "id", 456); return ds; }); @@ -98,11 +93,10 @@ void createDataSource_specificFolder() { } @Test - @DisplayName("폴대 생성 실패 - folderId가 주어졌는데 대상 폴더가 없으면 예외") + @DisplayName("폴더 생성 실패 - folderId가 주어졌는데 대상 폴더가 없으면 예외") void createDataSource_folderNotFound() { // given Integer folderId = 999; - when(folderRepository.findById(eq(folderId))).thenReturn(Optional.empty()); // when / then @@ -116,7 +110,6 @@ void createDataSource_folderNotFound() { void createDataSource_defaultFolderNotFound() { // given int currentMemberId = 10; - PersonalArchive pa = new PersonalArchive(new Member("u1","p", Provider.KAKAO,null)); when(personalArchiveRepository.findByMemberId(eq(currentMemberId))) .thenReturn(Optional.of(pa)); @@ -131,15 +124,17 @@ void createDataSource_defaultFolderNotFound() { // delete @Test - @DisplayName("단건 삭제 성공 - 존재하는 자료 삭제 시 ID 반환") + @DisplayName("단건 삭제 성공 - 존재하는 자료 삭제 시 ID 반환 (member 소유 확인)") void deleteById_success() { // given + int memberId = 5; int id = 123; DataSource mockData = new DataSource(); - when(dataSourceRepository.findById(id)).thenReturn(Optional.of(mockData)); // when - int deletedId = dataSourceService.deleteById(id); + when(dataSourceRepository.findByIdAndMemberId(id, memberId)).thenReturn(Optional.of(mockData)); + + int deletedId = dataSourceService.deleteById(memberId, id); // then assertThat(deletedId).isEqualTo(id); @@ -150,11 +145,12 @@ void deleteById_success() { @DisplayName("단건 삭제 실패 - 자료가 존재하지 않으면 예외 발생") void deleteById_notFound() { // given + int memberId = 5; int id = 999; - when(dataSourceRepository.findById(id)).thenReturn(Optional.empty()); + when(dataSourceRepository.findByIdAndMemberId(id, memberId)).thenReturn(Optional.empty()); // when & then - assertThrows(NoResultException.class, () -> dataSourceService.deleteById(id)); + assertThrows(NoResultException.class, () -> dataSourceService.deleteById(memberId, id)); verify(dataSourceRepository, never()).delete(any()); } @@ -162,10 +158,12 @@ void deleteById_notFound() { @Test @DisplayName("다건 삭제 성공 - 일괄 삭제") void deleteMany_success() { + Integer memberId = 2; List ids = List.of(1, 2, 3); - when(dataSourceRepository.findExistingIds(ids)).thenReturn(ids); - dataSourceService.deleteMany(ids); + when(dataSourceRepository.findExistingIdsInMember(memberId, ids)).thenReturn(ids); + + dataSourceService.deleteMany(memberId, ids); verify(dataSourceRepository).deleteAllByIdInBatch(ids); } @@ -173,17 +171,19 @@ void deleteMany_success() { @Test @DisplayName("다건 삭제 실패 - 요청 배열이 비어있음 → 400") void deleteMany_empty() { - assertThrows(IllegalArgumentException.class, () -> dataSourceService.deleteMany(List.of())); + Integer memberId = 2; + assertThrows(IllegalArgumentException.class, () -> dataSourceService.deleteMany(memberId, List.of())); verifyNoInteractions(dataSourceRepository); } @Test @DisplayName("다건 삭제 실패 - 일부 ID 미존재 → 404") void deleteMany_partialMissing() { + Integer memberId = 2; List ids = List.of(1, 2, 3); - when(dataSourceRepository.findExistingIds(ids)).thenReturn(List.of(1, 3)); + when(dataSourceRepository.findExistingIdsInMember(memberId, ids)).thenReturn(List.of(1, 3)); - assertThrows(NoResultException.class, () -> dataSourceService.deleteMany(ids)); + assertThrows(NoResultException.class, () -> dataSourceService.deleteMany(memberId, ids)); verify(dataSourceRepository, never()).deleteAllByIdInBatch(any()); } @@ -202,7 +202,7 @@ void moveOne_ok() { ReflectionTestUtils.setField(ds, "id", dsId); ds.setTitle("A"); ds.setFolder(from); - when(dataSourceRepository.findById(dsId)).thenReturn(Optional.of(ds)); + when(dataSourceRepository.findByIdAndMemberId(dsId, memberId)).thenReturn(Optional.of(ds)); when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); DataSourceService.MoveResult rs = dataSourceService.moveDataSource(memberId, dsId, toId); @@ -213,7 +213,7 @@ void moveOne_ok() { } @Test - @DisplayName("단건이동 성공: 기본 폴더(null)로 이동") + @DisplayName("단건 이동 성공: 기본 폴더(null) -> 200") void moveOne_default_ok() { Integer memberId = 7, dsId = 1, fromId = 100, defaultId = 999; @@ -224,7 +224,7 @@ void moveOne_default_ok() { ReflectionTestUtils.setField(ds, "id", dsId); ds.setTitle("문서A"); ds.setFolder(from); - when(dataSourceRepository.findById(dsId)).thenReturn(Optional.of(ds)); + when(dataSourceRepository.findByIdAndMemberId(dsId, memberId)).thenReturn(Optional.of(ds)); when(folderRepository.findDefaultFolderByMemberId(memberId)) .thenReturn(Optional.of(defaultFolder)); @@ -246,7 +246,7 @@ void moveOne_idempotent() { ReflectionTestUtils.setField(ds, "id", dsId); ds.setTitle("A"); ds.setFolder(same); - when(dataSourceRepository.findById(dsId)).thenReturn(Optional.of(ds)); + when(dataSourceRepository.findByIdAndMemberId(dsId, memberId)).thenReturn(Optional.of(ds)); when(folderRepository.findById(folderId)).thenReturn(Optional.of(same)); DataSourceService.MoveResult rs = dataSourceService.moveDataSource(memberId, dsId, folderId); @@ -256,11 +256,12 @@ void moveOne_idempotent() { } @Test - @DisplayName("단건 이동 실패: 자료 없음 → NoResultException") + @DisplayName("단건 이동 실패: 자료 없음 → NoResultException (소유자 검증)") void moveOne_notFound_data() { - when(dataSourceRepository.findById(1)).thenReturn(Optional.empty()); + Integer memberId = 1, dsId = 1; + when(dataSourceRepository.findByIdAndMemberId(dsId, memberId)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> dataSourceService.moveDataSource(1, 1, 200)) + assertThatThrownBy(() -> dataSourceService.moveDataSource(memberId, dsId, 200)) .isInstanceOf(NoResultException.class) .hasMessageContaining("존재하지 않는 자료"); } @@ -268,23 +269,46 @@ void moveOne_notFound_data() { @Test @DisplayName("단건 이동 실패: 폴더 없음 → NoResultException") void moveOne_notFound_folder() { + Integer memberId = 1; Folder from = new Folder(); ReflectionTestUtils.setField(from, "id", 100); DataSource ds = new DataSource(); ReflectionTestUtils.setField(ds, "id", 1); ds.setTitle("A"); ds.setFolder(from); - when(dataSourceRepository.findById(1)).thenReturn(Optional.of(ds)); + when(dataSourceRepository.findByIdAndMemberId(1, memberId)).thenReturn(Optional.of(ds)); when(folderRepository.findById(200)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> dataSourceService.moveDataSource(1, 1, 200)) + assertThatThrownBy(() -> dataSourceService.moveDataSource(memberId, 1, 200)) .isInstanceOf(NoResultException.class) .hasMessageContaining("존재하지 않는 폴더"); } // 자료 다건 이동 @Test - @DisplayName("다건: folderId=null → 기본 폴더로 이동") + @DisplayName("다건 이동 성공: 지정 폴더로 이동") + void moveMany_ok() { + Integer memberId = 1; + Integer toId = 200; + Folder from = new Folder(); ReflectionTestUtils.setField(from, "id", 100); + Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); + + DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(from); + DataSource b = new DataSource(); ReflectionTestUtils.setField(b, "id", 2); b.setTitle("B"); b.setFolder(from); + + when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); + // 소유자 검증: member 소유인 id들 반환 + when(dataSourceRepository.findExistingIdsInMember(memberId, List.of(1,2))).thenReturn(List.of(1,2)); + when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a,b)); + + dataSourceService.moveDataSources(memberId, toId, List.of(1,2)); + + assertThat(a.getFolder().getId()).isEqualTo(toId); + assertThat(b.getFolder().getId()).isEqualTo(toId); + } + + @Test + @DisplayName("다건 이동 성공: folderId=null → 기본 폴더로 이동") void moveMany_default_ok() { Integer memberId = 7, defaultId = 999; @@ -295,6 +319,7 @@ void moveMany_default_ok() { DataSource b = new DataSource(); ReflectionTestUtils.setField(b, "id", 2); b.setTitle("B"); b.setFolder(from); when(folderRepository.findDefaultFolderByMemberId(memberId)).thenReturn(Optional.of(defaultFolder)); + when(dataSourceRepository.findExistingIdsInMember(memberId, List.of(1,2))).thenReturn(List.of(1,2)); when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a,b)); dataSourceService.moveDataSources(memberId, null, List.of(1,2)); @@ -305,67 +330,31 @@ void moveMany_default_ok() { } @Test - @DisplayName("다건: folderId=null & 기본 폴더 없음 → NoResultException") + @DisplayName("다건 이동 실패: folderId=null & 기본 폴더 없음 → NoResultException") void moveMany_default_missing() { when(folderRepository.findDefaultFolderByMemberId(7)).thenReturn(Optional.empty()); - + // 멤버 소유 검증 전이라도 default 조회에서 예외 발생 assertThatThrownBy(() -> dataSourceService.moveDataSources(7, null, List.of(1))) .isInstanceOf(NoResultException.class) .hasMessageContaining("기본 폴더"); } @Test - @DisplayName("다건: 지정 폴더로 이동") - void moveMany_ok() { - Integer toId = 200; - Folder from = new Folder(); ReflectionTestUtils.setField(from, "id", 100); - Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); - - DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(from); - DataSource b = new DataSource(); ReflectionTestUtils.setField(b, "id", 2); b.setTitle("B"); b.setFolder(from); - - when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); - when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a,b)); - - dataSourceService.moveDataSources(1, toId, List.of(1,2)); - - assertThat(a.getFolder().getId()).isEqualTo(toId); - assertThat(b.getFolder().getId()).isEqualTo(toId); - } - - @Test - @DisplayName("다건: 모두 동일 폴더 → 멱등") - void moveMany_idempotent() { - Integer toId = 200; - Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); - - DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(to); - DataSource b = new DataSource(); ReflectionTestUtils.setField(b, "id", 2); b.setTitle("B"); b.setFolder(to); - - when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); - when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a,b)); - - dataSourceService.moveDataSources(1, toId, List.of(1,2)); - - verify(folderRepository).findById(toId); - verify(dataSourceRepository).findAllByIdIn(List.of(1,2)); - verifyNoMoreInteractions(folderRepository, dataSourceRepository); - } - - @Test - @DisplayName("다건: 일부 미존재 → NoResultException") + @DisplayName("다건 이동 실패: 일부 미존재 → NoResultException (소유자 검증 실패)") void moveMany_someNotFound() { + Integer memberId = 1; Integer toId = 200; Folder to = new Folder(); ReflectionTestUtils.setField(to, "id", toId); DataSource a = new DataSource(); ReflectionTestUtils.setField(a, "id", 1); a.setTitle("A"); a.setFolder(new Folder()); when(folderRepository.findById(toId)).thenReturn(Optional.of(to)); - when(dataSourceRepository.findAllByIdIn(List.of(1,2))).thenReturn(List.of(a)); // 2 없음 + // 소유자 검증에서 일부만 리턴 + when(dataSourceRepository.findExistingIdsInMember(memberId, List.of(1,2))).thenReturn(List.of(1)); - assertThatThrownBy(() -> dataSourceService.moveDataSources(1, toId, List.of(1,2))) + assertThatThrownBy(() -> dataSourceService.moveDataSources(memberId, toId, List.of(1,2))) .isInstanceOf(NoResultException.class) - .hasMessageContaining("존재하지 않는 항목"); + .hasMessageContaining("존재하지 않거나 소유자가 다른 자료 ID 포함"); } @Test @@ -381,7 +370,7 @@ void moveMany_notFound_folder() { @Test @DisplayName("다건: 요소 null → IllegalArgumentException") void moveMany_elementNull() { - List ids = Arrays.asList(1, null, 3); // ← null 허용 + List ids = Arrays.asList(1, null, 3); assertThatThrownBy(() -> dataSourceService.moveDataSources(1, 200, ids)) .isInstanceOf(IllegalArgumentException.class) @@ -391,25 +380,21 @@ void moveMany_elementNull() { @Test @DisplayName("다건: 요청에 중복된 자료 ID 포함 → IllegalArgumentException") void moveMany_duplicatedIds_illegalArgument() { - // given List ids = List.of(1, 2, 2, 3); // 2가 중복 - // when & then assertThatThrownBy(() -> dataSourceService.moveDataSources(7, 200, ids)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("같은 자료를 두 번 선택했습니다") .hasMessageContaining("2"); - // 리포지토리 호출 전 단계에서 막혀야 함 verifyNoInteractions(folderRepository, dataSourceRepository); } @Test @DisplayName("다건: folderId=null + 중복된 자료 ID 포함 → IllegalArgumentException (default 조회 전 차단)") void moveMany_default_withDuplicatedIds_illegalArgument() { - // given List ids = List.of(5, 5); // 중복 - // when & then + assertThatThrownBy(() -> dataSourceService.moveDataSources(7, null, ids)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("같은 자료를 두 번 선택했습니다") @@ -422,15 +407,16 @@ void moveMany_default_withDuplicatedIds_illegalArgument() { @Test @DisplayName("수정 성공: 제목과 요약 일부/전체 변경") void update_ok() { + Integer memberId = 3; DataSource ds = new DataSource(); ReflectionTestUtils.setField(ds, "id", 7); ds.setTitle("old"); ds.setSummary("old sum"); - when(dataSourceRepository.findById(anyInt())) + when(dataSourceRepository.findByIdAndMemberId(eq(7), eq(memberId))) .thenReturn(Optional.of(ds)); - Integer id = dataSourceService.updateDataSource(7, "new", null); + Integer id = dataSourceService.updateDataSource(memberId, 7, "new", null); assertThat(id).isEqualTo(7); assertThat(ds.getTitle()).isEqualTo("new"); @@ -440,10 +426,11 @@ void update_ok() { @Test @DisplayName("수정 실패: 존재하지 않는 자료") void update_notFound() { - when(dataSourceRepository.findById(anyInt())) + Integer memberId = 3; + when(dataSourceRepository.findByIdAndMemberId(anyInt(), eq(memberId))) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> dataSourceService.updateDataSource(1, "t", "s")) + assertThatThrownBy(() -> dataSourceService.updateDataSource(memberId, 1, "t", "s")) .isInstanceOf(NoResultException.class) .hasMessageContaining("존재하지 않는 자료"); } From b8e8c6d4f3af01a31e2b562c8d43888ba2d69c8c Mon Sep 17 00:00:00 2001 From: taekkong <141305946+taekkong@users.noreply.github.com> Date: Sat, 27 Sep 2025 19:44:33 +0900 Subject: [PATCH 049/132] =?UTF-8?q?fix=20:=20SpringDoc=EA=B3=BC=20Spring?= =?UTF-8?q?=20AI=20=EA=B0=84=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 2e173227..095ea94d 100644 --- a/build.gradle +++ b/build.gradle @@ -79,7 +79,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' // Spring AI - implementation "org.springframework.ai:spring-ai-starter-model-openai" + implementation ('org.springframework.ai:spring-ai-starter-model-openai'){ + exclude group: 'io.swagger.core.v3', module: 'swagger-annotations' + } // 크롤링 implementation("org.jsoup:jsoup:1.21.2") From 10588d9f98c627b50f47daf8bbb9ce8ff9f569af Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Mon, 29 Sep 2025 09:31:56 +0900 Subject: [PATCH 050/132] =?UTF-8?q?[Feat/OPS-327]=20=EC=9E=90=EB=A3=8C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20llm=20=EC=97=B0=EB=8F=99=20=20=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-255 : datasource 테이블 sources 칼럼 추가 * refactor/OPS-319 : 아카이브 로그인 연동 * refactor/OPS-319 : 아카이브 로그인 연동 * refactor/OPS-327 : 자료 등록 LLM 연동 --- .../datasource/repository/TagRepository.java | 10 + .../datasource/service/DataSourceService.java | 56 +++++- .../folder/service/FolderServiceTest.java | 5 +- .../controller/DatasourceControllerTest.java | 46 ++++- .../service/DataSourceServiceTest.java | 188 +++++++++++++++++- 5 files changed, 288 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java index 24cadb08..41d75c01 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/TagRepository.java @@ -1,9 +1,19 @@ package org.tuna.zoopzoop.backend.domain.datasource.repository; 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; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; +import java.util.List; + @Repository public interface TagRepository extends JpaRepository { + @Query(""" + select distinct t.tagName + from Tag t + where t.dataSource.folder.id = :folderId + """) + List findDistinctTagNamesByFolderId(@Param("folderId") Integer folderId); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java index 3e6ba728..4cb3aa96 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceService.java @@ -8,10 +8,15 @@ import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto; import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; +import java.io.IOException; import java.time.LocalDate; import java.util.*; import java.util.stream.Collectors; @@ -22,6 +27,8 @@ public class DataSourceService { private final DataSourceRepository dataSourceRepository; private final FolderRepository folderRepository; private final PersonalArchiveRepository personalArchiveRepository; + private final TagRepository tagRepository; + private final DataProcessorService dataProcessorService; /** * 지정한 folder 위치에 자료 생성 @@ -35,23 +42,52 @@ public int createDataSource(int currentMemberId, String sourceUrl, Integer folde folder = folderRepository.findById(folderId) .orElseThrow(() -> new NoResultException("존재하지 않는 폴더입니다.")); - DataSource ds = buildDataSource(sourceUrl, folder); - DataSource saved = dataSourceRepository.save(ds); + // 폴더 하위 자료 태그 수집(중복 X) + List contextTags = collectDistinctTagsOfFolder(folder.getId()); + DataSource ds = buildDataSource(folder, sourceUrl, contextTags); + + // 4) 저장 + final DataSource saved = dataSourceRepository.save(ds); return saved.getId(); } - private DataSource buildDataSource(String sourceUrl, Folder folder) { + // 폴더 하위 태그 중복없이 list 반환 + private List collectDistinctTagsOfFolder(Integer folderId) { + List names = tagRepository.findDistinctTagNamesByFolderId(folderId); + + return names.stream() + .map(Tag::new) + .toList(); + } + + private DataSource buildDataSource(Folder folder, String sourceUrl, List tagList) { + final DataSourceDto dataSourceDto; + try { + dataSourceDto = dataProcessorService.process(sourceUrl, tagList); + } catch (IOException e) { + throw new RuntimeException("자료 처리 중 오류가 발생했습니다.", e); + } + DataSource ds = new DataSource(); ds.setFolder(folder); - ds.setSourceUrl(sourceUrl); - ds.setTitle("자료 제목"); - ds.setSource("www.examplesource.com"); - ds.setSummary("설명"); - ds.setImageUrl("www.example.com/img"); - ds.setDataCreatedDate(LocalDate.now()); - ds.setCategory(Category.IT); + ds.setSourceUrl(dataSourceDto.sourceUrl()); + ds.setTitle(dataSourceDto.title()); + ds.setSummary(dataSourceDto.summary()); + ds.setDataCreatedDate(dataSourceDto.dataCreatedDate()); + ds.setImageUrl(dataSourceDto.imageUrl()); + ds.setSource(dataSourceDto.source()); + ds.setCategory(dataSourceDto.category()); ds.setActive(true); + + if (dataSourceDto.tags() != null) { + for (String tagName : dataSourceDto.tags()) { + Tag tag = new Tag(tagName); + tag.setDataSource(ds); + ds.getTags().add(tag); + } + } + return ds; } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java index 394a939a..7ec3393c 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/service/FolderServiceTest.java @@ -17,6 +17,7 @@ import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; import org.tuna.zoopzoop.backend.domain.datasource.dto.FileSummary; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; @@ -272,7 +273,7 @@ void getFilesInFolderForPersonal_success() { d1.setSourceUrl("http://src/a"); d1.setImageUrl("http://img/a"); d1.setTags(List.of(new Tag("tag1"), new Tag("tag2"))); - d1.setCategory(org.tuna.zoopzoop.backend.domain.datasource.entity.Category.IT); + d1.setCategory(Category.IT); DataSource d2 = new DataSource(); ReflectionTestUtils.setField(d2, "id", 11); @@ -282,7 +283,7 @@ void getFilesInFolderForPersonal_success() { d2.setSourceUrl("http://src/b"); d2.setImageUrl("http://img/b"); d2.setTags(List.of()); - d2.setCategory(org.tuna.zoopzoop.backend.domain.datasource.entity.Category.SCIENCE); + d2.setCategory(Category.SCIENCE); when(dataSourceRepository.findAllByFolder(folder)).thenReturn(List.of(d1, d2)); diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java index 2643b8c0..8f9e70a0 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceControllerTest.java @@ -2,9 +2,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.TestExecutionEvent; import org.springframework.security.test.context.support.WithUserDetails; @@ -15,11 +19,13 @@ import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService; import org.tuna.zoopzoop.backend.domain.datasource.dto.*; import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; import org.tuna.zoopzoop.backend.domain.member.enums.Provider; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; @@ -28,6 +34,8 @@ import java.util.List; import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -37,7 +45,6 @@ @Transactional @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DatasourceControllerTest { - @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @@ -47,13 +54,47 @@ class DatasourceControllerTest { @Autowired private FolderRepository folderRepository; @Autowired private DataSourceRepository dataSourceRepository; - private final String TEST_PROVIDER_KEY = "testUser_sc1111"; // WithUserDetails username -> "KAKAO:testUser_sc1111" + private final String TEST_PROVIDER_KEY = "testUser_sc1111"; private Integer testMemberId; private Integer docsFolderId; private Integer dataSourceId1; private Integer dataSourceId2; + @TestConfiguration + static class StubConfig { + @Bean + @Primary + DataProcessorService stubDataProcessorService() throws Exception { + return new DataProcessorService(null, null) { + @Override + public DataSourceDto process(String url, List tagList) { + return new DataSourceDto( + "테스트제목", + "테스트요약", + LocalDate.of(2025, 9, 1), + url, + "https://img.example/test.png", + "example.com", + Category.IT, + List.of("ML","Infra") + ); + } + }; + } + + @Bean + @Primary + TagRepository stubTagRepository() { + TagRepository mock = Mockito.mock(TagRepository.class); + + when(mock.findDistinctTagNamesByFolderId(anyInt())) + .thenReturn(java.util.List.of("AI", "Spring")); + + return mock; + } + } + @BeforeAll void beforeAll() { try { @@ -110,7 +151,6 @@ void beforeAll() { @AfterAll void afterAll() { - // 생성한 자료/폴더/멤버 삭제 try { if (dataSourceId1 != null) dataSourceRepository.findById(dataSourceId1).ifPresent(dataSourceRepository::delete); } catch (Exception ignored) {} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java index 2660cb89..1c4c8327 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -12,11 +13,18 @@ import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.dataprocessor.service.DataProcessorService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.DataSourceDto; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import java.io.IOException; +import java.time.LocalDate; import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -33,13 +41,20 @@ class DataSourceServiceTest { @Mock private DataSourceRepository dataSourceRepository; @Mock private FolderRepository folderRepository; @Mock private PersonalArchiveRepository personalArchiveRepository; + @Mock private TagRepository tagRepository; + @Mock private DataProcessorService dataProcessorService; @InjectMocks private DataSourceService dataSourceService; + private DataSourceDto dataSourceDto(String title, String summary, LocalDate date, String url, + String img, String source, Category cat, List tags) { + return new DataSourceDto(title, summary, date, url, img, source, cat, tags); + } + // create @Test @DisplayName("폴더 생성 성공- folderId=null 이면 default 폴더에 자료 생성") - void createDataSource_defaultFolder() { + void createDataSource_defaultFolder() throws IOException { int currentMemberId = 10; String sourceUrl = "https://example.com/a"; @@ -51,9 +66,23 @@ void createDataSource_defaultFolder() { .thenReturn(Optional.of(pa)); Folder defaultFolder = new Folder("default"); + ReflectionTestUtils.setField(defaultFolder, "id", 321); + when(folderRepository.findByArchiveIdAndIsDefaultTrue(anyInt())) .thenReturn(Optional.of(defaultFolder)); + when(tagRepository.findDistinctTagNamesByFolderId(eq(321))) + .thenReturn(List.of("AI", "Spring")); + + DataSourceDto returnedDto = dataSourceDto( + "제목A", "요약A", LocalDate.of(2025, 9, 1), sourceUrl, + "https://img.example/a.png", "example.com", Category.IT, + List.of("ML", "Infra") + ); + doReturn(returnedDto) + .when(dataProcessorService) + .process(eq(sourceUrl), anyList()); + when(dataSourceRepository.save(any(DataSource.class))) .thenAnswer(inv -> { DataSource ds = inv.getArgument(0); @@ -67,7 +96,7 @@ void createDataSource_defaultFolder() { @Test @DisplayName("폴더 생성 성공- folderId가 주어지면 해당 폴더에 자료 생성") - void createDataSource_specificFolder() { + void createDataSource_specificFolder() throws IOException { // given int currentMemberId = 10; String sourceUrl = "https://example.com/b"; @@ -78,6 +107,18 @@ void createDataSource_specificFolder() { when(folderRepository.findById(eq(folderId))).thenReturn(Optional.of(target)); + when(tagRepository.findDistinctTagNamesByFolderId(eq(folderId))) + .thenReturn(List.of("News", "Kotlin")); + + DataSourceDto returnedDto = dataSourceDto( + "제목B", "요약B", LocalDate.of(2025, 9, 2), sourceUrl, + "https://img.example/2.png", "tistory", Category.SCIENCE, + List.of("ML", "Infra") + ); + doReturn(returnedDto) + .when(dataProcessorService) + .process(eq(sourceUrl), anyList()); + when(dataSourceRepository.save(any(DataSource.class))) .thenAnswer(inv -> { DataSource ds = inv.getArgument(0); @@ -122,6 +163,149 @@ void createDataSource_defaultFolderNotFound() { ); } + //dataprocess 호출 테스트 + @Test + @DisplayName("자료 생성 성공 - 지정 폴더 + 컨텍스트 태그 수집 + process 호출 + DTO 매핑/태그 영속화") + void createDataSource_specificFolder_process_and_tags() throws Exception{ + // given + int currentMemberId = 10; + String sourceUrl = "https://example.com/b"; + Integer folderId = 77; + + Folder target = new Folder("target"); + ReflectionTestUtils.setField(target, "id", folderId); + + // 폴더 조회 + when(folderRepository.findById(eq(folderId))).thenReturn(Optional.of(target)); + // 컨텍스트 태그(distinct) + when(tagRepository.findDistinctTagNamesByFolderId(eq(folderId))) + .thenReturn(List.of("News", "Kotlin")); + // process 결과 DTO + DataSourceDto returnedDto = dataSourceDto( + "제목B", "요약B", LocalDate.of(2025, 9, 2), sourceUrl, + "https://img.example/2.png", "tistory", Category.SCIENCE, + List.of("ML", "Infra") + ); + when(dataProcessorService.process(eq(sourceUrl), anyList())).thenReturn(returnedDto); + + // save 캡처 + ArgumentCaptor dsCaptor = ArgumentCaptor.forClass(DataSource.class); + when(dataSourceRepository.save(dsCaptor.capture())) + .thenAnswer(inv -> { + DataSource ds = dsCaptor.getValue(); + ReflectionTestUtils.setField(ds, "id", 456); + return ds; + }); + + // when + int id = dataSourceService.createDataSource(currentMemberId, sourceUrl, folderId); + + // then + assertThat(id).isEqualTo(456); + + DataSource saved = dsCaptor.getValue(); + assertThat(saved.getFolder().getId()).isEqualTo(folderId); + assertThat(saved.getTitle()).isEqualTo("제목B"); + assertThat(saved.getSummary()).isEqualTo("요약B"); + assertThat(saved.getSourceUrl()).isEqualTo(sourceUrl); + assertThat(saved.getImageUrl()).isEqualTo("https://img.example/2.png"); + assertThat(saved.getSource()).isEqualTo("tistory"); + assertThat(saved.getCategory()).isEqualTo(Category.SCIENCE); + assertThat(saved.isActive()).isTrue(); + + // 태그 매핑 검증 + assertThat(saved.getTags()).hasSize(2); + assertThat(saved.getTags().stream().map(Tag::getTagName).toList()) + .containsExactlyInAnyOrder("ML", "Infra"); + assertThat(saved.getTags().stream().allMatch(t -> t.getDataSource() == saved)).isTrue(); + + // 컨텍스트 태그가 process 에 전달되었는지 검증 + ArgumentCaptor> ctxTagsCaptor = ArgumentCaptor.forClass(List.class); + verify(dataProcessorService).process(eq(sourceUrl), ctxTagsCaptor.capture()); + assertThat(ctxTagsCaptor.getValue().stream().map(Tag::getTagName).toList()) + .containsExactlyInAnyOrder("News", "Kotlin"); + + verify(tagRepository).findDistinctTagNamesByFolderId(folderId); + verifyNoInteractions(personalArchiveRepository); // 지정 폴더 경로이므로 호출 X + } + + // collectDistinctTagsOfFolder - tag 추출 단위 테스트 + + @Test + @DisplayName("태그 컨텍스트 수집 성공 - 폴더 하위 자료 태그명 distinct → Tag 리스트 변환") + void collectDistinctTagsOfFolder_success() { + // given + Integer folderId = 321; + when(tagRepository.findDistinctTagNamesByFolderId(eq(folderId))) + .thenReturn(List.of("AI", "Spring", "JPA")); + + // when (private 메서드 호출) + @SuppressWarnings("unchecked") + List ctxTags = (List) ReflectionTestUtils.invokeMethod( + dataSourceService, "collectDistinctTagsOfFolder", folderId + ); + + // then + assertThat(ctxTags).hasSize(3); + assertThat(ctxTags.stream().map(Tag::getTagName).toList()) + .containsExactlyInAnyOrder("AI", "Spring", "JPA"); + assertThat(ctxTags.stream().allMatch(t -> t.getDataSource() == null)).isTrue(); + + verify(tagRepository).findDistinctTagNamesByFolderId(folderId); + } + + // buildDataSource 단위 테스트 + + @Test + @DisplayName("엔티티 빌드 성공 - process 호출 결과 DTO를 DataSource에 매핑 + 태그 양방향 세팅") + void buildDataSource_maps_dto_and_tags() throws Exception{ + // given + Folder folder = new Folder("f"); + ReflectionTestUtils.setField(folder, "id", 77); + String url = "https://example.com/x"; + + // 컨텍스트 태그(폴더 하위) - process 인자로만 사용됨 + List context = List.of(new Tag("Ctx1"), new Tag("Ctx2")); + + // process 결과 DTO + DataSourceDto returnedDto = dataSourceDto( + "T", "S", LocalDate.of(2025, 9, 1), url, + "https://img", "example.com", Category.IT, + List.of("A", "B") // DTO 태그 + ); + when(dataProcessorService.process(eq(url), anyList())).thenReturn(returnedDto); + + // when (private 메서드 호출) + DataSource ds = (DataSource) ReflectionTestUtils.invokeMethod( + dataSourceService, "buildDataSource", folder, url, context + ); + + // then + assertThat(ds).isNotNull(); + assertThat(ds.getFolder().getId()).isEqualTo(77); + assertThat(ds.getTitle()).isEqualTo("T"); + assertThat(ds.getSummary()).isEqualTo("S"); + assertThat(ds.getSourceUrl()).isEqualTo(url); + assertThat(ds.getImageUrl()).isEqualTo("https://img"); + assertThat(ds.getSource()).isEqualTo("example.com"); + assertThat(ds.getCategory()).isEqualTo(Category.IT); + assertThat(ds.isActive()).isTrue(); + + // 태그 매핑 검증 + assertThat(ds.getTags()).hasSize(2); + assertThat(ds.getTags().stream().map(Tag::getTagName).toList()) + .containsExactlyInAnyOrder("A", "B"); + assertThat(ds.getTags().stream().allMatch(t -> t.getDataSource() == ds)).isTrue(); + + // process 호출시 컨텍스트 태그 전달 검증 + ArgumentCaptor> ctxTagsCaptor = ArgumentCaptor.forClass(List.class); + verify(dataProcessorService).process(eq(url), ctxTagsCaptor.capture()); + assertThat(ctxTagsCaptor.getValue().stream().map(Tag::getTagName).toList()) + .containsExactlyInAnyOrder("Ctx1", "Ctx2"); + } + + + // delete @Test @DisplayName("단건 삭제 성공 - 존재하는 자료 삭제 시 ID 반환 (member 소유 확인)") From 7e27e6dddc6af1955a0d918ca3454e9e471f9d16 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Mon, 29 Sep 2025 10:17:42 +0900 Subject: [PATCH 051/132] =?UTF-8?q?[feat/OPS-328]=20=EC=9D=B8=EC=A6=9D/?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-328 : RefreshToken을 서버에서 저장하도록 변경. API 일부 수정. * feat/OPS-328 : 확장 프로그램 로그인 메소드 추가 및 데이터 캐싱 메소드 추가. --- .../auth/controller/ApiV1AuthController.java | 109 ++++++++++++------ .../domain/auth/dto/AuthResultData.java | 19 +++ .../domain/auth/entity/AuthResult.java | 23 ++++ .../domain/auth/entity/RefreshToken.java | 39 +++++++ ...tomOAuth2AuthorizationRequestResolver.java | 23 +++- .../auth/handler/OAuth2SuccessHandler.java | 60 ++++++---- .../repository/RefreshTokenRepository.java | 14 +++ .../auth/service/RefreshTokenService.java | 54 +++++++++ .../controller/ApiV1MemberController.java | 5 +- .../dto/res/ResBodyForGetMemberInfo.java | 2 - .../dto/res/ResBodyForGetMemberInfoV2.java | 19 +++ 11 files changed, 304 insertions(+), 63 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/AuthResultData.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/AuthResult.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/RefreshToken.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/repository/RefreshTokenRepository.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/RefreshTokenService.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfoV2.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java index 60d187ba..3f9cfca2 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java @@ -8,12 +8,13 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; import org.springframework.web.bind.annotation.*; -import org.springframework.web.reactive.function.client.WebClient; -import org.tuna.zoopzoop.backend.domain.auth.service.KakaoUserInfoService; +import org.tuna.zoopzoop.backend.domain.auth.dto.AuthResultData; +import org.tuna.zoopzoop.backend.domain.auth.entity.AuthResult; +import org.tuna.zoopzoop.backend.domain.auth.entity.RefreshToken; +import org.tuna.zoopzoop.backend.domain.auth.service.RefreshTokenService; import org.tuna.zoopzoop.backend.domain.member.entity.Member; -import org.tuna.zoopzoop.backend.domain.member.service.MemberService; -import org.tuna.zoopzoop.backend.global.config.jwt.JwtProperties; import org.tuna.zoopzoop.backend.global.rsData.RsData; import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; @@ -23,10 +24,8 @@ @Tag(name = "ApiV1AuthController", description = "인증/인가 REST API 컨트롤러") public class ApiV1AuthController { private final JwtUtil jwtUtil; - private final MemberService memberService; - private final JwtProperties jwtProperties; - private final KakaoUserInfoService kakaoUserInfoService; - private final WebClient webClient; + private final RefreshTokenService refreshTokenService; + private final AuthResult authResult; /** * 사용자 로그아웃 API @@ -34,58 +33,79 @@ public class ApiV1AuthController { */ @GetMapping("/logout") @Operation(summary = "사용자 로그아웃") - public ResponseEntity> logout(HttpServletResponse response) { + public ResponseEntity> logout( + @CookieValue(name = "sessionId") + String sessionId, + HttpServletResponse response) { + + // 서버에서 RefreshToken 삭제 + refreshTokenService.deleteBySessionId(sessionId); + + // 클라이언트 쿠키 삭제 (AccessToken + SessionId) ResponseCookie accessCookie = ResponseCookie.from("accessToken", "") .httpOnly(true) .path("/") - .maxAge(0) // 쿠키 삭제 + .maxAge(0) .sameSite("Lax") .build(); - ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", "") + ResponseCookie sessionCookie = ResponseCookie.from("sessionId", "") .httpOnly(true) .path("/") - .maxAge(0) // 쿠키 삭제 + .maxAge(0) .sameSite("Lax") .build(); response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, sessionCookie.toString()); return ResponseEntity .status(HttpStatus.OK) - .body(new RsData<>( - "200", - "정상적으로 로그아웃 했습니다.", - null - ) - ); + .body(new RsData<>("200", "정상적으로 로그아웃 했습니다.", null)); } /** * refreshToken 기반으로 accessToken 재발급 - * @param refreshToken 쿠키에 포함된 현재 로그인한 사용자의 refreshToken + * @param sessionId 쿠키에 포함된 현재 로그인한 사용자의 sessionId. * @param response Servlet 기반 웹에서 server -> client로 http 응답을 보내기 위한 객체, 자동 주입. */ @PostMapping("/refresh") - @Operation(summary = "사용자 액세스 토큰 재발급 (리프레시 토큰이 유효할 경우)") - public ResponseEntity> refreshToken(@CookieValue(name = "refreshToken", required = false) String refreshToken, - HttpServletResponse response) { + @Operation(summary = "사용자 액세스 토큰 재발급 (서버 저장 RefreshToken 사용)") + public ResponseEntity> refreshToken( + @CookieValue(name = "sessionId") + String sessionId, + HttpServletResponse response + ) { - if (refreshToken == null || !jwtUtil.validateToken(refreshToken) || !jwtUtil.isRefreshToken(refreshToken)) { + if (sessionId == null) { return ResponseEntity .status(HttpStatus.UNAUTHORIZED) - .body(new RsData<>( - "401", - "유효하지 않은 리프레시 토큰입니다.", - null - )); + .body(new RsData<>("401", "세션이 존재하지 않습니다.", null)); + } + + // sessionId로 RefreshToken 조회 + RefreshToken refreshTokenEntity; + try { + refreshTokenEntity = refreshTokenService.getBySessionId(sessionId); + } catch (AuthenticationException e) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(new RsData<>("401", e.getMessage(), null)); + } + + String refreshToken = refreshTokenEntity.getRefreshToken(); + + // RefreshToken 유효성 검사 + if (!jwtUtil.validateToken(refreshToken) || !jwtUtil.isRefreshToken(refreshToken)) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(new RsData<>("401", "유효하지 않은 리프레시 토큰입니다.", null)); } - String providerKey = jwtUtil.getProviderKeyFromToken(refreshToken); - Member member = memberService.findByProviderKey(providerKey); + Member member = refreshTokenEntity.getMember(); + // 새 AccessToken 발급 String newAccessToken = jwtUtil.generateToken(member); ResponseCookie accessCookie = ResponseCookie.from("accessToken", newAccessToken) @@ -97,12 +117,33 @@ public ResponseEntity> refreshToken(@CookieValue(name = "refreshTok response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + return ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>("200", "액세스 토큰을 재발급 했습니다.", null)); + } + + @GetMapping("/result") + @Operation(summary = "확장프로그램 백그라운드 풀링 대응 API") + public ResponseEntity> pullingResult( + @RequestParam String state + ) { + AuthResultData resultData = authResult.get(state); + if(resultData == null) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(new RsData<>( + "404", + "state에 해당하는 토큰이 준비되지 않았거나, 잘못된 state 입니다.", + null + ) + ); + } return ResponseEntity .status(HttpStatus.OK) .body(new RsData<>( - "200", - "액세스 토큰을 재발급 했습니다.", - null + "200", + "토큰이 정상적으로 발급되었습니다.", + resultData )); } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/AuthResultData.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/AuthResultData.java new file mode 100644 index 00000000..dbdace4a --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/AuthResultData.java @@ -0,0 +1,19 @@ +package org.tuna.zoopzoop.backend.domain.auth.dto; + +public class AuthResultData { + private final String accessToken; + private final String sessionId; + + public AuthResultData(String accessToken, String sessionId) { + this.accessToken = accessToken; + this.sessionId = sessionId; + } + + public String getAccessToken() { + return accessToken; + } + + public String getSessionId() { + return sessionId; + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/AuthResult.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/AuthResult.java new file mode 100644 index 00000000..e29721ac --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/AuthResult.java @@ -0,0 +1,23 @@ +package org.tuna.zoopzoop.backend.domain.auth.entity; + +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.auth.dto.AuthResultData; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class AuthResult { + private final Map results = new ConcurrentHashMap<>(); + + public void put(String state, String accessToken, String sessionId) { + results.put(state, new AuthResultData(accessToken, sessionId)); + } + + public AuthResultData get(String state) { + return results.remove(state); + } + + public void consume(String state) { + results.remove(state); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/RefreshToken.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/RefreshToken.java new file mode 100644 index 00000000..414ad42e --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/RefreshToken.java @@ -0,0 +1,39 @@ +package org.tuna.zoopzoop.backend.domain.auth.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RefreshToken extends BaseEntity { + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", unique = true, nullable = false) + private Member member; + + @Column(name = "session_id", unique = true, nullable = false) + private String sessionId; + + @Column(unique = true, nullable = false) + private String refreshToken; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "expired_at") + private LocalDateTime expiredAt; + + @PrePersist + public void prePersist() { + if(createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/global/CustomOAuth2AuthorizationRequestResolver.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/global/CustomOAuth2AuthorizationRequestResolver.java index 04429882..f673afba 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/global/CustomOAuth2AuthorizationRequestResolver.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/global/CustomOAuth2AuthorizationRequestResolver.java @@ -1,11 +1,16 @@ package org.tuna.zoopzoop.backend.domain.auth.global; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { private final OAuth2AuthorizationRequestResolver defaultResolver; @@ -28,13 +33,25 @@ private OAuth2AuthorizationRequest customize(OAuth2AuthorizationRequest req, Htt if (req == null) return null; String source = request.getParameter("source"); // 로그인 시작 시 전달된 source - OAuth2AuthorizationRequest.Builder builder = OAuth2AuthorizationRequest.from(req); if ("extension".equals(source)) { - // state에 source 정보를 안전하게 포함 - builder.state("source:extension;" + req.getState()); + String state = request.getParameter("state"); + Map stateData = new HashMap<>(); + stateData.put("source", "extension"); + stateData.put("customState", state); + stateData.put("originalState", req.getState()); + + try { + String encodedState = Base64.getUrlEncoder() + .encodeToString(new ObjectMapper().writeValueAsBytes(stateData)); + builder.state(encodedState); + } catch (Exception e) { + e.printStackTrace(); + return builder.build(); + } } + return builder.build(); } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java index abd7e075..7b5e15cc 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java @@ -1,5 +1,7 @@ package org.tuna.zoopzoop.backend.domain.auth.handler; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -12,6 +14,8 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.auth.entity.AuthResult; +import org.tuna.zoopzoop.backend.domain.auth.service.RefreshTokenService; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; @@ -20,6 +24,8 @@ import java.io.IOException; import java.net.URLEncoder; +import java.util.Base64; +import java.util.Map; @Component @RequiredArgsConstructor @@ -29,6 +35,8 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final JwtProperties jwtProperties; private final MemberRepository memberRepository; private final MemberService memberService; + private final RefreshTokenService refreshTokenService; + private final AuthResult authResult; @Value("${front.redirect_domain}") private String redirect_domain; @@ -41,9 +49,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo Authentication authentication) throws IOException { // OAuth2 로그인 사용자의 속성 - OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); - // 소셜 로그인 공급자(Google, Kakao) + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); String registrationId = ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId(); // 공급자 별로 DB 에서 회원 조회 @@ -58,24 +65,35 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo throw new IllegalArgumentException(registrationId + "는 지원하지 않는 소셜 로그인입니다."); } - // 조회된 회원 정보를 기반으로 AccessToken 및 RefreshToken 생성 + // 조회된 회원 정보를 기반으로 AccessToken 생성 String accessToken = jwtUtil.generateToken(member); + + // RefreshToken 생성 및 DB 저장, SessionId 생성 String refreshToken = jwtUtil.generateRefreshToken(member); + String sessionId = refreshTokenService.saveSession(member, refreshToken); - String source = request.getParameter("source"); - String state = request.getParameter("state"); - log.info("[OAuth2SuccessHandler] Source: {}", source); - log.info("[OAuth2SuccessHandler] State: {}", state); - boolean isExtension = state != null && state.contains("source:extension"); + log.info("[OAuth2SuccessHandler] Member: {}, SessionId: {}", member.getId(), sessionId); - // 확장 프로그램에서 로그인 했을 경우. - if(isExtension){ - String redirectUrl = redirect_domain + "/extension/callback" - + "?success=true" - + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") - + "&refreshToken=" + URLEncoder.encode(refreshToken, "UTF-8"); - response.sendRedirect(redirectUrl); - return; + String state = request.getParameter("state"); + if(state != null && state.startsWith("ey")) { + Map stateData = new ObjectMapper().readValue( + Base64.getUrlDecoder().decode(state), + new TypeReference>() { + } + ); + + String source = stateData.get("source"); + String customState = stateData.get("customState"); + + log.info("[OAuth2SuccessHandler] Source: {}", source); + log.info("[OAuth2SuccessHandler] CustomState: {}", customState); + + // 확장 프로그램에서 로그인 했을 경우. + if ("extension".equals(source)) { + authResult.put(customState, accessToken, sessionId); + response.sendRedirect(redirect_domain + "/extension/success"); + return; + } } if ("http://localhost:3000".equals(redirect_domain)) { @@ -83,7 +101,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo String redirectUrl = redirect_domain + "/api/auth/callback" + "?success=true" + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") - + "&refreshToken=" + URLEncoder.encode(refreshToken, "UTF-8"); + + "&sessionId=" + URLEncoder.encode(sessionId, "UTF-8"); response.sendRedirect(redirectUrl); } else { @@ -98,12 +116,10 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo .sameSite("None") .build(); - ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken) + ResponseCookie sessionCookie = ResponseCookie.from("sessionId", sessionId) .httpOnly(true) .path("/") - .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) - // .domain() // 프론트엔드 & 백엔드 상위 도메인 - // .secure(true) // https 필수 설정. + .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) // RefreshToken 유효기간과 동일하게 .domain(redirect_domain) .secure(true) .sameSite("None") @@ -111,7 +127,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo // HTTP 응답에서 쿠키 값 추가. response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, sessionCookie.toString()); // 로그인 성공 후 리다이렉트. // 배포 시에 프론트엔드와 조율이 필요한 부분일 듯 함. diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..89a81727 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,14 @@ +package org.tuna.zoopzoop.backend.domain.auth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.tuna.zoopzoop.backend.domain.auth.entity.RefreshToken; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; + +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Optional findBySessionId(String sessionId); + Optional findByMember(Member member); +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/RefreshTokenService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/RefreshTokenService.java new file mode 100644 index 00000000..9ec6aea2 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/RefreshTokenService.java @@ -0,0 +1,54 @@ +package org.tuna.zoopzoop.backend.domain.auth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.auth.entity.RefreshToken; +import org.tuna.zoopzoop.backend.domain.auth.repository.RefreshTokenRepository; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + private final JwtUtil jwtUtil; + + private LocalDateTime getExpirationLocalDateTimeFromToken(String token) { + Date expirationDate = jwtUtil.getExpirationDateFromToken(token); // 기존 메서드 + if (expirationDate == null) return null; + + return LocalDateTime.ofInstant(expirationDate.toInstant(), ZoneId.systemDefault()); + } + + public String saveSession(Member member, String refreshToken) { + String sessionId = UUID.randomUUID().toString(); + + refreshTokenRepository.findByMember(member).ifPresent(refreshTokenRepository::delete); + + RefreshToken token = RefreshToken.builder() + .member(member) + .refreshToken(refreshToken) + .sessionId(sessionId) + .expiredAt(getExpirationLocalDateTimeFromToken(refreshToken)) + .build(); + + refreshTokenRepository.save(token); + return sessionId; + } + + public RefreshToken getBySessionId(String sessionId) { + return refreshTokenRepository.findBySessionId(sessionId) + .orElseThrow(() -> new BadCredentialsException("세션을 찾을 수 없습니다.")); + } + + public void deleteBySessionId(String sessionId) { + refreshTokenRepository.findBySessionId(sessionId) + .orElseThrow(() -> new BadCredentialsException("잘못된 요청입니다.")); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java index a38d67ad..70b6d872 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java @@ -11,6 +11,7 @@ import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMemberName; import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForEditMemberName; import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfo; +import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfoV2; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; import org.tuna.zoopzoop.backend.global.rsData.RsData; @@ -40,7 +41,7 @@ public class ApiV1MemberController { */ @GetMapping("/me") @Operation(summary = "사용자 정보 조회") - public ResponseEntity> getMemberInfo( + public ResponseEntity> getMemberInfo( @AuthenticationPrincipal CustomUserDetails userDetails ) { Member member = userDetails.getMember(); @@ -50,7 +51,7 @@ public ResponseEntity> getMemberInfo( new RsData<>( "200", "사용자 정보를 조회했습니다.", - new ResBodyForGetMemberInfo(member) + new ResBodyForGetMemberInfoV2(member) ) ); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java index 5bd2df27..91e714c1 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfo.java @@ -5,14 +5,12 @@ public record ResBodyForGetMemberInfo( Integer id, String name, -// String email, String profileUrl ) { public ResBodyForGetMemberInfo(Member member){ this( member.getId(), member.getName(), -// member.getEmail(), member.getProfileImageUrl() ); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfoV2.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfoV2.java new file mode 100644 index 00000000..9eeb0059 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfoV2.java @@ -0,0 +1,19 @@ +package org.tuna.zoopzoop.backend.domain.member.dto.res; + +import org.tuna.zoopzoop.backend.domain.member.entity.Member; + +public record ResBodyForGetMemberInfoV2( + Integer id, + String name, + String profileUrl, + String provider +) { + public ResBodyForGetMemberInfoV2(Member member){ + this( + member.getId(), + member.getName(), + member.getProfileImageUrl(), + member.getProvider().name() + ); + } +} From 2512ccdd1de0eb3197f3b57152a30af8c16c840c Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Mon, 29 Sep 2025 11:26:56 +0900 Subject: [PATCH 052/132] =?UTF-8?q?[refactor/OPS-337]=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EC=9D=BC?= =?UTF-8?q?=EB=B6=80=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95.=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-337 : 프론트 요구사항 반영 및 에러 수정. * refactor/OPS-337 : 테스트 케이스 수정. --- .../auth/controller/ApiV1AuthController.java | 5 ++ .../controller/ApiV1MemberController.java | 62 +++++++++++++++++-- .../member/dto/req/ReqBodyForEditMember.java | 17 +++++ .../req/ReqBodyForEditMemberProfileImage.java | 13 ++++ .../member/dto/res/ResBodyForEditMember.java | 11 ++++ .../res/ResBodyForEditMemberProfileImage.java | 9 +++ .../dto/res/ResBodyForGetMemberInfoV2.java | 8 ++- .../backend/domain/member/entity/Member.java | 7 ++- .../domain/member/service/MemberService.java | 37 +++++++++++ .../controller/MemberControllerTest.java | 9 +-- 10 files changed, 166 insertions(+), 12 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMember.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMemberProfileImage.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMember.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMemberProfileImage.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java index 3f9cfca2..a7c675d9 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java @@ -122,6 +122,11 @@ public ResponseEntity> refreshToken( .body(new RsData<>("200", "액세스 토큰을 재발급 했습니다.", null)); } + /** + * 확장프로그램의 액세스 토큰 발급을 위한 백그라운드 풀링에 대응하는 API + * @param state 확장프로그램 로그인 시 전달한 state 값. + */ + @GetMapping("/result") @Operation(summary = "확장프로그램 백그라운드 풀링 대응 API") public ResponseEntity> pullingResult( diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java index 70b6d872..2db7f4e6 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java @@ -8,10 +8,10 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMember; import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMemberName; -import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForEditMemberName; -import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfo; -import org.tuna.zoopzoop.backend.domain.member.dto.res.ResBodyForGetMemberInfoV2; +import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMemberProfileImage; +import org.tuna.zoopzoop.backend.domain.member.dto.res.*; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; import org.tuna.zoopzoop.backend.global.rsData.RsData; @@ -62,14 +62,14 @@ public ResponseEntity> getMemberInfo( * @param userDetails @AuthenticationPrincipal로 받아오는 현재 사용자 정보 * @param reqBodyForEditMemberName 수정할 닉네임을 받아오는 reqDto */ - @PutMapping("/edit") + @PutMapping("/edit/name") @Operation(summary = "사용자 닉네임 수정") public ResponseEntity> editMemberName( @AuthenticationPrincipal CustomUserDetails userDetails, @Valid @RequestBody ReqBodyForEditMemberName reqBodyForEditMemberName ) { Member member = userDetails.getMember(); - member.updateName(reqBodyForEditMemberName.newName()); + memberService.updateMemberName(member, reqBodyForEditMemberName.newName()); return ResponseEntity .status(HttpStatus.OK) .body( @@ -81,12 +81,64 @@ public ResponseEntity> editMemberName( ); } + /** + * 현재 로그인한 사용자의 프로필 이미지를 변경하는 API + * HTTP METHOD: PUT + * @param userDetails @AuthenticationPrincipal로 받아오는 현재 사용자 정보 + * @param reqBodyForEditMemberProfileImage 수정할 프로필 이미지를 받아오는 dto + */ + @PutMapping("/edit/image") + @Operation(summary = "사용자 닉네임 수정") + public ResponseEntity> editMemberProfileImage( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody ReqBodyForEditMemberProfileImage reqBodyForEditMemberProfileImage + ) { + Member member = userDetails.getMember(); + memberService.updateMemberProfileUrl(member, reqBodyForEditMemberProfileImage.file()); + return ResponseEntity + .status(HttpStatus.OK) + .body( + new RsData<>( + "200", + "사용자의 프로필 이미지를 변경했습니다.", + new ResBodyForEditMemberProfileImage(member.getProfileImageUrl()) + ) + ); + } + + /** + * 현재 로그인한 사용자의 닉네임과 프로필 이미지를 변경하는 API + * HTTP METHOD: PUT + * @param userDetails @AuthenticationPrincipal로 받아오는 현재 사용자 정보 + * @param reqBodyForEditMember 수정할 프로필 정보를 받아오는 dto + */ + @PutMapping("/edit") + @Operation(summary = "사용자 프로필 수정") + public ResponseEntity> editMemberProfile( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody ReqBodyForEditMember reqBodyForEditMember + ) { + Member member = userDetails.getMember(); + memberService.updateMemberProfile(member, reqBodyForEditMember.newName(), reqBodyForEditMember.file()); + return ResponseEntity + .status(HttpStatus.OK) + .body( + new RsData<>( + "200", + "사용자의 프로필을 변경했습니다.", + new ResBodyForEditMember(member.getName(), member.getProfileImageUrl()) + ) + ); + } + + /** * 현재 로그인한 사용자를 삭제하는 API * 사용할 지 모르겠음. * HTTP METHOD: DELETE * @param userDetails @AuthenticationPrincipal로 받아오는 현재 사용자 정보 */ + @DeleteMapping @Operation(summary = "사용자 삭제") public ResponseEntity> deleteMember( diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMember.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMember.java new file mode 100644 index 00000000..ee7d8f53 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMember.java @@ -0,0 +1,17 @@ +package org.tuna.zoopzoop.backend.domain.member.dto.req; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.web.multipart.MultipartFile; + +public record ReqBodyForEditMember ( + @NotBlank(message = "잘못된 요청입니다.") //MethodArgumentException + String newName, + @NotNull(message = "파일을 선택해주세요.") + MultipartFile file +) { + public ReqBodyForEditMember(String newName, MultipartFile file) { + this.newName = newName; + this.file = file; + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMemberProfileImage.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMemberProfileImage.java new file mode 100644 index 00000000..7075531f --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/req/ReqBodyForEditMemberProfileImage.java @@ -0,0 +1,13 @@ +package org.tuna.zoopzoop.backend.domain.member.dto.req; + +import jakarta.validation.constraints.NotNull; +import org.springframework.web.multipart.MultipartFile; + +public record ReqBodyForEditMemberProfileImage ( + @NotNull(message = "파일을 선택해주세요.") + MultipartFile file +) { + public ReqBodyForEditMemberProfileImage(MultipartFile file) { + this.file = file; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMember.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMember.java new file mode 100644 index 00000000..527acc6b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMember.java @@ -0,0 +1,11 @@ +package org.tuna.zoopzoop.backend.domain.member.dto.res; + +public record ResBodyForEditMember( + String name, + String profileUrl +) { + public ResBodyForEditMember(String name, String profileUrl) { + this.name = name; + this.profileUrl = profileUrl; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMemberProfileImage.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMemberProfileImage.java new file mode 100644 index 00000000..40798e71 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForEditMemberProfileImage.java @@ -0,0 +1,9 @@ +package org.tuna.zoopzoop.backend.domain.member.dto.res; + +public record ResBodyForEditMemberProfileImage( + String profileUrl +) { + public ResBodyForEditMemberProfileImage(String profileUrl) { + this.profileUrl = profileUrl; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfoV2.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfoV2.java index 9eeb0059..4b390675 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfoV2.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/dto/res/ResBodyForGetMemberInfoV2.java @@ -2,18 +2,22 @@ import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import java.time.LocalDateTime; + public record ResBodyForGetMemberInfoV2( Integer id, String name, String profileUrl, - String provider + String provider, + LocalDateTime createAt ) { public ResBodyForGetMemberInfoV2(Member member){ this( member.getId(), member.getName(), member.getProfileImageUrl(), - member.getProvider().name() + member.getProvider().name(), + member.getCreateDate() ); } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java index a438e413..b5ad97b9 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/entity/Member.java @@ -54,9 +54,14 @@ public Member(String name, String providerKey, Provider provider, String profile //---------- 메소드 ----------// public boolean isActive() { return this.active; } - public void updateName(String name) { //사용자 이름 수정 + public String updateName(String name) { //사용자 이름 수정 this.name = name; + return this.name; } //사용자 이름 변경 public void deactivate() { this.active = false; } //soft-delete public void activate() { this.active = true; } //restore + public String updateProfileUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + return this.profileImageUrl; + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java index 0b2ec51b..d6016230 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/service/MemberService.java @@ -5,10 +5,14 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.enums.Provider; import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.global.aws.S3Service; +import java.io.IOException; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -17,6 +21,7 @@ @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; + private final S3Service s3Service; //회원 조회 관련 public Member findById(Integer id) { @@ -84,8 +89,40 @@ public void updateMemberName(Member member, String newName){ throw new DataIntegrityViolationException("이미 사용중인 이름입니다."); } member.updateName(generateUniqueUserNameTag(newName)); + memberRepository.save(member); } + @Transactional + public void updateMemberProfileUrl(Member member, MultipartFile file){ + String extension = StringUtils.getFilenameExtension(file.getOriginalFilename()); + String fileName = "profile/" + member.getId() + "/profile." + extension; + try { + String newUrl = s3Service.upload(file, fileName); + member.updateProfileUrl(newUrl); + memberRepository.save(member); + } catch (IOException e) { + throw new IllegalArgumentException("잘못된 파일 입력입니다."); + } + } + + @Transactional + public void updateMemberProfile(Member member, String newName, MultipartFile file){ + if(memberRepository.findByName(newName).isPresent()) { + throw new DataIntegrityViolationException("이미 사용중인 이름입니다."); + } + member.updateName(generateUniqueUserNameTag(newName)); + String extension = StringUtils.getFilenameExtension(file.getOriginalFilename()); + String fileName = "profile/" + member.getId() + "/profile." + extension; + try { + String newUrl = s3Service.upload(file, fileName); + member.updateProfileUrl(newUrl); + memberRepository.save(member); + } catch (IOException e) { + throw new IllegalArgumentException("잘못된 파일 입력입니다."); + } + } + + //회원 삭제/복구 관련 public void softDeleteMember(Member member){ member.deactivate(); } public void hardDeleteMember(Member member){ memberRepository.delete(member); } diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java index 06af9ce6..16fcde3a 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java @@ -16,6 +16,7 @@ import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -158,13 +159,13 @@ void getMemberInfoByNameUnauthorized() throws Exception { @DisplayName("사용자 이름 수정 - 성공(200)") void editMemberNameSuccess() throws Exception { ReqBodyForEditMemberName reqBodyForEditMemberName = new ReqBodyForEditMemberName("test3"); - mockMvc.perform(put("/api/v1/member/edit") + mockMvc.perform(put("/api/v1/member/edit/name") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reqBodyForEditMemberName))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.msg").value("사용자의 닉네임을 변경했습니다.")) - .andExpect(jsonPath("$.data.name").value("test3")); + .andExpect(jsonPath("$.data.name").value(containsString("test"))); } @Test @@ -172,7 +173,7 @@ void editMemberNameSuccess() throws Exception { @DisplayName("사용자 이름 수정 - 실패(400, Bad_Request)") void editMemberNameFailedByBadRequest() throws Exception { ReqBodyForEditMemberName reqBodyForEditMemberName = new ReqBodyForEditMemberName(""); - mockMvc.perform(put("/api/v1/member/edit") + mockMvc.perform(put("/api/v1/member/edit/name") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reqBodyForEditMemberName))) .andExpect(status().isBadRequest()) @@ -184,7 +185,7 @@ void editMemberNameFailedByBadRequest() throws Exception { @DisplayName("사용자 이름 수정 - 실패(401, Unauthorized)") void editMemberNameFailedByUnauthorized() throws Exception { ReqBodyForEditMemberName reqBodyForEditMemberName = new ReqBodyForEditMemberName("test3"); - mockMvc.perform(put("/api/v1/member/edit") + mockMvc.perform(put("/api/v1/member/edit/name") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(reqBodyForEditMemberName))) .andExpect(status().isUnauthorized()) From d6878aa6a2c4be8d5af2644fbf502bf94d5cfcbe Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:22:04 +0900 Subject: [PATCH 053/132] =?UTF-8?q?refactor/OPS-338=20:=20=EC=95=84?= =?UTF-8?q?=EC=B9=B4=EC=9D=B4=EB=B8=8C=20mock=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=9E=85=EB=A0=A5=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-338 : 아카이브 mock 데이터 입력 * refactor/OPS-338 : 아카이브 mock 데이터 입력 * refactor/OPS-338 : mock 추가로 인한 testcase 수정 --- .../folder/repository/FolderRepository.java | 4 + .../repository/DataSourceRepository.java | 2 + .../backend/domain/graph/entity/Node.java | 4 +- .../initData/PersonalArchiveInitData.java | 107 ++++++++++++++++++ src/main/resources/application.yml | 7 +- .../controller/MemberControllerTest.java | 1 + 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/initData/PersonalArchiveInitData.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java index 9f5048c1..9464ca5e 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/archive/folder/repository/FolderRepository.java @@ -58,4 +58,8 @@ public interface FolderRepository extends JpaRepository{ """) Optional findByIdAndMemberId(@Param("folderId") Integer folderId, @Param("memberId") Integer memberId); + + Optional findByArchiveIdAndName(Integer archiveId, String name); + + List findAllByArchiveId(Integer archiveId); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java index fcbf8a65..ca43036d 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/repository/DataSourceRepository.java @@ -39,5 +39,7 @@ public interface DataSourceRepository extends JpaRepository """) List findExistingIdsInMember(@Param("memberId") Integer memberId, @Param("ids") Collection ids); + Optional findByFolderIdAndTitle(Integer folderId, String title); + } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Node.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Node.java index 5a835461..ee5eb848 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Node.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Node.java @@ -26,8 +26,8 @@ public class Node extends BaseEntity { @ElementCollection @CollectionTable(name = "node_data", joinColumns = @JoinColumn(name = "node_id")) - @MapKeyColumn(name = "key") - @Column(name = "value") + @MapKeyColumn(name = "data_key") + @Column(name = "data_value") private Map data = new HashMap<>(); @Column diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/initData/PersonalArchiveInitData.java b/src/main/java/org/tuna/zoopzoop/backend/global/initData/PersonalArchiveInitData.java new file mode 100644 index 00000000..8dfddb8c --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/initData/PersonalArchiveInitData.java @@ -0,0 +1,107 @@ +package org.tuna.zoopzoop.backend.global.initData; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; + +import java.time.LocalDate; +import java.util.List; + +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(value = "app.seed.enabled", havingValue = "true") +public class PersonalArchiveInitData { + + private final MemberRepository memberRepository; + private final FolderRepository folderRepository; + private final DataSourceRepository dataSourceRepository; + + @Autowired @Lazy + private PersonalArchiveInitData self; + + @Bean + ApplicationRunner archiveInitRunner() { + return args -> self.initAll(); // 프록시 경유 + } + + @Transactional + public void initAll() { + System.out.println(">>> seed initAll start"); + + final String providerKeyEmail = "kjjeaus@gmail.com"; + + Member member = memberRepository + .findByProviderAndProviderKey(Provider.KAKAO, providerKeyEmail) + .orElseGet(() -> memberRepository.save( + Member.builder() + .name("kjjeaus") + .providerKey(providerKeyEmail) + .provider(Provider.KAKAO) + .profileImageUrl("https://img.example.com/profile.png") + .build() + )); + + PersonalArchive pa = member.getPersonalArchive(); + + folderRepository.findByArchiveIdAndName(pa.getArchive().getId(), "default") + .orElseGet(() -> { + Folder df = pa.getArchive().getFolders().stream() + .filter(Folder::isDefault) + .findFirst() + .orElse(new Folder("default")); + df.setArchive(pa.getArchive()); + df.setDefault(true); + return folderRepository.save(df); + }); + + for (String name : List.of("inbox","research","ai","reading-list")) { + folderRepository.findByArchiveIdAndName(pa.getArchive().getId(), name) + .orElseGet(() -> { + Folder f = new Folder(name); + f.setDefault(false); + f.setArchive(pa.getArchive()); + return folderRepository.save(f); + }); + } + + List persistedFolders = folderRepository.findAllByArchiveId(pa.getArchive().getId()); + + for (Folder folder : persistedFolders) { + for (int i = 1; i <= 3; i++) { + String title = folder.getName() + "-자료" + i; + if (dataSourceRepository.findByFolderIdAndTitle(folder.getId(), title).isPresent()) continue; + + DataSource ds = new DataSource(); + ds.setFolder(folder); + ds.setTitle(title); + ds.setSummary("초기 목데이터"); + ds.setDataCreatedDate(LocalDate.now().minusDays(i)); + ds.setSourceUrl("https://example.com/" + folder.getName() + "/" + i); + ds.setImageUrl("https://example.com/img/" + i + ".png"); + ds.setSource("Seed"); + ds.setCategory(Category.IT); + ds.setActive(true); + + dataSourceRepository.save(ds); + } + } + + System.out.println(">>> seed initAll end"); + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f6cd5b26..078a2d05 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,4 +45,9 @@ logging: org.springframework.transaction.interceptor: TRACE com.back: DEBUG server: - port: 8080 \ No newline at end of file + port: 8080 + + +app: + seed: + enabled: true \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java index 16fcde3a..87c22390 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/controller/MemberControllerTest.java @@ -40,6 +40,7 @@ public class MemberControllerTest { @BeforeAll void setUp() { + memberRepository.deleteAll(); Member member1 = memberService.createMember( "test1", "url", From 2b90a906b1d52c4c88237cb4716abc7e4cde22dd Mon Sep 17 00:00:00 2001 From: main <87134708+yonggi1234@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:50:15 +0900 Subject: [PATCH 054/132] =?UTF-8?q?[feat/OPS-338]=20=EC=95=84=EC=B9=B4?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20mock=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-338 : 아카이브 mock 데이터 입력 * refactor/OPS-338 : 아카이브 mock 데이터 입력 * refactor/OPS-338 : mock 추가로 인한 testcase 수정 * refactor/OPS-338 : Dev 토큰 발급 컨트롤러 추가 --- .../auth/dev/controller/DevController.java | 35 +++++++++++++++++++ .../global/security/SecurityConfig.java | 3 +- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/auth/dev/controller/DevController.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dev/controller/DevController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dev/controller/DevController.java new file mode 100644 index 00000000..7f59d272 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dev/controller/DevController.java @@ -0,0 +1,35 @@ +package org.tuna.zoopzoop.backend.domain.auth.dev.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.global.security.jwt.JwtUtil; + +import java.util.Map; + +@Profile({"local","dev","staging","test"}) +@RestController +@RequestMapping("/dev") +@RequiredArgsConstructor +public class DevController { + + private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; + + @GetMapping("/token") + public Map issueToken( + @RequestParam Provider provider, + @RequestParam String key + ) { + Member m = memberRepository.findByProviderAndProviderKey(provider, key) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "member not found")); + + String accessToken = jwtUtil.generateToken(m); + return Map.of("accessToken", accessToken); + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java index 2f0c70e3..3761712f 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java @@ -43,7 +43,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/webjars/**", "/api/v1/**", // API 테스트용으로 모두 허용. 차후 필수로 변경 필요. "/test/**", // 테스트용으로 모두 허용. 차후 삭제 필요. - "/actuator/health" // health 체크용 + "/actuator/health", // health 체크용 + "/dev/**" // ← 추가: dev 토큰 발급은 누구나 접근 ).permitAll() .anyRequest().authenticated() ) From 8ec5147f6ff8f8d50a3342d5a9b55cd99bb577c4 Mon Sep 17 00:00:00 2001 From: Hyeok Jin Kim <71928299+EpicFn@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:08:38 +0900 Subject: [PATCH 055/132] =?UTF-8?q?[Feat/OPS-326]=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=B2=B4=EA=B3=84=20=EA=B5=AC=EC=B6=95=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor : graph 도메인을 dashboard 도메인으로 수정, dashboard 엔티티 생성 * feat : 스페이스 단건 조회 시 대시보드 id도 함께 반환 * refactor : API 명 변경 * dashboard 기반으로 저장/조회 되도록 변경 * refactor : 테스트 코드 수정 * fix : 반환 메세지 수정 * feat : 서명 검증 로직 구현 * refactor : signature 서비스 분리 * fix : 오타 수정 * fix : CI 파이프라인 수정 --------- Co-authored-by: EpicFn --- .github/workflows/test-server-ci.yml | 2 + build.gradle | 2 + .../controller/ApiV1DashboardController.java | 76 +++++ .../dto/BodyForReactFlow.java | 49 +++- .../domain/dashboard/entity/Dashboard.java | 36 +++ .../{graph => dashboard}/entity/Edge.java | 4 +- .../{graph => dashboard}/entity/Graph.java | 2 +- .../{graph => dashboard}/entity/Node.java | 4 +- .../{graph => dashboard}/enums/EdgeType.java | 2 +- .../domain/dashboard/enums/NodeType.java | 5 + .../repository/DashboardRepository.java | 9 + .../repository/EdgeRepository.java | 4 +- .../repository/GraphRepository.java | 4 +- .../repository/NodeRepository.java | 4 +- .../dashboard/service/DashboardService.java | 115 ++++++++ .../service/GraphService.java | 6 +- .../dashboard/service/SignatureService.java | 72 +++++ .../controller/ApiV1GraphController.java | 60 ---- .../backend/domain/graph/enums/NodeType.java | 5 - .../controller/ApiV1SpaceController.java | 3 +- .../space/dto/res/ResBodyForSpaceInfo.java | 3 +- .../domain/space/space/entity/Space.java | 18 +- .../exception/GlobalExceptionHandler.java | 11 + .../application-secrets.yml.template | 5 +- src/main/resources/application-test.yml | 3 + .../controller/DashboardControllerTest.java | 272 ++++++++++++++++++ .../service/GraphServiceTest.java | 14 +- .../graph/controller/GraphControllerTest.java | 129 --------- .../controller/ApiV1SpaceControllerTest.java | 3 +- 29 files changed, 695 insertions(+), 227 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java rename src/main/java/org/tuna/zoopzoop/backend/domain/{graph => dashboard}/dto/BodyForReactFlow.java (66%) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Dashboard.java rename src/main/java/org/tuna/zoopzoop/backend/domain/{graph => dashboard}/entity/Edge.java (83%) rename src/main/java/org/tuna/zoopzoop/backend/domain/{graph => dashboard}/entity/Graph.java (91%) rename src/main/java/org/tuna/zoopzoop/backend/domain/{graph => dashboard}/entity/Node.java (86%) rename src/main/java/org/tuna/zoopzoop/backend/domain/{graph => dashboard}/enums/EdgeType.java (57%) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/NodeType.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/DashboardRepository.java rename src/main/java/org/tuna/zoopzoop/backend/domain/{graph => dashboard}/repository/EdgeRepository.java (51%) rename src/main/java/org/tuna/zoopzoop/backend/domain/{graph => dashboard}/repository/GraphRepository.java (62%) rename src/main/java/org/tuna/zoopzoop/backend/domain/{graph => dashboard}/repository/NodeRepository.java (51%) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java rename src/main/java/org/tuna/zoopzoop/backend/domain/{graph => dashboard}/service/GraphService.java (76%) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/SignatureService.java delete mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/controller/ApiV1GraphController.java delete mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/NodeType.java create mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/DashboardControllerTest.java rename src/test/java/org/tuna/zoopzoop/backend/domain/{graph => dashboard}/service/GraphServiceTest.java (82%) delete mode 100644 src/test/java/org/tuna/zoopzoop/backend/domain/graph/controller/GraphControllerTest.java diff --git a/.github/workflows/test-server-ci.yml b/.github/workflows/test-server-ci.yml index 3e9c06b4..5dd3ce1d 100644 --- a/.github/workflows/test-server-ci.yml +++ b/.github/workflows/test-server-ci.yml @@ -61,6 +61,8 @@ jobs: echo "spring.cloud.aws.s3.bucket: ${{ secrets.AWS_S3_BUCKET_NAME }}" >> src/main/resources/application-secrets.yml echo "spring.cloud.aws.stack.auto: false" >> src/main/resources/application-secrets.yml + echo "liveblocks.secret-key: ${{ secrets.LIVEBLOCKS_SECRET_KEY }}" >> src/main/resources/application-secrets.yml + # 6. application-secrets-server.yml 생성 - name: Generate application-secrets-server.yml run: | diff --git a/build.gradle b/build.gradle index 095ea94d..7b09ad62 100644 --- a/build.gradle +++ b/build.gradle @@ -98,6 +98,8 @@ dependencies { // Playwright for Java implementation 'com.microsoft.playwright:playwright:1.54.0' + // Apache Commons Codec + implementation"commons-codec:commons-codec:1.19.0" } dependencyManagement { diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java new file mode 100644 index 00000000..9e12f49c --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java @@ -0,0 +1,76 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; +import org.tuna.zoopzoop.backend.domain.dashboard.service.DashboardService; +import org.tuna.zoopzoop.backend.domain.dashboard.service.GraphService; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +import java.nio.file.AccessDeniedException; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/dashboard") +@Tag(name = "ApiV1GraphController", description = "React-flow 데이터 컨트롤러") +public class ApiV1DashboardController { + private final DashboardService dashboardService; + + /** + * React-flow 데이터 저장(갱신) API + * @param dashboardId React-flow 데이터의 dashboard 식별 id + * @param requestBody React-flow 에서 보내주는 body 전체 + * @param signature Liveblocks-Signature 헤더 값 + * @return ResponseEntity> + */ + @PutMapping("/{dashboardId}/graph") + @Operation(summary = "React-flow 데이터 저장(갱신)") + public ResponseEntity> updateGraph( + @PathVariable Integer dashboardId, + @RequestBody String requestBody, + @RequestHeader("Liveblocks-Signature") String signature + ) { + dashboardService.verifyAndUpdateGraph(dashboardId, requestBody, signature); + + return ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "React-flow 데이터를 저장했습니다.", + null + )); + } + + /** + * React-flow 데이터 조회 API + * @param dashboardId React-flow 데이터의 dashboard 식별 id + */ + @GetMapping("/{dashboardId}/graph") + @Operation(summary = "React-flow 데이터 조회") + public ResponseEntity> getGraph( + @PathVariable Integer dashboardId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) throws AccessDeniedException { + // TODO : 권한 체크 로직 추가 + Member member = userDetails.getMember(); + dashboardService.verifyAccessPermission(member, dashboardId); + + Graph graph = dashboardService.getGraphByDashboardId(dashboardId); + return ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "ID: " + dashboardId + " 의 React-flow 데이터를 조회했습니다.", + BodyForReactFlow.from(graph) + )); + + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/dto/BodyForReactFlow.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/BodyForReactFlow.java similarity index 66% rename from src/main/java/org/tuna/zoopzoop/backend/domain/graph/dto/BodyForReactFlow.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/BodyForReactFlow.java index 79d97b13..037b8882 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/dto/BodyForReactFlow.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/BodyForReactFlow.java @@ -1,11 +1,11 @@ -package org.tuna.zoopzoop.backend.domain.graph.dto; +package org.tuna.zoopzoop.backend.domain.dashboard.dto; import com.fasterxml.jackson.annotation.JsonProperty; -import org.tuna.zoopzoop.backend.domain.graph.entity.Edge; -import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; -import org.tuna.zoopzoop.backend.domain.graph.entity.Node; -import org.tuna.zoopzoop.backend.domain.graph.enums.EdgeType; -import org.tuna.zoopzoop.backend.domain.graph.enums.NodeType; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Node; +import org.tuna.zoopzoop.backend.domain.dashboard.enums.EdgeType; +import org.tuna.zoopzoop.backend.domain.dashboard.enums.NodeType; import java.util.List; import java.util.Map; @@ -15,6 +15,7 @@ public record BodyForReactFlow( List nodes, List edges ) { + public record NodeDto( @JsonProperty("id") String nodeKey, @JsonProperty("type") String nodeType, @@ -105,4 +106,40 @@ public static BodyForReactFlow from(Graph graph) { return new BodyForReactFlow(nodeDtos, edgeDtos); } + + public List toNodeEntities(Graph graph) { + return this.nodes().stream() + .map(dto -> { + Node node = new Node(); + node.setNodeKey(dto.nodeKey()); + node.setNodeType(NodeType.valueOf(dto.nodeType().toUpperCase())); + node.setData(dto.data()); + node.setPositonX(dto.positionDto().x()); + node.setPositonY(dto.positionDto().y()); + node.setGraph(graph); // 연관관계 설정 + return node; + }) + .toList(); + } + + public List toEdgeEntities(Graph graph) { + return this.edges().stream() + .map(dto -> { + Edge edge = new Edge(); + edge.setEdgeKey(dto.edgeKey()); + edge.setSourceNodeKey(dto.sourceNodeKey()); + edge.setTargetNodeKey(dto.targetNodeKey()); + edge.setEdgeType(EdgeType.valueOf(dto.edgeType().toUpperCase())); + edge.setAnimated(dto.isAnimated()); + if (dto.styleDto() != null) { + edge.setStroke(dto.styleDto().stroke()); + edge.setStrokeWidth(dto.styleDto().strokeWidth()); + } + edge.setGraph(graph); // 연관관계 설정 + return edge; + }) + .toList(); + } + + } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Dashboard.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Dashboard.java new file mode 100644 index 00000000..b13006c4 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Dashboard.java @@ -0,0 +1,36 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; + +@Entity +@Getter +@Setter +public class Dashboard extends BaseEntity { + // 대시보드의 이름 + @Column(nullable = false) + private String name; + + // 이 대시보드가 속한 스페이스 + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "space_id") + private Space space; + + // 이 대시보드가 담고 있는 그래프 콘텐츠 (1:1 관계) + // Cascade 설정을 통해 Dashboard 저장 시 Graph도 함께 저장되도록 함 + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "graph_id") + private Graph graph; + + // Dashboard 생성 시 비어있는 Graph를 함께 생성하는 편의 메서드 + public static Dashboard create(String name, Space space) { + Dashboard dashboard = new Dashboard(); + dashboard.setName(name); + dashboard.setSpace(space); + dashboard.setGraph(new Graph()); // 비어있는 Graph 생성 및 연결 + return dashboard; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Edge.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Edge.java similarity index 83% rename from src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Edge.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Edge.java index e6fdf5de..65538cc1 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Edge.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Edge.java @@ -1,9 +1,9 @@ -package org.tuna.zoopzoop.backend.domain.graph.entity; +package org.tuna.zoopzoop.backend.domain.dashboard.entity; import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; -import org.tuna.zoopzoop.backend.domain.graph.enums.EdgeType; +import org.tuna.zoopzoop.backend.domain.dashboard.enums.EdgeType; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; @Getter diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Graph.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Graph.java similarity index 91% rename from src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Graph.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Graph.java index 53c10d0a..7f70b105 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Graph.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Graph.java @@ -1,4 +1,4 @@ -package org.tuna.zoopzoop.backend.domain.graph.entity; +package org.tuna.zoopzoop.backend.domain.dashboard.entity; import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Node.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Node.java similarity index 86% rename from src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Node.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Node.java index ee5eb848..64a3b3a8 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/entity/Node.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Node.java @@ -1,9 +1,9 @@ -package org.tuna.zoopzoop.backend.domain.graph.entity; +package org.tuna.zoopzoop.backend.domain.dashboard.entity; import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; -import org.tuna.zoopzoop.backend.domain.graph.enums.NodeType; +import org.tuna.zoopzoop.backend.domain.dashboard.enums.NodeType; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; import java.util.HashMap; diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/EdgeType.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/EdgeType.java similarity index 57% rename from src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/EdgeType.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/EdgeType.java index e184fc13..7d90eaa8 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/EdgeType.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/EdgeType.java @@ -1,4 +1,4 @@ -package org.tuna.zoopzoop.backend.domain.graph.enums; +package org.tuna.zoopzoop.backend.domain.dashboard.enums; public enum EdgeType { DEFAULT, diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/NodeType.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/NodeType.java new file mode 100644 index 00000000..0111ba3b --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/enums/NodeType.java @@ -0,0 +1,5 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.enums; + +public enum NodeType { + CUSTOM +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/DashboardRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/DashboardRepository.java new file mode 100644 index 00000000..0b163042 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/DashboardRepository.java @@ -0,0 +1,9 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Dashboard; + +@Repository +public interface DashboardRepository extends JpaRepository { +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/EdgeRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/EdgeRepository.java similarity index 51% rename from src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/EdgeRepository.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/EdgeRepository.java index 1c1eb6de..1d7823ab 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/EdgeRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/EdgeRepository.java @@ -1,7 +1,7 @@ -package org.tuna.zoopzoop.backend.domain.graph.repository; +package org.tuna.zoopzoop.backend.domain.dashboard.repository; import org.springframework.data.jpa.repository.JpaRepository; -import org.tuna.zoopzoop.backend.domain.graph.entity.Edge; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge; public interface EdgeRepository extends JpaRepository { } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/GraphRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/GraphRepository.java similarity index 62% rename from src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/GraphRepository.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/GraphRepository.java index 87da826a..e75b7682 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/GraphRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/GraphRepository.java @@ -1,7 +1,7 @@ -package org.tuna.zoopzoop.backend.domain.graph.repository; +package org.tuna.zoopzoop.backend.domain.dashboard.repository; import org.springframework.data.jpa.repository.JpaRepository; -import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; import java.util.Optional; diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/NodeRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/NodeRepository.java similarity index 51% rename from src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/NodeRepository.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/NodeRepository.java index c3082b45..6342c6a2 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/repository/NodeRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/repository/NodeRepository.java @@ -1,7 +1,7 @@ -package org.tuna.zoopzoop.backend.domain.graph.repository; +package org.tuna.zoopzoop.backend.domain.dashboard.repository; import org.springframework.data.jpa.repository.JpaRepository; -import org.tuna.zoopzoop.backend.domain.graph.entity.Node; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Node; public interface NodeRepository extends JpaRepository { } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java new file mode 100644 index 00000000..d1d8636d --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java @@ -0,0 +1,115 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.NoResultException; +import lombok.RequiredArgsConstructor; +import org.apache.commons.codec.binary.Hex; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Dashboard; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Node; +import org.tuna.zoopzoop.backend.domain.dashboard.repository.DashboardRepository; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.nio.file.AccessDeniedException; +import java.security.MessageDigest; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class DashboardService { + private final DashboardRepository dashboardRepository; + private final MembershipService membershipService; + private final ObjectMapper objectMapper; + private final SignatureService signatureService; + + + + // =========================== Graph 관련 메서드 =========================== + + /** + * 대시보드 ID를 통해 Graph 데이터를 조회하는 메서드 + */ + @Transactional(readOnly = true) + public Graph getGraphByDashboardId(Integer dashboardId) { + Dashboard dashboard = dashboardRepository.findById(dashboardId) + .orElseThrow(() -> new NoResultException(dashboardId + " ID를 가진 대시보드를 찾을 수 없습니다.")); + + return dashboard.getGraph(); + } + + /** + * 특정 대시보드의 Graph 데이터를 덮어쓰는(수정) 메서드 + */ + public void updateGraph(Integer dashboardId, BodyForReactFlow dto) { + Graph graph = getGraphByDashboardId(dashboardId); + + // 기존 Graph의 노드와 엣지를 모두 삭제 + graph.getNodes().clear(); + graph.getEdges().clear(); + + // DTO로부터 새로운 노드와 엣지 Entity 리스트를 생성 + List newNodes = dto.toNodeEntities(graph); // DTO에 변환 로직이 있다고 가정 + List newEdges = dto.toEdgeEntities(graph); + + // Graph에 새로운 리스트를 추가 + graph.getNodes().addAll(newNodes); + graph.getEdges().addAll(newEdges); + + } + + /** + * 서명 검증 후 Graph 업데이트를 수행하는 메서드 + * @param dashboardId 대시보드 ID + * @param requestBody 요청 바디 + * @param signatureHeader 서명 헤더 + */ + public void verifyAndUpdateGraph(Integer dashboardId, String requestBody, String signatureHeader) { + // 1. 서명 검증 + if (!signatureService.isValidSignature(requestBody, signatureHeader)) { + throw new SecurityException("Invalid webhook signature."); + } + + // 2. 검증 통과 후, 기존 업데이트 로직 실행 + try { + BodyForReactFlow dto = objectMapper.readValue(requestBody, BodyForReactFlow.class); + updateGraph(dashboardId, dto); + } catch (NoResultException e) { + throw new NoResultException(dashboardId + " ID를 가진 대시보드를 찾을 수 없습니다."); + } + catch (Exception e) { + throw new RuntimeException("Failed to process request body.", e); + } + } + + // =========================== 권한 관련 메서드 =========================== + + /** + * 대시보드 접근 권한을 검증하는 메서드 + * @param member 접근을 시도하는 멤버 + * @param dashboardId 접근하려는 대시보드 ID + */ + public void verifyAccessPermission(Member member, Integer dashboardId) throws AccessDeniedException { + Dashboard dashboard = dashboardRepository.findById(dashboardId) + .orElseThrow(() -> new NoResultException(dashboardId + " ID를 가진 대시보드를 찾을 수 없습니다.")); + + try { + membershipService.findByMemberAndSpace(member, dashboard.getSpace()); + } catch (NoResultException e) { + throw new AccessDeniedException("대시보드의 접근 권한이 없습니다."); + } + } + + + + +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphService.java similarity index 76% rename from src/main/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphService.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphService.java index bb8b44c4..d6069ffd 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphService.java @@ -1,11 +1,11 @@ -package org.tuna.zoopzoop.backend.domain.graph.service; +package org.tuna.zoopzoop.backend.domain.dashboard.service; import jakarta.persistence.NoResultException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; -import org.tuna.zoopzoop.backend.domain.graph.repository.GraphRepository; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; +import org.tuna.zoopzoop.backend.domain.dashboard.repository.GraphRepository; @Service @RequiredArgsConstructor diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/SignatureService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/SignatureService.java new file mode 100644 index 00000000..0c06021e --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/SignatureService.java @@ -0,0 +1,72 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.service; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.codec.binary.Hex; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Service +@RequiredArgsConstructor +public class SignatureService { + @Value("${liveblocks.secret-key}") + private String liveblocksSecretKey; + + // 5분 (밀리초 단위) + private static final long TOLERANCE_IN_MILLIS = 5 * 60 * 1000; + + /** + * LiveBlocks Webhook 요청의 유효성을 검증하는 메서드 + * @param requestBody 요청 바디 + * @param signatureHeader LiveBlocks가 제공하는 서명 헤더 + * @return 서명이 유효하면 true, 그렇지 않으면 false + */ + public boolean isValidSignature(String requestBody, String signatureHeader) { + try { + // 1. 헤더 파싱 + String[] parts = signatureHeader.split(","); + long timestamp = -1; + String signatureHashFromHeader = null; + + for (String part : parts) { + String[] pair = part.split("=", 2); + if (pair.length == 2) { + if ("t".equals(pair[0])) { + timestamp = Long.parseLong(pair[1]); + } else if ("v1".equals(pair[0])) { + signatureHashFromHeader = pair[1]; + } + } + } + + if (timestamp == -1 || signatureHashFromHeader == null) { + return false; // 헤더 형식이 잘못됨 + } + + // 2. 리플레이 공격 방지를 위한 타임스탬프 검증 (선택사항) + long now = System.currentTimeMillis(); + if (now - timestamp > TOLERANCE_IN_MILLIS) { + return false; // 너무 오래된 요청 + } + + // 3. 서명 재생성 + String payload = timestamp + "." + requestBody; + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec(liveblocksSecretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKeySpec); + byte[] expectedHashBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + + // 4. 서명 비교 (타이밍 공격 방지를 위해 MessageDigest.isEqual 사용) + byte[] signatureHashBytesFromHeader = Hex.decodeHex(signatureHashFromHeader); + return MessageDigest.isEqual(expectedHashBytes, signatureHashBytesFromHeader); + + } catch (Exception e) { + // 파싱 실패, 디코딩 실패 등 모든 예외는 검증 실패로 간주 + return false; + } + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/controller/ApiV1GraphController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/controller/ApiV1GraphController.java deleted file mode 100644 index 3cf584f8..00000000 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/controller/ApiV1GraphController.java +++ /dev/null @@ -1,60 +0,0 @@ -package org.tuna.zoopzoop.backend.domain.graph.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.tuna.zoopzoop.backend.domain.graph.dto.BodyForReactFlow; -import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; -import org.tuna.zoopzoop.backend.domain.graph.service.GraphService; -import org.tuna.zoopzoop.backend.global.rsData.RsData; - -@RestController -@RequiredArgsConstructor -@RequestMapping("api/v1/graph") -@Tag(name = "ApiV1GraphController", description = "React-flow 데이터 컨트롤러") -public class ApiV1GraphController { - private final GraphService graphService; - - /** - * LiveBlocks를 위한 React-flow 데이터 저장 API - * @param bodyForReactFlow React-flow 데이터를 가지고 있는 Dto - */ - @PostMapping - @Operation(summary = "React-flow 데이터 저장") - public ResponseEntity> createGraph( - @RequestBody BodyForReactFlow bodyForReactFlow - ) { - Graph graph = bodyForReactFlow.toEntity(); - graphService.saveGraph(graph); - - return ResponseEntity - .status(HttpStatus.OK) - .body(new RsData<>( - "200", - "React-flow 데이터를 저장 했습니다.", - null - )); - } - - /** - * LiveBlocks를 위한 React-flow 데이터 조회 API - * @param id React-flow 데이터의 graph 식별 id - */ - @GetMapping("/{id}") - @Operation(summary = "React-flow 데이터 조회") - public ResponseEntity> getGraph( - @PathVariable Integer id - ) { - Graph graph = graphService.getGraph(id); - return ResponseEntity - .status(HttpStatus.OK) - .body(new RsData<>( - "200", - "ID: " + id + " 의 React-flow 데이터를 조회했습니다.", - BodyForReactFlow.from(graph) - )); - } -} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/NodeType.java b/src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/NodeType.java deleted file mode 100644 index 872f054c..00000000 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/graph/enums/NodeType.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.tuna.zoopzoop.backend.domain.graph.enums; - -public enum NodeType { - CUSTOM -} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java index 7e00a289..f6a96d17 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java @@ -176,7 +176,8 @@ public RsData getSpace( space.getName(), space.getThumbnailUrl(), membership.getAuthority().name(), - space.getSharingArchive().getId() + space.getSharingArchive().getId(), + space.getDashboard().getId() ); return new RsData<>( diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInfo.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInfo.java index c143b926..1a8b73f4 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInfo.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/dto/res/ResBodyForSpaceInfo.java @@ -5,6 +5,7 @@ public record ResBodyForSpaceInfo ( String spaceName, String thumbnailUrl, String userAuthority, - Integer sharingArchiveId + Integer sharingArchiveId, + Integer dashboardId ){ } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java index 5d0db6ea..48610340 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/space/space/entity/Space.java @@ -5,6 +5,7 @@ import lombok.Getter; import lombok.Setter; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.SharingArchive; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Dashboard; import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; import org.tuna.zoopzoop.backend.global.jpa.entity.BaseEntity; @@ -34,12 +35,20 @@ public class Space extends BaseEntity { @OneToMany(mappedBy = "space", cascade = CascadeType.ALL, orphanRemoval = true) private List memberShips; + // 연결된 Dashboard + @OneToOne(mappedBy = "space", cascade = CascadeType.ALL, orphanRemoval = true) + private Dashboard dashboard; + + + // ================ 생성 메서드 ================ + public Space() { this.sharingArchive = new SharingArchive(this); + this.dashboard = Dashboard.create(this.name + "의 대시보드", this); } @Builder - public Space(String name, Boolean active, String thumbnailUrl) { + public Space(String name, Boolean active, String thumbnailUrl, String dashboardName) { this.name = name; if (active != null) this.active = active; @@ -47,5 +56,12 @@ public Space(String name, Boolean active, String thumbnailUrl) { this.thumbnailUrl = thumbnailUrl; this.sharingArchive = new SharingArchive(this); + + if (dashboardName == null || dashboardName.isBlank()) { + dashboardName = name + "의 대시보드"; + } + Dashboard dashboard = Dashboard.create(dashboardName, this); + + this.dashboard = dashboard; } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java index 88c25588..55f22b0f 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java @@ -178,6 +178,17 @@ public ResponseEntity> handle(MissingServletRequestPartException e) ); } + @ExceptionHandler(SecurityException.class) + public ResponseEntity> handleSecurityException(SecurityException e) { + return new ResponseEntity<>( + new RsData<>( + "403", // 또는 "401" + e.getMessage() + ), + FORBIDDEN // 또는 UNAUTHORIZED + ); + } + @ExceptionHandler(Exception.class) // 내부 서버 에러(= 따로 Exception을 지정하지 않은 경우.) public ResponseEntity> handleException(Exception e) { return new ResponseEntity<>( diff --git a/src/main/resources/application-secrets.yml.template b/src/main/resources/application-secrets.yml.template index 4ff88092..8f7e5116 100644 --- a/src/main/resources/application-secrets.yml.template +++ b/src/main/resources/application-secrets.yml.template @@ -49,4 +49,7 @@ jwt: access-token-validity: {ACCESSTOKEN_VALIDITY} refresh-token-validity: {REFRESHTOKEN_VALIDITY} -OPENAI_API_KEY: {OPENAI_API_KEY} \ No newline at end of file +OPENAI_API_KEY: {OPENAI_API_KEY} + +liveblocks: + secret-key: {LIVEBLOCKS_SECRET_KEY} \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 943fc4d4..bcd65722 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -16,3 +16,6 @@ spring: sql: init: mode: never + +liveblocks: + secret-key: test_dummy_liveblocks_secret_key \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/DashboardControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/DashboardControllerTest.java new file mode 100644 index 00000000..5263a2e0 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/DashboardControllerTest.java @@ -0,0 +1,272 @@ +package org.tuna.zoopzoop.backend.domain.dashboard.controller; + +import org.apache.commons.codec.binary.Hex; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.TestExecutionEvent; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; +import org.tuna.zoopzoop.backend.domain.dashboard.repository.GraphRepository; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; +import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; +import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; +import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; +import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; +import org.tuna.zoopzoop.backend.testSupport.ControllerTestSupport; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) +@Transactional +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DashboardControllerTest extends ControllerTestSupport { + @Autowired + private SpaceService spaceService; + + @Autowired + private MemberService memberService; + + @Autowired + private MembershipService membershipService; + + private Integer authorizedDashboardId; + private Integer unauthorizedDashboardId; + + @Value("${liveblocks.secret-key}") + private String testSecretKey; + + @BeforeAll + void setUp() { + // 1. 유저 생성 + memberService.createMember("tester1_forDashboardControllerTest", "url", "dc1111", Provider.KAKAO); + memberService.createMember("tester2_forDashboardControllerTest", "url", "dc2222", Provider.KAKAO); + + // 2. 스페이스 생성 (생성과 동시에 대시보드도 생성됨) + Space space1 = spaceService.createSpace("TestSpace1_forDashboardControllerTest", "thumb1"); + Space space2 = spaceService.createSpace("TestSpace2_forDashboardControllerTest", "thumb2"); + + // 테스트에서 사용할 대시보드 ID 저장 + this.authorizedDashboardId = space1.getDashboard().getId(); + this.unauthorizedDashboardId = space2.getDashboard().getId(); + + // 3. 멤버십 설정 + // user1은 Test Space 1에만 멤버로 가입 (접근 권한 있음) + membershipService.addMemberToSpace( + memberService.findByKakaoKey("dc1111"), + space1, + Authority.ADMIN + ); + // user2는 Test Space 2에만 멤버로 가입 + membershipService.addMemberToSpace( + memberService.findByKakaoKey("dc2222"), + space2, + Authority.ADMIN + ); + } + + // ============================= GET GRAPH ============================= // + + @Test + @WithUserDetails(value = "KAKAO:dc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("대시보드 그래프 데이터 조회 - 성공") + void getGraph_Success() throws Exception { + // Given + String url = String.format("/api/v1/dashboard/%d/graph", authorizedDashboardId); + + // When + ResultActions resultActions = performGet(url); + + // Then + expectOk( + resultActions, + String.format("ID: %d 의 React-flow 데이터를 조회했습니다.", authorizedDashboardId) + ); + resultActions + .andExpect(jsonPath("$.data.nodes").isArray()) + .andExpect(jsonPath("$.data.nodes").isEmpty()) + .andExpect(jsonPath("$.data.edges").isArray()) + .andExpect(jsonPath("$.data.edges").isEmpty()); + } + + @Test + @WithUserDetails(value = "KAKAO:dc2222", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("대시보드 그래프 데이터 조회 - 실패: 접근 권한 없음") + void getGraph_Fail_Forbidden() throws Exception { + // Given + // user2는 space1의 멤버가 아니므로, space1의 대시보드에 접근할 수 없음 + String url = String.format("/api/v1/dashboard/%d/graph", authorizedDashboardId); + + // When + ResultActions resultActions = performGet(url); + + // Then + // TODO: 실제 구현된 권한 체크 로직의 예외 메시지에 따라 "권한이 없습니다." 부분을 수정해야 합니다. + expectForbidden(resultActions, "대시보드의 접근 권한이 없습니다."); + } + + @Test + @WithUserDetails(value = "KAKAO:dc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("대시보드 그래프 데이터 조회 - 실패: 존재하지 않는 대시보드") + void getGraph_Fail_NotFound() throws Exception { + // Given + Integer nonExistentDashboardId = 9999; + String url = String.format("/api/v1/dashboard/%d/graph", nonExistentDashboardId); + + // When + ResultActions resultActions = performGet(url); + + // Then + expectNotFound( + resultActions, + nonExistentDashboardId + " ID를 가진 대시보드를 찾을 수 없습니다." + ); + } + + // ============================= UPDATE GRAPH ============================= // + + @Test + @WithUserDetails(value = "KAKAO:dc1111", setupBefore = TestExecutionEvent.TEST_METHOD) + @DisplayName("대시보드 그래프 데이터 저장 - 성공") + void updateGraph_Success() throws Exception { + // Given + String url = String.format("/api/v1/dashboard/%d/graph", authorizedDashboardId); + String requestBody = createReactFlowJsonBody(); + String validSignature = generateLiveblocksSignature(requestBody); + + // When: 데이터 저장 + ResultActions updateResult = mvc.perform(put(url) + .contentType(MediaType.APPLICATION_JSON) + .header("Liveblocks-Signature", validSignature) // ★ 서명 헤더 추가 + .content(requestBody)); + + // Then: 저장 성공 응답 확인 + expectOk( + updateResult, + "React-flow 데이터를 저장했습니다." + ); + + // When: 데이터 재조회하여 검증 + ResultActions getResult = performGet(url); + + // Then: 재조회 결과가 수정한 데이터와 일치하는지 확인 + getResult + .andExpect(jsonPath("$.data.nodes", hasSize(2))) + .andExpect(jsonPath("$.data.edges", hasSize(1))) + .andExpect(jsonPath("$.data.nodes[0].id").value("1")) + .andExpect(jsonPath("$.data.nodes[0].data.title").value("노드1")) + .andExpect(jsonPath("$.data.edges[0].id").value("e1-2")); + } + + @Test + @DisplayName("대시보드 그래프 데이터 저장 - 실패: 존재하지 않는 대시보드") + void updateGraph_Fail_NotFound() throws Exception { + // Given + Integer nonExistentDashboardId = 9999; + String url = String.format("/api/v1/dashboard/%d/graph", nonExistentDashboardId); + String requestBody = createReactFlowJsonBody(); + String validSignature = generateLiveblocksSignature(requestBody); + + // When: 데이터 저장 + ResultActions resultActions = mvc.perform(put(url) + .contentType(MediaType.APPLICATION_JSON) + .header("Liveblocks-Signature", validSignature) // ★ 서명 헤더 추가 + .content(requestBody)); + + // Then + expectNotFound( + resultActions, + nonExistentDashboardId + " ID를 가진 대시보드를 찾을 수 없습니다." + ); + } + + @Test + @DisplayName("대시보드 그래프 데이터 저장 - 실패: 서명 검증 실패") + void updateGraph_Fail_Forbidden() throws Exception { + // Given + String url = String.format("/api/v1/dashboard/%d/graph", authorizedDashboardId); + String requestBody = createReactFlowJsonBody(); + String invalidSignature = "t=123,v1=invalid_signature"; // 유효하지 않은 서명 + + // When + ResultActions resultActions = mvc.perform(put(url) + .contentType(MediaType.APPLICATION_JSON) + .header("Liveblocks-Signature", invalidSignature) // ★ 잘못된 서명 헤더 추가 + .content(requestBody)); + + // Then + expectForbidden(resultActions, "Invalid webhook signature."); + } + + // ======================= TEST DATA FACTORIES ======================== // + + private String createReactFlowJsonBody() { + return """ + { + "nodes": [ + { + "id": "1", + "type": "CUSTOM", + "data": { "title": "노드1", "description": "설명1" }, + "position": { "x": 100, "y": 200 } + }, + { + "id": "2", + "type": "CUSTOM", + "data": { "title": "노드2" }, + "position": { "x": 300, "y": 400 } + } + ], + "edges": [ + { + "id": "e1-2", + "source": "1", + "target": "2", + "type": "SMOOTHSTEP", + "animated": true, + "style": { "stroke": "#999", "strokeWidth": 2.0 } + } + ] + } + """; + } + + // ======================= HELPER METHODS ======================== // + private String generateLiveblocksSignature(String requestBody) throws NoSuchAlgorithmException, InvalidKeyException { + long timestamp = System.currentTimeMillis(); + String payload = timestamp + "." + requestBody; + + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec secretKeySpec = new SecretKeySpec(testSecretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKeySpec); + byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + + // v1 서명은 해시값의 Hex 인코딩 문자열입니다. + String signatureHash = Hex.encodeHexString(hash); + + return String.format("t=%d,v1=%s", timestamp, signatureHash); + } + +} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphServiceTest.java similarity index 82% rename from src/test/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphServiceTest.java rename to src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphServiceTest.java index 6fcde640..3a2cbe62 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/graph/service/GraphServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/dashboard/service/GraphServiceTest.java @@ -1,4 +1,4 @@ -package org.tuna.zoopzoop.backend.domain.graph.service; +package org.tuna.zoopzoop.backend.domain.dashboard.service; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -7,12 +7,12 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; -import org.tuna.zoopzoop.backend.domain.graph.entity.Edge; -import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; -import org.tuna.zoopzoop.backend.domain.graph.entity.Node; -import org.tuna.zoopzoop.backend.domain.graph.enums.EdgeType; -import org.tuna.zoopzoop.backend.domain.graph.enums.NodeType; -import org.tuna.zoopzoop.backend.domain.graph.repository.GraphRepository; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; +import org.tuna.zoopzoop.backend.domain.dashboard.entity.Node; +import org.tuna.zoopzoop.backend.domain.dashboard.enums.EdgeType; +import org.tuna.zoopzoop.backend.domain.dashboard.enums.NodeType; +import org.tuna.zoopzoop.backend.domain.dashboard.repository.GraphRepository; import java.util.Map; diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/graph/controller/GraphControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/graph/controller/GraphControllerTest.java deleted file mode 100644 index 2676dc2b..00000000 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/graph/controller/GraphControllerTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package org.tuna.zoopzoop.backend.domain.graph.controller; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; -import org.tuna.zoopzoop.backend.domain.graph.entity.Graph; -import org.tuna.zoopzoop.backend.domain.graph.repository.GraphRepository; - -import static org.hamcrest.Matchers.hasSize; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Transactional -class GraphControllerTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private GraphRepository graphRepository; - - @BeforeEach - void setUp() { - graphRepository.deleteAll(); // 테스트 전 DB 초기화 - } - - @AfterEach - void cleanUp() { - graphRepository.deleteAll(); // Graph만 삭제 - // 필요하면 다른 Repository도 순서대로 삭제 - } - - // 단위 테스트가 백엔드 컨벡션 원칙이나, 서비스 특성 상 단위 테스트가 어려워 - // 일단 통합 테스트로 진행합니다. - @Test - @DisplayName("React-flow 데이터 저장 및 조회 테스트 - JSON 방식") - void createAndGetGraphTest() throws Exception { - // 테스트 용 React-flow JSON - String jsonBody = """ - { - "nodes": [ - { - "id": "1", - "type": "CUSTOM", - "data": { - "title": "노드1", - "description": "설명1" - }, - "position": { - "x": 100, - "y": 200 - } - }, - { - "id": "2", - "type": "CUSTOM", - "data": { - "title": "노드2" - }, - "position": { - "x": 300, - "y": 400 - } - } - ], - "edges": [ - { - "id": "e1-2", - "source": "1", - "target": "2", - "type": "SMOOTHSTEP", - "animated": true, - "style": { - "stroke": "#999", - "strokeWidth": 2.0 - } - } - ] - } - """; - - // React-flow 데이터 저장 - mockMvc.perform(post("/api/v1/graph") - .contentType(MediaType.APPLICATION_JSON) - .content(jsonBody)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("200")) - .andExpect(jsonPath("$.msg").value("React-flow 데이터를 저장 했습니다.")) - .andExpect(jsonPath("$.data").isEmpty()); - - // DB 무결성 검증 - assertEquals(1, graphRepository.count()); - Graph savedGraph = graphRepository.findAll().get(0); - assertEquals(2, savedGraph.getNodes().size()); - assertEquals(1, savedGraph.getEdges().size()); - - // 저장된 React-flow 데이터 조회 - mockMvc.perform(get("/api/v1/graph/{id}", savedGraph.getId()) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("200")) - .andExpect(jsonPath("$.msg").value("ID: " + savedGraph.getId() + " 의 React-flow 데이터를 조회했습니다.")) - .andExpect(jsonPath("$.data.nodes", hasSize(2))) - .andExpect(jsonPath("$.data.edges", hasSize(1))) - .andExpect(jsonPath("$.data.nodes[0].id").value("1")) - .andExpect(jsonPath("$.data.nodes[0].type").value("CUSTOM")) - .andExpect(jsonPath("$.data.nodes[0].data.title").value("노드1")) - .andExpect(jsonPath("$.data.edges[0].id").value("e1-2")) - .andExpect(jsonPath("$.data.edges[0].animated").value(true)); - } - - -} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java index b270d411..2641cef5 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceControllerTest.java @@ -518,7 +518,8 @@ void getSpace_Success() throws Exception { .andExpect(jsonPath("$.data.spaceName").value("기존 스페이스 1_forSpaceControllerTest")) .andExpect(jsonPath("$.data.thumbnailUrl").value("thumbnailUrl1")) .andExpect(jsonPath("$.data.userAuthority").value("ADMIN")) - .andExpect(jsonPath("$.data.sharingArchiveId").value(space.getSharingArchive().getId())); + .andExpect(jsonPath("$.data.sharingArchiveId").value(space.getSharingArchive().getId())) + .andExpect(jsonPath("$.data.dashboardId").value(space.getDashboard().getId())); } @Test From 21834c468bbb5e62ab366a99ba6eb061a23f6e0f Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:36:21 +0900 Subject: [PATCH 056/132] =?UTF-8?q?[feat/OPS-329]=20Sentry=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=9C=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=B6=95.=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat/OPS-329 : Sentry를 통한 모니터링 환경 구축. * feat/OPS-329: 추가로 프론트 요구 사항 반영. --- .github/workflows/test-server-ci.yml | 2 + build.gradle | 17 ++++++++- .../repository/RefreshTokenRepository.java | 2 + .../auth/service/RefreshTokenService.java | 8 ++++ .../controller/ApiV1MemberController.java | 3 ++ .../global/config/sentry/SentryConfig.java | 20 ++++++++++ .../exception/GlobalExceptionHandler.java | 4 +- .../global/security/SecurityConfig.java | 1 - .../security/jwt/JwtAuthenticationFilter.java | 11 ++++++ .../backend/global/security/jwt/JwtUtil.java | 10 ++--- .../global/test/ApiV1TestController.java | 38 +++++++++++++++++++ src/main/resources/application-dev.yml | 10 ++++- src/main/resources/application-server.yml | 10 ++++- src/main/resources/application.yml | 1 + 14 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/SentryConfig.java create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/test/ApiV1TestController.java diff --git a/.github/workflows/test-server-ci.yml b/.github/workflows/test-server-ci.yml index 5dd3ce1d..ed2b5349 100644 --- a/.github/workflows/test-server-ci.yml +++ b/.github/workflows/test-server-ci.yml @@ -20,6 +20,8 @@ jobs: # ================================== ci: runs-on: ubuntu-latest + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} steps: # 1. 소스 코드 체크아웃 diff --git a/build.gradle b/build.gradle index 7b09ad62..aa4f5b60 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.5' id 'io.spring.dependency-management' version '1.1.7' + id "io.sentry.jvm.gradle" version "5.12.0" } group = 'org.tuna.zoopzoop' @@ -97,7 +98,10 @@ dependencies { // Playwright for Java implementation 'com.microsoft.playwright:playwright:1.54.0' - + + // Sentry (모니터링 용) + implementation 'io.sentry:sentry-spring-boot-starter-jakarta:8.22.0' + // Apache Commons Codec implementation"commons-codec:commons-codec:1.19.0" } @@ -111,3 +115,14 @@ dependencyManagement { tasks.named('test') { useJUnitPlatform() } + +sentry { + // Generates a JVM (Java, Kotlin, etc.) source bundle and uploads your source code to Sentry. + // This enables source context, allowing you to see your source + // code as part of your stack traces in Sentry. + includeSourceContext = true + + org = "whitedoggy" + projectName = "zoopzoop-backend" + authToken = System.getenv("SENTRY_AUTH_TOKEN") +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/repository/RefreshTokenRepository.java index 89a81727..e948eeb7 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/repository/RefreshTokenRepository.java @@ -5,10 +5,12 @@ import org.tuna.zoopzoop.backend.domain.auth.entity.RefreshToken; import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import java.util.List; import java.util.Optional; @Repository public interface RefreshTokenRepository extends JpaRepository { Optional findBySessionId(String sessionId); Optional findByMember(Member member); + List findAllByMember(Member member); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/RefreshTokenService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/RefreshTokenService.java index 9ec6aea2..5a1c4a0c 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/RefreshTokenService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/service/RefreshTokenService.java @@ -11,6 +11,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Date; +import java.util.List; import java.util.UUID; @Service @@ -51,4 +52,11 @@ public void deleteBySessionId(String sessionId) { refreshTokenRepository.findBySessionId(sessionId) .orElseThrow(() -> new BadCredentialsException("잘못된 요청입니다.")); } + + public void deleteByMember(Member member) { + List tokens = refreshTokenRepository.findAllByMember(member); + if (!tokens.isEmpty()) { + refreshTokenRepository.deleteAll(tokens); + } + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java index 2db7f4e6..13fbd1e9 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java @@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.auth.service.RefreshTokenService; import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMember; import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMemberName; import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMemberProfileImage; @@ -25,6 +26,7 @@ @Tag(name = "ApiV1MemberController", description = "사용자 REST API 컨트롤러") public class ApiV1MemberController { private final MemberService memberService; + private final RefreshTokenService refreshTokenService; /// api/v1/member/me : 사용자 정보 조회 (GET) /// api/v1/member/edit : 사용자 닉네임 수정 (PUT) /// api/v1/member : 사용자 탈퇴 (DELETE) @@ -145,6 +147,7 @@ public ResponseEntity> deleteMember( @AuthenticationPrincipal CustomUserDetails userDetails ) { Member member = userDetails.getMember(); + refreshTokenService.deleteByMember(member); memberService.hardDeleteMember(member); return ResponseEntity .status(HttpStatus.OK) diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/SentryConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/SentryConfig.java new file mode 100644 index 00000000..9bab38fc --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/SentryConfig.java @@ -0,0 +1,20 @@ +package org.tuna.zoopzoop.backend.global.config.sentry; + +import io.sentry.SentryOptions; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SentryConfig { + + @Bean + public SentryOptions.BeforeSendCallback beforeSend() { + return (event, hint) -> { + if(event.getMessage() != null + && event.getMessage().getFormatted().contains("JWT 토큰")) { + return null; + } + return event; + }; + } +} diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java index 55f22b0f..e62e05e7 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/exception/GlobalExceptionHandler.java @@ -3,15 +3,14 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import io.sentry.Sentry; import jakarta.persistence.NoResultException; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -34,6 +33,7 @@ public class GlobalExceptionHandler { @ExceptionHandler(NoResultException.class) // 자료를 찾지 못했을 경우. public ResponseEntity> handleNoResultException(NoResultException e) { + Sentry.captureException(e); return new ResponseEntity<>( new RsData<>( "404", diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java index 3761712f..201daeff 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/SecurityConfig.java @@ -46,7 +46,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/actuator/health", // health 체크용 "/dev/**" // ← 추가: dev 토큰 발급은 누구나 접근 ).permitAll() - .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(authorization -> authorization diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java index f3e362d3..92565d29 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtAuthenticationFilter.java @@ -25,10 +25,21 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final CustomUserDetailsService userDetailsService; + // 필터를 적용할 URL 패턴 + private static final String API_PREFIX = "/api/v1"; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String path = request.getRequestURI(); + + // /api/ 로 시작하지 않으면 JWT 검증 건너뜀 + if (!path.startsWith(API_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + String token = getTokenFromRequest(request); // Authorization 헤더에서 JWT 토큰 추출 log.info("[JwtFilter] Token from request: {}", token); log.info("[JwtFilter] Token valid? {}", jwtUtil.validateToken(token)); diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java index 70f5389c..11be7d4b 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/security/jwt/JwtUtil.java @@ -86,15 +86,15 @@ public boolean validateToken(String token) { .parseSignedClaims(token); return true; } catch (ExpiredJwtException e) { - log.error("JWT 토큰이 만료되었습니다: {}", e.getMessage()); + log.info("JWT 토큰이 만료되었습니다: {}", e.getMessage()); } catch (UnsupportedJwtException e) { - log.error("지원되지 않는 JWT 토큰입니다: {}", e.getMessage()); + log.info("지원되지 않는 JWT 토큰입니다: {}", e.getMessage()); } catch (MalformedJwtException e) { - log.error("잘못된 JWT 토큰입니다: {}", e.getMessage()); + log.info("잘못된 JWT 토큰입니다: {}", e.getMessage()); } catch (SecurityException e) { - log.error("JWT 서명이 잘못되었습니다: {}", e.getMessage()); + log.info("JWT 서명이 잘못되었습니다: {}", e.getMessage()); } catch (IllegalArgumentException e) { - log.error("JWT 토큰이 비어있습니다: {}", e.getMessage()); + log.info("JWT 토큰이 비어있습니다: {}", e.getMessage()); } return false; } diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/test/ApiV1TestController.java b/src/main/java/org/tuna/zoopzoop/backend/global/test/ApiV1TestController.java new file mode 100644 index 00000000..9533da70 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/test/ApiV1TestController.java @@ -0,0 +1,38 @@ +package org.tuna.zoopzoop.backend.global.test; + +import io.sentry.Sentry; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.tuna.zoopzoop.backend.global.rsData.RsData; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/v1/test") +@Tag(name = "ApiV1TestController", description = "사용자 REST API 컨트롤러") +public class ApiV1TestController { + @GetMapping + @Operation(summary = "사용자 정보 조회") + public ResponseEntity> test( + ) { + try { + throw new Exception("Sentry 모니터링 테스트 예외입니다."); + } catch (Exception e) { + Sentry.captureException(e); + } + return ResponseEntity + .status(HttpStatus.OK) + .body( + new RsData<>( + "200", + "테스트 메소드를 실행합니다.", + null + ) + ); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 21ae205c..4a0101ce 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -8,4 +8,12 @@ spring: jpa: hibernate: ddl-auto: create-drop - show-sql: true \ No newline at end of file + show-sql: true + +sentry: + dsn: https://60f1acad189d2994353d59b7895076ee@o4510100579155968.ingest.us.sentry.io/4510100584923136 + # Add data like request headers and IP for users, + # see https://docs.sentry.io/platforms/java/guides/spring-boot/data-management/data-collected/ for more info + send-default-pii: true + environment: local + traces-sample-rate: 0.0 \ No newline at end of file diff --git a/src/main/resources/application-server.yml b/src/main/resources/application-server.yml index ccac4471..dc165266 100644 --- a/src/main/resources/application-server.yml +++ b/src/main/resources/application-server.yml @@ -15,4 +15,12 @@ management: include: health,info endpoint: health: - show-details: always \ No newline at end of file + show-details: always + +sentry: + dsn: https://60f1acad189d2994353d59b7895076ee@o4510100579155968.ingest.us.sentry.io/4510100584923136 + # Add data like request headers and IP for users, + # see https://docs.sentry.io/platforms/java/guides/spring-boot/data-management/data-collected/ for more info + send-default-pii: true + environment: prod + traces-sample-rate: 0.2 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 078a2d05..1acbd1a7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,6 +44,7 @@ logging: org.hibernate.orm.jdbc.extract: TRACE org.springframework.transaction.interceptor: TRACE com.back: DEBUG + server: port: 8080 From 8f8e0329954d5e90399270037c29df06b87aae3e Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:32:00 +0900 Subject: [PATCH 057/132] =?UTF-8?q?refactor/OPS-353=20:=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EC=99=84=EB=A3=8C.=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/sentry/ProfileChecker.java | 17 +++++++++++++++++ .../global/config/sentry/SentryConfig.java | 10 +++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/ProfileChecker.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/ProfileChecker.java b/src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/ProfileChecker.java new file mode 100644 index 00000000..20f60b10 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/ProfileChecker.java @@ -0,0 +1,17 @@ +package org.tuna.zoopzoop.backend.global.config.sentry; + +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +public class ProfileChecker { + private final Environment environment; + + public ProfileChecker(Environment environment) { + this.environment = environment; + } + + public String[] getActiveProfiles() { + return environment.getActiveProfiles(); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/SentryConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/SentryConfig.java index 9bab38fc..cded6847 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/SentryConfig.java +++ b/src/main/java/org/tuna/zoopzoop/backend/global/config/sentry/SentryConfig.java @@ -1,15 +1,23 @@ package org.tuna.zoopzoop.backend.global.config.sentry; import io.sentry.SentryOptions; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration +@RequiredArgsConstructor +@Slf4j public class SentryConfig { + private final ProfileChecker profileChecker; @Bean public SentryOptions.BeforeSendCallback beforeSend() { return (event, hint) -> { + log.info("[Sentry] 현재 프로필: {}", profileChecker.getActiveProfiles()[0]); + if("test".equals(profileChecker.getActiveProfiles()[0])) return null; + log.info("[Sentry] 정상 통과됨."); if(event.getMessage() != null && event.getMessage().getFormatted().contains("JWT 토큰")) { return null; @@ -17,4 +25,4 @@ public SentryOptions.BeforeSendCallback beforeSend() { return event; }; } -} +} \ No newline at end of file From 9f8834b8aa41b49160896b267762e7616e4d4c74 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Tue, 30 Sep 2025 09:24:09 +0900 Subject: [PATCH 058/132] =?UTF-8?q?[refactor/OPS-354]=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EB=B3=80=EA=B2=BD.=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor/OPS-354 : 테스트 케이스 사용 프로필 변경. * refactor/OPS-354 : 이래도 안돼? --- .../java/org/tuna/zoopzoop/backend/BackendApplicationTests.java | 2 ++ .../domain/archive/folder/controller/FolderControllerTest.java | 2 +- .../backend/domain/datasource/service/AiServiceTest.java | 2 ++ .../domain/datasource/service/CrawlerManagerServiceTest.java | 2 ++ .../domain/datasource/service/DataSourceServiceTest.java | 2 ++ .../backend/domain/member/repository/MemberRepositoryTest.java | 2 ++ 6 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/tuna/zoopzoop/backend/BackendApplicationTests.java b/src/test/java/org/tuna/zoopzoop/backend/BackendApplicationTests.java index c4793833..e006b5ab 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/BackendApplicationTests.java +++ b/src/test/java/org/tuna/zoopzoop/backend/BackendApplicationTests.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest class BackendApplicationTests { diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java index 95fa1504..6963d59f 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/archive/folder/controller/FolderControllerTest.java @@ -42,7 +42,7 @@ @AutoConfigureMockMvc @Transactional @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class FolderControllerIntegrationTest { +class FolderControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiServiceTest.java index ebefee04..7480f3d9 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/AiServiceTest.java @@ -6,6 +6,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.test.context.ActiveProfiles; import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AnalyzeContentDto; import org.tuna.zoopzoop.backend.domain.datasource.ai.service.AiService; import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; @@ -17,6 +18,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; +@ActiveProfiles("test") @ExtendWith(MockitoExtension.class) class AiServiceTest { @Mock diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java index 9617fd0f..ee578354 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/CrawlerManagerServiceTest.java @@ -10,6 +10,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.SpecificSiteDto; import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.UnspecificSiteDto; @@ -27,6 +28,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.when; +@ActiveProfiles("test") @ExtendWith(MockitoExtension.class) public class CrawlerManagerServiceTest { @InjectMocks diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java index 1c4c8327..1e156817 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/datasource/service/DataSourceServiceTest.java @@ -8,6 +8,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.PersonalArchive; import org.tuna.zoopzoop.backend.domain.archive.archive.repository.PersonalArchiveRepository; @@ -35,6 +36,7 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +@ActiveProfiles("test") @ExtendWith(MockitoExtension.class) class DataSourceServiceTest { diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java index 4a745e5e..5427af14 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/member/repository/MemberRepositoryTest.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; import org.tuna.zoopzoop.backend.domain.archive.archive.entity.Archive; import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; import org.tuna.zoopzoop.backend.domain.member.entity.Member; @@ -15,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; +@ActiveProfiles("test") @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) public class MemberRepositoryTest { From 83ccc6d68ee37ae3930a8ebed65f94a17103ecac Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Tue, 30 Sep 2025 09:25:19 +0900 Subject: [PATCH 059/132] =?UTF-8?q?refactor/OPS-355=20:=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81.=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/member/controller/ApiV1MemberController.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java index 13fbd1e9..e25f9869 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/member/controller/ApiV1MemberController.java @@ -8,10 +8,10 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import org.tuna.zoopzoop.backend.domain.auth.service.RefreshTokenService; import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMember; import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMemberName; -import org.tuna.zoopzoop.backend.domain.member.dto.req.ReqBodyForEditMemberProfileImage; import org.tuna.zoopzoop.backend.domain.member.dto.res.*; import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.member.service.MemberService; @@ -87,16 +87,16 @@ public ResponseEntity> editMemberName( * 현재 로그인한 사용자의 프로필 이미지를 변경하는 API * HTTP METHOD: PUT * @param userDetails @AuthenticationPrincipal로 받아오는 현재 사용자 정보 - * @param reqBodyForEditMemberProfileImage 수정할 프로필 이미지를 받아오는 dto + * @param file 수정할 프로필 이미지를 받아오는 MultipartFile */ @PutMapping("/edit/image") @Operation(summary = "사용자 닉네임 수정") public ResponseEntity> editMemberProfileImage( @AuthenticationPrincipal CustomUserDetails userDetails, - @Valid @RequestBody ReqBodyForEditMemberProfileImage reqBodyForEditMemberProfileImage + @RequestPart("file") MultipartFile file ) { Member member = userDetails.getMember(); - memberService.updateMemberProfileUrl(member, reqBodyForEditMemberProfileImage.file()); + memberService.updateMemberProfileUrl(member, file); return ResponseEntity .status(HttpStatus.OK) .body( From c7af82d4a9147ed73f9fe5ccb042206802930d68 Mon Sep 17 00:00:00 2001 From: Whitedoggy <82828857+Kimgooner@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:26:14 +0900 Subject: [PATCH 060/132] =?UTF-8?q?feat/OPS-358:=20Redis=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20RedisConf?= =?UTF-8?q?ig=20=EC=84=A4=EC=A0=95=20=EC=99=84=EB=A3=8C.=20AuthResultData?= =?UTF-8?q?=20=EC=A7=81=EB=A0=AC=ED=99=94=20=EB=B0=8F=20AuthResult=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=EC=97=90=20TTL=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?.=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 ++ .../auth/controller/ApiV1AuthController.java | 9 +++--- .../domain/auth/dto/AuthResultData.java | 28 ++++++++----------- .../domain/auth/entity/AuthResult.java | 21 ++++++++------ .../global/config/redis/RedisConfig.java | 28 +++++++++++++++++++ src/main/resources/application.yml | 12 +++++++- 6 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/global/config/redis/RedisConfig.java diff --git a/build.gradle b/build.gradle index aa4f5b60..f5ff8bba 100644 --- a/build.gradle +++ b/build.gradle @@ -104,6 +104,9 @@ dependencies { // Apache Commons Codec implementation"commons-codec:commons-codec:1.19.0" + + // Redis (Spring starter) + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } dependencyManagement { diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java index a7c675d9..68ff3ef0 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/controller/ApiV1AuthController.java @@ -69,7 +69,6 @@ public ResponseEntity> logout( * @param sessionId 쿠키에 포함된 현재 로그인한 사용자의 sessionId. * @param response Servlet 기반 웹에서 server -> client로 http 응답을 보내기 위한 객체, 자동 주입. */ - @PostMapping("/refresh") @Operation(summary = "사용자 액세스 토큰 재발급 (서버 저장 RefreshToken 사용)") public ResponseEntity> refreshToken( @@ -77,7 +76,6 @@ public ResponseEntity> refreshToken( String sessionId, HttpServletResponse response ) { - if (sessionId == null) { return ResponseEntity .status(HttpStatus.UNAUTHORIZED) @@ -91,7 +89,11 @@ public ResponseEntity> refreshToken( } catch (AuthenticationException e) { return ResponseEntity .status(HttpStatus.UNAUTHORIZED) - .body(new RsData<>("401", e.getMessage(), null)); + .body(new RsData<>( + "401", + e.getMessage(), + null + )); } String refreshToken = refreshTokenEntity.getRefreshToken(); @@ -126,7 +128,6 @@ public ResponseEntity> refreshToken( * 확장프로그램의 액세스 토큰 발급을 위한 백그라운드 풀링에 대응하는 API * @param state 확장프로그램 로그인 시 전달한 state 값. */ - @GetMapping("/result") @Operation(summary = "확장프로그램 백그라운드 풀링 대응 API") public ResponseEntity> pullingResult( diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/AuthResultData.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/AuthResultData.java index dbdace4a..07694675 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/AuthResultData.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/dto/AuthResultData.java @@ -1,19 +1,15 @@ package org.tuna.zoopzoop.backend.domain.auth.dto; -public class AuthResultData { - private final String accessToken; - private final String sessionId; - - public AuthResultData(String accessToken, String sessionId) { - this.accessToken = accessToken; - this.sessionId = sessionId; - } - - public String getAccessToken() { - return accessToken; - } - - public String getSessionId() { - return sessionId; - } +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuthResultData implements Serializable { + private String accessToken; + private String sessionId; } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/AuthResult.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/AuthResult.java index e29721ac..043f04a1 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/AuthResult.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/entity/AuthResult.java @@ -1,23 +1,26 @@ package org.tuna.zoopzoop.backend.domain.auth.entity; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.tuna.zoopzoop.backend.domain.auth.dto.AuthResultData; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; + +import java.time.Duration; @Component +@RequiredArgsConstructor public class AuthResult { - private final Map results = new ConcurrentHashMap<>(); + private final RedisTemplate redisTemplate; + private static final String PREFIX = "auth:result:"; public void put(String state, String accessToken, String sessionId) { - results.put(state, new AuthResultData(accessToken, sessionId)); + AuthResultData data = new AuthResultData(accessToken, sessionId); + redisTemplate.opsForValue().set(PREFIX + state, data, Duration.ofMinutes(1)); // TTL 1분, 프론트단에선 백그라운드 풀링 형식으로 계속 작동할 것이므로. } public AuthResultData get(String state) { - return results.remove(state); - } - - public void consume(String state) { - results.remove(state); + AuthResultData data = redisTemplate.opsForValue().get(PREFIX + state); + if (data != null) redisTemplate.delete("auth:" + state); + return data; } } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/global/config/redis/RedisConfig.java b/src/main/java/org/tuna/zoopzoop/backend/global/config/redis/RedisConfig.java new file mode 100644 index 00000000..437b3935 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/global/config/redis/RedisConfig.java @@ -0,0 +1,28 @@ +package org.tuna.zoopzoop.backend.global.config.redis; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.tuna.zoopzoop.backend.domain.auth.dto.AuthResultData; + +@Configuration +@EnableCaching +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key Serializer + template.setKeySerializer(new StringRedisSerializer()); + // Value Serializer (JSON 직렬화) + template.setValueSerializer(new Jackson2JsonRedisSerializer<>(AuthResultData.class)); + + return template; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1acbd1a7..c13af761 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,6 +35,17 @@ spring: options: model: meta-llama/llama-4-scout-17b-16e-instruct temperature: 0 + data: #RedisTemplate 등을 사용하기 위한 직접 연결용 + redis: + host: localhost + port: 6379 + timeout: 6000 + cache: #Spring Cache를 사용하기 위한 Redis + type: redis + redis: + time-to-live: 300000 + cache-null-values: false + key-prefix: springdoc: default-produces-media-type: application/json;charset=UTF-8 @@ -48,7 +59,6 @@ logging: server: port: 8080 - app: seed: enabled: true \ No newline at end of file From 23b8a7feb648de7f5d76e302e634b7ab9aaad129 Mon Sep 17 00:00:00 2001 From: osh5030 <72571931+ohsoohyuk@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:43:04 +0900 Subject: [PATCH 061/132] =?UTF-8?q?feat/OPS-356=20:=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EB=B8=94=EB=A1=9C=EA=B7=B8=20=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=83=9D=EC=84=B1=20(#93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/datasource/ai/prompt/AiPrompt.java | 62 ++++++------- .../datasource/crawler/service/Crawler.java | 3 +- .../crawler/service/GenericCrawler.java | 2 +- .../crawler/service/NaverBlogCrawler.java | 86 ++++++++++++++++++ .../crawler/service/SupportedDomain.java | 3 +- .../service/CrawlerManagerServiceTest.java | 89 +++++++++++++++++-- 6 files changed, 204 insertions(+), 41 deletions(-) create mode 100644 src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverBlogCrawler.java diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java index 0f5fb194..4e564c07 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java @@ -22,36 +22,36 @@ public class AiPrompt { // 내용 요약, 태그 추출, 카테고리 선정 프롬프트 public static final String SUMMARY_TAG_CATEGORY = """ - 너는 뉴스, 블로그 등 내용 요약 및 분류 AI야. 아래의 규칙에 따라 답변해. - - [규칙] - 1. 주어진 content를 50자 이상 100자 이하로 간단히 요약해라. - 2. 아래 Category 목록 중에서 content와 가장 적절한 카테고리 하나를 정확히 선택해라. - - POLITICS("정치") - - ECONOMY("경제") - - SOCIETY("사회") - - IT("IT") - - SCIENCE("과학") - - CULTURE("문화") - - SPORTS("스포츠") - - ENVIRONMENT("환경") - - HISTORY("역사") - - WORLD("세계") - 3. 내가 제공하는 태그 목록을 참고해서, content와 관련된 태그를 3~5개 생성해라. - - 제공된 태그와 중복 가능하다. - - 필요하면 새로운 태그를 만들어도 된다. - 4. 출력은 반드시 아래 JSON 형식으로 해라. Markdown 문법(```)은 쓰지 마라. - - 해당 정보가 없으면 null말고 무조건 빈 문자열로 출력해줘라. - - [출력 JSON 형식] - { - "summary": "내용 요약 (50~100자)", - "category": "선택된 카테고리 ENUM 이름", - "tags": ["태그1", "태그2", "태그3", ...] - } - - [입력 데이터] - content: %s - existingTags: %s + 너는 뉴스, 블로그 등 내용 요약 및 분류 AI야. 아래의 규칙에 따라 답변해. + + [규칙] + 1. 주어진 content를 50자 이상 100자 이하로 간단히 요약해라. + 2. 아래 Category 목록 중에서 content와 가장 적절한 카테고리 하나를 정확히 선택해라. + - POLITICS("정치") + - ECONOMY("경제") + - SOCIETY("사회") + - IT("IT") + - SCIENCE("과학") + - CULTURE("문화") + - SPORTS("스포츠") + - ENVIRONMENT("환경") + - HISTORY("역사") + - WORLD("세계") + 3. 내가 제공하는 태그 목록을 참고해서, content와 관련된 태그를 3~5개 생성해라. + - 제공된 태그와 중복 가능하다. + - 필요하면 새로운 태그를 만들어도 된다. + 4. 출력은 반드시 아래 JSON 형식으로 해라. Markdown 문법(```)은 쓰지 마라. + - 해당 정보가 없으면 null말고 무조건 빈 문자열로 출력해줘라. + + [출력 JSON 형식] + { + "summary": "내용 요약 (50~100자)", + "category": "선택된 카테고리 ENUM 이름", + "tags": ["태그1", "태그2", "태그3", ...] + } + + [입력 데이터] + content: %s + existingTags: %s """; } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java index e8eb831a..6ab8f443 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java @@ -3,10 +3,11 @@ import org.jsoup.nodes.Document; import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; +import java.io.IOException; import java.time.LocalDate; public interface Crawler { boolean supports(String domain); - CrawlerResult extract(Document doc); + CrawlerResult extract(Document doc) throws IOException; LocalDate transLocalDate(String rawDate); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java index 8efd1c49..c36ede86 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java @@ -20,7 +20,7 @@ public boolean supports(String url) { @Override public CrawlerResult extract(Document doc) { // 불필요한 태그 제거 - doc.select("script, style, noscript, iframe, nav, header, footer, form, aside, meta, link").remove(); + doc.select("script, style, noscript, meta, link").remove(); // 본문만 가져오기 (HTML) String cleanHtml = doc.body().html(); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverBlogCrawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverBlogCrawler.java new file mode 100644 index 00000000..640686a7 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverBlogCrawler.java @@ -0,0 +1,86 @@ +package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; +import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.SpecificSiteDto; + +import java.io.IOException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class NaverBlogCrawler implements Crawler { + private static final SupportedDomain DOMAIN = SupportedDomain.NAVERBLOG; + private static final DateTimeFormatter NAVERBLOG_FORMATTER = + DateTimeFormatter.ofPattern("yyyy. M. d. HH:mm"); + + @Override + public boolean supports(String domain) { + return domain.contains(DOMAIN.getDomain()); + } + + @Override + public CrawlerResult extract(Document doc) throws IOException { + /* + 블로그 본문은