From bce7dbc252e4dfe10c505eb4f377d5d1828d15a6 Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Mon, 8 Dec 2025 10:50:37 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat=20:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=B2=B4=ED=81=AC=20(nickname=20unique),?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=EC=BD=94=EB=93=9C=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/controller/UserController.java | 15 ++++++++++----- .../ita/tinybite/domain/user/entity/User.java | 6 +++--- .../tinybite/domain/user/service/UserService.java | 6 ++++++ .../global/exception/errorcode/AuthErrorCode.java | 6 +++++- .../global/sms/controller/SmsAuthController.java | 10 +++++----- .../global/sms/service/SmsAuthService.java | 13 ++++++++----- 6 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/main/java/ita/tinybite/domain/user/controller/UserController.java b/src/main/java/ita/tinybite/domain/user/controller/UserController.java index 5e5ca01..2b93a13 100644 --- a/src/main/java/ita/tinybite/domain/user/controller/UserController.java +++ b/src/main/java/ita/tinybite/domain/user/controller/UserController.java @@ -9,7 +9,7 @@ import static ita.tinybite.global.response.APIResponse.success; @RestController -@RequestMapping("/api/v1/user/me") +@RequestMapping("/api/v1/user") public class UserController { private final UserService userService; @@ -18,27 +18,32 @@ public UserController(UserService userService) { this.userService = userService; } - @GetMapping public APIResponse getUser() { return success(userService.getUser()); } - @PatchMapping + @PatchMapping("/me") public APIResponse updateUser(@Valid @RequestBody UpdateUserReqDto req) { userService.updateUser(req); return success(); } - @PatchMapping("/location") + @PatchMapping("/me/location") public APIResponse updateLocation(@RequestParam(defaultValue = "37.3623504988728") String latitude, @RequestParam(defaultValue = "127.117057453619") String longitude) { userService.updateLocation(latitude, longitude); return success(); } - @DeleteMapping + @DeleteMapping("/me") public APIResponse deleteUser() { userService.deleteUser(); return success(); } + + @GetMapping("/nickname/check") + public APIResponse validateNickname(@RequestParam String nickname) { + userService.validateNickname(nickname); + return success(); + } } diff --git a/src/main/java/ita/tinybite/domain/user/entity/User.java b/src/main/java/ita/tinybite/domain/user/entity/User.java index f817069..1791de6 100644 --- a/src/main/java/ita/tinybite/domain/user/entity/User.java +++ b/src/main/java/ita/tinybite/domain/user/entity/User.java @@ -25,6 +25,9 @@ public class User extends BaseEntity { @Column(nullable = false, length = 50, unique = true) private String email; + @Column(nullable = false, length = 30, unique = true) + private String nickname; + @Column(length = 50) private String phone; @@ -36,9 +39,6 @@ public class User extends BaseEntity { @Column(nullable = false) private UserStatus status; - @Column(nullable = false, length = 100) - private String nickname; - @Column(nullable = false, length = 100) private String location; diff --git a/src/main/java/ita/tinybite/domain/user/service/UserService.java b/src/main/java/ita/tinybite/domain/user/service/UserService.java index 5b04ee6..a71130e 100644 --- a/src/main/java/ita/tinybite/domain/user/service/UserService.java +++ b/src/main/java/ita/tinybite/domain/user/service/UserService.java @@ -5,6 +5,8 @@ import ita.tinybite.domain.user.dto.res.UserResDto; import ita.tinybite.domain.user.entity.User; import ita.tinybite.domain.user.repository.UserRepository; +import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.exception.errorcode.AuthErrorCode; import ita.tinybite.global.location.LocationService; import org.springframework.stereotype.Service; @@ -43,4 +45,8 @@ public void deleteUser() { userRepository.delete(securityProvider.getCurrentUser()); } + public void validateNickname(String nickname) { + if(userRepository.existsByNickname(nickname)) + throw BusinessException.of(AuthErrorCode.DUPLICATED_NICKNAME); + } } diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java index 5618914..00c7180 100644 --- a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java +++ b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java @@ -7,7 +7,11 @@ public enum AuthErrorCode implements ErrorCode { INVALID_PHONE_NUMBER(HttpStatus.BAD_REQUEST, "INVALID_PHONE_NUMBER", "유효하지 않은 번호입니다."), - INVALID_AUTHCODE(HttpStatus.UNAUTHORIZED, "INVALID_AUTHCODE", "인증코드가 만료되었거나, 일치하지 않습니다.") + INVALID_AUTHCODE(HttpStatus.UNAUTHORIZED, "INVALID_AUTHCODE", "인증코드가 일치하지 않습니다."), + EXPIRED_AUTH_CODE(HttpStatus.BAD_REQUEST, "EXPIRED_AUTH_CODE", "인증시간이 만료되었습니다."), + + DUPLICATED_NICKNAME(HttpStatus.BAD_REQUEST, "DUPLICATED_NICKNAME", "중복된 닉네임입니다."), + ; private final HttpStatus httpStatus; diff --git a/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java b/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java index 57d84c9..eba0fa5 100644 --- a/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java +++ b/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java @@ -1,7 +1,5 @@ package ita.tinybite.global.sms.controller; -import ita.tinybite.global.exception.errorcode.AuthErrorCode; -import ita.tinybite.global.exception.errorcode.TaskErrorCode; import ita.tinybite.global.response.APIResponse; import ita.tinybite.global.sms.dto.req.CheckReqDto; import ita.tinybite.global.sms.dto.req.SendReqDto; @@ -11,6 +9,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import static ita.tinybite.global.response.APIResponse.success; + @RestController @RequestMapping("/api/v1/auth/sms") public class SmsAuthController { @@ -24,12 +24,12 @@ public SmsAuthController(SmsAuthService smsAuthService) { @PostMapping("/send") public APIResponse send(@RequestBody SendReqDto req) { smsAuthService.send(req.phone()); - return APIResponse.success(); + return success(); } @PostMapping("/check") public APIResponse check(@RequestBody CheckReqDto req) { - if(smsAuthService.check(req)) return APIResponse.success(); - return APIResponse.businessError(AuthErrorCode.INVALID_AUTHCODE); + smsAuthService.check(req); + return success(); } } diff --git a/src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java b/src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java index 781ecc0..66daf10 100644 --- a/src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java +++ b/src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java @@ -27,7 +27,7 @@ public SmsAuthService(SmsService smsService, RedisTemplate redis /** * 1. 인증코드 생성
* 2. 주어진 폰번호로 인증코드 전송
- * 3. DB에 {번호, 인증코드}쌍으로 저장 or 메모리에 저장 (ttl 설정 고려하기)
+ * 3. redis에 {번호, 인증코드}쌍으로 저장 (ttl 설정 고려)
*/ public void send(String phone) { validatePhoneNumber(phone); @@ -40,14 +40,17 @@ public void send(String phone) { /** * req.phone으로 redis 조회
* 조회한 authCode와 요청받은 authcode를 비교
- * 같으면 true, 다르면 false
+ * 조회된 authCode가 없을 시, 만료 혹은 요청 X
*/ - public boolean check(CheckReqDto req) { + public void check(CheckReqDto req) { validatePhoneNumber(req.phone()); String authCode = redisTemplate.opsForValue().get(req.phone()); - if(authCode == null) return false; - return authCode.equals(req.authCode()); + if(authCode == null) + throw BusinessException.of(AuthErrorCode.EXPIRED_AUTH_CODE); + + if(!authCode.equals(req.authCode())) + throw BusinessException.of(AuthErrorCode.INVALID_AUTHCODE); } private void validatePhoneNumber(String phone) { From 3573613a1d704efb7121ee8e089cb89d3daa3c03 Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Mon, 8 Dec 2025 13:18:00 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix=20:=20user=5Fid=20IDENTITY=20strategy?= =?UTF-8?q?=20&=20dev=20redis=20host=20=EC=9D=B4=EB=A6=84=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(localhost=20->=20redis)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/ita/tinybite/domain/user/entity/User.java | 2 +- src/main/resources/application-dev.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/ita/tinybite/domain/user/entity/User.java b/src/main/java/ita/tinybite/domain/user/entity/User.java index 1791de6..8672f0c 100644 --- a/src/main/java/ita/tinybite/domain/user/entity/User.java +++ b/src/main/java/ita/tinybite/domain/user/entity/User.java @@ -18,7 +18,7 @@ public class User extends BaseEntity { @Id - @GeneratedValue + @GeneratedValue(strategy = GenerationType.IDENTITY) @Comment("uid") private Long userId; diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 580242a..f656fb0 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -16,7 +16,7 @@ spring: data: redis: - host: localhost + host: redis port: 6379 kakao: From 08cfb63c68280e10b4fe1cce7de2f701c9c7c304 Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Mon, 8 Dec 2025 13:26:01 +0900 Subject: [PATCH 3/8] =?UTF-8?q?test=EC=BD=94=EB=93=9C=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20&=20swagger=20url=20=EC=82=AD=EC=A0=9C=20&=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=A4=91=EB=B3=B5=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tinybite/global/config/SwaggerConfig.java | 1 - src/main/resources/application-dev.yaml | 7 +- src/main/resources/application-local.yaml | 5 - .../tinybite/TinyBiteApplicationTests.java | 15 --- .../domain/user/service/UserServiceTest.java | 97 +++++++++++++++++++ .../service/fake/FakeLocationService.java | 11 +++ .../service/fake/FakeSecurityProvider.java | 23 +++++ .../global/sms/SmsAuthServiceTest.java | 8 ++ src/test/resources/application-test.yaml | 15 +-- 9 files changed, 149 insertions(+), 33 deletions(-) delete mode 100644 src/test/java/ita/tinybite/TinyBiteApplicationTests.java create mode 100644 src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java create mode 100644 src/test/java/ita/tinybite/domain/user/service/fake/FakeLocationService.java create mode 100644 src/test/java/ita/tinybite/domain/user/service/fake/FakeSecurityProvider.java diff --git a/src/main/java/ita/tinybite/global/config/SwaggerConfig.java b/src/main/java/ita/tinybite/global/config/SwaggerConfig.java index a120ca4..6c6fdc8 100644 --- a/src/main/java/ita/tinybite/global/config/SwaggerConfig.java +++ b/src/main/java/ita/tinybite/global/config/SwaggerConfig.java @@ -18,7 +18,6 @@ public class SwaggerConfig { @Bean public OpenAPI openAPI() { return new OpenAPI() - .servers(List.of(new Server().url("https://growinserver.shop"))) .addSecurityItem(new SecurityRequirement().addList("BearerAuth")) .components( new Components() diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index f656fb0..8410dca 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -25,9 +25,4 @@ kakao: naver: client-id: ${NAVER_CLIENT_ID} - secret: ${NAVER_CLIENT_SECRET} - -jwt: - secret: ${JWT_SECRET} - access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY} - refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY} + secret: ${NAVER_CLIENT_SECRET} \ No newline at end of file diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index f607a37..9de545a 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -33,11 +33,6 @@ naver: client-id: ${NAVER_CLIENT_ID} secret: ${NAVER_CLIENT_SECRET} -jwt: - secret: ${JWT_SECRET} - access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY} - refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY} - logging: level: org.hibernate.SQL: debug \ No newline at end of file diff --git a/src/test/java/ita/tinybite/TinyBiteApplicationTests.java b/src/test/java/ita/tinybite/TinyBiteApplicationTests.java deleted file mode 100644 index 4030bf9..0000000 --- a/src/test/java/ita/tinybite/TinyBiteApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package ita.tinybite; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest -@ActiveProfiles("test") -class TinyBiteApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java b/src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java new file mode 100644 index 0000000..ae14b5b --- /dev/null +++ b/src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java @@ -0,0 +1,97 @@ +package ita.tinybite.domain.user.service; + +import ita.tinybite.domain.user.constant.LoginType; +import ita.tinybite.domain.user.constant.UserStatus; +import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; +import ita.tinybite.domain.user.dto.res.UserResDto; +import ita.tinybite.domain.user.entity.User; +import ita.tinybite.domain.user.repository.UserRepository; +import ita.tinybite.domain.user.service.fake.FakeLocationService; +import ita.tinybite.domain.user.service.fake.FakeSecurityProvider; +import ita.tinybite.global.exception.BusinessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import static org.assertj.core.api.Assertions.*; + +@DataJpaTest +class UserServiceTest { + + @Autowired + private UserRepository userRepository; + + // Fake 객체 + private FakeSecurityProvider securityProvider; + private FakeLocationService locationService; + + // 테스트 객체 + private UserService userService; + + @BeforeEach + void setUp() { + securityProvider = new FakeSecurityProvider(userRepository); + locationService = new FakeLocationService(); + userService = new UserService(securityProvider, userRepository, locationService); + + User user = User.builder() + .email("yyytir777@gmail.com") + .nickname("임원재") + .location("분당구 정자동") + .status(UserStatus.ACTIVE) + .type(LoginType.KAKAO) + .build(); + + userRepository.save(user); + securityProvider.setCurrentUser(user); + } + + @Test + void getUser() { + UserResDto user = userService.getUser(); + assertThat(user).isNotNull(); + } + + @Test + void updateUser() { + // given + UpdateUserReqDto req = new UpdateUserReqDto("updated_nickname"); + + // when + userService.updateUser(req); + + // then + assertThat(securityProvider.getCurrentUser().getNickname()).isEqualTo("updated_nickname"); + } + + @Test + void updateLocation() { + // given + String latitude = "12.123145"; String longitude = "123.123123"; + + // when + userService.updateLocation(latitude, longitude); + + // then + assertThat(securityProvider.getCurrentUser().getLocation()).isEqualTo(locationService.getLocation(latitude, longitude)); + } + + @Test + void deleteUser() { + // when + User currentUser = securityProvider.getCurrentUser(); + userService.deleteUser(); + + // then + assertThat(userRepository.findById(currentUser.getUserId())).isEmpty(); + } + + @Test + void validateNickname() { + assertThatThrownBy(() -> userService.validateNickname("임원재")) + .isInstanceOf(BusinessException.class); + + assertThatNoException().isThrownBy(() -> userService.validateNickname("임원재1")); + } +} \ No newline at end of file diff --git a/src/test/java/ita/tinybite/domain/user/service/fake/FakeLocationService.java b/src/test/java/ita/tinybite/domain/user/service/fake/FakeLocationService.java new file mode 100644 index 0000000..d959117 --- /dev/null +++ b/src/test/java/ita/tinybite/domain/user/service/fake/FakeLocationService.java @@ -0,0 +1,11 @@ +package ita.tinybite.domain.user.service.fake; + +import ita.tinybite.global.location.LocationService; + +public class FakeLocationService extends LocationService { + + @Override + public String getLocation(String latitude, String longitude) { + return latitude + " " + longitude; + } +} diff --git a/src/test/java/ita/tinybite/domain/user/service/fake/FakeSecurityProvider.java b/src/test/java/ita/tinybite/domain/user/service/fake/FakeSecurityProvider.java new file mode 100644 index 0000000..e368543 --- /dev/null +++ b/src/test/java/ita/tinybite/domain/user/service/fake/FakeSecurityProvider.java @@ -0,0 +1,23 @@ +package ita.tinybite.domain.user.service.fake; + +import ita.tinybite.domain.auth.service.SecurityProvider; +import ita.tinybite.domain.user.entity.User; +import ita.tinybite.domain.user.repository.UserRepository; + +public class FakeSecurityProvider extends SecurityProvider { + + private User currentUser; + + public FakeSecurityProvider(UserRepository userRepository) { + super(userRepository); + } + + public void setCurrentUser(User user) { + currentUser = user; + } + + @Override + public User getCurrentUser() { + return currentUser; + } +} diff --git a/src/test/java/ita/tinybite/global/sms/SmsAuthServiceTest.java b/src/test/java/ita/tinybite/global/sms/SmsAuthServiceTest.java index 407877a..eb0a4bc 100644 --- a/src/test/java/ita/tinybite/global/sms/SmsAuthServiceTest.java +++ b/src/test/java/ita/tinybite/global/sms/SmsAuthServiceTest.java @@ -1,10 +1,12 @@ package ita.tinybite.global.sms; import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.sms.dto.req.CheckReqDto; import ita.tinybite.global.sms.fake.FakeRedisTemplate; import ita.tinybite.global.sms.fake.FakeSmsService; import ita.tinybite.global.sms.service.SmsAuthService; import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; import static org.assertj.core.api.Assertions.*; @@ -35,4 +37,10 @@ void should_fail_when_smsAuth_send_with_invalid_phone() { .isInstanceOf(BusinessException.class); } } + + @Test + void should_fail_when_smsAuth_send_with_expired_auth_code() { + assertThatThrownBy(() -> smsAuthService.check(new CheckReqDto(SUCCESS_PHONE_NUMBER, "123456"))) + .isInstanceOf(BusinessException.class); + } } diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 9ade865..58872b9 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -3,9 +3,12 @@ spring: activate: on-profile: "test" -# h2 사용하기? -# datasource: -# driver-class-name: com.mysql.cj.jdbc.Driver -# url: ${TEST_DB_URL} -# username: ${TEST_DB_USERNAME} -# password: ${TEST_DB_PASSWORD} \ No newline at end of file + profiles: + active: "test" + +# h2 사용 + datasource: + url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: \ No newline at end of file From ac96fa0716e8d0d5fce810f09c1338da10959a41 Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Tue, 9 Dec 2025 10:26:21 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix=20:=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/ita/tinybite/domain/user/controller/UserController.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/ita/tinybite/domain/user/controller/UserController.java b/src/main/java/ita/tinybite/domain/user/controller/UserController.java index 2b93a13..d1d72a6 100644 --- a/src/main/java/ita/tinybite/domain/user/controller/UserController.java +++ b/src/main/java/ita/tinybite/domain/user/controller/UserController.java @@ -18,6 +18,7 @@ public UserController(UserService userService) { this.userService = userService; } + @GetMapping("/me") public APIResponse getUser() { return success(userService.getUser()); } From a70941b7fd7db96964ac29f10b94923ad0f1c18b Mon Sep 17 00:00:00 2001 From: Youjin <114673063+marshmallowing@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:53:53 +0900 Subject: [PATCH 5/8] Feature/26 notification (#29) --- build.gradle | 3 + .../ita/tinybite/TinyBiteApplication.java | 2 + .../controller/FcmTokenController.java | 29 +++ .../converter/NotificationLogConverter.java | 23 ++ .../NotificationRequestConverter.java | 26 +++ .../dto/request/FcmTokenRequest.java | 8 + .../request/NotificationMulticastRequest.java | 43 ++++ .../dto/request/NotificationRequest.java | 12 + .../request/NotificationSingleRequest.java | 42 ++++ .../domain/notification/entity/FcmToken.java | 42 ++++ .../notification/entity/Notification.java | 54 +++++ .../notification/enums/NotificationType.java | 32 +++ .../infra/creator/APNsConfigCreator.java | 32 +++ .../infra/fcm/FcmNotificationSender.java | 63 ++++++ .../infra/fcm/FcmTokenScheduler.java | 27 +++ .../helper/NotificationTransactionHelper.java | 55 +++++ .../repository/FcmTokenRepository.java | 30 +++ .../repository/NotificationRepository.java | 8 + .../service/ChatNotificationService.java | 74 +++++++ .../notification/service/FcmTokenService.java | 85 +++++++ .../service/NotificationLogService.java | 20 ++ .../service/PartyNotificationService.java | 208 ++++++++++++++++++ .../service/facade/NotificationFacade.java | 81 +++++++ .../service/manager/ChatMessageManager.java | 43 ++++ .../service/manager/PartyMessageManager.java | 99 +++++++++ .../ita/tinybite/global/config/FcmConfig.java | 60 +++++ .../global/exception/EventException.java | 20 -- .../exception/GlobalExceptionHandler.java | 9 - .../exception/errorcode/EventErrorCode.java | 27 --- .../exception/errorcode/FcmErrorCode.java | 22 ++ src/main/resources/application.yaml | 10 +- 31 files changed, 1232 insertions(+), 57 deletions(-) create mode 100644 src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java create mode 100644 src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java create mode 100644 src/main/java/ita/tinybite/domain/notification/converter/NotificationRequestConverter.java create mode 100644 src/main/java/ita/tinybite/domain/notification/dto/request/FcmTokenRequest.java create mode 100644 src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java create mode 100644 src/main/java/ita/tinybite/domain/notification/dto/request/NotificationRequest.java create mode 100644 src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java create mode 100644 src/main/java/ita/tinybite/domain/notification/entity/FcmToken.java create mode 100644 src/main/java/ita/tinybite/domain/notification/entity/Notification.java create mode 100644 src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java create mode 100644 src/main/java/ita/tinybite/domain/notification/infra/creator/APNsConfigCreator.java create mode 100644 src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java create mode 100644 src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java create mode 100644 src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java create mode 100644 src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java create mode 100644 src/main/java/ita/tinybite/domain/notification/repository/NotificationRepository.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java create mode 100644 src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java create mode 100644 src/main/java/ita/tinybite/global/config/FcmConfig.java delete mode 100644 src/main/java/ita/tinybite/global/exception/EventException.java delete mode 100644 src/main/java/ita/tinybite/global/exception/errorcode/EventErrorCode.java create mode 100644 src/main/java/ita/tinybite/global/exception/errorcode/FcmErrorCode.java diff --git a/build.gradle b/build.gradle index 7c91ca6..2c7d6b8 100644 --- a/build.gradle +++ b/build.gradle @@ -63,6 +63,9 @@ dependencies { // sms implementation 'com.solapi:sdk:1.0.3' + + // firebase + implementation 'com.google.firebase:firebase-admin:9.1.1' } tasks.named('test') { diff --git a/src/main/java/ita/tinybite/TinyBiteApplication.java b/src/main/java/ita/tinybite/TinyBiteApplication.java index 6f957ed..65e2736 100644 --- a/src/main/java/ita/tinybite/TinyBiteApplication.java +++ b/src/main/java/ita/tinybite/TinyBiteApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class TinyBiteApplication { public static void main(String[] args) { SpringApplication.run(TinyBiteApplication.class, args); diff --git a/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java b/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java new file mode 100644 index 0000000..7616daa --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java @@ -0,0 +1,29 @@ +package ita.tinybite.domain.notification.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import ita.tinybite.domain.notification.dto.request.FcmTokenRequest; +import ita.tinybite.domain.notification.service.FcmTokenService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/fcm") +public class FcmTokenController { + + private final FcmTokenService fcmTokenService; + + // token 이미 존재 시 업데이트 해줌 + @PostMapping("/token") + public ResponseEntity registerToken(@RequestBody @Valid FcmTokenRequest request, + @RequestHeader(name = "User-ID") Long currentUserId) { + fcmTokenService.saveOrUpdateToken(currentUserId, request.token()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java b/src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java new file mode 100644 index 0000000..65c1628 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/converter/NotificationLogConverter.java @@ -0,0 +1,23 @@ +package ita.tinybite.domain.notification.converter; + +import org.springframework.stereotype.Component; + +import ita.tinybite.domain.notification.entity.Notification; +import ita.tinybite.domain.notification.enums.NotificationType; + +@Component +public class NotificationLogConverter { + + public Notification toEntity(Long targetUserId, String type, String title,String detail) { + + NotificationType notificationType = NotificationType.valueOf(type); + + return Notification.builder() + .userId(targetUserId) + .notificationType(notificationType) + .notificationTitle(title) + .notificationDetail(detail) + .isRead(false) + .build(); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/converter/NotificationRequestConverter.java b/src/main/java/ita/tinybite/domain/notification/converter/NotificationRequestConverter.java new file mode 100644 index 0000000..286c2d9 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/converter/NotificationRequestConverter.java @@ -0,0 +1,26 @@ +package ita.tinybite.domain.notification.converter; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; + +@Component +public class NotificationRequestConverter { + + public NotificationMulticastRequest toMulticastRequest( + List tokens, + String title, + String body, + Map data) { + + return NotificationMulticastRequest.builder() + .tokens(tokens) + .title(title) + .body(body) + .data(data) + .build(); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/dto/request/FcmTokenRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/FcmTokenRequest.java new file mode 100644 index 0000000..baea5f1 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/FcmTokenRequest.java @@ -0,0 +1,8 @@ +package ita.tinybite.domain.notification.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record FcmTokenRequest( + @NotNull(message = "FCM 토큰은 필수입니다.") + String token +) {} diff --git a/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java new file mode 100644 index 0000000..fd4981a --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationMulticastRequest.java @@ -0,0 +1,43 @@ +package ita.tinybite.domain.notification.dto.request; + +import java.util.List; +import java.util.Map; + +import com.google.firebase.internal.NonNull; +import com.google.firebase.messaging.MulticastMessage; +import com.google.firebase.messaging.Notification; + +import lombok.Builder; + +@Builder +public record NotificationMulticastRequest( + @NonNull List tokens, + String title, + String body, + Map data +) implements NotificationRequest { + + public MulticastMessage.Builder buildSendMessage() { + MulticastMessage.Builder builder = MulticastMessage.builder() + .setNotification(toNotification()) + .addAllTokens(tokens); + + if (this.data != null && !this.data.isEmpty()) { + builder.putAllData(this.data); + } + + return builder; + } + + public Notification toNotification() { + return Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + } + + @Override + public Map data() { + return this.data; + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationRequest.java new file mode 100644 index 0000000..8d3f0c1 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationRequest.java @@ -0,0 +1,12 @@ +package ita.tinybite.domain.notification.dto.request; + +import java.util.Map; + +import com.google.firebase.messaging.Notification; + +public interface NotificationRequest { + String title(); + String body(); + Notification toNotification(); + Map data(); +} diff --git a/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java new file mode 100644 index 0000000..0365a8f --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/dto/request/NotificationSingleRequest.java @@ -0,0 +1,42 @@ +package ita.tinybite.domain.notification.dto.request; + +import java.util.Map; + +import com.google.firebase.internal.NonNull; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; + +import lombok.Builder; + +@Builder +public record NotificationSingleRequest( + @NonNull String token, + String title, + String body, + Map data +) implements NotificationRequest { + + public Message.Builder buildMessage() { + Message.Builder builder = Message.builder() + .setToken(token) + .setNotification(toNotification()); + + if (this.data != null && !this.data.isEmpty()) { + builder.putAllData(this.data); + } + + return builder; + } + + public Notification toNotification() { + return Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + } + + @Override + public Map data() { + return this.data; + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/entity/FcmToken.java b/src/main/java/ita/tinybite/domain/notification/entity/FcmToken.java new file mode 100644 index 0000000..ad7bc49 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/entity/FcmToken.java @@ -0,0 +1,42 @@ +package ita.tinybite.domain.notification.entity; + +import ita.tinybite.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "fcm_tokens") +public class FcmToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "token", nullable = false) + private String token; + + @Builder.Default + @Column(name = "is_active", nullable = false) + private Boolean isActive = Boolean.TRUE; + + public void updateToken(String newToken) { + this.token = newToken; + this.isActive = Boolean.TRUE; + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/entity/Notification.java b/src/main/java/ita/tinybite/domain/notification/entity/Notification.java new file mode 100644 index 0000000..3fcd1ab --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/entity/Notification.java @@ -0,0 +1,54 @@ +package ita.tinybite.domain.notification.entity; + +import ita.tinybite.domain.notification.enums.NotificationType; +import ita.tinybite.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "notification") +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "notification_type", nullable = false) + private NotificationType notificationType; + + @Column(name = "notification_title", nullable = false) + private String notificationTitle; + + @Column(name = "notification_detail", columnDefinition = "TEXT") + private String notificationDetail; + + @Builder.Default + @Column(name = "is_read", nullable = false) + private Boolean isRead = Boolean.FALSE; + + public void markAsRead() { + if (Boolean.FALSE.equals(this.isRead)) { + this.isRead = Boolean.TRUE; + } + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java b/src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java new file mode 100644 index 0000000..7ad0e68 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java @@ -0,0 +1,32 @@ +package ita.tinybite.domain.notification.enums; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public enum NotificationType { + // 채팅 + CHAT_NEW_MESSAGE, + CHAT_UNREAD_REMINDER, + + // 파티 참여 + PARTY_APPROVAL, + PARTY_REJECTION, + PARTY_AUTO_CLOSE, + PARTY_ORDER_COMPLETE, + PARTY_DELIVERY_REMINDER, + PARTY_COMPLETE, + + // 파티 운영 + PARTY_NEW_REQUEST, + PARTY_MEMBER_LEAVE, + PARTY_MANAGER_DELIVERY_REMINDER, + + // 마케팅 알림 + MARKETING_LOCAL_NEW_PARTY, + MARKETING_WEEKLY_POPULAR, + MARKETING_PROMOTION_EVENT; + +} diff --git a/src/main/java/ita/tinybite/domain/notification/infra/creator/APNsConfigCreator.java b/src/main/java/ita/tinybite/domain/notification/infra/creator/APNsConfigCreator.java new file mode 100644 index 0000000..22725bc --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/infra/creator/APNsConfigCreator.java @@ -0,0 +1,32 @@ +package ita.tinybite.domain.notification.infra.creator; + +import com.google.firebase.messaging.ApnsConfig; +import com.google.firebase.messaging.Aps; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class APNsConfigCreator { + + public static ApnsConfig createDefaultConfig() { + return ApnsConfig.builder() + .setAps(Aps.builder() + .setSound("default") + .setBadge(1) + .build() + ) + .build(); + } + + // 이벤트별로 동적인 뱃지 숫자 설정 + public static ApnsConfig createConfigWithBadge(int badgeCount) { + return ApnsConfig.builder() + .setAps(Aps.builder() + .setSound("default") + .setBadge(badgeCount) + .build() + ) + .build(); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java new file mode 100644 index 0000000..fd68b4c --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmNotificationSender.java @@ -0,0 +1,63 @@ +package ita.tinybite.domain.notification.infra.fcm; + +import org.springframework.stereotype.Service; + +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MulticastMessage; + +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.dto.request.NotificationSingleRequest; +import ita.tinybite.domain.notification.infra.creator.APNsConfigCreator; +import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.exception.errorcode.FcmErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FcmNotificationSender { + + private final FirebaseMessaging firebaseMessaging; + + private static final int MULTICAST_TOKEN_LIMIT = 500; + + public String send(final NotificationSingleRequest request) { + try { + Message message = request.buildMessage() + .setApnsConfig(APNsConfigCreator.createDefaultConfig()) + .build(); + + String response = firebaseMessaging.send(message); + log.info("단일 알림 전송 성공 (토큰: {}): {}", request.token(), response); + return response; + + } catch (FirebaseMessagingException e) { + log.error("FCM 단일 전송 실패 (토큰: {}): {}", request.token(), e.getMessage(), e); + throw new BusinessException(FcmErrorCode.CANNOT_SEND_NOTIFICATION); + } + } + + public BatchResponse send(final NotificationMulticastRequest request) { + if (request.tokens().size() > MULTICAST_TOKEN_LIMIT) { + log.warn("멀티캐스트 실패: 토큰 {}개 (500개 제한 초과)", request.tokens().size()); + throw new BusinessException(FcmErrorCode.FCM_TOKEN_LIMIT_EXCEEDED); + } + try { + MulticastMessage message = request.buildSendMessage() + .setApnsConfig(APNsConfigCreator.createDefaultConfig()) + .build(); + + BatchResponse response = firebaseMessaging.sendMulticast(message); + log.info("멀티캐스트 전송 완료. 성공: {}, 실패: {}", + response.getSuccessCount(), response.getFailureCount()); + return response; + } catch (FirebaseMessagingException e) { + log.error("FCM 멀티캐스트 전송 중 FCM 서버 오류 발생", e); + throw new BusinessException(FcmErrorCode.CANNOT_SEND_NOTIFICATION); + } + } +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java new file mode 100644 index 0000000..8185086 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/infra/fcm/FcmTokenScheduler.java @@ -0,0 +1,27 @@ +package ita.tinybite.domain.notification.infra.fcm; // 별도의 scheduler 패키지 권장 + +import ita.tinybite.domain.notification.service.FcmTokenService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.temporal.ChronoUnit; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FcmTokenScheduler { + + private final FcmTokenService fcmTokenService; + + /** + * 비활성 토큰 정기 삭제 배치 작업. + * 매일 새벽 3시 0분에 실행되며, 3개월 이상 접속 기록이 없는 비활성 토큰을 삭제 + */ + @Scheduled(cron = "0 0 3 * * *") + public void deleteOldTokensBatch() { + int deletedCount = fcmTokenService.deleteOldInactiveTokens(ChronoUnit.MONTHS, 3); + log.info("오래된 FCM 토큰 정기 삭제 배치 완료. 삭제 건수: {}", deletedCount); + } +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java b/src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java new file mode 100644 index 0000000..22f7b1a --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/infra/helper/NotificationTransactionHelper.java @@ -0,0 +1,55 @@ +package ita.tinybite.domain.notification.infra.helper; + +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.MessagingErrorCode; +import com.google.firebase.messaging.SendResponse; +import ita.tinybite.domain.notification.service.FcmTokenService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationTransactionHelper { + + private final FcmTokenService fcmTokenService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) // 메인 트랜잭션에 영향을 주지 않도록 분리 + public void handleBatchResponse(BatchResponse response, List allTokens) { + if (response.getFailureCount() > 0) { + List failedTokens = extractUnregisteredTokens(response, allTokens); + if (!failedTokens.isEmpty()) { + fcmTokenService.deactivateTokens(failedTokens); + log.warn("FCM 응답 기반 토큰 {}건 비활성화 완료. 실패 건수: {}", failedTokens.size(), response.getFailureCount()); + } + } + } + + private List extractUnregisteredTokens(BatchResponse response, List allTokens) { + List unregisteredTokens = new ArrayList<>(); + + for (int i = 0; i < response.getResponses().size(); i++) { + SendResponse sendResponse = response.getResponses().get(i); + + if (!sendResponse.isSuccessful()) { + var exception = sendResponse.getException(); + + if (exception != null) { + var errorCode = exception.getMessagingErrorCode(); + + if ((errorCode == MessagingErrorCode.UNREGISTERED + || errorCode == MessagingErrorCode.INVALID_ARGUMENT)) { + unregisteredTokens.add(allTokens.get(i)); + } + } + } + } + return unregisteredTokens; + } +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java new file mode 100644 index 0000000..98eebf1 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/repository/FcmTokenRepository.java @@ -0,0 +1,30 @@ +package ita.tinybite.domain.notification.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import ita.tinybite.domain.notification.entity.FcmToken; + +public interface FcmTokenRepository extends JpaRepository { + + Optional findByUserIdAndToken(Long userId, String token); + List findAllByUserIdAndIsActiveTrue(Long userId); + List findAllByUserIdInAndIsActiveTrue(List userIds); + + @Modifying + @Query("UPDATE FcmToken t SET t.isActive = :isActive, t.updatedAt = current_timestamp WHERE t.token IN :tokens") + int updateIsActiveByTokenIn( + @Param("tokens") List tokens, + @Param("isActive") Boolean isActive + ); + + @Modifying + @Query("DELETE FROM FcmToken t WHERE t.isActive = FALSE AND t.updatedAt < :cutoffTime") + int deleteByIsActiveFalseAndUpdatedAtBefore(@Param("cutoffTime") LocalDateTime cutoffTime); +} diff --git a/src/main/java/ita/tinybite/domain/notification/repository/NotificationRepository.java b/src/main/java/ita/tinybite/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..0a56b28 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,8 @@ +package ita.tinybite.domain.notification.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ita.tinybite.domain.notification.entity.Notification; + +public interface NotificationRepository extends JpaRepository { +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java new file mode 100644 index 0000000..eea149b --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/ChatNotificationService.java @@ -0,0 +1,74 @@ +package ita.tinybite.domain.notification.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.firebase.messaging.BatchResponse; + +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import ita.tinybite.domain.notification.infra.fcm.FcmNotificationSender; +import ita.tinybite.domain.notification.infra.helper.NotificationTransactionHelper; +import ita.tinybite.domain.notification.service.manager.ChatMessageManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ChatNotificationService { + + private final FcmNotificationSender fcmNotificationSender; + private final FcmTokenService fcmTokenService; + private final ChatMessageManager chatMessageManager; + private final NotificationLogService notificationLogService; + private final NotificationTransactionHelper notificationTransactionHelper; + + @Transactional + public void sendNewChatMessage( + Long targetUserId, + Long chatRoomId, + String senderName, + String messageContent + ) { + String title = "💬 " + senderName + "님의 새 메시지"; + notificationLogService.saveLog(targetUserId, NotificationType.CHAT_NEW_MESSAGE.name(), title, messageContent); + + // 추후 구현 필요 사항: 뱃지 카운트 + // APNs 뱃지 카운트를 동적으로 설정? + // 안 읽은 메시지 알림 반환 방식 정의 필요 + // ChatService를 통해 해당 senderName을 통해 총 안 읽은 메시지 주입받아 이를 통해 뱃지 카운트 형성 + // 현재는 뱃지 카운트 인자 없이 단일 알림 여러개 전송 구조 + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + chatMessageManager.createNewChatMessageRequest(tokens, chatRoomId, title, senderName, messageContent); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + @Transactional + public void sendUnreadReminderNotification(Long targetUserId, Long chatRoomId) { + String title = "🔔 놓친 메시지가 있어요!"; + String detail = "안 읽은 메시지가 있어요! 지금 확인해 보세요."; + notificationLogService.saveLog(targetUserId, NotificationType.CHAT_UNREAD_REMINDER.name(), title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + chatMessageManager.createUnreadReminderRequest(tokens, chatRoomId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java new file mode 100644 index 0000000..2e486d1 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/FcmTokenService.java @@ -0,0 +1,85 @@ +package ita.tinybite.domain.notification.service; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ita.tinybite.domain.notification.entity.FcmToken; +import ita.tinybite.domain.notification.repository.FcmTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class FcmTokenService { + private final FcmTokenRepository fcmTokenRepository; + + @Transactional + public void saveOrUpdateToken(Long userId, String token) { + Optional existingToken = fcmTokenRepository.findByUserIdAndToken(userId, token); + + if (existingToken.isPresent()) { + FcmToken fcmToken = existingToken.get(); + fcmToken.updateToken(token); + } else { + FcmToken newToken = FcmToken.builder() + .userId(userId) + .token(token) + .build(); + fcmTokenRepository.save(newToken); + } + } + + public List getTokensByUserId(Long userId) { + return fcmTokenRepository.findAllByUserIdAndIsActiveTrue(userId).stream() + .map(FcmToken::getToken) + .collect(Collectors.toList()); + } + + public List getTokensByUserIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + return fcmTokenRepository.findAllByUserIdInAndIsActiveTrue(userIds).stream() + .map(FcmToken::getToken) + .collect(Collectors.toList()); + } + + // 단일 사용자 토큰 조회 + public List getTokensAndLogIfEmpty(Long targetUserId) { // (이름 변경) + List tokens = getTokensByUserId(targetUserId); + if (tokens.isEmpty()) { + log.warn("알림 대상 사용자 ID: {}에 유효한 FCM 토큰이 없습니다. (푸시 Skip)", targetUserId); + } + return tokens; + } + + // 다중 사용자 토큰 조회 + public List getMulticastTokensAndLogIfEmpty(List userIds) { // (이름 변경) + List tokens = getTokensByUserIds(userIds); + if (tokens.isEmpty()) { + log.warn("알림 대상 사용자 목록(IDs: {})에 유효한 FCM 토큰이 없습니다. (푸시 Skip)", userIds); + } + return tokens; + } + + @Transactional + public void deactivateTokens(List tokens) { + if (tokens.isEmpty()) return; + int updatedCount = fcmTokenRepository.updateIsActiveByTokenIn(tokens, Boolean.FALSE); + log.info("FCM 응답 기반 토큰 {}건 비활성화 완료.", updatedCount); + } + + @Transactional + public int deleteOldInactiveTokens(ChronoUnit unit, long amount) { + LocalDateTime cutoffTime = LocalDateTime.now().minus(amount,unit); + return fcmTokenRepository.deleteByIsActiveFalseAndUpdatedAtBefore(cutoffTime); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java b/src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java new file mode 100644 index 0000000..5e67f45 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/NotificationLogService.java @@ -0,0 +1,20 @@ +package ita.tinybite.domain.notification.service; + +import org.springframework.stereotype.Service; + +import ita.tinybite.domain.notification.converter.NotificationLogConverter; +import ita.tinybite.domain.notification.entity.Notification; +import ita.tinybite.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NotificationLogService { + private final NotificationRepository notificationRepository; + private final NotificationLogConverter notificationLogConverter; + + public void saveLog(Long targetUserId, String type, String title, String detail) { + Notification notification = notificationLogConverter.toEntity(targetUserId, type, title, detail); + notificationRepository.save(notification); + } +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java new file mode 100644 index 0000000..f28c2cb --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/PartyNotificationService.java @@ -0,0 +1,208 @@ +package ita.tinybite.domain.notification.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.firebase.messaging.BatchResponse; + +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import ita.tinybite.domain.notification.infra.fcm.FcmNotificationSender; +import ita.tinybite.domain.notification.infra.helper.NotificationTransactionHelper; +import ita.tinybite.domain.notification.service.manager.PartyMessageManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PartyNotificationService { + + private final FcmNotificationSender fcmNotificationSender; + private final FcmTokenService fcmTokenService; + private final PartyMessageManager partyMessageManager; + private final NotificationLogService notificationLogService; + private final NotificationTransactionHelper notificationTransactionHelper; + + @Transactional + public void sendApprovalNotification(Long targetUserId, Long partyId) { + String title = "🎉 파티 참여 승인"; + String detail = "파티 참여가 승인되었습니다! 지금 확인하세요."; + notificationLogService.saveLog(targetUserId, NotificationType.PARTY_APPROVAL.name(), title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); + if (tokens.isEmpty()) { + return; + } + NotificationMulticastRequest request = + partyMessageManager.createApprovalRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + @Transactional + public void sendRejectionNotification(Long targetUserId, Long partyId) { + String title = "🚨 파티 참여 거절"; + String detail = "죄송합니다. 파티 참여가 거절되었습니다."; + notificationLogService.saveLog(targetUserId, NotificationType.PARTY_REJECTION.name(), title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); + if (tokens.isEmpty()) { + return; + } + NotificationMulticastRequest request = + partyMessageManager.createRejectionRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + /** + * 아래 메서드들 파티장,파티멤버의 알림 내용 다른지에 따라 추후 수정 필요 + */ + + @Transactional + public void sendAutoCloseNotification(List memberIds, Long partyId, Long managerId) { + String title = "🎉 파티 자동 마감"; + String memberDetail = "참여 인원이 모두 차서 파티가 마감되었습니다."; + String managerDetail = "축하합니다! 목표 인원 달성으로 파티가 자동 마감되었습니다."; + + memberIds.forEach(userId -> { + String detail = userId.equals(managerId) ? managerDetail : memberDetail; + notificationLogService.saveLog(userId, NotificationType.PARTY_AUTO_CLOSE.name(), title, detail); + }); + + List tokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(memberIds); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createAutoCloseRequest(tokens, partyId, title, memberDetail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + @Transactional + public void sendOrderCompleteNotification(List memberIds, Long partyId) { + String title = "✅ 상품 주문 완료"; + String detail = "파티장이 상품 주문을 완료했습니다!"; + memberIds.forEach(userId -> + notificationLogService.saveLog(userId, NotificationType.PARTY_ORDER_COMPLETE.name(), title, detail) + ); + + List tokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(memberIds); + if (tokens.isEmpty()) { + return; + } + NotificationMulticastRequest request = + partyMessageManager.createOrderCompleteRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + @Transactional + public void sendDeliveryReminderNotification(List memberIds, Long partyId, Long managerId) { + + // 파티 멤버 + String memberTitle = "⏰ 수령 준비 알림"; + String memberDetail = "수령 시간 30분 전입니다! 늦지 않게 준비해주세요."; + List commonMembers = memberIds.stream() + .filter(id -> !id.equals(managerId)) + .toList(); + + if (!commonMembers.isEmpty()) { + commonMembers.forEach(userId -> + notificationLogService.saveLog(userId, NotificationType.PARTY_DELIVERY_REMINDER.name(), memberTitle, memberDetail) + ); + + List memberTokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(commonMembers); + if (!memberTokens.isEmpty()) { + NotificationMulticastRequest memberRequest = + partyMessageManager.createDeliveryReminderRequest(memberTokens, partyId, memberTitle, memberDetail); + + BatchResponse memberResponse = fcmNotificationSender.send(memberRequest); + notificationTransactionHelper.handleBatchResponse(memberResponse, memberTokens); + } + } + + // 파티장 + String managerTitle = "📍 수령 장소 이동 알림"; + String managerDetail = "수령 시간이 30분 남았습니다. 수령 장소로 이동해주세요!"; + + notificationLogService.saveLog(managerId, NotificationType.PARTY_MANAGER_DELIVERY_REMINDER.name(), managerTitle, managerDetail); + + List managerTokens = fcmTokenService.getTokensAndLogIfEmpty(managerId); + if (!managerTokens.isEmpty()) { + NotificationMulticastRequest managerRequest = + partyMessageManager.createManagerDeliveryReminderRequest(managerTokens, partyId, managerTitle, managerDetail); + + BatchResponse managerResponse = fcmNotificationSender.send(managerRequest); + notificationTransactionHelper.handleBatchResponse(managerResponse, managerTokens); + } + } + + @Transactional + public void sendPartyCompleteNotification(List memberIds, Long partyId) { + String title = "👋 파티 종료"; + String detail = "파티장이 수령 완료 처리했습니다. 파티가 종료되었습니다."; + memberIds.forEach(userId -> + notificationLogService.saveLog(userId, NotificationType.PARTY_COMPLETE.name(), title, detail) + ); + + List tokens = fcmTokenService.getMulticastTokensAndLogIfEmpty(memberIds); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createPartyCompleteRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + @Transactional + public void sendNewPartyRequestNotification(Long managerId, Long partyId) { + String title = "🔔 새 참여 요청"; + String detail = "새로운 참여 요청이 도착했습니다. 지금 승인해 주세요."; + + notificationLogService.saveLog(managerId, NotificationType.PARTY_NEW_REQUEST.name(), title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(managerId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createNewPartyRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } + + + @Transactional + public void sendMemberLeaveNotification(Long managerId, Long partyId, String leaverName) { + String title = "⚠️ 파티원 이탈"; + String detail = leaverName + "님이 파티에서 나갔습니다."; + + notificationLogService.saveLog(managerId, NotificationType.PARTY_MEMBER_LEAVE.name(), title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(managerId); + if (tokens.isEmpty()) { + return; + } + + NotificationMulticastRequest request = + partyMessageManager.createMemberLeaveRequest(tokens, partyId, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java b/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java new file mode 100644 index 0000000..d232065 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java @@ -0,0 +1,81 @@ +package ita.tinybite.domain.notification.service.facade; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ita.tinybite.domain.notification.service.ChatNotificationService; +import ita.tinybite.domain.notification.service.PartyNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 비즈니스 서비스(Party, Chat)로부터 요청을 받아 + * 하위 NotificationService로 위임 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationFacade { + + private final PartyNotificationService partyNotificationService; + private final ChatNotificationService chatNotificationService; + + @Transactional + public void notifyApproval(Long targetUserId, Long partyId) { + partyNotificationService.sendApprovalNotification(targetUserId, partyId); + } + + @Transactional + public void notifyRejection(Long targetUserId, Long partyId) { + partyNotificationService.sendRejectionNotification(targetUserId, partyId); + } + + @Transactional + public void notifyPartyAutoClose(List memberIds, Long partyId, Long managerId) { + partyNotificationService.sendAutoCloseNotification(memberIds, partyId, managerId); + } + + @Transactional + public void notifyOrderComplete(List memberIds, Long partyId) { + partyNotificationService.sendOrderCompleteNotification(memberIds, partyId); + } + + @Transactional + public void notifyDeliveryReminder(List memberIds, Long partyId, Long managerId) { + partyNotificationService.sendDeliveryReminderNotification(memberIds, partyId, managerId); + } + + @Transactional + public void notifyPartyComplete(List memberIds, Long partyId) { + partyNotificationService.sendPartyCompleteNotification(memberIds, partyId); + } + + @Transactional + public void notifyNewPartyRequest(Long managerId, Long partyId) { + partyNotificationService.sendNewPartyRequestNotification(managerId, partyId); + } + + @Transactional + public void notifyMemberLeave(Long managerId, Long partyId, String leaverName) { + partyNotificationService.sendMemberLeaveNotification(managerId, partyId, leaverName); + } + + @Transactional + public void notifyNewChatMessage( + Long targetUserId, + Long chatRoomId, + String senderName, + String messageContent + ) { + chatNotificationService.sendNewChatMessage(targetUserId, chatRoomId, senderName, messageContent); + } + + // 스케줄러/채팅 서비스가 호출하며, 알림 도메인은 전송만 처리 + @Transactional + public void notifyUnreadReminder(Long targetUserId, Long chatRoomId) { + chatNotificationService.sendUnreadReminderNotification(targetUserId, chatRoomId); + } + +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java b/src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java new file mode 100644 index 0000000..3d73162 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/manager/ChatMessageManager.java @@ -0,0 +1,43 @@ +package ita.tinybite.domain.notification.service.manager; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import ita.tinybite.domain.notification.converter.NotificationRequestConverter; +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ChatMessageManager { + + private final NotificationRequestConverter requestConverter; + private static final String KEY_CHAT_ROOM_ID = "chatRoomId"; + private static final String KEY_EVENT_TYPE = "eventType"; + private static final String KEY_SENDER_NAME = "senderName"; + + public NotificationMulticastRequest createNewChatMessageRequest( + List tokens, Long chatRoomId, String title, String senderName, String content) { + + Map data = new HashMap<>(); + data.put(KEY_CHAT_ROOM_ID, String.valueOf(chatRoomId)); + data.put(KEY_EVENT_TYPE, NotificationType.CHAT_NEW_MESSAGE.name()); + data.put(KEY_SENDER_NAME, senderName); + + return requestConverter.toMulticastRequest(tokens, title, content, data); + } + + public NotificationMulticastRequest createUnreadReminderRequest( + List tokens, Long chatRoomId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_CHAT_ROOM_ID, String.valueOf(chatRoomId)); + data.put(KEY_EVENT_TYPE, NotificationType.CHAT_UNREAD_REMINDER.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } +} diff --git a/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java b/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java new file mode 100644 index 0000000..3ca8e39 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/manager/PartyMessageManager.java @@ -0,0 +1,99 @@ +package ita.tinybite.domain.notification.service.manager; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import ita.tinybite.domain.notification.converter.NotificationRequestConverter; +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class PartyMessageManager { + + private final NotificationRequestConverter requestConverter; + + private static final String KEY_PARTY_ID = "partyId"; + private static final String KEY_EVENT_TYPE = "eventType"; + + public NotificationMulticastRequest createApprovalRequest(List tokens, Long partyId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_APPROVAL.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createRejectionRequest(List tokens, Long partyId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_REJECTION.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createAutoCloseRequest(List tokens, Long partyId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_AUTO_CLOSE.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createOrderCompleteRequest(List tokens, Long partyId, String title, String detail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_ORDER_COMPLETE.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createDeliveryReminderRequest(List memberTokens, Long partyId, String title, String memberDetail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_DELIVERY_REMINDER.name()); + + return requestConverter.toMulticastRequest(memberTokens, title, memberDetail, data); + } + + public NotificationMulticastRequest createManagerDeliveryReminderRequest(List managerTokens, Long partyId, String title, String managerDetail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_MANAGER_DELIVERY_REMINDER.name()); + + return requestConverter.toMulticastRequest(managerTokens, title, managerDetail, data); + } + + public NotificationMulticastRequest createPartyCompleteRequest(List tokens, Long partyId, String title, String detail) { + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_COMPLETE.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createNewPartyRequest(List tokens, Long partyId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_NEW_REQUEST.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } + + public NotificationMulticastRequest createMemberLeaveRequest(List tokens, Long partyId, String title, String detail) { + + Map data = new HashMap<>(); + data.put(KEY_PARTY_ID, String.valueOf(partyId)); + data.put(KEY_EVENT_TYPE, NotificationType.PARTY_MEMBER_LEAVE.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } +} diff --git a/src/main/java/ita/tinybite/global/config/FcmConfig.java b/src/main/java/ita/tinybite/global/config/FcmConfig.java new file mode 100644 index 0000000..00f156e --- /dev/null +++ b/src/main/java/ita/tinybite/global/config/FcmConfig.java @@ -0,0 +1,60 @@ +package ita.tinybite.global.config; + +import java.io.IOException; +import java.io.InputStream; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +public class FcmConfig { + + @Value("${fcm.file_path}") + private String fcmConfigPath; + + @PostConstruct + public void initialize() { + if (!FirebaseApp.getApps().isEmpty()) { + log.info("Firebase app has been initialized successfully."); + return; + } + try { + ClassPathResource resource = new ClassPathResource(fcmConfigPath); + try (InputStream stream = resource.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(stream)) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options); + log.info("Firebase app has been initialized successfully."); + } + } + } catch (IOException e) { + log.error("Error initializing Firebase app: Firebase 설정 파일을 읽을 수 없습니다.", e); + throw new IllegalStateException("Firebase 초기화 실패: 설정 파일 오류", e); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging() { + try { + return FirebaseMessaging.getInstance(FirebaseApp.getInstance()); + } catch (IllegalStateException e) { + log.error("FirebaseMessaging Bean 등록 실패", e); + throw e; + } + } + +} diff --git a/src/main/java/ita/tinybite/global/exception/EventException.java b/src/main/java/ita/tinybite/global/exception/EventException.java deleted file mode 100644 index 85b641a..0000000 --- a/src/main/java/ita/tinybite/global/exception/EventException.java +++ /dev/null @@ -1,20 +0,0 @@ -package ita.tinybite.global.exception; - -import ita.tinybite.global.exception.errorcode.ErrorCode; -import lombok.Getter; - -@Getter -public class EventException extends RuntimeException { - - private final ErrorCode errorCode; - - public EventException(ErrorCode errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; - } - - public static EventException of(ErrorCode errorCode) { - return new EventException(errorCode); - } -} - diff --git a/src/main/java/ita/tinybite/global/exception/GlobalExceptionHandler.java b/src/main/java/ita/tinybite/global/exception/GlobalExceptionHandler.java index c1ebbdf..df9b724 100644 --- a/src/main/java/ita/tinybite/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/ita/tinybite/global/exception/GlobalExceptionHandler.java @@ -16,15 +16,6 @@ @RestControllerAdvice public class GlobalExceptionHandler { - // 이벤트 에러 처리 - @ExceptionHandler(EventException.class) - public ResponseEntity> handleEventException(EventException exception) { - log.error(exception.getMessage()); - - return ResponseEntity.status(exception.getErrorCode().getHttpStatus()) - .body(APIResponse.businessError(exception.getErrorCode())); - } - // 비즈니스 에러 처리 @ExceptionHandler(BusinessException.class) public ResponseEntity> handleBusinessException(BusinessException exception) { diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/EventErrorCode.java b/src/main/java/ita/tinybite/global/exception/errorcode/EventErrorCode.java deleted file mode 100644 index 3c87357..0000000 --- a/src/main/java/ita/tinybite/global/exception/errorcode/EventErrorCode.java +++ /dev/null @@ -1,27 +0,0 @@ -package ita.tinybite.global.exception.errorcode; - -import org.springframework.http.HttpStatus; - -import lombok.Getter; - -@Getter -public enum EventErrorCode implements ErrorCode { - INVALID_VALUE(HttpStatus.BAD_REQUEST, "INVALID_VALUE", "잘못된 상태값입니다."), - EVENT_NOT_FOUND(HttpStatus.NOT_FOUND, "EVENT_NOT_FOUND", "일정을 찾을 수 없습니다."), - INVALID_REPEAT_COUNT(HttpStatus.BAD_REQUEST, "INVALID_REPEAT_COUNT", "반복 횟수가 허용 범위를 벗어났습니다."), - INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "INVALID_DATE_RANGE", "종료 날짜는 시작 날짜보다 앞설 수 없습니다."), - MISSING_TIME(HttpStatus.BAD_REQUEST, "MISSING_TIME", "시작 시간과 종료 시간은 모두 입력되어야 합니다."), - INVALID_TIME_RANGE(HttpStatus.BAD_REQUEST, "INVALID_TIME_RANGE", "종료 시간은 시작 시간보다 같거나 뒤여야 합니다."), - EVENT_NOT_OWNER(HttpStatus.FORBIDDEN, "EVENT_NOT_OWNER", "본인의 이벤트가 아닙니다.") - ; - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - EventErrorCode(HttpStatus httpStatus, String code, String message) { - this.httpStatus = httpStatus; - this.code = code; - this.message = message; - } -} diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/FcmErrorCode.java b/src/main/java/ita/tinybite/global/exception/errorcode/FcmErrorCode.java new file mode 100644 index 0000000..b4afa92 --- /dev/null +++ b/src/main/java/ita/tinybite/global/exception/errorcode/FcmErrorCode.java @@ -0,0 +1,22 @@ +package ita.tinybite.global.exception.errorcode; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public enum FcmErrorCode implements ErrorCode{ + CANNOT_SEND_NOTIFICATION(HttpStatus.INTERNAL_SERVER_ERROR, "CANNOT_SEND_NOTIFICATION", "알림 메시지 전송에 실패했습니다."), + FCM_TOKEN_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST,"FCM_TOKEN_LIMIT_EXCEEDED", "FCM 멀티캐스트 요청의 토큰 개수 제한을 초과했습니다.") + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + FcmErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 860b3b9..5601d5b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,4 +1,6 @@ spring: + config: + import: optional:file:.env[.properties] application: name: tinybite @@ -17,4 +19,10 @@ jwt: sms: api-key: ${SMS_API_KEY} - api-secret: ${SMS_API_SECRET} \ No newline at end of file + api-secret: ${SMS_API_SECRET} + +fcm: + file_path: firebase/tinybite_fcm.json + url: https://fcm.googleapis.com/v1/projects/${FCM_PROJECT_ID}/messages:send + google_api: https://www.googleapis.com/auth/cloud-platform + project_id: ${FCM_PROJECT_ID} \ No newline at end of file From cbbf5974f5f0fdddb5be077c9cabecf66806a5fc Mon Sep 17 00:00:00 2001 From: Lim Wonjae Date: Thu, 11 Dec 2025 23:37:04 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat=20:=20google=20login=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : google login 구현 완료 (ios 구현 중) --- build.gradle | 3 +- .../auth/controller/AuthController.java | 46 +++++- .../dto/request/GoogleAndAppleLoginReq.java | 7 + .../request/GoogleAndAppleSignupRequest.java | 18 ++ .../domain/auth/service/AuthService.java | 154 +++++++++++++++++- .../domain/user/constant/LoginType.java | 4 +- .../domain/user/constant/PlatformType.java | 6 + .../ita/tinybite/domain/user/entity/User.java | 10 ++ .../tinybite/global/config/AppleConfig.java | 19 +++ .../exception/errorcode/AuthErrorCode.java | 6 +- src/main/resources/application-local.yaml | 8 + 11 files changed, 267 insertions(+), 14 deletions(-) create mode 100644 src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleLoginReq.java create mode 100644 src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java create mode 100644 src/main/java/ita/tinybite/domain/user/constant/PlatformType.java create mode 100644 src/main/java/ita/tinybite/global/config/AppleConfig.java diff --git a/build.gradle b/build.gradle index 2c7d6b8..5afd796 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,6 @@ dependencies { // spring starter implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' //security @@ -64,6 +63,8 @@ dependencies { // sms implementation 'com.solapi:sdk:1.0.3' + // google login + implementation 'com.google.api-client:google-api-client:2.7.2' // firebase implementation 'com.google.firebase:firebase-admin:9.1.1' } diff --git a/src/main/java/ita/tinybite/domain/auth/controller/AuthController.java b/src/main/java/ita/tinybite/domain/auth/controller/AuthController.java index 30288dc..d220452 100644 --- a/src/main/java/ita/tinybite/domain/auth/controller/AuthController.java +++ b/src/main/java/ita/tinybite/domain/auth/controller/AuthController.java @@ -1,8 +1,6 @@ package ita.tinybite.domain.auth.controller; -import ita.tinybite.domain.auth.dto.request.KakaoLoginRequest; -import ita.tinybite.domain.auth.dto.request.KakaoSignupRequest; -import ita.tinybite.domain.auth.dto.request.RefreshTokenRequest; +import ita.tinybite.domain.auth.dto.request.*; import ita.tinybite.domain.auth.dto.response.AuthResponse; import ita.tinybite.domain.auth.service.AuthService; import ita.tinybite.global.response.APIResponse; @@ -18,6 +16,8 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.util.UriComponentsBuilder; +import static ita.tinybite.global.response.APIResponse.success; + @Slf4j @RestController @RequestMapping("/api/v1/auth") @@ -55,7 +55,7 @@ public ResponseEntity> kakaoSignup( ) { AuthResponse response = authService.kakaoSignup(request); return ResponseEntity.status(HttpStatus.CREATED) - .body(APIResponse.success(response)); + .body(success(response)); } @PostMapping("/kakao/login") @@ -63,7 +63,39 @@ public ResponseEntity> kakaoLogin( @Valid @RequestBody KakaoLoginRequest request ) { AuthResponse response = authService.kakaoLogin(request); - return ResponseEntity.ok(APIResponse.success(response)); + return ResponseEntity.ok(success(response)); + } + + @PostMapping("/google/signup") + public ResponseEntity> googleSignup( + @Valid @RequestBody GoogleAndAppleSignupRequest req + ) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(success(authService.googleSignup(req))); + } + + @PostMapping("/google/login") + public APIResponse googleLogin( + @Valid @RequestBody GoogleAndAppleLoginReq req + ) { + return success(authService.googleLogin(req)); + } + + @PostMapping("/apple/signup") + public ResponseEntity> appleSignup( + @Valid @RequestBody GoogleAndAppleSignupRequest req + ) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(success(authService.appleSignup(req))); + } + + @PostMapping("/apple/login") + public APIResponse appleLogin( + @Valid @RequestBody GoogleAndAppleLoginReq req + ) { + return success(authService.appleLogin(req)); } @PostMapping("/refresh") @@ -71,7 +103,7 @@ public ResponseEntity> refreshToken( @Valid @RequestBody RefreshTokenRequest request ) { AuthResponse response = authService.refreshToken(request); - return ResponseEntity.ok(APIResponse.success(response)); + return ResponseEntity.ok(success(response)); } @PostMapping("/logout") @@ -79,7 +111,7 @@ public ResponseEntity> logout( @RequestAttribute("userId") Long userId ) { authService.logout(userId); - return ResponseEntity.ok(APIResponse.success(null)); + return ResponseEntity.ok(success(null)); } } diff --git a/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleLoginReq.java b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleLoginReq.java new file mode 100644 index 0000000..5478216 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleLoginReq.java @@ -0,0 +1,7 @@ +package ita.tinybite.domain.auth.dto.request; + +import ita.tinybite.domain.user.constant.PlatformType; + +public record GoogleAndAppleLoginReq(String idToken, + PlatformType platformType) { +} diff --git a/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java new file mode 100644 index 0000000..d84585a --- /dev/null +++ b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java @@ -0,0 +1,18 @@ +package ita.tinybite.domain.auth.dto.request; + +import ita.tinybite.domain.user.constant.PlatformType; +import jakarta.validation.constraints.NotBlank; + +public record GoogleAndAppleSignupRequest( + @NotBlank(message = "idToken은 필수입니다") + String idToken, + @NotBlank(message = "전화번호는 필수입니다") + String phone, + @NotBlank(message = "닉네임은 필수입니다") + String nickname, + @NotBlank(message = "위치 정보 필수입니다") + String location, + @NotBlank(message = "플랫폼정보는 필수입니다") + PlatformType platform +) { +} diff --git a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java index 0b20c0c..4a3d504 100644 --- a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java +++ b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java @@ -1,8 +1,10 @@ package ita.tinybite.domain.auth.service; -import ita.tinybite.domain.auth.dto.request.KakaoLoginRequest; -import ita.tinybite.domain.auth.dto.request.KakaoSignupRequest; -import ita.tinybite.domain.auth.dto.request.RefreshTokenRequest; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import ita.tinybite.domain.auth.dto.request.*; import ita.tinybite.domain.auth.dto.response.AuthResponse; import ita.tinybite.domain.auth.dto.response.UserDto; import ita.tinybite.domain.auth.entity.JwtTokenProvider; @@ -11,17 +13,27 @@ import ita.tinybite.domain.auth.kakao.KakaoApiClient.KakaoUserInfo; import ita.tinybite.domain.auth.repository.RefreshTokenRepository; import ita.tinybite.domain.user.constant.LoginType; +import ita.tinybite.domain.user.constant.PlatformType; import ita.tinybite.domain.user.constant.UserStatus; import ita.tinybite.domain.user.entity.User; import ita.tinybite.domain.user.repository.UserRepository; +import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.exception.errorcode.AuthErrorCode; +import ita.tinybite.global.exception.errorcode.UserErrorCode; import ita.tinybite.global.util.NicknameGenerator; -import jakarta.transaction.Transactional; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.io.*; +import java.security.GeneralSecurityException; import java.time.LocalDateTime; +import java.util.Collections; @Slf4j @Service @@ -32,8 +44,18 @@ public class AuthService { private final RefreshTokenRepository refreshTokenRepository; private final KakaoApiClient kakaoApiClient; private final JwtTokenProvider jwtTokenProvider; + private final JwtDecoder appleJwtDecoder; private final NicknameGenerator nicknameGenerator; +// @Value("${apple.client-id}") +// private String appleClientId; + + @Value("${google.android-id}") + private String googleAndroidId; + + @Value("${google.ios-id}") + private String googleIosId; + @Transactional public AuthResponse kakaoSignup(KakaoSignupRequest request) { // 카카오 API로 유저 정보 조회 @@ -109,6 +131,129 @@ public AuthResponse kakaoLogin(KakaoLoginRequest request) { .build(); } + @Transactional + public AuthResponse googleSignup(@Valid GoogleAndAppleSignupRequest req) { + // idToken으로 이메일 추출 + String email = getEmailFromIdToken(req.idToken(), req.platform(), LoginType.GOOGLE); + + // 해당 이메일의 유저 find + User user = userRepository.findByEmail(email) + .orElseThrow(() -> BusinessException.of(UserErrorCode.USER_NOT_EXISTS)); + + // req필드로 유저 필드 업데이트 -> 실질적 회원가입 + user.updateSignupInfo(req, email); + userRepository.save(user); + + return getAuthResponse(user); + } + + @Transactional + public AuthResponse googleLogin(@Valid GoogleAndAppleLoginReq req) { + // idToken으로 이메일 추출 + String email = getEmailFromIdToken(req.idToken(), req.platformType(), LoginType.GOOGLE); + // 해당 이메일로 유저 찾은 후 응답 반환 (accessToken, refreshToken) + return getUser(email); + } + + @Transactional + public AuthResponse appleSignup(@Valid GoogleAndAppleSignupRequest req) { + String email = getEmailFromIdToken(req.idToken(), req.platform(), LoginType.APPLE); + + // 해당 이메일의 유저 find + User user = userRepository.findByEmail(email) + .orElseThrow(() -> BusinessException.of(UserErrorCode.USER_NOT_EXISTS)); + + // req필드로 유저 필드 업데이트 -> 실질적 회원가입 + user.updateSignupInfo(req, email); + userRepository.save(user); + + return getAuthResponse(user); + } + + @Transactional + public AuthResponse appleLogin(@Valid GoogleAndAppleLoginReq req) { + // idToken으로 이메일 추출 + String email = getEmailFromIdToken(req.idToken(), req.platformType(), LoginType.APPLE); + // 해당 이메일로 유저 찾은 후 응답 반환 (AuthResponse) + return getUser(email); + } + + private AuthResponse getAuthResponse(User user) { + // 4. JWT 토큰 생성 + String accessToken = jwtTokenProvider.generateAccessToken(user); + String refreshToken = jwtTokenProvider.generateRefreshToken(user); + + // 5. 기존 Refresh Token 삭제 후 새로 저장 + refreshTokenRepository.deleteByUserId(user.getUserId()); + saveRefreshToken(user.getUserId(), refreshToken); + + log.info("로그인 성공 - User ID: {}, Email: {}", user.getUserId(), user.getEmail()); + + // 6. 응답 생성 + return AuthResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .expiresIn(3600L) + .user(UserDto.from(user)) + .build(); + } + + // 구글과 애플 통합 + private String getEmailFromIdToken(String idToken, PlatformType platformType, LoginType loginType) { + switch(loginType) { + case GOOGLE -> { + + String clientId = switch (platformType) { + case ANDROID -> googleAndroidId; + case IOS -> googleIosId; + }; + + try { + GoogleIdTokenVerifier googleVerifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) + .setAudience(Collections.singletonList(clientId)) + .build(); + + GoogleIdToken token = googleVerifier.verify(idToken); + + if(token == null) { + throw BusinessException.of(AuthErrorCode.INVALID_TOKEN); + } + + return token.getPayload().getEmail(); + + } catch (GeneralSecurityException | IOException e) { + throw BusinessException.of(AuthErrorCode.GOOGLE_LOGIN_ERROR); + } + } + case APPLE -> { + //TODO Apple 구현 예정 + } + } + return null; + } + + private AuthResponse getUser(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> { + // 이메일로 가입된 유저가 없을 시, INACTIVE로 임시 생성 + // 회원가입 시 해당 임시 유저를 통해 마저 회원가입 진행 + userRepository.save(User.builder() + .email(email) + .status(UserStatus.INACTIVE) + .build()); + + return BusinessException.of(UserErrorCode.USER_NOT_EXISTS); + }); + + // 3. 탈퇴한 사용자 체크 + if (user.getStatus() == UserStatus.WITHDRAW) { + throw new RuntimeException("탈퇴한 사용자입니다."); + } + + return getAuthResponse(user); + } + @Transactional public AuthResponse refreshToken(RefreshTokenRequest request) { String refreshTokenValue = request.getRefreshToken(); @@ -148,6 +293,7 @@ public AuthResponse refreshToken(RefreshTokenRequest request) { @Transactional public void logout(Long userId) { refreshTokenRepository.deleteByUserId(userId); + userRepository.deleteById(userId); log.info("로그아웃 - User ID: {}", userId); } diff --git a/src/main/java/ita/tinybite/domain/user/constant/LoginType.java b/src/main/java/ita/tinybite/domain/user/constant/LoginType.java index 09bc6b7..796e8d5 100644 --- a/src/main/java/ita/tinybite/domain/user/constant/LoginType.java +++ b/src/main/java/ita/tinybite/domain/user/constant/LoginType.java @@ -1,5 +1,7 @@ package ita.tinybite.domain.user.constant; public enum LoginType { - KAKAO, GOOGLE + KAKAO, GOOGLE, APPLE + + ; } diff --git a/src/main/java/ita/tinybite/domain/user/constant/PlatformType.java b/src/main/java/ita/tinybite/domain/user/constant/PlatformType.java new file mode 100644 index 0000000..36b0f29 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/constant/PlatformType.java @@ -0,0 +1,6 @@ +package ita.tinybite.domain.user.constant; + +public enum PlatformType { + ANDROID, + IOS, +} diff --git a/src/main/java/ita/tinybite/domain/user/entity/User.java b/src/main/java/ita/tinybite/domain/user/entity/User.java index 8672f0c..d6526a3 100644 --- a/src/main/java/ita/tinybite/domain/user/entity/User.java +++ b/src/main/java/ita/tinybite/domain/user/entity/User.java @@ -1,5 +1,6 @@ package ita.tinybite.domain.user.entity; +import ita.tinybite.domain.auth.dto.request.GoogleAndAppleSignupRequest; import ita.tinybite.domain.user.constant.LoginType; import ita.tinybite.domain.user.constant.UserStatus; import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; @@ -49,4 +50,13 @@ public void update(UpdateUserReqDto req) { public void updateLocation(String location) { this.location = location; } + + public void updateSignupInfo(GoogleAndAppleSignupRequest req, String email) { + this.email = (email); + this.nickname = (req.nickname()); + this.phone = (req.phone()); + this.location = (req.location()); + this.status = UserStatus.ACTIVE; + this.type = LoginType.GOOGLE; + } } diff --git a/src/main/java/ita/tinybite/global/config/AppleConfig.java b/src/main/java/ita/tinybite/global/config/AppleConfig.java new file mode 100644 index 0000000..0f82b12 --- /dev/null +++ b/src/main/java/ita/tinybite/global/config/AppleConfig.java @@ -0,0 +1,19 @@ +package ita.tinybite.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +@Configuration +public class AppleConfig { + + private static final String APPLE_JWK_URL = "https://appleid.apple.com/auth/keys"; + + @Bean + public JwtDecoder appleJwtDecoder() { + return NimbusJwtDecoder + .withJwkSetUri(APPLE_JWK_URL) + .build(); + } +} diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java index 00c7180..5e06b74 100644 --- a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java +++ b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java @@ -12,7 +12,11 @@ public enum AuthErrorCode implements ErrorCode { DUPLICATED_NICKNAME(HttpStatus.BAD_REQUEST, "DUPLICATED_NICKNAME", "중복된 닉네임입니다."), - ; + INVALID_TOKEN(HttpStatus.BAD_REQUEST, "INVALID_TOKEN", "유효하지 않은 토큰입니다."), + GOOGLE_LOGIN_ERROR(HttpStatus.BAD_REQUEST, "GOOGLE_LOGIN_ERROR", "구글 로그인 중 에러가 발생했습니다."), + APPLE_LOGIN_ERROR(HttpStatus.BAD_REQUEST, "APPLE_LOGIN_ERROR", "애플 로그인 중 에러가 발생했습니다."), + + INVALID_PLATFORM(HttpStatus.BAD_REQUEST, "INVALID_PLATFORM", "올바른 플랫폼이 아닙니다. (Android, iOS)"); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 9de545a..4148ea2 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -33,6 +33,14 @@ naver: client-id: ${NAVER_CLIENT_ID} secret: ${NAVER_CLIENT_SECRET} +google: + android-id: ${GOOGLE_ANDROID_CLIENT_ID} + ios-id: ${GOOGLE_IOS_CLIENT_ID} + +#apple: +# client-id: ${APPLE_CLIENT_ID} +# + logging: level: org.hibernate.SQL: debug \ No newline at end of file From d03fd30743e47d6db08e70d72e9e569b1e87dd61 Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Thu, 11 Dec 2025 23:58:12 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix=20:=20main=20push=20=EC=8B=9C=EC=97=90?= =?UTF-8?q?=EB=A7=8C=20workflow=20trigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd.yml | 50 ------------------------- .github/workflows/{ci.yml => ci-cd.yml} | 36 +++++++++++++++++- 2 files changed, 34 insertions(+), 52 deletions(-) delete mode 100644 .github/workflows/cd.yml rename .github/workflows/{ci.yml => ci-cd.yml} (62%) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 0ba7d5f..0000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: CD - -on: - workflow_run: - workflows: ["CI"] - types: - - completed - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - # 깃 레포 체크아웃 - - name: Checkout repository - uses: actions/checkout@v4 - - # .env 생성 - - name: Create .env file - run: echo "${{ secrets.ENV_FILE }}" > .env - - # EC2로 파일 전송 - - name: Copy files to EC2 - uses: appleboy/scp-action@master - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USERNAME }} - key: ${{ secrets.SSH_KEY }} - port: 22 - source: "docker/*,.env,nginx/,deploy.sh" - target: "/home/ubuntu/tinybite/" - - # EC2에서 도커 컨테이너 재배포 - - name: Deploy to EC2 - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USERNAME }} - key: ${{ secrets.SSH_KEY }} - port: 22 - script: | - cd /home/ubuntu/tinybite - mv /home/ubuntu/tinybite/docker/* /home/ubuntu/tinybite/ - - export GHCR_TOKEN=${{ secrets.GHCR_TOKEN }} - export GHCR_USERNAME=${{ github.actor }} - echo $GHCR_TOKEN | docker login ghcr.io -u $GHCR_USERNAME --password-stdin - - chmod +x deploy.sh - ./deploy.sh \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci-cd.yml similarity index 62% rename from .github/workflows/ci.yml rename to .github/workflows/ci-cd.yml index 3fcf536..5da633d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci-cd.yml @@ -3,8 +3,6 @@ name: CI on: push: branches: [ "main" ] - pull_request: - branches: [ "main" ] permissions: contents: read @@ -77,3 +75,37 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + # .env 생성 + - name: Create .env file + run: echo "${{ secrets.ENV_FILE }}" > .env + + # EC2로 파일 전송 + - name: Copy files to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: 22 + source: "docker/*,.env,nginx/,deploy.sh" + target: "/home/ubuntu/tinybite/" + + # EC2에서 도커 컨테이너 재배포 + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: 22 + script: | + cd /home/ubuntu/tinybite + mv /home/ubuntu/tinybite/docker/* /home/ubuntu/tinybite/ + + export GHCR_TOKEN=${{ secrets.GHCR_TOKEN }} + export GHCR_USERNAME=${{ github.actor }} + echo $GHCR_TOKEN | docker login ghcr.io -u $GHCR_USERNAME --password-stdin + + chmod +x deploy.sh + ./deploy.sh \ No newline at end of file From 729ae0a778414329bdfbba920d7e503ee47ce951 Mon Sep 17 00:00:00 2001 From: Lim Wonjae Date: Tue, 16 Dec 2025 11:45:31 +0900 Subject: [PATCH 8/8] Feature/#28 google apple login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : google login 구현 완료 * fix : user hard delete * feat : apple 로그인 구현 및 ddl-auto -> update 변경 --- .../request/GoogleAndAppleSignupRequest.java | 3 +- .../domain/auth/service/AuthService.java | 33 +++++++++++++++++-- .../exception/errorcode/AuthErrorCode.java | 4 ++- src/main/resources/application-dev.yaml | 6 +--- src/main/resources/application-local.yaml | 8 ++--- src/main/resources/application.yaml | 7 ++++ 6 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java index d84585a..27da6a0 100644 --- a/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java +++ b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java @@ -2,6 +2,7 @@ import ita.tinybite.domain.user.constant.PlatformType; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record GoogleAndAppleSignupRequest( @NotBlank(message = "idToken은 필수입니다") @@ -12,7 +13,7 @@ public record GoogleAndAppleSignupRequest( String nickname, @NotBlank(message = "위치 정보 필수입니다") String location, - @NotBlank(message = "플랫폼정보는 필수입니다") + @NotNull(message = "플랫폼정보는 필수입니다") PlatformType platform ) { } diff --git a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java index 4a3d504..76258ce 100644 --- a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java +++ b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java @@ -26,7 +26,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -47,8 +49,8 @@ public class AuthService { private final JwtDecoder appleJwtDecoder; private final NicknameGenerator nicknameGenerator; -// @Value("${apple.client-id}") -// private String appleClientId; + @Value("${apple.client-id}") + private String appleClientId; @Value("${google.android-id}") private String googleAndroidId; @@ -224,10 +226,35 @@ private String getEmailFromIdToken(String idToken, PlatformType platformType, Lo } catch (GeneralSecurityException | IOException e) { throw BusinessException.of(AuthErrorCode.GOOGLE_LOGIN_ERROR); + } catch (Exception e) { + throw BusinessException.of(AuthErrorCode.INVALID_TOKEN); } } case APPLE -> { - //TODO Apple 구현 예정 + String clientId = appleClientId; + + try { + Jwt jwt = appleJwtDecoder.decode(idToken); + + if(!"https://appleid.apple.com".equals(jwt.getIssuer().toString())) { + throw BusinessException.of(AuthErrorCode.INVALID_TOKEN); + } + + String aud = jwt.getAudience().get(0); + if (!aud.equals(clientId)) { + throw BusinessException.of(AuthErrorCode.INVALID_TOKEN); + } + + Object emailObject = jwt.getClaims().get("email"); + if(emailObject == null) { + throw BusinessException.of(AuthErrorCode.NOT_EXISTS_EMAIL); + } + return emailObject.toString(); + } catch (JwtException e) { + throw BusinessException.of(AuthErrorCode.INVALID_TOKEN); + } catch (Exception e) { + throw BusinessException.of(AuthErrorCode.APPLE_LOGIN_ERROR); + } } } return null; diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java index 5e06b74..78851ec 100644 --- a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java +++ b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java @@ -16,7 +16,9 @@ public enum AuthErrorCode implements ErrorCode { GOOGLE_LOGIN_ERROR(HttpStatus.BAD_REQUEST, "GOOGLE_LOGIN_ERROR", "구글 로그인 중 에러가 발생했습니다."), APPLE_LOGIN_ERROR(HttpStatus.BAD_REQUEST, "APPLE_LOGIN_ERROR", "애플 로그인 중 에러가 발생했습니다."), - INVALID_PLATFORM(HttpStatus.BAD_REQUEST, "INVALID_PLATFORM", "올바른 플랫폼이 아닙니다. (Android, iOS)"); + INVALID_PLATFORM(HttpStatus.BAD_REQUEST, "INVALID_PLATFORM", "올바른 플랫폼이 아닙니다. (Android, iOS)"), + NOT_EXISTS_EMAIL(HttpStatus.BAD_REQUEST, "NOT_EXISTS_EMAIL", "애플 이메일이 존재하지 않습니다.") + ; private final HttpStatus httpStatus; private final String code; diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 8410dca..80cd2c3 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -12,7 +12,7 @@ spring: jpa: show-sql: true hibernate: - ddl-auto: create-drop # 개발: update, 운영: validate 또는 none + ddl-auto: update # 개발: update, 운영: validate 또는 none data: redis: @@ -22,7 +22,3 @@ spring: kakao: client-id: ${KAKAO_CLIENT_ID} redirect-uri: ${KAKAO_REDIRECT_URI} - -naver: - client-id: ${NAVER_CLIENT_ID} - secret: ${NAVER_CLIENT_SECRET} \ No newline at end of file diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 4148ea2..0b5d0e2 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -28,10 +28,10 @@ spring: kakao: client-id: ${KAKAO_CLIENT_ID} redirect-uri: ${KAKAO_REDIRECT_URI} - -naver: - client-id: ${NAVER_CLIENT_ID} - secret: ${NAVER_CLIENT_SECRET} + +google: + android-id: ${GOOGLE_ANDROID_CLIENT_ID} + ios-id: ${GOOGLE_IOS_CLIENT_ID} google: android-id: ${GOOGLE_ANDROID_CLIENT_ID} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 5601d5b..70f448a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -21,6 +21,13 @@ sms: api-key: ${SMS_API_KEY} api-secret: ${SMS_API_SECRET} +apple: + client-id: ${APPLE_CLIENT_ID} + +naver: + client-id: ${NAVER_CLIENT_ID} + secret: ${NAVER_CLIENT_SECRET} + fcm: file_path: firebase/tinybite_fcm.json url: https://fcm.googleapis.com/v1/projects/${FCM_PROJECT_ID}/messages:send