diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index edb5faa..1a8dcaf 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -107,8 +107,8 @@ jobs: export GHCR_USERNAME=${{ github.actor }} echo $GHCR_TOKEN | docker login ghcr.io -u $GHCR_USERNAME --password-stdin - docker compose -f docker-compose.blue.yml pull - docker compose -f docker-compose.blue.yml down || true - docker compose -f docker-compose.blue.yml up -d + docker-compose -f docker-compose.blue.yml pull + docker-compose -f docker-compose.blue.yml down || true + docker-compose -f docker-compose.blue.yml up -d docker image prune -a -f \ No newline at end of file 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 27da6a0..02ca08a 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,8 +2,11 @@ import ita.tinybite.domain.user.constant.PlatformType; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import java.util.List; + public record GoogleAndAppleSignupRequest( @NotBlank(message = "idToken은 필수입니다") String idToken, @@ -14,6 +17,8 @@ public record GoogleAndAppleSignupRequest( @NotBlank(message = "위치 정보 필수입니다") String location, @NotNull(message = "플랫폼정보는 필수입니다") - PlatformType platform + PlatformType platform, + @NotEmpty + List agreedTerms ) { } diff --git a/src/main/java/ita/tinybite/domain/auth/repository/TermRepository.java b/src/main/java/ita/tinybite/domain/auth/repository/TermRepository.java new file mode 100644 index 0000000..2159e1f --- /dev/null +++ b/src/main/java/ita/tinybite/domain/auth/repository/TermRepository.java @@ -0,0 +1,21 @@ +package ita.tinybite.domain.auth.repository; + +import ita.tinybite.domain.user.entity.Term; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface TermRepository extends JpaRepository { + + @Query("SELECT t " + + "FROM Term t " + + "WHERE t.title IN :titles") + List findAllByTitle(List titles); + + @Query("SELECT t " + + "FROM Term t " + + "WHERE t.required = true") + List findRequiredTerm(); + +} 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 4f55cbe..6e3d3d6 100644 --- a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java +++ b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java @@ -13,10 +13,13 @@ import ita.tinybite.domain.auth.kakao.KakaoApiClient; import ita.tinybite.domain.auth.kakao.KakaoApiClient.KakaoUserInfo; import ita.tinybite.domain.auth.repository.RefreshTokenRepository; +import ita.tinybite.domain.auth.repository.TermRepository; 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.Term; import ita.tinybite.domain.user.entity.User; +import ita.tinybite.domain.user.entity.UserTermAgreement; import ita.tinybite.domain.user.repository.UserRepository; import ita.tinybite.global.exception.BusinessException; import ita.tinybite.global.exception.errorcode.AuthErrorCode; @@ -29,15 +32,14 @@ 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.Propagation; import org.springframework.transaction.annotation.Transactional; import java.io.*; import java.security.GeneralSecurityException; import java.time.LocalDateTime; import java.util.Collections; +import java.util.List; import java.util.Optional; @Slf4j @@ -46,11 +48,11 @@ public class AuthService { private final UserRepository userRepository; + private final TermRepository termRepository; 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; @@ -147,8 +149,29 @@ public AuthResponse googleSignup(@Valid GoogleAndAppleSignupRequest req) { // req필드로 유저 필드 업데이트 -> 실질적 회원가입 user.updateSignupInfo(req, email, LoginType.GOOGLE); - userRepository.save(user); + // 요청에서 동의한 termId 조합 + List terms = termRepository.findAllByTitle(req.agreedTerms()); + + // 약관의 이름이 일치하지 않아 사이즈가 다를 때 -> 잘못된 약관 예외 처리 + if(!(terms.size() == req.agreedTerms().size())) { + throw BusinessException.of(AuthErrorCode.INVALID_TERM); + } + + // 필수로 동의해야하는 항목에 모두 동의를 하지 않았을 때 -> 필수 약관에 동의하세요 + if(!terms.containsAll(termRepository.findRequiredTerm())) { + throw BusinessException.of(AuthErrorCode.PLEASE_AGREE_TERM); + } + + // 유저 - 약관 간 동의 항목 생성 + List agreements = terms + .stream().map(term -> UserTermAgreement.builder() + .user(user) + .term(term) + .build()).toList(); + + user.addTerms(agreements); + userRepository.save(user); return getAuthResponse(user); } diff --git a/src/main/java/ita/tinybite/domain/user/entity/Term.java b/src/main/java/ita/tinybite/domain/user/entity/Term.java new file mode 100644 index 0000000..6407ebd --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/entity/Term.java @@ -0,0 +1,29 @@ +package ita.tinybite.domain.user.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "terms") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Term { + + @Id + @Column(name = "term_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String title; + + private String description; + + private boolean required; + + private int version; + + // term에는 user_term_agreement 테이블 연관관계 필요 X +} diff --git a/src/main/java/ita/tinybite/domain/user/entity/TermCode.java b/src/main/java/ita/tinybite/domain/user/entity/TermCode.java new file mode 100644 index 0000000..ef589ee --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/entity/TermCode.java @@ -0,0 +1,10 @@ +package ita.tinybite.domain.user.entity; + +public enum TermCode { + AGE_OVER_14, // (필수) 만 14세 이상 + SERVICE_USE, // (필수) 서비스 이용약관 동의 + ELECTRONIC_FINANCE, // (필수) 전자금융거래 이용약관 동의 + PRIVACY_COLLECT, // (필수) 개인정보 수집 이용 동의 + PRIVACY_PROVIDE, // (필수) 개인정보 제공 동의 + MARKETING_RECEIVE // (선택) 쇼핑정보 및 혜택 수신 동의 +} 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 12c3b24..784f630 100644 --- a/src/main/java/ita/tinybite/domain/user/entity/User.java +++ b/src/main/java/ita/tinybite/domain/user/entity/User.java @@ -10,6 +10,9 @@ import lombok.*; import org.hibernate.annotations.Comment; +import java.util.ArrayList; +import java.util.List; + @Entity @Table(name = "users") @Getter @@ -43,6 +46,9 @@ public class User extends BaseEntity { @Column(length = 100) private String location; + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List agreements = new ArrayList<>();; + public void update(UpdateUserReqDto req) { this.nickname = req.nickname(); } @@ -59,4 +65,8 @@ public void updateSignupInfo(GoogleAndAppleSignupRequest req, String email, Logi this.status = UserStatus.ACTIVE; this.type = loginType; } + + public void addTerms(List agreements) { + this.agreements.addAll(agreements); + } } diff --git a/src/main/java/ita/tinybite/domain/user/entity/UserTermAgreement.java b/src/main/java/ita/tinybite/domain/user/entity/UserTermAgreement.java new file mode 100644 index 0000000..c8bdd0f --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/entity/UserTermAgreement.java @@ -0,0 +1,29 @@ +package ita.tinybite.domain.user.entity; + +import ita.tinybite.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + + +@Entity +@Table(name = "user_term_agreements", + uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "term_id"})}) +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserTermAgreement extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_term_agreement_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "term_id", nullable = false) + private Term term; +} 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 9975764..b7f2cc7 100644 --- a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java +++ b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java @@ -18,7 +18,10 @@ public enum AuthErrorCode implements ErrorCode { INVALID_PLATFORM(HttpStatus.BAD_REQUEST, "INVALID_PLATFORM", "올바른 플랫폼이 아닙니다. (Android, iOS)"), NOT_EXISTS_EMAIL(HttpStatus.BAD_REQUEST, "NOT_EXISTS_EMAIL", "애플 이메일이 존재하지 않습니다."), - INVALID_LOCATION(HttpStatus.BAD_REQUEST, "INVALID_LOCATION", "위치 정보가 올바르지 않습니다."); + INVALID_LOCATION(HttpStatus.BAD_REQUEST, "INVALID_LOCATION", "위치 정보가 올바르지 않습니다."), + INVALID_TERM(HttpStatus.BAD_REQUEST, "INVALID_TERM", "잘못된 약관입니다."), + + PLEASE_AGREE_TERM(HttpStatus.BAD_REQUEST, "PLEASE_AGREE_TERM", "필수 항목에 동의하지 않으셨습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/ita/tinybite/global/health/LoginReqDto.java b/src/main/java/ita/tinybite/global/health/LoginReqDto.java new file mode 100644 index 0000000..a9344fd --- /dev/null +++ b/src/main/java/ita/tinybite/global/health/LoginReqDto.java @@ -0,0 +1,4 @@ +package ita.tinybite.global.health; + +public record LoginReqDto(String email) { +} diff --git a/src/main/java/ita/tinybite/global/health/controller/HealthCheckController.java b/src/main/java/ita/tinybite/global/health/controller/HealthCheckController.java index 74f7233..9d042e8 100644 --- a/src/main/java/ita/tinybite/global/health/controller/HealthCheckController.java +++ b/src/main/java/ita/tinybite/global/health/controller/HealthCheckController.java @@ -1,16 +1,25 @@ package ita.tinybite.global.health.controller; +import io.swagger.v3.oas.annotations.Operation; import ita.tinybite.global.exception.BusinessException; import ita.tinybite.global.exception.errorcode.BusinessErrorCode; +import ita.tinybite.global.health.LoginReqDto; +import ita.tinybite.global.health.service.AuthTestService; import ita.tinybite.global.response.APIResponse; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import static ita.tinybite.global.response.APIResponse.*; @RestController public class HealthCheckController { + private final AuthTestService authTestService; + + public HealthCheckController(AuthTestService authTestService) { + this.authTestService = authTestService; + } + @GetMapping("/test/health") public ResponseEntity health() { return ResponseEntity.ok("UP"); @@ -18,7 +27,7 @@ public ResponseEntity health() { @GetMapping("/api/v1/test/health") public APIResponse test() { - return APIResponse.success("test"); + return success("test"); } @GetMapping("/api/v1/test/error/business") @@ -30,4 +39,10 @@ public APIResponse businessError() { public APIResponse commonError() throws Exception { throw new Exception("INTERNAL_SERVER_ERROR"); } + + @PostMapping("/api/v1/test/login") + @Operation(summary = "백엔드에서 유저 인증을 위한 API") + public APIResponse login(@RequestBody LoginReqDto req) { + return success(authTestService.getUser(req.email())); + } } diff --git a/src/main/java/ita/tinybite/global/health/service/AuthTestService.java b/src/main/java/ita/tinybite/global/health/service/AuthTestService.java new file mode 100644 index 0000000..b8d5daf --- /dev/null +++ b/src/main/java/ita/tinybite/global/health/service/AuthTestService.java @@ -0,0 +1,36 @@ +package ita.tinybite.global.health.service; + +import ita.tinybite.domain.auth.entity.JwtTokenProvider; +import ita.tinybite.domain.user.constant.LoginType; +import ita.tinybite.domain.user.constant.UserStatus; +import ita.tinybite.domain.user.entity.User; +import ita.tinybite.domain.user.repository.UserRepository; +import org.springframework.stereotype.Service; + +@Service +public class AuthTestService { + + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + + public AuthTestService(UserRepository userRepository, JwtTokenProvider jwtTokenProvider) { + this.userRepository = userRepository; + this.jwtTokenProvider = jwtTokenProvider; + } + + public String getUser(String email) { + User user = userRepository.findByEmail(email).orElseGet(() -> + { + User newUser = User.builder() + .email(email) + .type(LoginType.GOOGLE) + .status(UserStatus.ACTIVE) + .nickname(email) + .build(); + userRepository.save(newUser); + return newUser; + }); + + return jwtTokenProvider.generateAccessToken(user); + } +} diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 05f5d17..187064c 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -13,11 +13,10 @@ spring: jpa: open-in-view: false hibernate: - ddl-auto: create-drop + ddl-auto: update properties: hibernate: format_sql: true - show_sql: true dialect: org.hibernate.dialect.MySQLDialect data: diff --git a/src/main/resources/term/terms.sql b/src/main/resources/term/terms.sql new file mode 100644 index 0000000..770ed03 --- /dev/null +++ b/src/main/resources/term/terms.sql @@ -0,0 +1,23 @@ +-- 만 14세 이상 +INSERT INTO terms (title, description, required, version) +VALUES ('AGE_OVER_14', '본 서비스는 만 14세 이상만 이용 가능합니다.', true, 1); + +-- 서비스 이용약관 +INSERT INTO terms (title, description, required, version) +VALUES ('SERVICE_USE', '서비스 이용을 위한 기본 약관입니다.', true, 1); + +-- 전자금융거래 이용약관 +INSERT INTO terms (title, description, required, version) +VALUES ('ELECTRONIC_FINANCE', '전자금융거래 관련 약관입니다.', true, 1); + +-- 개인정보 수집 이용 +INSERT INTO terms (title, description, required, version) +VALUES ('PRIVACY_COLLECT', '서비스 제공을 위한 개인정보 수집 및 이용에 대한 동의입니다.', true, 1); + +-- 개인정보 제공 +INSERT INTO terms (title, description, required, version) +VALUES ('PRIVACY_PROVIDE', '제3자에게 개인정보를 제공하는 것에 대한 동의입니다.', true, 1); + +-- 마케팅 정보 수신 +INSERT INTO terms (title, description, required, version) +VALUES ('MARKETING_RECEIVE', '이벤트 및 혜택 정보 수신에 대한 동의입니다.', false, 1); \ No newline at end of file