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
1 change: 1 addition & 0 deletions .github/workflows/cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ env:
FCM_PROJECT_ID: ${{ secrets.FCM_PROJECT_ID }}
AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}

permissions:
contents: read
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ logs/*.log
.env
fluent-bit/fluent-bit.conf
fluent-bit/fluent-bit.yaml

src/main/resources/key/AuthKey_FH4V72Y38Q.p8
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation "org.springframework.retry:spring-retry"
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-mail'

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand Down
78 changes: 78 additions & 0 deletions src/main/java/earlybird/earlybird/email/SendEmailService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package earlybird.earlybird.email;

import static earlybird.earlybird.promotion.email.entity.PromotionEmailMessageType.*;

import earlybird.earlybird.common.util.LocalDateTimeUtil;
import earlybird.earlybird.promotion.email.entity.PromotionEmailMessageType;
import earlybird.earlybird.promotion.email.entity.PromotionEmailVerification;

import jakarta.mail.Message;
import jakarta.mail.internet.MimeMessage;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Slf4j
@RequiredArgsConstructor
@Service
public class SendEmailService {

private final JavaMailSender javaMailSender;

@Retryable(maxAttempts = 5, backoff = @Backoff(delay = 1000))
@Async
public void send(
PromotionEmailVerification promotionEmailVerification,
PromotionEmailMessageType promotionEmailMessageType) {
try {
MimeMessage message = javaMailSender.createMimeMessage();

message.addRecipients(Message.RecipientType.TO, promotionEmailVerification.getEmail());
message.setFrom("[email protected]");

message.setSubject(promotionEmailMessageType.getTitle());

String verificationUrl = getVerificationUrl(promotionEmailVerification);

String messageText = getMessageText(promotionEmailMessageType, verificationUrl);

message.setText(messageText, "utf-8", "html");

javaMailSender.send(message);
promotionEmailVerification.setSentAt(LocalDateTimeUtil.getLocalDateTimeNow());

log.info(
"Promotion code email sent successfully to: {}",
promotionEmailVerification.getEmail());

} catch (Exception e) {
log.error(
"Failed to send promotion code email to: {}",
promotionEmailVerification.getEmail(),
e);
throw new RuntimeException("Failed to send email", e);
}
}

private static String getMessageText(
PromotionEmailMessageType promotionEmailMessageType, String verificationUrl) {

if (promotionEmailMessageType.equals(BERKELEY_6_MONTH_FREE))
return String.format(
promotionEmailMessageType.getMessageText(), verificationUrl, verificationUrl);
else throw new IllegalArgumentException();
}

private String getVerificationUrl(PromotionEmailVerification promotionEmailVerification) {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("https://earlybirdteam.com/promotion/apple/univ/email?code=");
stringBuilder.append(promotionEmailVerification.getPromotionUrlUuid().getUuid());
return stringBuilder.toString();
}
}
7 changes: 6 additions & 1 deletion src/main/java/earlybird/earlybird/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ public enum ErrorCode {
HttpStatus.BAD_REQUEST, "요청한 알림 ID에 해당하는 디바이스 토큰과 요청한 디바이스 토큰이 일치하지 않습니다."),
APPOINTMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 약속입니다."),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."),
DELETED_APPOINTMENT_EXCEPTION(HttpStatus.NOT_FOUND, "삭제된 일정입니다.");
DELETED_APPOINTMENT_EXCEPTION(HttpStatus.NOT_FOUND, "삭제된 일정입니다."),
APPLE_PROMOTION_URL_LIST_IS_EMPTY_EXCEPTION(
HttpStatus.INTERNAL_SERVER_ERROR, "발급 가능한 애플 프로모션 URL이 없습니다."),
INVALID_PROMOTION_EMAIL_EXCEPTION(HttpStatus.BAD_REQUEST, "프로모션 대상 이메일이 아닙니다."),
PROMOTION_EMAIL_DOMAIN_NAME_IS_ALREADY_EXISTS_EXCEPTION(
HttpStatus.BAD_REQUEST, "프로모션 이메일 도메인이 이미 존재합니다.");

private final HttpStatus status;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package earlybird.earlybird.error.exception;

import earlybird.earlybird.error.ErrorCode;

public class ApplePromotionUrlListIsEmptyException extends BusinessBaseException {
public ApplePromotionUrlListIsEmptyException() {
super(ErrorCode.APPLE_PROMOTION_URL_LIST_IS_EMPTY_EXCEPTION);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package earlybird.earlybird.error.exception;

import earlybird.earlybird.error.ErrorCode;

public class InvalidPromotionEmailException extends BusinessBaseException {
public InvalidPromotionEmailException() {
super(ErrorCode.INVALID_PROMOTION_EMAIL_EXCEPTION);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package earlybird.earlybird.error.exception;

import static earlybird.earlybird.error.ErrorCode.PROMOTION_EMAIL_DOMAIN_NAME_IS_ALREADY_EXISTS_EXCEPTION;

public class PromotionEmailDomainNameIsAlreadyExistsException extends BusinessBaseException {
public PromotionEmailDomainNameIsAlreadyExistsException() {
super(PROMOTION_EMAIL_DOMAIN_NAME_IS_ALREADY_EXISTS_EXCEPTION);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package earlybird.earlybird.promotion.apple;

import earlybird.earlybird.common.BaseTimeEntity;
import earlybird.earlybird.promotion.entity.PromotionCampaign;

import jakarta.persistence.*;

import lombok.Getter;

import java.time.LocalDateTime;

@Getter
@Table(name = "apple_promotion_urls")
@Entity
public class ApplePromotionUrl extends BaseTimeEntity {

@Column(name = "apple_promotion_urls_id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Long id;

// 애플에서 제공하는 프로모션 URL
@Column(name = "apple_promotion_urls_url", nullable = false, unique = true)
private String url;

// 설명
@Column(name = "apple_promotion_urls_description")
private String description;

// URL 발급 여부
// 발급된 URL은 다시 사용하면 안 됨
@Column(name = "apple_promotion_urls_is_used", nullable = false)
private Boolean isUsed;

// 프로모션 URL 만료 시간
@Column(name = "apple_promotion_urls_expired_at", nullable = false)
private LocalDateTime expiredAt;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "promotion_campaigns_id", nullable = false)
private PromotionCampaign promotionCampaign;

public void setUsed() {
this.isUsed = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package earlybird.earlybird.promotion.apple;

import jakarta.persistence.LockModeType;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;

import java.util.List;
import java.util.Optional;

public interface ApplePromotionUrlRepository extends JpaRepository<ApplePromotionUrl, Long> {

@Lock(LockModeType.PESSIMISTIC_WRITE)
List<ApplePromotionUrl> findAllByIsUsedFalseAndPromotionCampaignId(Long promotionCampaignId);

Optional<ApplePromotionUrl> findByUrl(String url);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package earlybird.earlybird.promotion.apple.service;

import earlybird.earlybird.common.util.LocalDateTimeUtil;
import earlybird.earlybird.error.exception.ApplePromotionUrlListIsEmptyException;
import earlybird.earlybird.promotion.apple.ApplePromotionUrl;
import earlybird.earlybird.promotion.apple.ApplePromotionUrlRepository;
import earlybird.earlybird.promotion.apple.service.response.GetApplePromotionUrlServiceResponse;
import earlybird.earlybird.promotion.email.entity.PromotionEmailVerification;
import earlybird.earlybird.promotion.email.repository.PromotionEmailVerificationRepository;
import earlybird.earlybird.promotion.entity.PromotionUrlUuid;
import earlybird.earlybird.promotion.repository.PromotionUrlUuidRepository;

import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.UUID;

@RequiredArgsConstructor
@Service
public class GetApplePromotionUrlService {

private final ApplePromotionUrlRepository applePromotionUrlRepository;
private final PromotionEmailVerificationRepository promotionEmailVerificationRepository;
private final PromotionUrlUuidRepository promotionUrlUuidRepository;

@Transactional
public GetApplePromotionUrlServiceResponse getPromotionUrl(Long promotionCampaignId) {

List<ApplePromotionUrl> urls =
applePromotionUrlRepository.findAllByIsUsedFalseAndPromotionCampaignId(
promotionCampaignId);

if (urls.isEmpty()) {
throw new ApplePromotionUrlListIsEmptyException();
}

ApplePromotionUrl promotionUrl =
urls.stream()
.filter(
url ->
url.getExpiredAt()
.isAfter(LocalDateTimeUtil.getLocalDateTimeNow()))
.findFirst()
.orElseThrow(ApplePromotionUrlListIsEmptyException::new);

promotionUrl.setUsed();
return new GetApplePromotionUrlServiceResponse(promotionUrl.getUrl());
}

@Transactional
public GetApplePromotionUrlServiceResponse getPromotionUrlByUuid(String promotionCodeUuid) {

UUID uuid = UUID.fromString(promotionCodeUuid);

PromotionUrlUuid promotionUrlUuid =
promotionUrlUuidRepository.findByUuid(uuid).orElseThrow();

PromotionEmailVerification promotionEmailVerification =
promotionEmailVerificationRepository
.findByPromotionUrlUuid(promotionUrlUuid)
.orElseThrow();

String promotionUrl =
promotionEmailVerification.getEmailPromotionCodeIssuance().getPromotionCode();

if (!promotionEmailVerification.getVerificationIsSuccess()) {
setStatusToSuccess(promotionEmailVerification);
setUsed(promotionUrl);
}

return GetApplePromotionUrlServiceResponse.builder().promotionUrl(promotionUrl).build();
}

private void setUsed(String promotionUrl) {
ApplePromotionUrl applePromotionUrl =
applePromotionUrlRepository.findByUrl(promotionUrl).orElseThrow();
applePromotionUrl.setUsed();
}

private void setStatusToSuccess(PromotionEmailVerification promotionEmailVerification) {
promotionEmailVerification.setVerifiedAt(LocalDateTimeUtil.getLocalDateTimeNow());
promotionEmailVerification.setVerificationIsSuccess(true);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package earlybird.earlybird.promotion.apple.service.response;

import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@Builder
@RequiredArgsConstructor
public class GetApplePromotionUrlServiceResponse {
private final String promotionUrl;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package earlybird.earlybird.promotion.controller;

import earlybird.earlybird.promotion.controller.request.CreatePromotionCampaignRequest;
import earlybird.earlybird.promotion.controller.response.CreatePromotionCampaignResponse;
import earlybird.earlybird.promotion.service.CreatePromotionCampaignService;
import earlybird.earlybird.promotion.service.request.CreatePromotionCampaignServiceRequest;
import earlybird.earlybird.promotion.service.response.CreatePromotionCampaignServiceResponse;

import lombok.RequiredArgsConstructor;

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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RequestMapping("/api/v1/promotion/campaign")
@RestController
public class PromotionCampaignController {

private final CreatePromotionCampaignService createPromotionCampaignService;

@PostMapping
public ResponseEntity<?> createPromotionCampaign(
@RequestBody CreatePromotionCampaignRequest request) {
CreatePromotionCampaignServiceRequest serviceRequest =
CreatePromotionCampaignServiceRequest.from(request);
CreatePromotionCampaignServiceResponse serviceResponse =
createPromotionCampaignService.create(serviceRequest);
CreatePromotionCampaignResponse response =
CreatePromotionCampaignResponse.from(serviceResponse);
return ResponseEntity.ok().body(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package earlybird.earlybird.promotion.controller.request;

import com.fasterxml.jackson.annotation.JsonFormat;

import earlybird.earlybird.promotion.entity.PromotionCampaignType;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Getter
@NoArgsConstructor
public class CreatePromotionCampaignRequest {

@NotBlank private String promotionCampaignName;

@NotBlank private String promotionCampaignDescription;

@NotNull
@JsonFormat(
shape = JsonFormat.Shape.STRING,
pattern = "yyyy-MM-dd HH:mm:ss",
timezone = "Asia/Seoul")
private LocalDateTime startTime;

@NotNull
@JsonFormat(
shape = JsonFormat.Shape.STRING,
pattern = "yyyy-MM-dd HH:mm:ss",
timezone = "Asia/Seoul")
private LocalDateTime endTime;

@NotNull private Long perUserLimit;

@NotNull private Long totalIssueLimit;

@NotNull private PromotionCampaignType promotionCampaignType;
}
Loading