Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public enum ErrorCode implements ErrorResultCode {

MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자 정보가 존재하지 않습니다."),
ALREADY_REGISTERED_MEMBER(HttpStatus.CONFLICT, "이미 가입된 회원입니다."),
REQUIRED_AGREEMENT_NOT_ACCEPTED(HttpStatus.BAD_REQUEST, "필수 약관에 모두 동의해야 합니다."),
INVALID_TIME_ZONE(HttpStatus.BAD_REQUEST, "Time-Zone 헤더 값이 올바르지 않습니다."),
UNSUPPORTED_IMAGE_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 파일 확장자입니다.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ public class MemberController implements MemberControllerDocs {

@PostMapping(path = "/member/register")
public ResponseEntity<SuccessResponse<Void>> register(
@Valid @RequestBody RegisterRequest request
@Valid @RequestBody RegisterRequest request,
@RequestHeader(name = "Time-Zone") ZoneId timeZone
) {
memberService.register(request.toCommand());
memberService.register(request.toCommand(timeZone));

return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(SuccessCode.MEMBER_REGISTER));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ public interface MemberControllerDocs {

@Operation(
summary = "소셜 회원가입",
description = "OIDC ID 토큰과 닉네임으로 회원가입합니다. 완료 후 POST /auth/login으로 토큰을 발급받으세요."
description = "OIDC ID 토큰, 닉네임, 약관 동의 여부로 회원가입합니다. 필수 약관(서비스 이용약관, 개인정보 처리방침, "
+ "위치기반 서비스 이용약관, 만 14세 이상 확인)에 모두 동의해야 하며, 동의 시점은 Time-Zone 헤더 기준으로 저장됩니다. "
+ "완료 후 POST /auth/login으로 토큰을 발급받으세요."
)
@ApiResponses({
@ApiResponse(responseCode = "201", description = "회원가입 성공"),
Expand All @@ -47,6 +49,12 @@ public interface MemberControllerDocs {
examples = {
@ExampleObject(name = "INVALID_NICKNAME_FORMAT",
value = "{\"status\":400,\"code\":\"INVALID_INPUT_VALUE\",\"message\":\"닉네임은 한글, 영어, 숫자와 공백을 사용하여 1자 이상 12자 이하로 입력해야 하며, 공백은 연속될 수 없습니다.\"}"),
@ExampleObject(name = "REQUIRED_AGREEMENT_NOT_ACCEPTED",
value = "{\"status\":400,\"code\":\"REQUIRED_AGREEMENT_NOT_ACCEPTED\",\"message\":\"필수 약관에 모두 동의해야 합니다.\"}"),
@ExampleObject(name = "MISSING_REQUEST_HEADER",
value = "{\"status\":400,\"code\":\"MISSING_REQUEST_HEADER\",\"message\":\"필수 요청 헤더가 누락되었습니다.\"}"),
@ExampleObject(name = "INVALID_HEADER",
value = "{\"status\":400,\"code\":\"INVALID_HEADER\",\"message\":\"헤더값이 올바르지 않습니다.\"}"),
@ExampleObject(name = "INVALID_REQUEST_BODY",
value = "{\"status\":400,\"code\":\"INVALID_REQUEST_BODY\",\"message\":\"요청 바디를 읽을 수 없습니다.\"}")
})),
Expand All @@ -65,7 +73,16 @@ public interface MemberControllerDocs {
examples = @ExampleObject(name = "ALREADY_REGISTERED_MEMBER",
value = "{\"status\":409,\"code\":\"ALREADY_REGISTERED_MEMBER\",\"message\":\"이미 가입된 회원입니다.\"}")))
})
ResponseEntity<SuccessResponse<Void>> register(@Valid @RequestBody RegisterRequest request);
ResponseEntity<SuccessResponse<Void>> register(
@io.swagger.v3.oas.annotations.parameters.RequestBody(required = true,
content = @Content(schema = @Schema(implementation = RegisterRequest.class),
examples = @ExampleObject(value = "{\"provider\":\"APPLE\",\"idToken\":\"eyJ...\",\"nickname\":\"루티\","
+ "\"agreements\":{\"serviceTerms\":true,\"privacyPolicy\":true,\"locationServiceTerms\":true,"
+ "\"over14\":true,\"marketingConsent\":false}}")))
@Valid @RequestBody RegisterRequest request,
@Parameter(description = "IANA Time Zone ID", example = "Asia/Seoul", required = true)
@RequestHeader("Time-Zone") ZoneId timeZone
);

@Operation(summary = "회원 탈퇴", description = "인증된 회원의 정보를 삭제하고, 보유한 액세스/리프레시 토큰을 무효화합니다.")
@SecurityRequirement(name = "bearerAuth")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.sopt.routee.member.internal.controller.dto.request;

import org.sopt.routee.member.internal.service.dto.command.AgreementCommand;

import jakarta.validation.constraints.NotNull;

public record AgreementRequest(
@NotNull // 서비스 이용 약관
Boolean serviceTerms,
@NotNull // 개인정보 처리 방침
Boolean privacyPolicy,
@NotNull // 위치기반 서비스 이용약관
Boolean locationServiceTerms,
@NotNull // 만 14세 이상 여부
Boolean over14,
@NotNull // 마케팅 활용 및 광고성 정보 수신 동의
Boolean marketingConsent
) {
public AgreementCommand toCommand() {
return new AgreementCommand(serviceTerms, privacyPolicy, locationServiceTerms, over14, marketingConsent);
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package org.sopt.routee.member.internal.controller.dto.request;

import java.time.ZoneId;

import org.sopt.routee.external.api.type.OAuthProvider;
import org.sopt.routee.member.internal.service.dto.command.RegisterCommand;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
Expand All @@ -14,9 +17,12 @@ public record RegisterRequest(
String idToken,
@NotBlank
@Pattern(regexp = "^(?=.{1,12}$)[가-힣a-zA-Z0-9]+(?: [가-힣a-zA-Z0-9]+)*$", message = "닉네임은 한글, 영어, 숫자와 공백을 사용하여 1자 이상 12자 이하로 입력해야 하며, 공백은 연속될 수 없습니다.")
String nickname
String nickname,
@NotNull
@Valid
AgreementRequest agreements
) {
public RegisterCommand toCommand(){
return new RegisterCommand(provider, idToken, nickname);
public RegisterCommand toCommand(ZoneId timeZone) {
return new RegisterCommand(provider, idToken, nickname, agreements.toCommand(), timeZone);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package org.sopt.routee.member.internal.entity;

import java.time.Instant;
import java.time.ZoneId;

import org.sopt.routee.entity.BaseEntity;

import io.hypersistence.utils.hibernate.id.Tsid;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Builder
@Table(name = "member_agreement",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_member_agreement_member_id",
columnNames = {"member_id"})
})
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberAgreement extends BaseEntity {
@Id
@Tsid
private Long id;

@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "member_id", nullable = false, updatable = false)
private Member member;

@Column(name = "service_terms_agreed_at")
private Instant serviceTermsAgreedAt;

@Column(name = "service_terms_agreed_zone")
private ZoneId serviceTermsAgreedZone;

@Column(name = "privacy_policy_agreed_at")
private Instant privacyPolicyAgreedAt;

@Column(name = "privacy_policy_agreed_zone")
private ZoneId privacyPolicyAgreedZone;

@Column(name = "location_service_terms_agreed_at")
private Instant locationServiceTermsAgreedAt;

@Column(name = "location_service_terms_agreed_zone")
private ZoneId locationServiceTermsAgreedZone;

@Column(name = "over14_confirmed_at")
private Instant over14ConfirmedAt;

@Column(name = "over14_confirmed_zone")
private ZoneId over14ConfirmedZone;

@Column(name = "marketing_consent_agreed_at")
private Instant marketingConsentAgreedAt;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Column(name = "marketing_consent_agreed_zone")
private ZoneId marketingConsentAgreedZone;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.sopt.routee.member.internal.exception;

import org.sopt.routee.exception.BaseException;
import org.sopt.routee.member.internal.code.ErrorCode;

public class RequiredAgreementNotAcceptedException extends BaseException {

public RequiredAgreementNotAcceptedException() {
super(ErrorCode.REQUIRED_AGREEMENT_NOT_ACCEPTED);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package org.sopt.routee.member.internal.mapper;

import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.List;

import org.sopt.routee.activity.api.result.MonthlyActivityDailySummaryResult;
import org.sopt.routee.member.internal.service.dto.command.AgreementCommand;
import org.sopt.routee.member.internal.service.dto.command.RegisterCommand;
import org.sopt.routee.member.internal.service.dto.result.ActivitySummaryResult;
import org.sopt.routee.member.internal.service.dto.result.DailySummary;
Expand All @@ -16,6 +18,7 @@
import org.sopt.routee.member.api.result.TokenClaimsResult;
import org.sopt.routee.member.api.type.MemberRole;
import org.sopt.routee.member.internal.entity.Member;
import org.sopt.routee.member.internal.entity.MemberAgreement;
import org.sopt.routee.util.TimeZoneUtils;

import lombok.AccessLevel;
Expand All @@ -34,6 +37,24 @@ public static Member toEntity(RegisterCommand command, String oauthId) {
.build();
}

public static MemberAgreement toAgreementEntity(
Member member, AgreementCommand agreement, Instant agreedAt, ZoneId agreedZone
) {
return MemberAgreement.builder()
.member(member)
.serviceTermsAgreedAt(agreement.serviceTerms() ? agreedAt : null)
.serviceTermsAgreedZone(agreement.serviceTerms() ? agreedZone : null)
.privacyPolicyAgreedAt(agreement.privacyPolicy() ? agreedAt : null)
.privacyPolicyAgreedZone(agreement.privacyPolicy() ? agreedZone : null)
.locationServiceTermsAgreedAt(agreement.locationServiceTerms() ? agreedAt : null)
.locationServiceTermsAgreedZone(agreement.locationServiceTerms() ? agreedZone : null)
.over14ConfirmedAt(agreement.over14() ? agreedAt : null)
.over14ConfirmedZone(agreement.over14() ? agreedZone : null)
.marketingConsentAgreedAt(agreement.marketingConsent() ? agreedAt : null)
.marketingConsentAgreedZone(agreement.marketingConsent() ? agreedZone : null)
.build();
}

public static TokenClaimsResult toTokenClaimsResult(Member member) {
return new TokenClaimsResult(member.getId(), member.getRole().name());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.sopt.routee.member.internal.repository;

import java.util.Optional;

import org.sopt.routee.member.internal.entity.MemberAgreement;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberAgreementRepository extends JpaRepository<MemberAgreement, Long> {

Optional<MemberAgreement> findByMember_Id(Long memberId);

boolean existsByMember_Id(Long memberId);

void deleteByMember_Id(Long memberId);

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.sopt.routee.member.internal.service;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.List;
Expand All @@ -19,6 +21,7 @@
import org.sopt.routee.external.api.type.OAuthProvider;
import org.sopt.routee.external.api.port.OidcVerifyPort;
import org.sopt.routee.member.api.event.MemberWithdrawnEvent;
import org.sopt.routee.member.internal.service.dto.command.AgreementCommand;
import org.sopt.routee.member.internal.service.dto.command.ProfileImageUploadUrlCommand;
import org.sopt.routee.member.internal.service.dto.command.RegisterCommand;
import org.sopt.routee.member.internal.service.dto.command.UpdateNicknameCommand;
Expand All @@ -33,10 +36,13 @@
import org.sopt.routee.member.internal.entity.Member;
import org.sopt.routee.member.internal.exception.AlreadyRegisteredMemberException;
import org.sopt.routee.member.internal.exception.MemberNotFoundException;
import org.sopt.routee.member.internal.exception.RequiredAgreementNotAcceptedException;
import org.sopt.routee.member.internal.exception.UnsupportedImageFileExtensionException;
import org.sopt.routee.member.internal.mapper.MemberMapper;
import org.sopt.routee.member.internal.repository.MemberAgreementRepository;
import org.sopt.routee.member.internal.repository.MemberRepository;
import org.sopt.routee.member.internal.service.validator.ProfileImageFileNameValidator;
import org.sopt.routee.util.TimeZoneUtils;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -53,6 +59,7 @@ public class MemberService {
private final OidcVerifyPort oidcVerifyPort;
private final ActivityUseCase activityUseCase;
private final MemberRepository memberRepository;
private final MemberAgreementRepository memberAgreementRepository;
private final ApplicationEventPublisher applicationEventPublisher;
private final FileUploadPresignPort fileUploadPresignPort;
private final FileImageAccessUrlPort fileImageAccessUrlPort;
Expand All @@ -75,19 +82,38 @@ public boolean existsById(long memberId) {

@Transactional
public void register(RegisterCommand command) {
validateRequiredAgreements(command.agreement());

String oauthId = oidcVerifyPort.extractSubject(command.provider(), command.idToken());

if (memberRepository.existsByOauthIdAndOauthProvider(oauthId, command.provider())) {
throw new AlreadyRegisteredMemberException();
}
memberRepository.save(MemberMapper.toEntity(command, oauthId));

Member savedMember = memberRepository.save(MemberMapper.toEntity(command, oauthId));

Instant agreedAt = TimeZoneUtils.toUtcInstantTime(LocalDateTime.now(command.timeZone()), command.timeZone());
memberAgreementRepository.save(
MemberMapper.toAgreementEntity(savedMember, command.agreement(), agreedAt, command.timeZone()));
}

private void validateRequiredAgreements(AgreementCommand agreement) {
boolean allRequiredAccepted = agreement.serviceTerms()
&& agreement.privacyPolicy()
&& agreement.locationServiceTerms()
&& agreement.over14();

if (!allRequiredAccepted) {
throw new RequiredAgreementNotAcceptedException();
}
}

public void withdraw(long memberId, String accessTokenHash, String refreshTokenHash) {
transactionTemplate.executeWithoutResult(status -> {
Member member = memberRepository.findById(memberId)
.orElseThrow(MemberNotFoundException::new);

memberAgreementRepository.deleteByMember_Id(memberId);
memberRepository.delete(member);

activityUseCase.deleteForMemberWithdrawal(memberId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.sopt.routee.member.internal.service.dto.command;

public record AgreementCommand(
boolean serviceTerms,
boolean privacyPolicy,
boolean locationServiceTerms,
boolean over14,
boolean marketingConsent
) {
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package org.sopt.routee.member.internal.service.dto.command;

import java.time.ZoneId;

import org.sopt.routee.external.api.type.OAuthProvider;

public record RegisterCommand(
OAuthProvider provider,
String idToken,
String nickname
String nickname,
AgreementCommand agreement,
ZoneId timeZone
) {
}
Loading