From 4b79a54105a57b1b451a35ff0ff2ec3daa7bce2a Mon Sep 17 00:00:00 2001 From: diacond Date: Mon, 1 Sep 2025 11:47:18 +0900 Subject: [PATCH 01/20] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8/=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EA=B0=80=EC=9E=85api=20+=20jwt=EC=9D=B4=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=20api=20=EB=B3=B4=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 24 ++- .../domain/mileage/MileageRepository.java | 1 - .../global/jwt/JwtAuthenticationFilter.java | 1 - .../receiptpowerserver/MemberLoginTest.java | 88 +++++++++ .../receiptpowerserver/MemberSignupTest.java | 74 +++++++ .../receiptpowerserver/MileageTest.java | 185 ++++++++++++++++++ src/test/resources/application-test.yml | 6 +- 7 files changed, 370 insertions(+), 9 deletions(-) create mode 100644 src/test/java/com/cagong/receiptpowerserver/MemberLoginTest.java create mode 100644 src/test/java/com/cagong/receiptpowerserver/MemberSignupTest.java create mode 100644 src/test/java/com/cagong/receiptpowerserver/MileageTest.java diff --git a/build.gradle b/build.gradle index 7fafc7f..fd78837 100644 --- a/build.gradle +++ b/build.gradle @@ -24,30 +24,42 @@ repositories { } dependencies { + // 기본 웹, 유효성 검사, Lombok implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-validation' // 추가 + implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // 데이터베이스 (JPA, MySQL) implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'com.mysql:mysql-connector-j' implementation 'org.hibernate.orm:hibernate-community-dialects' + // 보안 (Spring Security) implementation 'org.springframework.boot:spring-boot-starter-security' - // JWT 관련 의존성 추가 + // JWT (토큰) implementation 'io.jsonwebtoken:jjwt-api:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' - testImplementation 'io.rest-assured:rest-assured:5.5.0' + // .env 파일 로더 + implementation 'io.github.cdimascio:dotenv-java:2.2.0' + + // 웹소켓 + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.security:spring-security-messaging' + + // Swagger (API 문서) + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + // 테스트용 라이브러리 testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.rest-assured:rest-assured:5.5.0' runtimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' } tasks.named('test') { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/mileage/MileageRepository.java b/src/main/java/com/cagong/receiptpowerserver/domain/mileage/MileageRepository.java index 2d2d5c3..e2a98a2 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/mileage/MileageRepository.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/mileage/MileageRepository.java @@ -6,7 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; - import java.util.List; import java.util.Optional; diff --git a/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtAuthenticationFilter.java b/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtAuthenticationFilter.java index 5c97748..dfcafeb 100644 --- a/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtAuthenticationFilter.java @@ -2,7 +2,6 @@ import com.cagong.receiptpowerserver.domain.member.Member; import com.cagong.receiptpowerserver.domain.member.MemberRepository; -import com.cagong.receiptpowerserver.global.security.CustomUserDetails; import com.cagong.receiptpowerserver.global.security.CustomUserDetailsService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/src/test/java/com/cagong/receiptpowerserver/MemberLoginTest.java b/src/test/java/com/cagong/receiptpowerserver/MemberLoginTest.java new file mode 100644 index 0000000..34fcd3a --- /dev/null +++ b/src/test/java/com/cagong/receiptpowerserver/MemberLoginTest.java @@ -0,0 +1,88 @@ +package com.cagong.receiptpowerserver; + +import com.cagong.receiptpowerserver.domain.member.MemberService; +import com.cagong.receiptpowerserver.domain.member.dto.MemberSignupRequest; +import com.cagong.receiptpowerserver.domain.member.dto.MemberLoginRequest; +import com.cagong.receiptpowerserver.domain.member.dto.MemberLoginResponse; +import org.junit.jupiter.api.BeforeEach; +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 static org.assertj.core.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +public class MemberLoginTest { + + @Autowired + private MemberService memberService; + + @BeforeEach + void setUp() { + // 테스트용 회원 생성 + MemberSignupRequest signupRequest = new MemberSignupRequest( + "testuser", + "test@example.com", + "password123" + ); + memberService.signup(signupRequest); + } + + @Test + void 사용자명으로_로그인_성공() { + // given + MemberLoginRequest request = new MemberLoginRequest("testuser", "password123"); + + // when + MemberLoginResponse response = memberService.login(request); + + // then + assertThat(response.getUsername()).isEqualTo("testuser"); + assertThat(response.getEmail()).isEqualTo("test@example.com"); + assertThat(response.getAccessToken()).isNotNull(); + assertThat(response.getTokenType()).isEqualTo("Bearer"); + assertThat(response.getMessage()).contains("성공적으로 완료"); + } + + @Test + void 이메일로_로그인_성공() { + // given + MemberLoginRequest request = new MemberLoginRequest("test@example.com", "password123"); + + // when + MemberLoginResponse response = memberService.login(request); + + // then + assertThat(response.getUsername()).isEqualTo("testuser"); + assertThat(response.getEmail()).isEqualTo("test@example.com"); + assertThat(response.getAccessToken()).isNotNull(); + } + + @Test + void 잘못된_비밀번호로_로그인_실패() { + // given + MemberLoginRequest request = new MemberLoginRequest("testuser", "wrongpassword"); + + // when & then + assertThatThrownBy(() -> { + memberService.login(request); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("비밀번호가 일치하지 않습니다"); + } + + @Test + void 존재하지_않는_사용자로_로그인_실패() { + // given + MemberLoginRequest request = new MemberLoginRequest("nonexistent", "password123"); + + // when & then + assertThatThrownBy(() -> { + memberService.login(request); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("존재하지 않는 사용자명"); + } +} diff --git a/src/test/java/com/cagong/receiptpowerserver/MemberSignupTest.java b/src/test/java/com/cagong/receiptpowerserver/MemberSignupTest.java new file mode 100644 index 0000000..e3b31e8 --- /dev/null +++ b/src/test/java/com/cagong/receiptpowerserver/MemberSignupTest.java @@ -0,0 +1,74 @@ +package com.cagong.receiptpowerserver; + +import com.cagong.receiptpowerserver.domain.member.Member; +import com.cagong.receiptpowerserver.domain.member.MemberRepository; +import com.cagong.receiptpowerserver.domain.member.MemberService; +import com.cagong.receiptpowerserver.domain.member.dto.MemberSignupRequest; +import com.cagong.receiptpowerserver.domain.member.dto.MemberSignupResponse; +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 static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +public class MemberSignupTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Test + void 회원가입_성공() { + // given + MemberSignupRequest request = new MemberSignupRequest( + "testuser", + "test@example.com", + "password123" + ); + + // when + MemberSignupResponse response = memberService.signup(request); + + // then + assertThat(response.getUsername()).isEqualTo("testuser"); + assertThat(response.getEmail()).isEqualTo("test@example.com"); + assertThat(response.getMessage()).contains("성공적으로 완료"); + + // 실제 저장 확인 + Member savedMember = memberRepository.findById(response.getId()).orElse(null); + assertThat(savedMember).isNotNull(); + assertThat(savedMember.getPassword()).isNotEqualTo("password123"); // 암호화 확인 + } + + @Test + void 중복_사용자명으로_회원가입_실패() { + // given + memberService.signup(new MemberSignupRequest("duplicate", "test1@example.com", "password123")); + + // when & then + assertThatThrownBy(() -> { + memberService.signup(new MemberSignupRequest("duplicate", "test2@example.com", "password456")); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 사용 중인 사용자명"); + } + + @Test + void 중복_이메일로_회원가입_실패() { + // given + memberService.signup(new MemberSignupRequest("user1", "duplicate@example.com", "password123")); + + // when & then + assertThatThrownBy(() -> { + memberService.signup(new MemberSignupRequest("user2", "duplicate@example.com", "password456")); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 사용 중인 이메일"); + } +} diff --git a/src/test/java/com/cagong/receiptpowerserver/MileageTest.java b/src/test/java/com/cagong/receiptpowerserver/MileageTest.java new file mode 100644 index 0000000..da4f26a --- /dev/null +++ b/src/test/java/com/cagong/receiptpowerserver/MileageTest.java @@ -0,0 +1,185 @@ +package com.cagong.receiptpowerserver; + +import com.cagong.receiptpowerserver.domain.cafe.Cafe; +import com.cagong.receiptpowerserver.domain.cafe.CafeRepository; +import com.cagong.receiptpowerserver.domain.member.Member; +import com.cagong.receiptpowerserver.domain.member.MemberRepository; +import com.cagong.receiptpowerserver.domain.mileage.Mileage; +import com.cagong.receiptpowerserver.domain.mileage.MileageRepository; +import org.assertj.core.api.Assertions; +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.springframework.test.annotation.DirtiesContext; + +import java.util.List; +import java.util.Optional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +public class MileageTest { + @Autowired + private MileageRepository mileageRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CafeRepository cafeRepository; + + // 공통 테스트 데이터 생성 메서드 + private Member createTestMember(String username, String email) { + Member member = Member.builder() + .username(username) + .email(email) + .password("password123") + .build(); + return memberRepository.save(member); + } + + private Cafe createTestCafe(String name, String address, String phoneNumber) { + Cafe cafe = Cafe.builder() + .name(name) + .address(address) + .phoneNumber(phoneNumber) + .build(); + return cafeRepository.save(cafe); + } + + @Test + @DisplayName("마일리지 저장 및 조회 테스트") + void 마일리지_저장_및_조회_테스트() { + // Given - 테스트 데이터 준비 + Member savedMember = createTestMember("마일리지테스터", "mileage@test.com"); + Cafe savedCafe = createTestCafe("스타벅스 강남점", "서울시 강남구 역삼동", "02-1234-5678"); + + // When - 마일리지 저장 + Mileage mileage = Mileage.builder() + .point(1000) + .member(savedMember) + .cafe(savedCafe) + .build(); + Mileage savedMileage = mileageRepository.save(mileage); + + // Then - 검증 + Optional found = mileageRepository.findById(savedMileage.getId()); + Assertions.assertThat(found).isPresent(); + Assertions.assertThat(found.get().getPoint()).isEqualTo(1000); + + // 연관관계 검증을 위한 페치 조인 사용 또는 트랜잭션 내에서 접근 + Assertions.assertThat(found.get().getMember().getUsername()).isEqualTo("마일리지테스터"); + Assertions.assertThat(found.get().getCafe().getName()).isEqualTo("스타벅스 강남점"); + } + + @Test + @DisplayName("한 회원의 여러 마일리지 적립 테스트") + void 한_회원의_여러_마일리지_적립_테스트() { + // Given + Member savedMember = createTestMember("포인트수집가", "collector@test.com"); + Cafe savedCafe1 = createTestCafe("이디야 신촌점", "서울시 서대문구 신촌동", "02-111-2222"); + Cafe savedCafe2 = createTestCafe("커피빈 홍대점", "서울시 마포구 홍익동", "02-333-4444"); + + // When - 여러 마일리지 적립 + Mileage mileage1 = Mileage.builder() + .point(500) + .member(savedMember) + .cafe(savedCafe1) + .build(); + + Mileage mileage2 = Mileage.builder() + .point(1500) + .member(savedMember) + .cafe(savedCafe2) + .build(); + + mileageRepository.save(mileage1); + mileageRepository.save(mileage2); + + // Then - 효율적인 조회 사용 + List memberMileages = mileageRepository.findByMemberId(savedMember.getId()); + + Assertions.assertThat(memberMileages).hasSize(2); + + int totalPoints = memberMileages.stream() + .mapToInt(Mileage::getPoint) + .sum(); + + Assertions.assertThat(totalPoints).isEqualTo(2000); + } + + @Test + @DisplayName("마일리지 엔티티 연관관계 테스트") + void 마일리지_엔티티_연관관계_테스트() { + // Given + Member member = Member.builder() + .username("연관관계테스터") + .email("relation@test.com") + .password("password123") + .build(); + Member savedMember = memberRepository.save(member); + + Cafe cafe = Cafe.builder() + .name("투썸플레이스 종로점") + .address("서울시 종로구 종로1가") + .phoneNumber("02-555-6666") + .build(); + Cafe savedCafe = cafeRepository.save(cafe); + + // When + Mileage mileage = Mileage.builder() + .point(2500) + .member(savedMember) + .cafe(savedCafe) + .build(); + Mileage savedMileage = mileageRepository.save(mileage); + + // Then - 연관관계 검증 + Assertions.assertThat(savedMileage.getMember()).isNotNull(); + Assertions.assertThat(savedMileage.getCafe()).isNotNull(); + Assertions.assertThat(savedMileage.getMember().getEmail()).isEqualTo("relation@test.com"); + Assertions.assertThat(savedMileage.getCafe().getAddress()).isEqualTo("서울시 종로구 종로1가"); + } + + @Test + @DisplayName("마일리지 포인트 검증 테스트 - 음수 포인트 포함") + void 마일리지_포인트_검증_테스트() { + // Given + Member savedMember = createTestMember("포인트테스터", "points@test.com"); + Cafe savedCafe = createTestCafe("카페베네 성수점", "서울시 성동구 성수동", "02-777-8888"); + + // When & Then - 다양한 포인트 값 테스트 + Mileage mileage1 = Mileage.builder() + .point(0) // 0점도 허용 + .member(savedMember) + .cafe(savedCafe) + .build(); + + Mileage mileage2 = Mileage.builder() + .point(10000) // 큰 포인트도 허용 + .member(savedMember) + .cafe(savedCafe) + .build(); + + Mileage saved1 = mileageRepository.save(mileage1); + Mileage saved2 = mileageRepository.save(mileage2); + + Assertions.assertThat(saved1.getPoint()).isEqualTo(0); + Assertions.assertThat(saved2.getPoint()).isEqualTo(10000); + + // 음수 포인트 테스트 추가 (비즈니스 로직에 따라) + Mileage negativePointMileage = Mileage.builder() + .point(-500) // 음수 포인트 (차감 등의 용도) + .member(savedMember) + .cafe(savedCafe) + .build(); + + Mileage savedNegative = mileageRepository.save(negativePointMileage); + Assertions.assertThat(savedNegative.getPoint()).isEqualTo(-500); + } + +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index e624c68..5b9e668 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -14,6 +14,7 @@ spring: h2: console: enabled: true +<<<<<<< HEAD path: /h2-console security: oauth2: @@ -30,4 +31,7 @@ spring: client-name: Google jwt: - secret: ThisIsA_VeryLongAndSecureSecretKeyForJwtTEST_YouShouldChange \ No newline at end of file + secret: ThisIsA_VeryLongAndSecureSecretKeyForJwtTEST_YouShouldChange +======= + path: /h2-console +>>>>>>> e3a1992 (로그인/회원 가입api + jwt이용한 api 보안) From 3a0c4977736d5045b1baef3b034fda92e19915c1 Mon Sep 17 00:00:00 2001 From: diacond Date: Fri, 26 Sep 2025 02:20:55 +0900 Subject: [PATCH 02/20] =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=94=94=EB=B2=84=EA=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReceiptPowerServerApplication.java | 12 +- .../domain/chat/ChatRoom.java | 70 +++++++++++ .../domain/chat/ChatRoomController.java | 114 ++++++++++++++++++ .../domain/chat/ChatRoomService.java | 94 +++++++++++++++ .../domain/chat/ChatRoomStatus.java | 7 ++ .../chat/dto/ChatRoomCreateRequest.java | 40 ++++++ .../domain/chat/dto/ChatRoomResponse.java | 30 +++++ .../chat/dto/ChatRoomStatusUpdateRequest.java | 13 ++ .../domain/location/Location.java | 35 ++++++ .../domain/member/MemberService.java | 2 +- .../member/dto/MemberLoginResponse.java | 8 +- .../exception/NotFoundException.java | 28 +++++ .../global/config/SecurityConfig.java | 51 +++++--- .../global/jwt/JwtUtil.java | 15 ++- src/main/resources/application.yml | 2 +- 15 files changed, 488 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoom.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomController.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomService.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomStatus.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomCreateRequest.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomResponse.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomStatusUpdateRequest.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/exception/NotFoundException.java diff --git a/src/main/java/com/cagong/receiptpowerserver/ReceiptPowerServerApplication.java b/src/main/java/com/cagong/receiptpowerserver/ReceiptPowerServerApplication.java index 96ea406..259f54e 100644 --- a/src/main/java/com/cagong/receiptpowerserver/ReceiptPowerServerApplication.java +++ b/src/main/java/com/cagong/receiptpowerserver/ReceiptPowerServerApplication.java @@ -3,11 +3,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -@SpringBootApplication -public class ReceiptPowerServerApplication { +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; - public static void main(String[] args) { - SpringApplication.run(ReceiptPowerServerApplication.class, args); - } +@EnableJpaAuditing // 0917 JPA Auditing +@SpringBootApplication +public class ReceiptPowerServerApplication { + public static void main(String[] args) { + SpringApplication.run(ReceiptPowerServerApplication.class, args); + } } diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoom.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoom.java new file mode 100644 index 0000000..14ae970 --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoom.java @@ -0,0 +1,70 @@ +package com.cagong.receiptpowerserver.domain.chat; + +import com.cagong.receiptpowerserver.domain.location.Location; +import com.cagong.receiptpowerserver.domain.member.Member; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class ChatRoom { + + // 기본값 설정 + private static final Integer DEFAULT_MAX_PARTICIPANTS = 10; // 기본값 우선 10명 + private static final Double DEFAULT_SEARCH_RADIUS = 1.0; // 반경 기본값 1km + + //기본 식별자 + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 채팅방 제목 - 필수 입력 + @Column(nullable = false) + private String title; + + // 위치 정보 - 1대1 관계 사용, ChatRoom : Location = 1:1 + @OneToOne(cascade = CascadeType.ALL) + @JoinColumn(name = "location_id") + private Location location; + + // 생성자 정보 - 다대일 관게 ChatRoom : Member = N:1 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id") + private Member creator; + + // 채팅방 설정 + @Column(nullable = false) + private Integer maxParticipants = DEFAULT_MAX_PARTICIPANTS; // 최대 참여자 수 - 필수 입력, 기본값 : 10명 + + private Double searchRadius = DEFAULT_SEARCH_RADIUS; // 검색 반경(km), 기본값 : 1km + + // 상태 관리 - ChatRoomStatus Enum - ACTIVE, INACTIVE, CLOSED + @Enumerated(EnumType.STRING) + private ChatRoomStatus status = ChatRoomStatus.ACTIVE; + + @CreatedDate + private LocalDateTime createdAt; + + @Builder + public ChatRoom(String title, Location location, Member creator, Integer maxParticipants, Double searchRadius) { + this.title = title; + this.location = location; + this.creator = creator; + this.maxParticipants = maxParticipants != null ? maxParticipants : DEFAULT_MAX_PARTICIPANTS; + this.searchRadius = searchRadius != null ? searchRadius : DEFAULT_SEARCH_RADIUS; + } + + // 상태 변경 메서드 추가 + public void setStatus(ChatRoomStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomController.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomController.java new file mode 100644 index 0000000..cbfb222 --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomController.java @@ -0,0 +1,114 @@ +package com.cagong.receiptpowerserver.domain.chat; + +import com.cagong.receiptpowerserver.domain.chat.dto.ChatRoomCreateRequest; +import com.cagong.receiptpowerserver.domain.chat.dto.ChatRoomResponse; +import com.cagong.receiptpowerserver.domain.chat.dto.ChatRoomStatusUpdateRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.lang.reflect.Method; +import java.util.List; + +@RestController +@RequestMapping("/api/chat-rooms") +@RequiredArgsConstructor +public class ChatRoomController { + + private final ChatRoomService chatRoomService; + + @PostMapping + public ResponseEntity create( + @Valid @RequestBody ChatRoomCreateRequest request, + Authentication authentication + ) { + Long authenticatedUserId = extractUserId(authentication); + + ChatRoomResponse response = chatRoomService.create(request, authenticatedUserId); + + return ResponseEntity.created( + ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(response.getId()) + .toUri() + ).body(response); + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id) { + return ResponseEntity.ok(chatRoomService.getById(id)); + } + + @GetMapping("/me") + public ResponseEntity> getMyRooms(Authentication authentication) { + Long authenticatedUserId = extractUserId(authentication); + return ResponseEntity.ok(chatRoomService.getMyRooms(authenticatedUserId)); + } + + @GetMapping("/nearby") + public ResponseEntity> nearby( + @RequestParam Double latitude, + @RequestParam Double longitude, + @RequestParam(name = "radiusKm", defaultValue = "3.0") Double radiusKm + ) { + return ResponseEntity.ok(chatRoomService.findNearby(latitude, longitude, radiusKm)); + } + + @PatchMapping("/{id}/status") + public ResponseEntity updateStatus( + @PathVariable Long id, + @Valid @RequestBody ChatRoomStatusUpdateRequest request, + Authentication authentication + ) { + Long authenticatedUserId = extractUserId(authentication); + return ResponseEntity.ok( + chatRoomService.updateStatus(id, authenticatedUserId, request.getStatus()) + ); + } + + /** + * 인증 주체에서 Long 타입의 사용자 ID를 추출합니다. + * 지원 케이스: + * - UserDetails#getUsername()이 숫자 문자열인 경우 + * - principal이 String이고 숫자 문자열인 경우 + * - principal이 getId() 메서드(Long 반환)를 보유한 커스텀 Principal인 경우(리플렉션) + */ + private Long extractUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + throw new org.springframework.security.authentication.AuthenticationCredentialsNotFoundException("Unauthenticated"); + } + + Object principal = authentication.getPrincipal(); + + if (principal instanceof UserDetails userDetails) { + try { + return Long.parseLong(userDetails.getUsername()); + } catch (NumberFormatException ignored) { + // fall through + } + } + + if (principal instanceof String s) { + try { + return Long.parseLong(s); + } catch (NumberFormatException ignored) { + // fall through + } + } + + try { + Method getId = principal.getClass().getMethod("getId"); + Object id = getId.invoke(principal); + if (id instanceof Long l) return l; + if (id instanceof Number n) return n.longValue(); + } catch (Exception ignored) { + // fall through + } + + throw new org.springframework.security.authentication.BadCredentialsException("Cannot resolve user id from principal"); + } +} diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomService.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomService.java new file mode 100644 index 0000000..c249067 --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomService.java @@ -0,0 +1,94 @@ +// domain/chat/ChatRoomService.java + +package com.cagong.receiptpowerserver.domain.chat; + +import com.cagong.receiptpowerserver.domain.cafe.Cafe; +import com.cagong.receiptpowerserver.domain.cafe.CafeRepository; +import com.cagong.receiptpowerserver.domain.member.Member; +import com.cagong.receiptpowerserver.domain.member.MemberRepository; +import com.cagong.receiptpowerserver.domain.chat.dto.ChatRoomCreateRequest; +import com.cagong.receiptpowerserver.domain.chat.dto.ChatRoomResponse; +import com.cagong.receiptpowerserver.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ChatRoomService { + private final ChatRoomRepository chatRoomRepository; + private final MemberRepository memberRepository; + private final CafeRepository cafeRepository; + + @Transactional + public ChatRoomResponse create(ChatRoomCreateRequest req, Long authenticatedUserId) { + Member creator = memberRepository.findById(authenticatedUserId) + .orElseThrow(() -> new NotFoundException("creator not found: " + authenticatedUserId)); + + Cafe cafe = cafeRepository.findById(req.getCafeId()) + .orElseThrow(() -> new NotFoundException("cafe not found: " + req.getCafeId())); + + String title = req.getTitle().trim(); + if (title.isEmpty()) { + throw new IllegalArgumentException("title must not be blank"); + } + + ChatRoom chatRoom = ChatRoom.builder() + .title(title) + .creator(creator) + .cafe(cafe) + .maxParticipants(req.getMaxParticipants()) + .build(); + + ChatRoom saved = chatRoomRepository.save(chatRoom); + + return toResponse(saved); + } + + @Transactional(readOnly = true) + public List getRoomsByCafe(Long cafeId) { + return chatRoomRepository.findByCafeIdAndStatus(cafeId, ChatRoomStatus.ACTIVE) + .stream().map(this::toResponse).toList(); + } + + @Transactional(readOnly = true) + public ChatRoomResponse getById(Long id) { + ChatRoom room = chatRoomRepository.findById(id) + .orElseThrow(() -> new NotFoundException("chat room not found: " + id)); + return toResponse(room); + } + + @Transactional(readOnly = true) + public List getMyRooms(Long authenticatedUserId) { + Member creator = memberRepository.findById(authenticatedUserId) + .orElseThrow(() -> new NotFoundException("creator not found: " + authenticatedUserId)); + return chatRoomRepository.findByCreatorAndStatus(creator, ChatRoomStatus.ACTIVE) + .stream().map(this::toResponse).toList(); + } + + @Transactional + public ChatRoomResponse updateStatus(Long roomId, Long authenticatedUserId, ChatRoomStatus newStatus) { + ChatRoom room = chatRoomRepository.findById(roomId) + .orElseThrow(() -> new NotFoundException("chat room not found: " + roomId)); + + if (room.getCreator() == null || !room.getCreator().getId().equals(authenticatedUserId)) { + throw new IllegalStateException("only creator can change the room status"); + } + + room.setStatus(newStatus); + return toResponse(room); + } + + private ChatRoomResponse toResponse(ChatRoom saved) { + return ChatRoomResponse.builder() + .id(saved.getId()) + .title(saved.getTitle()) + .creatorId(saved.getCreator() != null ? saved.getCreator().getId() : null) + .maxParticipants(saved.getMaxParticipants()) + .status(saved.getStatus().name()) + .createdAt(saved.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomStatus.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomStatus.java new file mode 100644 index 0000000..f15df92 --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomStatus.java @@ -0,0 +1,7 @@ +package com.cagong.receiptpowerserver.domain.chat; + +public enum ChatRoomStatus { + ACTIVE, + INACTIVE, + CLOSED +} diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomCreateRequest.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomCreateRequest.java new file mode 100644 index 0000000..5587730 --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomCreateRequest.java @@ -0,0 +1,40 @@ +package com.cagong.receiptpowerserver.domain.chat.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ChatRoomCreateRequest { + + @NotBlank + @Size(max = 255) + private String title; + + // creatorId는 인증 정보에서만 사용하도록 제거되었습니다. + + @Min(2) + @Max(100) // 정책에 맞춰 조정 + private Integer maxParticipants; // 옵션: null 이면 기본값(엔티티에서 10) + + @DecimalMin(value = "1.0") + @DecimalMax(value = "50.0") // 정책에 맞춰 조정 (예: 최대 50km) + private Double searchRadius; // 옵션: null 이면 기본값(엔티티에서 1.0) + + @NotNull + @DecimalMin(value = "-90.0") + @DecimalMax(value = "90.0") + private Double latitude; + + @NotNull + @DecimalMin(value = "-180.0") + @DecimalMax(value = "180.0") + private Double longitude; +} diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomResponse.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomResponse.java new file mode 100644 index 0000000..ad20748 --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomResponse.java @@ -0,0 +1,30 @@ +package com.cagong.receiptpowerserver.domain.chat.dto; + +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ChatRoomResponse { + + private final Long id; + private final String title; + private final Long creatorId; + private final Integer maxParticipants; + private final Double searchRadius; + private final String status; + private final LocalDateTime createdAt; + + @Builder + public ChatRoomResponse(Long id, String title, Long creatorId, + Integer maxParticipants, Double searchRadius, + String status, LocalDateTime createdAt) { + this.id = id; + this.title = title; + this.creatorId = creatorId; + this.maxParticipants = maxParticipants; + this.searchRadius = searchRadius; + this.status = status; + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomStatusUpdateRequest.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomStatusUpdateRequest.java new file mode 100644 index 0000000..501daf8 --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomStatusUpdateRequest.java @@ -0,0 +1,13 @@ +package com.cagong.receiptpowerserver.domain.chat.dto; + +import com.cagong.receiptpowerserver.domain.chat.ChatRoomStatus; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ChatRoomStatusUpdateRequest { + @NotNull + private ChatRoomStatus status; +} diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java b/src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java new file mode 100644 index 0000000..319655c --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java @@ -0,0 +1,35 @@ +package com.cagong.receiptpowerserver.domain.location; + +import jakarta.persistence.*; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PUBLIC) +public class Location { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Double latitude; // 위도 + + @Column(nullable = false) + private Double longitude; // 경도 + + private String address; // 주소 + + @CreatedDate + private LocalDateTime createdAt; + + public Location(@NotNull @DecimalMin(value = "-90.0") @DecimalMax(value = "90.0") Double latitude, @NotNull @DecimalMin(value = "-180.0") @DecimalMax(value = "180.0") Double longitude) { + } +} diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/member/MemberService.java b/src/main/java/com/cagong/receiptpowerserver/domain/member/MemberService.java index 103e951..88e8d86 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/member/MemberService.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/member/MemberService.java @@ -53,7 +53,7 @@ public MemberLoginResponse login(MemberLoginRequest request) { } // 3. JWT 토큰 생성 - String accessToken = jwtUtil.generateAccessToken(member.getId(), member.getUsername()); + String accessToken = jwtUtil.generateAccessToken(member.getId(), member.getId().toString()); return new MemberLoginResponse(member, accessToken); } diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/member/dto/MemberLoginResponse.java b/src/main/java/com/cagong/receiptpowerserver/domain/member/dto/MemberLoginResponse.java index db1001b..46fcc10 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/member/dto/MemberLoginResponse.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/member/dto/MemberLoginResponse.java @@ -5,20 +5,18 @@ @Getter public class MemberLoginResponse { - + private final Long id; - private final String username; private final String email; private final String accessToken; private final String tokenType; private final String message; - + public MemberLoginResponse(Member member, String accessToken) { this.id = member.getId(); - this.username = member.getUsername(); this.email = member.getEmail(); this.accessToken = accessToken; this.tokenType = "Bearer"; this.message = "로그인이 성공적으로 완료되었습니다."; } -} +} \ No newline at end of file diff --git a/src/main/java/com/cagong/receiptpowerserver/exception/NotFoundException.java b/src/main/java/com/cagong/receiptpowerserver/exception/NotFoundException.java new file mode 100644 index 0000000..696d57f --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/exception/NotFoundException.java @@ -0,0 +1,28 @@ +package com.cagong.receiptpowerserver.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * 리소스를 찾지 못했을 때 던지는 예외. + * 컨트롤러 계층까지 전파되면 404 Not Found로 응답합니다. + */ +@ResponseStatus(HttpStatus.NOT_FOUND) +public class NotFoundException extends RuntimeException { + + public NotFoundException() { + super(); + } + + public NotFoundException(String message) { + super(message); + } + + public NotFoundException(String message, Throwable cause) { + super(message, cause); + } + + public NotFoundException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/cagong/receiptpowerserver/global/config/SecurityConfig.java b/src/main/java/com/cagong/receiptpowerserver/global/config/SecurityConfig.java index 80328c9..2fbda2b 100644 --- a/src/main/java/com/cagong/receiptpowerserver/global/config/SecurityConfig.java +++ b/src/main/java/com/cagong/receiptpowerserver/global/config/SecurityConfig.java @@ -1,32 +1,53 @@ package com.cagong.receiptpowerserver.global.config; -import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; // CorsConfiguration 추가 +import org.springframework.web.cors.CorsConfigurationSource; // CorsConfigurationSource 추가 +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; // UrlBasedCorsConfigurationSource 추가 + +import java.util.Arrays; // Arrays 추가 @Configuration -@EnableWebSecurity -@RequiredArgsConstructor public class SecurityConfig { - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) - .formLogin(form -> form.disable()); // login 기본 폼 로그인 비활성화 - + .cors(Customizer.withDefaults()) // CORS 설정 추가 + .authorizeHttpRequests(auth -> auth + // 조회 API는 공개 + .requestMatchers(HttpMethod.GET, "/api/chat-rooms/**").permitAll() + // 생성/상태 변경은 인증 필요 + .requestMatchers(HttpMethod.POST, "/api/chat-rooms").authenticated() + .requestMatchers(HttpMethod.PATCH, "/api/chat-rooms/**").authenticated() + .anyRequest().permitAll() + ) + .httpBasic(Customizer.withDefaults()); return http.build(); } -} + + // CORS 설정 빈 추가 + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("http://localhost:8080", "null")); // 모든 출처 허용. "null"은 로컬 파일 접근을 허용 + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); // 모든 HTTP 메서드 허용 + configuration.setAllowedHeaders(Arrays.asList("*")); // 모든 헤더 허용 + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtUtil.java b/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtUtil.java index 66421a3..8910dde 100644 --- a/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtUtil.java +++ b/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtUtil.java @@ -10,14 +10,17 @@ @Component public class JwtUtil { - + private final SecretKey key; private final long accessTokenExpiration; - - public JwtUtil(@Value("${jwt.secret:receiptpowersecretkeythatissecureenoughforjwttoken}") String secret, - @Value("${jwt.access-token-expiration:86400000}") long accessTokenExpiration) { - this.key = Keys.hmacShaKeyFor(secret.getBytes()); - this.accessTokenExpiration = accessTokenExpiration; + + // 여기에 직접 키를 하드코딩합니다. + private static final String SECRET_KEY_STRING = "ff0d12fcdb370301eef108a0e87970dd3082e23766c077fe1386126fa513d32b"; + private static final long ACCESS_TOKEN_EXPIRATION = 86400000; + + public JwtUtil() { + this.key = Keys.hmacShaKeyFor(SECRET_KEY_STRING.getBytes()); + this.accessTokenExpiration = ACCESS_TOKEN_EXPIRATION; } public String generateAccessToken(Long userId, String username) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a44a6da..b7d2d60 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -25,4 +25,4 @@ spring: client-name: Google jwt: - secret: "${JWT_SECRET}" \ No newline at end of file + secret: "${JWT_SECRET}" From 804fb31b9a708cc66360ff23af71919d152d9770 Mon Sep 17 00:00:00 2001 From: diacond Date: Tue, 30 Sep 2025 14:20:43 +0900 Subject: [PATCH 03/20] =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A4&=EC=B1=84=ED=8C=85=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../domain/chat/ChatMessageController.java | 38 ++++++ .../domain/chat/ChatRoom.java | 20 ++- .../domain/chat/ChatRoomController.java | 31 ++--- .../domain/chat/ChatRoomRepository.java | 15 +++ .../domain/chat/ChatRoomService.java | 2 +- .../domain/chat/dto/ChatMessageRequest.java | 22 ++++ .../chat/dto/ChatRoomCreateRequest.java | 19 +-- .../domain/chat/dto/ChatRoomResponse.java | 4 +- .../domain/location/Location.java | 4 +- .../global/config/SecurityConfig.java | 47 ++++--- .../global/config/WebSocketConfig.java | 40 ++++++ .../global/jwt/JwtUtil.java | 3 + .../global/jwt/StompHandler.java | 55 ++++++++ .../global/security/CustomUserDetails.java | 18 +-- src/main/resources/static/chat.html | 119 ++++++++++++++++++ .../receiptpowerserver/MemberLoginTest.java | 88 ------------- .../member/auth/MemberLoginTest.java | 6 +- 18 files changed, 370 insertions(+), 163 deletions(-) create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatMessageController.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomRepository.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatMessageRequest.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/global/config/WebSocketConfig.java create mode 100644 src/main/java/com/cagong/receiptpowerserver/global/jwt/StompHandler.java create mode 100644 src/main/resources/static/chat.html delete mode 100644 src/test/java/com/cagong/receiptpowerserver/MemberLoginTest.java diff --git a/build.gradle b/build.gradle index fd78837..f731b0e 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,8 @@ dependencies { // Swagger (API 문서) implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.security:spring-security-messaging' // 테스트용 라이브러리 testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatMessageController.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatMessageController.java new file mode 100644 index 0000000..257e520 --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatMessageController.java @@ -0,0 +1,38 @@ +// chat/ChatMessageController.java + +package com.cagong.receiptpowerserver.domain.chat; + +import com.cagong.receiptpowerserver.domain.chat.dto.ChatMessageRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Controller; + +@Controller +@RequiredArgsConstructor +public class ChatMessageController { + + private final SimpMessageSendingOperations messagingTemplate; + + @MessageMapping("/chat/message") + public void message(@Payload ChatMessageRequest message, StompHeaderAccessor headerAccessor) { + + // [핵심] STOMP 세션 속성에서 StompHandler가 저장해 둔 사용자 이름을 직접 가져옵니다. + String senderName = (String) headerAccessor.getSessionAttributes().get("username"); + + // 만약 비정상적인 접근으로 senderName이 없다면, 메시지 처리를 중단합니다. + if (senderName == null) { + return; + } + + message.setSender(senderName); + + if (ChatMessageRequest.MessageType.ENTER.equals(message.getType())) { + message.setMessage(senderName + "님이 입장하셨습니다."); + } + + messagingTemplate.convertAndSend("/sub/chat/room/" + message.getRoomId(), message); + } +} \ No newline at end of file diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoom.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoom.java index 14ae970..f59230e 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoom.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoom.java @@ -1,6 +1,6 @@ package com.cagong.receiptpowerserver.domain.chat; -import com.cagong.receiptpowerserver.domain.location.Location; +import com.cagong.receiptpowerserver.domain.cafe.Cafe; import com.cagong.receiptpowerserver.domain.member.Member; import jakarta.persistence.*; import lombok.AccessLevel; @@ -20,7 +20,6 @@ public class ChatRoom { // 기본값 설정 private static final Integer DEFAULT_MAX_PARTICIPANTS = 10; // 기본값 우선 10명 - private static final Double DEFAULT_SEARCH_RADIUS = 1.0; // 반경 기본값 1km //기본 식별자 @Id @@ -31,10 +30,10 @@ public class ChatRoom { @Column(nullable = false) private String title; - // 위치 정보 - 1대1 관계 사용, ChatRoom : Location = 1:1 - @OneToOne(cascade = CascadeType.ALL) - @JoinColumn(name = "location_id") - private Location location; + // 하나의 카페에 여러 채팅방 가능 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cafe_id") + private Cafe cafe; // 생성자 정보 - 다대일 관게 ChatRoom : Member = N:1 @ManyToOne(fetch = FetchType.LAZY) @@ -45,8 +44,6 @@ public class ChatRoom { @Column(nullable = false) private Integer maxParticipants = DEFAULT_MAX_PARTICIPANTS; // 최대 참여자 수 - 필수 입력, 기본값 : 10명 - private Double searchRadius = DEFAULT_SEARCH_RADIUS; // 검색 반경(km), 기본값 : 1km - // 상태 관리 - ChatRoomStatus Enum - ACTIVE, INACTIVE, CLOSED @Enumerated(EnumType.STRING) private ChatRoomStatus status = ChatRoomStatus.ACTIVE; @@ -55,12 +52,11 @@ public class ChatRoom { private LocalDateTime createdAt; @Builder - public ChatRoom(String title, Location location, Member creator, Integer maxParticipants, Double searchRadius) { + public ChatRoom(String title, Cafe cafe, Member creator, Integer maxParticipants) { this.title = title; - this.location = location; + this.cafe = cafe; // location -> cafe this.creator = creator; - this.maxParticipants = maxParticipants != null ? maxParticipants : DEFAULT_MAX_PARTICIPANTS; - this.searchRadius = searchRadius != null ? searchRadius : DEFAULT_SEARCH_RADIUS; + this.maxParticipants = maxParticipants != null ? maxParticipants : 10; } // 상태 변경 메서드 추가 diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomController.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomController.java index cbfb222..cc7cdf2 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomController.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomController.java @@ -3,6 +3,7 @@ import com.cagong.receiptpowerserver.domain.chat.dto.ChatRoomCreateRequest; import com.cagong.receiptpowerserver.domain.chat.dto.ChatRoomResponse; import com.cagong.receiptpowerserver.domain.chat.dto.ChatRoomStatusUpdateRequest; +import com.cagong.receiptpowerserver.global.security.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -15,13 +16,13 @@ import java.util.List; @RestController -@RequestMapping("/api/chat-rooms") +@RequestMapping("/api") @RequiredArgsConstructor public class ChatRoomController { private final ChatRoomService chatRoomService; - @PostMapping + @PostMapping("/chat-rooms") public ResponseEntity create( @Valid @RequestBody ChatRoomCreateRequest request, Authentication authentication @@ -38,6 +39,12 @@ public ResponseEntity create( ).body(response); } + // [추가] 특정 카페의 모든 채팅방을 조회하는 API + @GetMapping("/cafes/{cafeId}/chat-rooms") + public ResponseEntity> getRoomsByCafe(@PathVariable Long cafeId) { + return ResponseEntity.ok(chatRoomService.getRoomsByCafe(cafeId)); + } + @GetMapping("/{id}") public ResponseEntity getById(@PathVariable Long id) { return ResponseEntity.ok(chatRoomService.getById(id)); @@ -49,15 +56,6 @@ public ResponseEntity> getMyRooms(Authentication authenti return ResponseEntity.ok(chatRoomService.getMyRooms(authenticatedUserId)); } - @GetMapping("/nearby") - public ResponseEntity> nearby( - @RequestParam Double latitude, - @RequestParam Double longitude, - @RequestParam(name = "radiusKm", defaultValue = "3.0") Double radiusKm - ) { - return ResponseEntity.ok(chatRoomService.findNearby(latitude, longitude, radiusKm)); - } - @PatchMapping("/{id}/status") public ResponseEntity updateStatus( @PathVariable Long id, @@ -72,10 +70,6 @@ public ResponseEntity updateStatus( /** * 인증 주체에서 Long 타입의 사용자 ID를 추출합니다. - * 지원 케이스: - * - UserDetails#getUsername()이 숫자 문자열인 경우 - * - principal이 String이고 숫자 문자열인 경우 - * - principal이 getId() 메서드(Long 반환)를 보유한 커스텀 Principal인 경우(리플렉션) */ private Long extractUserId(Authentication authentication) { if (authentication == null || !authentication.isAuthenticated()) { @@ -84,6 +78,13 @@ private Long extractUserId(Authentication authentication) { Object principal = authentication.getPrincipal(); + // [수정된 부분]: CustomUserDetails 타입인지 확인하고 ID를 직접 추출 + if (principal instanceof CustomUserDetails customUserDetails) { + return customUserDetails.getId(); + } + + // --- 이하 코드는 기존 코드의 안전 장치로 사용됩니다 --- + if (principal instanceof UserDetails userDetails) { try { return Long.parseLong(userDetails.getUsername()); diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomRepository.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomRepository.java new file mode 100644 index 0000000..fced5e1 --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomRepository.java @@ -0,0 +1,15 @@ +package com.cagong.receiptpowerserver.domain.chat; + +import com.cagong.receiptpowerserver.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ChatRoomRepository extends JpaRepository { + + // [추가] cafeId로 활성화된 채팅방 목록을 찾는 메서드 + List findByCafeIdAndStatus(Long cafeId, ChatRoomStatus status); + + // 생성자별 채팅방 조회 + List findByCreatorAndStatus(Member creator, ChatRoomStatus status); +} diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomService.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomService.java index c249067..0f82697 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomService.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/ChatRoomService.java @@ -26,7 +26,6 @@ public class ChatRoomService { public ChatRoomResponse create(ChatRoomCreateRequest req, Long authenticatedUserId) { Member creator = memberRepository.findById(authenticatedUserId) .orElseThrow(() -> new NotFoundException("creator not found: " + authenticatedUserId)); - Cafe cafe = cafeRepository.findById(req.getCafeId()) .orElseThrow(() -> new NotFoundException("cafe not found: " + req.getCafeId())); @@ -47,6 +46,7 @@ public ChatRoomResponse create(ChatRoomCreateRequest req, Long authenticatedUser return toResponse(saved); } + // [추가] 특정 카페에 속한 채팅방 목록을 조회하는 서비스 메서드 @Transactional(readOnly = true) public List getRoomsByCafe(Long cafeId) { return chatRoomRepository.findByCafeIdAndStatus(cafeId, ChatRoomStatus.ACTIVE) diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatMessageRequest.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatMessageRequest.java new file mode 100644 index 0000000..2d9366a --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatMessageRequest.java @@ -0,0 +1,22 @@ +// chat/dto/ChatMessageRequest.java + +package com.cagong.receiptpowerserver.domain.chat.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class ChatMessageRequest { + + public enum MessageType { + ENTER, TALK, QUIT + } + + private MessageType type; // 메시지 타입 (입장, 대화, 퇴장) + private Long roomId; // 채팅방 ID + private String sender; // 채팅 참여자 이름 + private String message; // 메시지 내용 +} \ No newline at end of file diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomCreateRequest.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomCreateRequest.java index 5587730..76dc9d0 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomCreateRequest.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomCreateRequest.java @@ -1,7 +1,5 @@ package com.cagong.receiptpowerserver.domain.chat.dto; -import jakarta.validation.constraints.DecimalMax; -import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -18,23 +16,12 @@ public class ChatRoomCreateRequest { @Size(max = 255) private String title; - // creatorId는 인증 정보에서만 사용하도록 제거되었습니다. + // [추가] 카페 ID를 받도록 변경 + @NotNull + private Long cafeId; @Min(2) @Max(100) // 정책에 맞춰 조정 private Integer maxParticipants; // 옵션: null 이면 기본값(엔티티에서 10) - @DecimalMin(value = "1.0") - @DecimalMax(value = "50.0") // 정책에 맞춰 조정 (예: 최대 50km) - private Double searchRadius; // 옵션: null 이면 기본값(엔티티에서 1.0) - - @NotNull - @DecimalMin(value = "-90.0") - @DecimalMax(value = "90.0") - private Double latitude; - - @NotNull - @DecimalMin(value = "-180.0") - @DecimalMax(value = "180.0") - private Double longitude; } diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomResponse.java b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomResponse.java index ad20748..716af17 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomResponse.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/chat/dto/ChatRoomResponse.java @@ -11,19 +11,17 @@ public class ChatRoomResponse { private final String title; private final Long creatorId; private final Integer maxParticipants; - private final Double searchRadius; private final String status; private final LocalDateTime createdAt; @Builder public ChatRoomResponse(Long id, String title, Long creatorId, - Integer maxParticipants, Double searchRadius, + Integer maxParticipants, String status, LocalDateTime createdAt) { this.id = id; this.title = title; this.creatorId = creatorId; this.maxParticipants = maxParticipants; - this.searchRadius = searchRadius; this.status = status; this.createdAt = createdAt; } diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java b/src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java index 319655c..d955efd 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java @@ -30,6 +30,8 @@ public class Location { @CreatedDate private LocalDateTime createdAt; - public Location(@NotNull @DecimalMin(value = "-90.0") @DecimalMax(value = "90.0") Double latitude, @NotNull @DecimalMin(value = "-180.0") @DecimalMax(value = "180.0") Double longitude) { + public Location(Double latitude, Double longitude) { + this.latitude = latitude; + this.longitude = longitude; } } diff --git a/src/main/java/com/cagong/receiptpowerserver/global/config/SecurityConfig.java b/src/main/java/com/cagong/receiptpowerserver/global/config/SecurityConfig.java index 2fbda2b..1414898 100644 --- a/src/main/java/com/cagong/receiptpowerserver/global/config/SecurityConfig.java +++ b/src/main/java/com/cagong/receiptpowerserver/global/config/SecurityConfig.java @@ -1,46 +1,59 @@ package com.cagong.receiptpowerserver.global.config; +// 필요한 import 문들... +import com.cagong.receiptpowerserver.global.jwt.JwtAuthenticationFilter; // 이 부분을 import 해주세요. +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; -import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.config.http.SessionCreationPolicy; // Session 정책 import import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.web.cors.CorsConfiguration; // CorsConfiguration 추가 -import org.springframework.web.cors.CorsConfigurationSource; // CorsConfigurationSource 추가 -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; // UrlBasedCorsConfigurationSource 추가 +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; // 이 부분을 import 해주세요. +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Arrays; // Arrays 추가 +import java.util.Arrays; @Configuration +@RequiredArgsConstructor // final 필드 주입을 위해 추가 public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) - .cors(Customizer.withDefaults()) // CORS 설정 추가 + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - // 조회 API는 공개 + + .requestMatchers("/chat.html", "/ws-stomp/**").permitAll() + .requestMatchers(HttpMethod.POST, "/members/signup", "/members/login").permitAll() + // [수정] 아래 한 줄을 추가 -> 카페별 채팅방 조회 API를 허용 + .requestMatchers(HttpMethod.GET, "/api/cafes/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/chat-rooms/**").permitAll() - // 생성/상태 변경은 인증 필요 - .requestMatchers(HttpMethod.POST, "/api/chat-rooms").authenticated() - .requestMatchers(HttpMethod.PATCH, "/api/chat-rooms/**").authenticated() - .anyRequest().permitAll() + .anyRequest().authenticated() ) - .httpBasic(Customizer.withDefaults()); + .httpBasic(httpBasic -> httpBasic.disable()); + + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); } - // CORS 설정 빈 추가 + // CORS 설정 빈 (기존 코드와 거의 동일) @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(Arrays.asList("http://localhost:8080", "null")); // 모든 출처 허용. "null"은 로컬 파일 접근을 허용 - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); // 모든 HTTP 메서드 허용 - configuration.setAllowedHeaders(Arrays.asList("*")); // 모든 헤더 허용 + configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:8080")); // 프론트엔드 주소에 맞게 수정 + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(true); // 자격 증명 허용 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; diff --git a/src/main/java/com/cagong/receiptpowerserver/global/config/WebSocketConfig.java b/src/main/java/com/cagong/receiptpowerserver/global/config/WebSocketConfig.java new file mode 100644 index 0000000..01c3e0d --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/global/config/WebSocketConfig.java @@ -0,0 +1,40 @@ +// global/config/WebSocketConfig.java + +package com.cagong.receiptpowerserver.global.config; + +import com.cagong.receiptpowerserver.global.jwt.StompHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +// @RequiredArgsConstructor를 제거합니다. +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompHandler stompHandler; + + // [수정된 부분] 생성자를 직접 작성합니다. + public WebSocketConfig(StompHandler stompHandler) { + this.stompHandler = stompHandler; + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("*").withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/sub"); + registry.setApplicationDestinationPrefixes("/pub"); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompHandler); + } +} \ No newline at end of file diff --git a/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtUtil.java b/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtUtil.java index 8910dde..4cc1c79 100644 --- a/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtUtil.java +++ b/src/main/java/com/cagong/receiptpowerserver/global/jwt/JwtUtil.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Component; import javax.crypto.SecretKey; +import java.util.Base64; import java.util.Date; @Component @@ -19,6 +20,8 @@ public class JwtUtil { private static final long ACCESS_TOKEN_EXPIRATION = 86400000; public JwtUtil() { + // [최종 수정]: Base64 인코딩을 제거하고, 문자열을 직접 바이트로 변환합니다. + // 이 키는 이미 512비트 HMAC에 적합한 길이의 문자열입니다. this.key = Keys.hmacShaKeyFor(SECRET_KEY_STRING.getBytes()); this.accessTokenExpiration = ACCESS_TOKEN_EXPIRATION; } diff --git a/src/main/java/com/cagong/receiptpowerserver/global/jwt/StompHandler.java b/src/main/java/com/cagong/receiptpowerserver/global/jwt/StompHandler.java new file mode 100644 index 0000000..c1eb4e3 --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/global/jwt/StompHandler.java @@ -0,0 +1,55 @@ +// global/jwt/StompHandler.java + +package com.cagong.receiptpowerserver.global.jwt; + +import com.cagong.receiptpowerserver.global.security.CustomUserDetailsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StompHandler implements ChannelInterceptor { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + // STOMP 연결 요청일 때만 토큰을 검증하고 사용자 정보를 세션에 저장합니다. + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + try { + String jwt = accessor.getFirstNativeHeader("Authorization"); + + if (jwt != null && jwt.startsWith("Bearer ") && jwtUtil.validateToken(jwt.substring(7))) { + String token = jwt.substring(7); + Long userId = jwtUtil.getUserIdFromToken(token); + UserDetails userDetails = customUserDetailsService.loadUserById(userId); + + // [핵심] STOMP 세션 속성에 사용자 이름을 직접 저장합니다. + Map sessionAttributes = accessor.getSessionAttributes(); + if (sessionAttributes != null) { + sessionAttributes.put("username", userDetails.getUsername()); + log.info("User connected and authenticated: {}", userDetails.getUsername()); + } + } + } catch (Exception e) { + log.error("Authentication error during WebSocket connect", e); + // 여기서 예외를 던지면 클라이언트에게 오류가 전달됩니다. + // throw new MessagingException("Authentication failed"); + } + } + return message; + } +} \ No newline at end of file diff --git a/src/main/java/com/cagong/receiptpowerserver/global/security/CustomUserDetails.java b/src/main/java/com/cagong/receiptpowerserver/global/security/CustomUserDetails.java index badc71c..2a03f0f 100644 --- a/src/main/java/com/cagong/receiptpowerserver/global/security/CustomUserDetails.java +++ b/src/main/java/com/cagong/receiptpowerserver/global/security/CustomUserDetails.java @@ -1,3 +1,5 @@ +// global/security/CustomUserDetails.java + package com.cagong.receiptpowerserver.global.security; import com.cagong.receiptpowerserver.domain.member.Member; @@ -15,30 +17,30 @@ public class CustomUserDetails implements UserDetails { private final Long id; private final String email; private final String password; + private final String username; // [추가] 사용자 이름을 저장할 필드 public CustomUserDetails(Member member) { this.id = member.getId(); this.email = member.getEmail(); this.password = member.getPassword(); + this.username = member.getUsername(); // [추가] member 객체로부터 username을 가져와 저장 } @Override public String getUsername() { - return email; // Security에서 username으로 인식 + // [수정] ID 대신 실제 사용자 이름을 반환하도록 변경 + return username; } + // ... 나머지 코드는 그대로 ... @Override - public String getPassword() { - return password; - } + public String getPassword() { return password; } @Override - public Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority("ROLE_USER")); - } + public Collection getAuthorities() { return List.of(new SimpleGrantedAuthority("ROLE_USER")); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } -} +} \ No newline at end of file diff --git a/src/main/resources/static/chat.html b/src/main/resources/static/chat.html new file mode 100644 index 0000000..18f9cbf --- /dev/null +++ b/src/main/resources/static/chat.html @@ -0,0 +1,119 @@ + + + + Chat Test (Authenticated) + + + + + + +
+

WebSocket Chat Test (Authenticated)

+ +
+ +
+ +
+ + + +
+ +
+ + +
+ +
+
+ + + + + \ No newline at end of file diff --git a/src/test/java/com/cagong/receiptpowerserver/MemberLoginTest.java b/src/test/java/com/cagong/receiptpowerserver/MemberLoginTest.java deleted file mode 100644 index 34fcd3a..0000000 --- a/src/test/java/com/cagong/receiptpowerserver/MemberLoginTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.cagong.receiptpowerserver; - -import com.cagong.receiptpowerserver.domain.member.MemberService; -import com.cagong.receiptpowerserver.domain.member.dto.MemberSignupRequest; -import com.cagong.receiptpowerserver.domain.member.dto.MemberLoginRequest; -import com.cagong.receiptpowerserver.domain.member.dto.MemberLoginResponse; -import org.junit.jupiter.api.BeforeEach; -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 static org.assertj.core.api.Assertions.*; - -@SpringBootTest -@ActiveProfiles("test") -@Transactional -public class MemberLoginTest { - - @Autowired - private MemberService memberService; - - @BeforeEach - void setUp() { - // 테스트용 회원 생성 - MemberSignupRequest signupRequest = new MemberSignupRequest( - "testuser", - "test@example.com", - "password123" - ); - memberService.signup(signupRequest); - } - - @Test - void 사용자명으로_로그인_성공() { - // given - MemberLoginRequest request = new MemberLoginRequest("testuser", "password123"); - - // when - MemberLoginResponse response = memberService.login(request); - - // then - assertThat(response.getUsername()).isEqualTo("testuser"); - assertThat(response.getEmail()).isEqualTo("test@example.com"); - assertThat(response.getAccessToken()).isNotNull(); - assertThat(response.getTokenType()).isEqualTo("Bearer"); - assertThat(response.getMessage()).contains("성공적으로 완료"); - } - - @Test - void 이메일로_로그인_성공() { - // given - MemberLoginRequest request = new MemberLoginRequest("test@example.com", "password123"); - - // when - MemberLoginResponse response = memberService.login(request); - - // then - assertThat(response.getUsername()).isEqualTo("testuser"); - assertThat(response.getEmail()).isEqualTo("test@example.com"); - assertThat(response.getAccessToken()).isNotNull(); - } - - @Test - void 잘못된_비밀번호로_로그인_실패() { - // given - MemberLoginRequest request = new MemberLoginRequest("testuser", "wrongpassword"); - - // when & then - assertThatThrownBy(() -> { - memberService.login(request); - }).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("비밀번호가 일치하지 않습니다"); - } - - @Test - void 존재하지_않는_사용자로_로그인_실패() { - // given - MemberLoginRequest request = new MemberLoginRequest("nonexistent", "password123"); - - // when & then - assertThatThrownBy(() -> { - memberService.login(request); - }).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("존재하지 않는 사용자명"); - } -} diff --git a/src/test/java/com/cagong/receiptpowerserver/member/auth/MemberLoginTest.java b/src/test/java/com/cagong/receiptpowerserver/member/auth/MemberLoginTest.java index 49df5d7..073d5e6 100644 --- a/src/test/java/com/cagong/receiptpowerserver/member/auth/MemberLoginTest.java +++ b/src/test/java/com/cagong/receiptpowerserver/member/auth/MemberLoginTest.java @@ -41,7 +41,8 @@ void setUp() { MemberLoginResponse response = memberService.login(request); // then - assertThat(response.getUsername()).isEqualTo("testuser"); + // [수정됨] username 대신 ID와 email 검증 + assertThat(response.getId()).isNotNull(); assertThat(response.getEmail()).isEqualTo("test@example.com"); assertThat(response.getAccessToken()).isNotNull(); assertThat(response.getTokenType()).isEqualTo("Bearer"); @@ -57,7 +58,8 @@ void setUp() { MemberLoginResponse response = memberService.login(request); // then - assertThat(response.getUsername()).isEqualTo("testuser"); + // [수정됨] username 대신 ID가 null이 아닌지 확인 + assertThat(response.getId()).isNotNull(); assertThat(response.getEmail()).isEqualTo("test@example.com"); assertThat(response.getAccessToken()).isNotNull(); } From f6d97e9d2b0009ffba4982c2d4668350ca0b93d4 Mon Sep 17 00:00:00 2001 From: diacond Date: Tue, 30 Sep 2025 17:55:20 +0900 Subject: [PATCH 04/20] =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=EC=A7=80?= =?UTF-8?q?=EB=8F=84api=20+=20=EC=B1=84=ED=8C=85=EB=B0=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../receiptpowerserver/domain/cafe/Cafe.java | 12 +- .../domain/cafe/CafeController.java | 63 +++-------- .../domain/cafe/CafeRepository.java | 2 + .../domain/cafe/CafeService.java | 105 +++++++++--------- .../cafe/dto/CafeWithChatRoomsResponse.java | 31 ++++++ src/main/resources/application.yml | 4 + 6 files changed, 108 insertions(+), 109 deletions(-) create mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/cafe/dto/CafeWithChatRoomsResponse.java diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/Cafe.java b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/Cafe.java index a334ecc..75ec8fa 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/Cafe.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/Cafe.java @@ -16,6 +16,10 @@ public class Cafe { @Column(name = "cafe_id") private Long id; + // [추가] 카카오 장소 ID를 저장할 필드. 카페가 중복 저장되는 것을 막는다. + @Column(unique = true) + private String kakaoPlaceId; + private String name; private String address; @@ -27,12 +31,8 @@ public class Cafe { private String phoneNumber; @Builder - public Cafe(String name, String address, double latitude, double longitude, String phoneNumber) { - this.name = name; - this.address = address; - this.latitude = latitude; - this. longitude = longitude; - this.phoneNumber = phoneNumber; + public Cafe(String kakaoPlaceId, String name, String address, String phoneNumber) { + this.kakaoPlaceId = kakaoPlaceId; } public void updateFrom(CafeRequest request) { diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeController.java b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeController.java index 3671867..598f91f 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeController.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeController.java @@ -1,62 +1,27 @@ + +// domain/cafe/CafeController.java + package com.cagong.receiptpowerserver.domain.cafe; -import com.cagong.receiptpowerserver.domain.cafe.dto.CafeRequest; -import com.cagong.receiptpowerserver.domain.cafe.dto.CafeResponse; +import com.cagong.receiptpowerserver.domain.cafe.dto.CafeWithChatRoomsResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.net.URI; -import java.util.List; - @RestController +@RequestMapping("/api/cafes") @RequiredArgsConstructor -@RequestMapping("/cafes") public class CafeController { - private final CafeService cafeService; - - @GetMapping("/all") - public ResponseEntity> getAllCafes(){ - List responses = cafeService.getAllCafes(); - return ResponseEntity.ok().body(responses); - } - @GetMapping("/{cafeId}") - public ResponseEntity getCafeById(@PathVariable Long cafeId){ - try{ - CafeResponse response = cafeService.getCafeById(cafeId); - return ResponseEntity.ok().body(response); - } catch (IllegalArgumentException e){ - return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } - } - - @PostMapping - public ResponseEntity createCafe(@RequestBody CafeRequest request){ - Long cafeId = cafeService.createCafe(request); - return ResponseEntity.created(URI.create("/cafes/" + cafeId)).build(); - } - - @PutMapping("/{cafeId}") - public ResponseEntity updateCafe (@PathVariable Long cafeId, @RequestBody CafeRequest request){ - try { - cafeService.updateCafe(cafeId, request); - return ResponseEntity.ok().build(); - } catch (IllegalArgumentException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); - } - } + private final CafeService cafeService; - @DeleteMapping("{cafeId}") - public ResponseEntity deleteCafe(@PathVariable Long cafeId){ - try { - cafeService.deleteCafe(cafeId); - return ResponseEntity.noContent().build(); - } catch (IllegalArgumentException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); - } + // [수정] kakaoPlaceId 대신 query(검색어)를 받도록 변경 + @GetMapping("/details") + public ResponseEntity getCafeDetailsByQuery( + @RequestParam String query + ) { + // 서비스 메서드 이름도 변경 + CafeWithChatRoomsResponse response = cafeService.findOrCreateCafeByQueryAndGetDetails(query); + return ResponseEntity.ok(response); } } \ No newline at end of file diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeRepository.java b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeRepository.java index 8e1c550..1190f1a 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeRepository.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeRepository.java @@ -5,4 +5,6 @@ import java.util.Optional; public interface CafeRepository extends JpaRepository { + // [추가] kakaoPlaceId로 Cafe를 찾는 메서드 + Optional findByKakaoPlaceId(String kakaoPlaceId); } diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeService.java b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeService.java index d72d280..7b69127 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeService.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeService.java @@ -1,75 +1,72 @@ + +// domain/cafe/CafeService.java + package com.cagong.receiptpowerserver.domain.cafe; -import com.cagong.receiptpowerserver.domain.cafe.dto.CafeRequest; -import com.cagong.receiptpowerserver.domain.cafe.dto.CafeResponse; +import com.cagong.receiptpowerserver.domain.chat.ChatRoomService; +import com.cagong.receiptpowerserver.domain.chat.dto.ChatRoomResponse; +import com.cagong.receiptpowerserver.domain.cafe.dto.CafeWithChatRoomsResponse; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.stream.Collectors; +import org.springframework.web.client.RestTemplate; +import org.springframework.http.*; +import java.util.*; @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class CafeService { - private final CafeRepository cafeRepository; - public List getAllCafes(){ - return cafeRepository.findAll().stream() - .map(cafe -> new CafeResponse( - cafe.getId(), - cafe.getName(), - cafe.getAddress(), - cafe.getLatitude(), - cafe.getLongitude(), - cafe.getPhoneNumber() - )) - .collect(Collectors.toList()); - } + private final CafeRepository cafeRepository; + private final ChatRoomService chatRoomService; + private final RestTemplate restTemplate = new RestTemplate(); - public CafeResponse getCafeById(Long cafeId){ - return cafeRepository.findById(cafeId) - .map(cafe -> new CafeResponse( - cafe.getId(), - cafe.getName(), - cafe.getAddress(), - cafe.getLatitude(), - cafe.getLongitude(), - cafe.getPhoneNumber() - )) - .orElseThrow(() -> new IllegalArgumentException("해당 ID의 카페를 찾을 수 없습니다: " + cafeId)); - } + @Value("${kakao.api.key}") + private String kakaoApiKey; + // [수정] 메서드 이름과 파라미터 변경 @Transactional - public Long createCafe(CafeRequest request){ - Cafe cafe = Cafe.builder() - .name(request.getCafeName()) - .address(request.getAddress()) - .latitude(request.getLatitude()) - .longitude(request.getLongitude()) - .phoneNumber(request.getPhoneNumber()) - .build(); - Cafe saved = cafeRepository.save(cafe); - return saved.getId(); - } + public CafeWithChatRoomsResponse findOrCreateCafeByQueryAndGetDetails(String query) { + // 1. 카카오 API를 호출하여 검색어에 해당하는 장소 정보를 가져옵니다. + Map kakaoCafeInfo = callKakaoPlaceSearchApi(query); + String kakaoPlaceId = (String) kakaoCafeInfo.get("id"); - @Transactional - public void updateCafe(Long cafeId, CafeRequest request){ - Cafe cafe = cafeRepository.findById(cafeId) - .orElseThrow(() -> new IllegalArgumentException("해당 ID의 카페를 찾을 수 없습니다: " + cafeId)); + // 2. 카카오 ID를 기준으로 우리 DB에 카페가 이미 있는지 찾아봅니다. + Optional optionalCafe = cafeRepository.findByKakaoPlaceId(kakaoPlaceId); - cafe.updateFrom(request); + Cafe cafe; + if (optionalCafe.isPresent()) { + cafe = optionalCafe.get(); + } else { + // 3. DB에 없으면, 새로 Cafe 엔티티를 만들어 저장합니다. + Cafe newCafe = Cafe.builder() + .kakaoPlaceId(kakaoPlaceId) + .name((String) kakaoCafeInfo.get("place_name")) + .address((String) kakaoCafeInfo.get("road_address_name")) + .phoneNumber((String) kakaoCafeInfo.get("phone")) + .build(); + cafe = cafeRepository.save(newCafe); + } - cafeRepository.save(cafe); + // 4. 최종 카페 정보와 채팅방 목록을 합쳐서 반환합니다. + List chatRooms = chatRoomService.getRoomsByCafe(cafe.getId()); + return new CafeWithChatRoomsResponse(cafe, chatRooms); } - @Transactional - public void deleteCafe(Long cafeId){ - if(!cafeRepository.existsById(cafeId)){ - throw new IllegalArgumentException("해당 ID의 카페를 찾을 수 없습니다: " + cafeId); + // [수정] 파라미터를 query로 변경 + private Map callKakaoPlaceSearchApi(String query) { + String url = "https://dapi.kakao.com/v2/local/search/keyword.json?query=" + query; + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "KakaoAK " + kakaoApiKey); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, Map.class); + + List> documents = (List>) response.getBody().get("documents"); + if (documents == null || documents.isEmpty()) { + throw new RuntimeException("Could not find place info from Kakao API for query: " + query); } - cafeRepository.deleteById(cafeId); + return documents.get(0); } } - diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/dto/CafeWithChatRoomsResponse.java b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/dto/CafeWithChatRoomsResponse.java new file mode 100644 index 0000000..1dcfbb1 --- /dev/null +++ b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/dto/CafeWithChatRoomsResponse.java @@ -0,0 +1,31 @@ +// domain/cafe/dto/CafeWithChatRoomsResponse.java + +package com.cagong.receiptpowerserver.domain.cafe.dto; + +import com.cagong.receiptpowerserver.domain.cafe.Cafe; +import com.cagong.receiptpowerserver.domain.chat.dto.ChatRoomResponse; +import lombok.Getter; + +import java.util.List; + +@Getter +public class CafeWithChatRoomsResponse { + + // 1. 카페 정보 필드 + private final Long cafeId; + private final String name; + private final String address; + private final String phoneNumber; + + // 2. 해당 카페에 속한 채팅방 목록 필드 + private final List chatRooms; + + // 생성자: Cafe 객체와 ChatRoomResponse 리스트를 받아서 이 DTO를 만듭니다. + public CafeWithChatRoomsResponse(Cafe cafe, List chatRooms) { + this.cafeId = cafe.getId(); + this.name = cafe.getName(); + this.address = cafe.getAddress(); + this.phoneNumber = cafe.getPhoneNumber(); + this.chatRooms = chatRooms; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b7d2d60..2a98b79 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,3 +26,7 @@ spring: jwt: secret: "${JWT_SECRET}" +# [추가] 카카오 API 설정 +kakao: + api: + key: "${KAKAO_API_KEY}" # 실제 키 대신 .env 파일의 변수 이름을 사용 From b8fb0c39341c7e7b2c8cff87101e6dd4ca93cd09 Mon Sep 17 00:00:00 2001 From: diacond Date: Thu, 9 Oct 2025 15:08:15 +0900 Subject: [PATCH 05/20] =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9+=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EA=B8=B0=EB=8A=A5=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/location/Location.java | 37 --- src/main/resources/static/chat.html | 272 +++++++++++++----- 2 files changed, 200 insertions(+), 109 deletions(-) delete mode 100644 src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java b/src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java deleted file mode 100644 index d955efd..0000000 --- a/src/main/java/com/cagong/receiptpowerserver/domain/location/Location.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.cagong.receiptpowerserver.domain.location; - -import jakarta.persistence.*; -import jakarta.validation.constraints.DecimalMax; -import jakarta.validation.constraints.DecimalMin; -import jakarta.validation.constraints.NotNull; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.CreatedDate; - -import java.time.LocalDateTime; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PUBLIC) -public class Location { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private Double latitude; // 위도 - - @Column(nullable = false) - private Double longitude; // 경도 - - private String address; // 주소 - - @CreatedDate - private LocalDateTime createdAt; - - public Location(Double latitude, Double longitude) { - this.latitude = latitude; - this.longitude = longitude; - } -} diff --git a/src/main/resources/static/chat.html b/src/main/resources/static/chat.html index 18f9cbf..939c8c2 100644 --- a/src/main/resources/static/chat.html +++ b/src/main/resources/static/chat.html @@ -1,113 +1,241 @@ - Chat Test (Authenticated) + Cagong Chat with Map + + + + -
-

WebSocket Chat Test (Authenticated)

+
+ +
+

로그인

+
+
+ +
+ +
+

주변 카페 선택

+
+ +
-
- +
+

+

참여할 채팅방을 선택하거나, 새 채팅방을 만들어보세요.

+
    + +
    -
    - - - +
    +

    +
    + +
    -
    - - +
    +

    +
    +
    + + +
    +
    -
    - + +
    +

    로그인

    - +
    +

    회원가입

    @@ -54,33 +61,39 @@

    회원가입

    -
    -

    주변 카페 선택

    -
    - -
    + +
    -

    +

    전체 채팅방 목록

    참여할 채팅방을 선택하거나, 새 채팅방을 만들어보세요.

    -
      - - +
        + +
      + +
      +
      -

      -
      +

      새 채팅방 만들기

      +

      채팅방 이름은 "스타벅스 강남점"처럼 다른 사용자가 찾기 쉽게 지어주세요.

      +
      +
      + + +
      - +
      +

      - - + +
      @@ -91,9 +104,13 @@

      // 전역 변수 let jwtToken = null; let stompClient = null; - let currentCafe = null; let currentRoomId = null; - let map = null; + let myUsername = null; // [추가] 내 사용자명 저장 + + // 페이지 로드 시 로그인 뷰 표시 + document.addEventListener('DOMContentLoaded', () => { + showView('login-view'); + }); // 뷰 전환 헬퍼 function showView(viewId) { @@ -101,7 +118,7 @@

      document.getElementById(viewId).style.display = 'block'; } - // 회원가입 뷰를 보여주는 함수 + // 회원가입 뷰를 보여주는 함수 (이름 변경) function showSignupView() { showView('signup-view'); } @@ -124,16 +141,14 @@

      body: JSON.stringify({ username, email, password }) }); - // 서버에서 오는 에러 메시지를 받기 위한 처리 if (!response.ok) { - // 서버에서 JSON 형태의 에러 메시지를 보낸다면 아래와 같이 처리 가능 const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || '회원가입에 실패했습니다. (아이디/이메일 중복 등)'); } const data = await response.json(); alert(data.message || '회원가입 성공! 이제 로그인 해주세요.'); - showView('login-view'); // 성공 시 로그인 뷰로 전환 + showView('login-view'); } catch (error) { alert(error.message); @@ -144,159 +159,250 @@

      async function login() { const email = document.getElementById('login-email').value; const password = document.getElementById('login-password').value; + try { const response = await fetch('/members/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ usernameOrEmail: email, password: password }) }); - if (!response.ok) throw new Error('로그인 실패'); + + if (!response.ok) { + // 서버의 구체적인 에러 메시지를 표시 + const errorData = await response.json().catch(() => ({ message: '로그인 실패. 서버 응답을 확인하세요.' })); + throw new Error(errorData.message || '로그인 실패. 아이디 또는 비밀번호를 확인하세요.'); + } + const data = await response.json(); jwtToken = data.accessToken; + // [수정] 서버 응답에 username이 없다면, 일단 이메일(입력값)을 임시로 사용 + myUsername = data.username || email; + alert('로그인 성공!'); - showView('map-view'); - loadMap(); // 로그인 성공 후 지도 로드 - } catch (error) { alert(error.message); } + + // [수정] 지도 로드 대신 채팅방 목록 로드 + loadAllChatRooms(); + showView('chatroom-list-view'); + + } catch (error) { + alert(error.message); + } } function logout() { jwtToken = null; + myUsername = null; + if (stompClient) { + stompClient.disconnect(); + stompClient = null; + } alert('로그아웃 되었습니다.'); showView('login-view'); } - // 2. 지도 로드 및 카페 검색 - function loadMap() { - const mapContainer = document.getElementById('map'); - const mapOption = { - center: new kakao.maps.LatLng(37.4979, 127.0276), // 강남역 중심 - level: 3 - }; - map = new kakao.maps.Map(mapContainer, mapOption); - - const ps = new kakao.maps.services.Places(); - ps.keywordSearch('카페', (data, status, pagination) => { - if (status === kakao.maps.services.Status.OK) { - data.forEach(place => { - const marker = new kakao.maps.Marker({ - map: map, - position: new kakao.maps.LatLng(place.y, place.x) - }); - - // 마커에 클릭 이벤트를 등록합니다 - kakao.maps.event.addListener(marker, 'click', () => { - selectCafe(place.place_name); - }); - }); - } - }, { location: map.getCenter() }); - } + // 2. [수정] 지도 로드 함수 -> 전체 채팅방 로드 함수로 변경 + async function loadAllChatRooms() { + if (!jwtToken) { + alert('로그인이 필요합니다.'); + showView('login-view'); + return; + } - async function selectCafe(cafeName) { try { - const response = await fetch(`/api/cafes/details?query=${encodeURIComponent(cafeName)}`); - if (!response.ok) throw new Error('카페 정보를 불러오는 데 실패했습니다.'); - const data = await response.json(); - currentCafe = data; + // [수정] 백엔드의 "모든 채팅방 조회" API 호출 + const response = await fetch('/api/chatrooms', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${jwtToken}` + } + }); + + if (!response.ok) { + if(response.status === 401 || response.status === 403) { + throw new Error('인증이 만료되었습니다. 다시 로그인해주세요.'); + } + throw new Error('채팅방 목록을 불러오는 데 실패했습니다.'); + } + + const chatRooms = await response.json(); - document.getElementById('selected-cafe-name').innerText = data.name; const listElement = document.getElementById('chatroom-list'); - listElement.innerHTML = ''; - if (data.chatRooms.length === 0) { + listElement.innerHTML = ''; // 목록 초기화 + + if (chatRooms.length === 0) { listElement.innerHTML = '
    • 개설된 채팅방이 없습니다.
    • '; } else { - data.chatRooms.forEach(room => { + chatRooms.forEach(room => { const li = document.createElement('li'); - li.innerText = `${room.title} (ID: ${room.id})`; + + const titleSpan = document.createElement('span'); + titleSpan.innerText = room.title; + + const participantsSpan = document.createElement('span'); + // (TODO: 현재 참여 인원을 서버에서 보내주면 표시, 지금은 최대 인원만 표시) + participantsSpan.innerText = `(최대 ${room.maxParticipants}명)`; + participantsSpan.style.color = '#888'; + participantsSpan.style.marginLeft = '10px'; + + li.appendChild(titleSpan); + li.appendChild(participantsSpan); li.onclick = () => joinChatRoom(room.id, room.title); listElement.appendChild(li); }); } - showView('chatroom-list-view'); - } catch (error) { alert(error.message); } + } catch (error) { + alert(error.message); + // 토큰 만료 등의 사유일 수 있으므로 로그인 뷰로 보냄 + showView('login-view'); + } } - function backToMap() { - showView('map-view'); - } + // [삭제] selectCafe, backToMap 함수 삭제 // 3. 채팅방 생성/목록 뷰 관련 function showCreateRoomForm() { - document.getElementById('create-room-cafe-name').innerText = `${currentCafe.name}에 새 채팅방 만들기`; + // [수정] 카페 이름 관련 DOM 조작 삭제 + // document.getElementById('create-room-cafe-name').innerText = ...; + document.getElementById('room-title').value = ''; // 입력창 초기화 showView('create-chatroom-view'); } + // [수정] 함수 이름 변경 (기능은 동일) function backToChatRoomList() { + loadAllChatRooms(); // 목록 새로고침 showView('chatroom-list-view'); } async function createChatRoom() { const title = document.getElementById('room-title').value; - if (!title) { alert('채팅방 제목을 입력해주세요.'); return; } + const maxParticipants = document.getElementById('room-max-participants').value; + + if (!title) { + alert('채팅방 제목을 입력해주세요.'); + return; + } + try { - const response = await fetch('/api/chat-rooms', { + const response = await fetch('/api/chatrooms', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${jwtToken}` }, - body: JSON.stringify({ title: title, cafeId: currentCafe.cafeId, maxParticipants: 10 }) + // [수정] cafeId 제거, maxParticipants 추가 + body: JSON.stringify({ + title: title, + maxParticipants: parseInt(maxParticipants, 10) + }) }); - if (!response.ok) throw new Error('채팅방 생성 실패'); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || '채팅방 생성 실패'); + } + const newRoom = await response.json(); alert(`'${newRoom.title}' 채팅방이 생성되었습니다!`); - joinChatRoom(newRoom.id, newRoom.title); - } catch (error) { alert(error.message); } + joinChatRoom(newRoom.id, newRoom.title); // 생성 후 바로 참여 + + } catch (error) { + alert(error.message); + } } // 4. 채팅방 참여 (웹소켓) function joinChatRoom(roomId, roomTitle) { if (!jwtToken) { alert('로그인이 필요합니다.'); return; } + currentRoomId = roomId; + document.getElementById('messages').innerHTML = ''; // 이전 채팅 내용 삭제 + const socket = new SockJS('/ws-stomp'); stompClient = Stomp.over(socket); + + // JWT 토큰을 STOMP 헤더에 담아 전송 (StompHandler에서 인증) const headers = { 'Authorization': `Bearer ${jwtToken}` }; + stompClient.connect(headers, (frame) => { console.log('Connected: ' + frame); showView('chat-view'); document.getElementById('chat-room-title').innerText = roomTitle; - stompClient.subscribe(`/sub/chat/room/${roomId}`, (message) => showMessage(JSON.parse(message.body))); - stompClient.send("/pub/chat/message", {}, JSON.stringify({ type: 'ENTER', roomId: roomId })); - }, (error) => alert("연결 실패:\n" + error)); + + // 1. 이 방의 메시지를 구독 + stompClient.subscribe(`/sub/chat/room/${roomId}`, (message) => { + showMessage(JSON.parse(message.body)); + }); + + // 2. 서버에 입장 메시지(ENTER) 전송 + stompClient.send("/pub/chat/message", + {}, + JSON.stringify({ type: 'ENTER', roomId: roomId }) + ); + + }, (error) => { + console.error("STOMP 연결 실패", error); + alert("채팅 서버 연결에 실패했습니다. 로그인을 다시 시도해주세요.\n" + error); + showView('login-view'); + }); } function disconnect() { if (stompClient !== null) { + // 퇴장(QUIT) 메시지를 보낸 후 연결 종료 + stompClient.send("/pub/chat/message", + {}, + JSON.stringify({ type: 'QUIT', roomId: currentRoomId }) + ); + stompClient.disconnect(() => { alert("채팅방에서 나왔습니다."); - // 채팅방 목록 뷰로 돌아가기 전에, 해당 카페 정보를 다시 불러옵니다. - selectCafe(currentCafe.name); + loadAllChatRooms(); // 채팅방 목록 뷰 새로고침 + showView('chatroom-list-view'); }); } stompClient = null; + currentRoomId = null; } function sendMessage() { - const messageContent = document.getElementById('message-input').value; + const messageInput = document.getElementById('message-input'); + const messageContent = messageInput.value.trim(); + if (messageContent && stompClient) { - stompClient.send("/pub/chat/message", {}, JSON.stringify({ - type: 'TALK', - roomId: currentRoomId, - message: messageContent - })); - document.getElementById('message-input').value = ''; + stompClient.send("/pub/chat/message", + {}, + JSON.stringify({ + type: 'TALK', + roomId: currentRoomId, + message: messageContent + }) + ); + messageInput.value = ''; } } function showMessage(message) { const messages = document.getElementById('messages'); - const messageElement = document.createElement('p'); - let content = `[${message.sender}] ${message.message}`; + const p = document.createElement('p'); + + let content = ''; + + // ENTER/QUIT 타입은 서버가 보낸 메시지를 그대로 씀 if (message.type === 'ENTER' || message.type === 'QUIT') { + p.style.textAlign = 'center'; + p.style.color = '#888'; + p.style.fontSize = '0.9em'; content = `--- ${message.message} ---`; + } else { + // TALK 타입 + // [수정] myUsername 전역 변수와 비교 + p.style.fontWeight = (message.sender === myUsername) ? 'bold' : 'normal'; + p.style.color = (message.sender === myUsername) ? '#007bff' : '#333'; + content = `[${message.sender}] ${message.message}`; } - messageElement.appendChild(document.createTextNode(content)); - messages.appendChild(messageElement); + + p.appendChild(document.createTextNode(content)); + messages.appendChild(p); messages.scrollTop = messages.scrollHeight; } From bacc23fcd9ec4f43251bd62abb3c483873667713 Mon Sep 17 00:00:00 2001 From: YuYeonho Date: Fri, 14 Nov 2025 21:43:59 +0900 Subject: [PATCH 20/20] =?UTF-8?q?fix:=20=EC=9E=94=EA=B0=80=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../receiptpowerserver/domain/cafe/Cafe.java | 20 +------------------ .../domain/cafe/CafeController.java | 2 +- .../member/dto/MemberLoginResponse.java | 20 +++++++++---------- .../receiptpowerserver/cafe/CafeApiTest.java | 8 ++++---- 4 files changed, 16 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/Cafe.java b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/Cafe.java index 8cfa294..d030a8a 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/Cafe.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/Cafe.java @@ -39,29 +39,11 @@ public Cafe(String name, double latitude, double longitude, String address, Stri this.phoneNumber = phoneNumber; } - public void updateFrom(CafeRequest request) { + public void update(CafeUpdateRequest request) { this.name = request.getCafeName(); this.address = request.getAddress(); this.latitude = request.getLatitude(); this.longitude = request.getLongitude(); this.phoneNumber = request.getPhoneNumber(); } - - public void update(CafeUpdateRequest request) { - if (request.getCafeName() != null) { - this.name = request.getCafeName(); - } - if (request.getAddress() != null) { - this.address = request.getAddress(); - } - if (request.getLatitude() != null) { - this.latitude = request.getLatitude(); - } - if (request.getLongitude() != null) { - this.longitude = request.getLongitude(); - } - if (request.getPhoneNumber() != null) { - this.phoneNumber = request.getPhoneNumber(); - } - } } diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeController.java b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeController.java index 2660017..bd39006 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeController.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/cafe/CafeController.java @@ -12,7 +12,7 @@ import java.util.List; @RestController -@RequestMapping("/api/cafes") +@RequestMapping("/cafes") @RequiredArgsConstructor public class CafeController { diff --git a/src/main/java/com/cagong/receiptpowerserver/domain/member/dto/MemberLoginResponse.java b/src/main/java/com/cagong/receiptpowerserver/domain/member/dto/MemberLoginResponse.java index 4ffb54a..02fb84a 100644 --- a/src/main/java/com/cagong/receiptpowerserver/domain/member/dto/MemberLoginResponse.java +++ b/src/main/java/com/cagong/receiptpowerserver/domain/member/dto/MemberLoginResponse.java @@ -1,9 +1,11 @@ package com.cagong.receiptpowerserver.domain.member.dto; import com.cagong.receiptpowerserver.domain.member.Member; +import lombok.AllArgsConstructor; import lombok.Getter; @Getter +@AllArgsConstructor public class MemberLoginResponse { private Long id; @@ -18,17 +20,15 @@ private MemberLoginResponse() { this.message = "로그인이 성공적으로 완료되었습니다."; } - private MemberLoginResponse(Member member, String accessToken) { - this.id = member.getId(); - this.email = member.getEmail(); - this.username = member.getUsername(); - this.accessToken = accessToken; - this.tokenType = "Bearer"; - this.message = "로그인이 성공적으로 완료되었습니다."; - } - public static MemberLoginResponse of(Member member, String accessToken) { - return new MemberLoginResponse(member, accessToken); + return new MemberLoginResponse( + member.getId(), + member.getEmail(), + member.getUsername(), + accessToken, + "Bearer", + "로그인이 성공적으로 완료되었습니다." + ); } // 테스트용 diff --git a/src/test/java/com/cagong/receiptpowerserver/cafe/CafeApiTest.java b/src/test/java/com/cagong/receiptpowerserver/cafe/CafeApiTest.java index d1367a1..728ff07 100644 --- a/src/test/java/com/cagong/receiptpowerserver/cafe/CafeApiTest.java +++ b/src/test/java/com/cagong/receiptpowerserver/cafe/CafeApiTest.java @@ -97,7 +97,7 @@ void setup() { given() .header("Authorization", authorizationValue) .when() - .get("/api/cafes/all") // ✅ CafeController 경로와 일치 + .get("/cafes/all") // ✅ CafeController 경로와 일치 .then() // ... (검증) ... .statusCode(200) @@ -120,7 +120,7 @@ void setup() { given() .header("Authorization", authorizationValue) .when() - .get("/api/cafes/{cafeId}", cafeId) // ✅ CafeController 경로와 일치 + .get("/cafes/{cafeId}", cafeId) // ✅ CafeController 경로와 일치 .then() .statusCode(200); } @@ -140,7 +140,7 @@ void setup() { .contentType(ContentType.JSON) .body(request) .when() - .post("/api/cafes") // ✅ CafeController 경로와 일치 + .post("/cafes") // ✅ CafeController 경로와 일치 .then() .statusCode(201); } @@ -159,7 +159,7 @@ void setup() { given() .header("Authorization", authorizationValue) .when() - .delete("/api/cafes/{cafeId}", cafeId) // ✅ CafeController 경로와 일치 + .delete("/cafes/{cafeId}", cafeId) // ✅ CafeController 경로와 일치 .then() .statusCode(204); }