diff --git a/.github/workflows/code_test_pipeline.yaml b/.github/workflows/code_test_pipeline.yaml new file mode 100644 index 0000000..4d6c153 --- /dev/null +++ b/.github/workflows/code_test_pipeline.yaml @@ -0,0 +1,75 @@ +name: Code Test Pipeline +on: + pull_request: + branches: ["dev", "main"] + +permissions: + contents: read + checks: write + +env: + DB_URL: ${{ secrets.DB_URL }} + DB_USERNAME: ${{ secrets.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + SPRING_ACTIVE_PROFILE: ${{ vars.SPRING_ACTIVE_PROFILE }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + FCM_PROJECT_ID: ${{ secrets.FCM_PROJECT_ID }} + AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + +jobs: + formatting: + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + - name: Formatting Code with Google Java Style Guide + uses: axel-op/googlejavaformat-action@v3 + with: + args: "--replace --aosp" + github-token: ${{ secrets.GITHUB_TOKEN }} + + unit-test: + runs-on: ubuntu-latest + permissions: write-all + needs: [formatting] + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + cache: 'gradle' + + - name: Create firebase_admin_sdk_private_key.json from Secrets + run: | + mkdir -p $GITHUB_WORKSPACE/src/main/resources/key + echo "${{ secrets.FIREBASE_ADMIN_SDK_PRIVATE_KEY }}" | base64 --decode > $GITHUB_WORKSPACE/src/main/resources/key/firebase_admin_sdk_private_key.json + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Test with Gradle + run: ./gradlew --info test + + - name: Publish Test Report + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: "**/build/test-results/test/TEST-*.xml" diff --git a/.gitignore b/.gitignore index c2065bc..a66d729 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,10 @@ out/ ### VS Code ### .vscode/ + + +### +src/main/java/earlybird/earlybird/user/TestController.java +src/main/resources/key/firebase_admin_sdk_private_key.json + +logs/*.log diff --git a/build.gradle b/build.gradle index 9f59977..f683fe9 100644 --- a/build.gradle +++ b/build.gradle @@ -27,12 +27,32 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation "org.springframework.retry:spring-retry" + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'com.h2database:h2' + + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + implementation 'com.google.firebase:firebase-admin:9.3.0' + + testImplementation 'org.awaitility:awaitility:4.2.2' + + implementation(platform("software.amazon.awssdk:bom:2.27.21")) + implementation("software.amazon.awssdk:sdk-core") + implementation("software.amazon.awssdk:eventbridge") + implementation("software.amazon.awssdk:dynamodb") } tasks.named('test') { diff --git a/src/main/java/earlybird/earlybird/EarlybirdApplication.java b/src/main/java/earlybird/earlybird/EarlybirdApplication.java index 317b30d..382930f 100644 --- a/src/main/java/earlybird/earlybird/EarlybirdApplication.java +++ b/src/main/java/earlybird/earlybird/EarlybirdApplication.java @@ -2,12 +2,15 @@ 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; +@EnableScheduling +@EnableJpaAuditing @SpringBootApplication public class EarlybirdApplication { - public static void main(String[] args) { - SpringApplication.run(EarlybirdApplication.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(EarlybirdApplication.class, args); + } } diff --git a/src/main/java/earlybird/earlybird/appointment/controller/AppointmentController.java b/src/main/java/earlybird/earlybird/appointment/controller/AppointmentController.java new file mode 100644 index 0000000..3d2e2e6 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/controller/AppointmentController.java @@ -0,0 +1,64 @@ +package earlybird.earlybird.appointment.controller; + +import earlybird.earlybird.appointment.controller.request.CreateAppointmentRequest; +import earlybird.earlybird.appointment.controller.request.UpdateAppointmentRequest; +import earlybird.earlybird.appointment.controller.response.CreateAppointmentResponse; +import earlybird.earlybird.appointment.service.CreateAppointmentService; +import earlybird.earlybird.appointment.service.DeleteAppointmentService; +import earlybird.earlybird.appointment.service.UpdateAppointmentService; +import earlybird.earlybird.appointment.service.request.CreateAppointmentServiceRequest; +import earlybird.earlybird.appointment.service.request.DeleteAppointmentServiceRequest; +import earlybird.earlybird.appointment.service.request.UpdateAppointmentServiceRequest; +import earlybird.earlybird.appointment.service.response.CreateAppointmentServiceResponse; + +import jakarta.validation.Valid; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/appointments") +@RestController +public class AppointmentController { + + private final CreateAppointmentService createAppointmentService; + private final UpdateAppointmentService updateAppointmentService; + private final DeleteAppointmentService deleteAppointmentService; + + @PostMapping + public ResponseEntity createAppointment( + @Valid @RequestBody CreateAppointmentRequest request) { + + CreateAppointmentServiceRequest serviceRequest = + request.toCreateAppointmentServiceRequest(); + CreateAppointmentServiceResponse serviceResponse = + createAppointmentService.create(serviceRequest); + + return ResponseEntity.ok(CreateAppointmentResponse.from(serviceResponse)); + } + + @PatchMapping + public ResponseEntity updateAppointment( + @Valid @RequestBody UpdateAppointmentRequest request) { + + UpdateAppointmentServiceRequest serviceRequest = + request.toUpdateAppointmentServiceRequest(); + updateAppointmentService.update(serviceRequest); + + return ResponseEntity.ok().build(); + } + + @DeleteMapping + public ResponseEntity deleteAppointment( + @RequestHeader("appointmentId") Long appointmentId, + @RequestHeader("clientId") String clientId) { + + DeleteAppointmentServiceRequest serviceRequest = + new DeleteAppointmentServiceRequest(clientId, appointmentId); + deleteAppointmentService.delete(serviceRequest); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/controller/request/CreateAppointmentRequest.java b/src/main/java/earlybird/earlybird/appointment/controller/request/CreateAppointmentRequest.java new file mode 100644 index 0000000..3882e14 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/controller/request/CreateAppointmentRequest.java @@ -0,0 +1,59 @@ +package earlybird.earlybird.appointment.controller.request; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import earlybird.earlybird.appointment.service.request.CreateAppointmentServiceRequest; +import earlybird.earlybird.common.DayOfWeekUtil; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class CreateAppointmentRequest { + @NotBlank private String clientId; + @NotBlank private String deviceToken; + @NotBlank private String appointmentName; + @NotNull private Duration movingDuration; + @NotNull private Duration preparationDuration; + + @NotNull + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + private LocalDateTime firstAppointmentTime; + + // 월화수목금토일 + @NotNull + @Size(min = 7, max = 7, message = "repeatDayOfWeek 의 길이는 7이어야 합니다.") + private List repeatDayOfWeekBoolList; + + public CreateAppointmentServiceRequest toCreateAppointmentServiceRequest() { + + List repeatDayOfWeekList = + DayOfWeekUtil.convertBooleanListToDayOfWeekList(this.repeatDayOfWeekBoolList); + + return CreateAppointmentServiceRequest.builder() + .clientId(this.clientId) + .deviceToken(this.deviceToken) + .appointmentName(this.appointmentName) + .firstAppointmentTime(this.firstAppointmentTime) + .preparationDuration(this.preparationDuration) + .movingDuration(this.movingDuration) + .repeatDayOfWeekList(repeatDayOfWeekList) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/controller/request/UpdateAppointmentRequest.java b/src/main/java/earlybird/earlybird/appointment/controller/request/UpdateAppointmentRequest.java new file mode 100644 index 0000000..aa0f6c5 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/controller/request/UpdateAppointmentRequest.java @@ -0,0 +1,60 @@ +package earlybird.earlybird.appointment.controller.request; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import earlybird.earlybird.appointment.domain.AppointmentUpdateType; +import earlybird.earlybird.appointment.service.request.UpdateAppointmentServiceRequest; +import earlybird.earlybird.common.DayOfWeekUtil; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import lombok.Getter; + +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +public class UpdateAppointmentRequest { + + @NotNull private Long appointmentId; + @NotBlank private String clientId; + @NotBlank private String deviceToken; + @NotBlank private String appointmentName; + @NotNull private Duration movingDuration; + @NotNull private Duration preparationDuration; + + @NotNull + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + private LocalDateTime firstAppointmentTime; + + // 월화수목금토일 + @NotNull + @Size(min = 7, max = 7, message = "repeatDayOfWeek 의 길이는 7이어야 합니다.") + private List repeatDayOfWeekBoolList; + + @NotNull private AppointmentUpdateType updateType; + + public UpdateAppointmentServiceRequest toUpdateAppointmentServiceRequest() { + List repeatDayOfWeekList = + DayOfWeekUtil.convertBooleanListToDayOfWeekList(this.repeatDayOfWeekBoolList); + + return UpdateAppointmentServiceRequest.builder() + .appointmentId(appointmentId) + .clientId(clientId) + .deviceToken(deviceToken) + .appointmentName(appointmentName) + .movingDuration(movingDuration) + .preparationDuration(preparationDuration) + .firstAppointmentTime(firstAppointmentTime) + .repeatDayOfWeekList(repeatDayOfWeekList) + .updateType(updateType) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/controller/response/CreateAppointmentResponse.java b/src/main/java/earlybird/earlybird/appointment/controller/response/CreateAppointmentResponse.java new file mode 100644 index 0000000..0fa8a36 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/controller/response/CreateAppointmentResponse.java @@ -0,0 +1,22 @@ +package earlybird.earlybird.appointment.controller.response; + +import earlybird.earlybird.appointment.service.response.CreateAppointmentServiceResponse; + +import lombok.*; + +@Getter +public class CreateAppointmentResponse { + private final Long createdAppointmentId; + + @Builder + private CreateAppointmentResponse(Long createdAppointmentId) { + this.createdAppointmentId = createdAppointmentId; + } + + public static CreateAppointmentResponse from( + CreateAppointmentServiceResponse createAppointmentServiceResponse) { + return CreateAppointmentResponse.builder() + .createdAppointmentId(createAppointmentServiceResponse.getCreatedAppointmentId()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/controller/response/UpdateAppointmentResponse.java b/src/main/java/earlybird/earlybird/appointment/controller/response/UpdateAppointmentResponse.java new file mode 100644 index 0000000..8c40ee7 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/controller/response/UpdateAppointmentResponse.java @@ -0,0 +1,3 @@ +package earlybird.earlybird.appointment.controller.response; + +public class UpdateAppointmentResponse {} diff --git a/src/main/java/earlybird/earlybird/appointment/domain/Appointment.java b/src/main/java/earlybird/earlybird/appointment/domain/Appointment.java new file mode 100644 index 0000000..f901909 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/domain/Appointment.java @@ -0,0 +1,134 @@ +package earlybird.earlybird.appointment.domain; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import earlybird.earlybird.common.BaseTimeEntity; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; + +import jakarta.persistence.*; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +@SQLDelete(sql = "UPDATE appointment SET is_deleted = true WHERE appointment_id = ?") +@SQLRestriction("is_deleted = false") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Appointment extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "appointment_id") + private Long id; + + @Column(nullable = false) + private String appointmentName; + + @Column(nullable = false) + private String clientId; + + @Column(nullable = false) + private String deviceToken; + + @JsonIgnoreProperties({"appointment"}) + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "appointment") + private List fcmNotifications = new ArrayList<>(); + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "appointment") + @SQLRestriction("is_deleted = false") + private List repeatingDays = new ArrayList<>(); + + private LocalTime appointmentTime; + private Duration preparationDuration; + private Duration movingDuration; + + @Column(nullable = false) + private Boolean isDeleted = false; + + @Builder + private Appointment( + String appointmentName, + String clientId, + String deviceToken, + LocalTime appointmentTime, + Duration preparationDuration, + Duration movingDuration, + List repeatingDayOfWeeks) { + this.appointmentName = appointmentName; + this.clientId = clientId; + this.deviceToken = deviceToken; + this.appointmentTime = appointmentTime; + this.preparationDuration = preparationDuration; + this.movingDuration = movingDuration; + setRepeatingDays(repeatingDayOfWeeks); + } + + public List getRepeatingDays() { + return repeatingDays.stream().filter(repeatingDay -> !repeatingDay.isDeleted()).toList(); + } + + public void addFcmNotification(FcmNotification fcmNotification) { + this.fcmNotifications.add(fcmNotification); + fcmNotification.setAppointment(this); + } + + public void removeFcmNotification(FcmNotification fcmNotification) { + fcmNotifications.remove(fcmNotification); + } + + public void addRepeatingDay(RepeatingDay repeatingDay) { + if (repeatingDays.stream().noneMatch(r -> r.getId().equals(repeatingDay.getId()))) { + this.repeatingDays.add(repeatingDay); + } + } + + public void setRepeatingDays(List dayOfWeeks) { + setRepeatingDaysEmpty(); + dayOfWeeks.forEach(dayOfWeek -> this.repeatingDays.add(new RepeatingDay(dayOfWeek, this))); + } + + public void setRepeatingDaysEmpty() { + this.repeatingDays.forEach(RepeatingDay::setDeleted); + } + + public void changeAppointmentName(String newName) { + this.appointmentName = newName; + } + + public void changeDeviceToken(String newToken) { + this.deviceToken = newToken; + } + + public void changePreparationDuration(Duration newDuration) { + this.preparationDuration = newDuration; + } + + public void changeMovingDuration(Duration newDuration) { + this.movingDuration = newDuration; + } + + public void changeAppointmentTime(LocalTime newTime) { + this.appointmentTime = newTime; + } + + public boolean isDeleted() { + return isDeleted; + } + + public void setDeleted() { + this.isDeleted = true; + setRepeatingDaysEmpty(); + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/domain/AppointmentRepository.java b/src/main/java/earlybird/earlybird/appointment/domain/AppointmentRepository.java new file mode 100644 index 0000000..80b3c9d --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/domain/AppointmentRepository.java @@ -0,0 +1,5 @@ +package earlybird.earlybird.appointment.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AppointmentRepository extends JpaRepository {} diff --git a/src/main/java/earlybird/earlybird/appointment/domain/AppointmentUpdateType.java b/src/main/java/earlybird/earlybird/appointment/domain/AppointmentUpdateType.java new file mode 100644 index 0000000..6769d35 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/domain/AppointmentUpdateType.java @@ -0,0 +1,13 @@ +package earlybird.earlybird.appointment.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AppointmentUpdateType { + POSTPONE("약속 미루기"), + MODIFY("일정 수정하기"); + + private final String type; +} diff --git a/src/main/java/earlybird/earlybird/appointment/domain/RepeatingDay.java b/src/main/java/earlybird/earlybird/appointment/domain/RepeatingDay.java new file mode 100644 index 0000000..06d344c --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/domain/RepeatingDay.java @@ -0,0 +1,50 @@ +package earlybird.earlybird.appointment.domain; + +import earlybird.earlybird.common.BaseTimeEntity; + +import jakarta.persistence.*; + +import lombok.*; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; +import org.springframework.lang.NonNull; + +import java.time.DayOfWeek; + +@SQLDelete(sql = "UPDATE repeating_day SET is_deleted = true WHERE repeating_day_id = ?") +@SQLRestriction("is_deleted = false") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class RepeatingDay extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "repeating_day_id", nullable = false) + private Long id; + + @NonNull + @Column(nullable = false) + private DayOfWeek dayOfWeek; + + @NonNull + @ManyToOne + @JoinColumn(name = "appointemnt_id", nullable = false) + private Appointment appointment; + + private Boolean isDeleted = false; + + public void setDeleted() { + this.isDeleted = true; + } + + public boolean isDeleted() { + return isDeleted; + } + + protected RepeatingDay(@NonNull DayOfWeek dayOfWeek, @NonNull Appointment appointment) { + this.dayOfWeek = dayOfWeek; + this.appointment = appointment; + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/domain/RepeatingDayRepository.java b/src/main/java/earlybird/earlybird/appointment/domain/RepeatingDayRepository.java new file mode 100644 index 0000000..c10f9ea --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/domain/RepeatingDayRepository.java @@ -0,0 +1,11 @@ +package earlybird.earlybird.appointment.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.DayOfWeek; +import java.util.List; + +public interface RepeatingDayRepository extends JpaRepository { + + List findAllByDayOfWeek(DayOfWeek dayOfWeek); +} diff --git a/src/main/java/earlybird/earlybird/appointment/service/CreateAppointmentService.java b/src/main/java/earlybird/earlybird/appointment/service/CreateAppointmentService.java new file mode 100644 index 0000000..68c4493 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/service/CreateAppointmentService.java @@ -0,0 +1,61 @@ +package earlybird.earlybird.appointment.service; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.domain.AppointmentRepository; +import earlybird.earlybird.appointment.service.request.CreateAppointmentServiceRequest; +import earlybird.earlybird.appointment.service.response.CreateAppointmentServiceResponse; +import earlybird.earlybird.common.LocalDateTimeUtil; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; +import earlybird.earlybird.scheduler.notification.service.NotificationInfoFactory; +import earlybird.earlybird.scheduler.notification.service.register.RegisterAllNotificationAtSchedulerService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Map; + +@RequiredArgsConstructor +@Service +public class CreateAppointmentService { + + private final AppointmentRepository appointmentRepository; + private final RegisterAllNotificationAtSchedulerService registerService; + private final NotificationInfoFactory factory; + + @Transactional + public CreateAppointmentServiceResponse create(CreateAppointmentServiceRequest request) { + Appointment appointment = createAppointmentInstance(request); + Appointment savedAppointment = appointmentRepository.save(appointment); + + LocalDateTime firstAppointmentTime = request.getFirstAppointmentTime(); + LocalDateTime movingTime = + LocalDateTimeUtil.subtractDuration( + firstAppointmentTime, request.getMovingDuration()); + LocalDateTime preparationTime = + LocalDateTimeUtil.subtractDuration(movingTime, request.getPreparationDuration()); + + Map notificationInfo = + factory.createTargetTimeMap(preparationTime, movingTime, firstAppointmentTime); + registerService.register(appointment, notificationInfo); + + return CreateAppointmentServiceResponse.builder() + .createdAppointmentId(savedAppointment.getId()) + .build(); + } + + private Appointment createAppointmentInstance(CreateAppointmentServiceRequest request) { + return Appointment.builder() + .appointmentName(request.getAppointmentName()) + .clientId(request.getClientId()) + .deviceToken(request.getDeviceToken()) + .repeatingDayOfWeeks(request.getRepeatDayOfWeekList()) + .appointmentTime(request.getFirstAppointmentTime().toLocalTime()) + .preparationDuration(request.getPreparationDuration()) + .movingDuration(request.getMovingDuration()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/service/DeleteAppointmentService.java b/src/main/java/earlybird/earlybird/appointment/service/DeleteAppointmentService.java new file mode 100644 index 0000000..1212878 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/service/DeleteAppointmentService.java @@ -0,0 +1,28 @@ +package earlybird.earlybird.appointment.service; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.service.request.DeleteAppointmentServiceRequest; +import earlybird.earlybird.scheduler.notification.service.deregister.DeregisterNotificationService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class DeleteAppointmentService { + + private final FindAppointmentService findAppointmentService; + private final DeregisterNotificationService deregisterNotificationService; + + @Transactional + public void delete(DeleteAppointmentServiceRequest request) { + Appointment appointment = + findAppointmentService.findBy(request.getAppointmentId(), request.getClientId()); + deregisterNotificationService.deregister( + request.toDeregisterFcmMessageAtSchedulerServiceRequest()); + appointment.setRepeatingDaysEmpty(); + appointment.setDeleted(); + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/service/FindAppointmentService.java b/src/main/java/earlybird/earlybird/appointment/service/FindAppointmentService.java new file mode 100644 index 0000000..aaa3bfe --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/service/FindAppointmentService.java @@ -0,0 +1,35 @@ +package earlybird.earlybird.appointment.service; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.domain.AppointmentRepository; +import earlybird.earlybird.error.exception.AppointmentNotFoundException; +import earlybird.earlybird.error.exception.DeletedAppointmentException; +import earlybird.earlybird.scheduler.notification.service.deregister.request.DeregisterFcmMessageAtSchedulerServiceRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class FindAppointmentService { + + private final AppointmentRepository appointmentRepository; + + public Appointment findBy(Long appointmentId, String clientId) { + Appointment appointment = + appointmentRepository + .findById(appointmentId) + .orElseThrow(AppointmentNotFoundException::new); + + if (!appointment.getClientId().equals(clientId)) throw new AppointmentNotFoundException(); + + if (appointment.isDeleted()) throw new DeletedAppointmentException(); + + return appointment; + } + + public Appointment findBy(DeregisterFcmMessageAtSchedulerServiceRequest request) { + return findBy(request.getAppointmentId(), request.getClientId()); + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/service/UpdateAppointmentService.java b/src/main/java/earlybird/earlybird/appointment/service/UpdateAppointmentService.java new file mode 100644 index 0000000..d7b308d --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/service/UpdateAppointmentService.java @@ -0,0 +1,70 @@ +package earlybird.earlybird.appointment.service; + +import static earlybird.earlybird.appointment.domain.AppointmentUpdateType.MODIFY; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.domain.AppointmentUpdateType; +import earlybird.earlybird.appointment.service.request.UpdateAppointmentServiceRequest; +import earlybird.earlybird.common.LocalDateTimeUtil; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; +import earlybird.earlybird.scheduler.notification.service.NotificationInfoFactory; +import earlybird.earlybird.scheduler.notification.service.deregister.DeregisterNotificationService; +import earlybird.earlybird.scheduler.notification.service.deregister.request.DeregisterNotificationServiceRequestFactory; +import earlybird.earlybird.scheduler.notification.service.register.RegisterAllNotificationAtSchedulerService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Map; + +@RequiredArgsConstructor +@Service +public class UpdateAppointmentService { + + private final DeregisterNotificationService deregisterNotificationService; + private final FindAppointmentService findAppointmentService; + private final RegisterAllNotificationAtSchedulerService registerService; + private final NotificationInfoFactory factory; + + @Transactional + public void update(UpdateAppointmentServiceRequest request) { + + Appointment appointment = + findAppointmentService.findBy(request.getAppointmentId(), request.getClientId()); + AppointmentUpdateType updateType = request.getUpdateType(); + + if (updateType.equals(MODIFY)) { + modifyAppointment(appointment, request); + } + + deregisterNotificationService.deregister( + DeregisterNotificationServiceRequestFactory.create(request)); + + // TODO: create service 코드와 겹치는 코드 개선 방향 찾아보기 + LocalDateTime firstAppointmentTime = request.getFirstAppointmentTime(); + LocalDateTime movingTime = + LocalDateTimeUtil.subtractDuration( + firstAppointmentTime, request.getMovingDuration()); + LocalDateTime preparationTime = + LocalDateTimeUtil.subtractDuration(movingTime, request.getPreparationDuration()); + + Map notificationInfo = + factory.createTargetTimeMap(preparationTime, movingTime, firstAppointmentTime); + + registerService.register(appointment, notificationInfo); + } + + private void modifyAppointment( + Appointment appointment, UpdateAppointmentServiceRequest request) { + appointment.changeAppointmentName(request.getAppointmentName()); + appointment.changeDeviceToken(request.getDeviceToken()); + appointment.changePreparationDuration(request.getPreparationDuration()); + appointment.changeMovingDuration(request.getMovingDuration()); + appointment.changeAppointmentTime(request.getFirstAppointmentTime().toLocalTime()); + appointment.setRepeatingDays(request.getRepeatDayOfWeekList()); + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/service/request/CreateAppointmentServiceRequest.java b/src/main/java/earlybird/earlybird/appointment/service/request/CreateAppointmentServiceRequest.java new file mode 100644 index 0000000..1b854b6 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/service/request/CreateAppointmentServiceRequest.java @@ -0,0 +1,41 @@ +package earlybird.earlybird.appointment.service.request; + +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +public class CreateAppointmentServiceRequest { + + private final String appointmentName; + private final String clientId; + private final String deviceToken; + private final Duration movingDuration; + private final Duration preparationDuration; + private final LocalDateTime firstAppointmentTime; + private final List repeatDayOfWeekList; // 월화수목금토일 + + @Builder + private CreateAppointmentServiceRequest( + @NonNull String appointmentName, + @NonNull String clientId, + @NonNull String deviceToken, + @NonNull LocalDateTime firstAppointmentTime, + @NonNull Duration preparationDuration, + @NonNull Duration movingDuration, + @NonNull List repeatDayOfWeekList) { + + this.appointmentName = appointmentName; + this.clientId = clientId; + this.deviceToken = deviceToken; + this.firstAppointmentTime = firstAppointmentTime; + this.preparationDuration = preparationDuration; + this.movingDuration = movingDuration; + this.repeatDayOfWeekList = repeatDayOfWeekList; + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/service/request/DeleteAppointmentServiceRequest.java b/src/main/java/earlybird/earlybird/appointment/service/request/DeleteAppointmentServiceRequest.java new file mode 100644 index 0000000..dd9799e --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/service/request/DeleteAppointmentServiceRequest.java @@ -0,0 +1,30 @@ +package earlybird.earlybird.appointment.service.request; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationStatus.*; + +import earlybird.earlybird.scheduler.notification.service.deregister.request.DeregisterFcmMessageAtSchedulerServiceRequest; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class DeleteAppointmentServiceRequest { + + private final String clientId; + private final Long appointmentId; + + @Builder + public DeleteAppointmentServiceRequest(String clientId, Long appointmentId) { + this.clientId = clientId; + this.appointmentId = appointmentId; + } + + public DeregisterFcmMessageAtSchedulerServiceRequest + toDeregisterFcmMessageAtSchedulerServiceRequest() { + return DeregisterFcmMessageAtSchedulerServiceRequest.builder() + .clientId(clientId) + .appointmentId(appointmentId) + .targetNotificationStatus(CANCELLED) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/service/request/UpdateAppointmentServiceRequest.java b/src/main/java/earlybird/earlybird/appointment/service/request/UpdateAppointmentServiceRequest.java new file mode 100644 index 0000000..b07d3e8 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/service/request/UpdateAppointmentServiceRequest.java @@ -0,0 +1,47 @@ +package earlybird.earlybird.appointment.service.request; + +import earlybird.earlybird.appointment.domain.AppointmentUpdateType; + +import lombok.Builder; +import lombok.Getter; + +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +public class UpdateAppointmentServiceRequest { + + private final Long appointmentId; + private final String appointmentName; + private final String clientId; + private final String deviceToken; + private final Duration preparationDuration; + private final Duration movingDuration; + private final LocalDateTime firstAppointmentTime; + private final AppointmentUpdateType updateType; + private final List repeatDayOfWeekList; + + @Builder + private UpdateAppointmentServiceRequest( + Long appointmentId, + String appointmentName, + String clientId, + String deviceToken, + Duration preparationDuration, + Duration movingDuration, + LocalDateTime firstAppointmentTime, + AppointmentUpdateType updateType, + List repeatDayOfWeekList) { + this.appointmentId = appointmentId; + this.appointmentName = appointmentName; + this.clientId = clientId; + this.deviceToken = deviceToken; + this.preparationDuration = preparationDuration; + this.movingDuration = movingDuration; + this.firstAppointmentTime = firstAppointmentTime; + this.updateType = updateType; + this.repeatDayOfWeekList = repeatDayOfWeekList; + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/service/response/CreateAppointmentServiceResponse.java b/src/main/java/earlybird/earlybird/appointment/service/response/CreateAppointmentServiceResponse.java new file mode 100644 index 0000000..034a0b2 --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/service/response/CreateAppointmentServiceResponse.java @@ -0,0 +1,15 @@ +package earlybird.earlybird.appointment.service.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class CreateAppointmentServiceResponse { + + private final Long createdAppointmentId; + + @Builder + private CreateAppointmentServiceResponse(Long createdAppointmentId) { + this.createdAppointmentId = createdAppointmentId; + } +} diff --git a/src/main/java/earlybird/earlybird/appointment/service/response/UpdateAppointmentServiceResponse.java b/src/main/java/earlybird/earlybird/appointment/service/response/UpdateAppointmentServiceResponse.java new file mode 100644 index 0000000..b13713a --- /dev/null +++ b/src/main/java/earlybird/earlybird/appointment/service/response/UpdateAppointmentServiceResponse.java @@ -0,0 +1,3 @@ +package earlybird.earlybird.appointment.service.response; + +public class UpdateAppointmentServiceResponse {} diff --git a/src/main/java/earlybird/earlybird/aws/alb/HealthCheckController.java b/src/main/java/earlybird/earlybird/aws/alb/HealthCheckController.java new file mode 100644 index 0000000..26cab8f --- /dev/null +++ b/src/main/java/earlybird/earlybird/aws/alb/HealthCheckController.java @@ -0,0 +1,14 @@ +package earlybird.earlybird.aws.alb; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthCheckController { + + @GetMapping("/health") + public ResponseEntity healthCheck() { + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/earlybird/earlybird/aws/alb/ShowServerIpController.java b/src/main/java/earlybird/earlybird/aws/alb/ShowServerIpController.java new file mode 100644 index 0000000..86da24e --- /dev/null +++ b/src/main/java/earlybird/earlybird/aws/alb/ShowServerIpController.java @@ -0,0 +1,18 @@ +package earlybird.earlybird.aws.alb; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +@Controller +public class ShowServerIpController { + + @GetMapping("/ip") + public String showServerIp(Model model) throws UnknownHostException { + model.addAttribute("ip", InetAddress.getLocalHost().getHostAddress()); + return "showServerIp"; + } +} diff --git a/src/main/java/earlybird/earlybird/common/AsyncConfig.java b/src/main/java/earlybird/earlybird/common/AsyncConfig.java new file mode 100644 index 0000000..18f64f8 --- /dev/null +++ b/src/main/java/earlybird/earlybird/common/AsyncConfig.java @@ -0,0 +1,22 @@ +package earlybird.earlybird.common; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@EnableAsync +@Configuration +public class AsyncConfig { + + @Bean + public ThreadPoolTaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(8); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + executor.setThreadNamePrefix("Async-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/earlybird/earlybird/common/BaseTimeEntity.java b/src/main/java/earlybird/earlybird/common/BaseTimeEntity.java new file mode 100644 index 0000000..22c0173 --- /dev/null +++ b/src/main/java/earlybird/earlybird/common/BaseTimeEntity.java @@ -0,0 +1,22 @@ +package earlybird.earlybird.common; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; + +import lombok.Getter; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class BaseTimeEntity { + + @CreatedDate private LocalDateTime createdAt; + + @LastModifiedDate private LocalDateTime updatedAt; +} diff --git a/src/main/java/earlybird/earlybird/common/DayOfWeekUtil.java b/src/main/java/earlybird/earlybird/common/DayOfWeekUtil.java new file mode 100644 index 0000000..5c8adf1 --- /dev/null +++ b/src/main/java/earlybird/earlybird/common/DayOfWeekUtil.java @@ -0,0 +1,24 @@ +package earlybird.earlybird.common; + +import java.time.DayOfWeek; +import java.util.ArrayList; +import java.util.List; + +public class DayOfWeekUtil { + + /** + * @param dayOfWeekBooleanList 월화수목금토일 + */ + public static List convertBooleanListToDayOfWeekList( + List dayOfWeekBooleanList) { + if (dayOfWeekBooleanList == null || dayOfWeekBooleanList.size() != 7) + throw new IllegalArgumentException("dayOfWeekBooleanList must contain exactly 7 days"); + + List dayOfWeekList = new ArrayList<>(); + for (int i = 0; i < 7; i++) { + if (dayOfWeekBooleanList.get(i)) dayOfWeekList.add(DayOfWeek.of(i + 1)); + } + + return dayOfWeekList; + } +} diff --git a/src/main/java/earlybird/earlybird/common/DefaultTimeZoneConfig.java b/src/main/java/earlybird/earlybird/common/DefaultTimeZoneConfig.java new file mode 100644 index 0000000..f177761 --- /dev/null +++ b/src/main/java/earlybird/earlybird/common/DefaultTimeZoneConfig.java @@ -0,0 +1,16 @@ +package earlybird.earlybird.common; + +import jakarta.annotation.PostConstruct; + +import org.springframework.context.annotation.Configuration; + +import java.util.TimeZone; + +@Configuration +public class DefaultTimeZoneConfig { + + @PostConstruct + public void setDefaultTimeZoneToKST() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } +} diff --git a/src/main/java/earlybird/earlybird/common/InstantUtil.java b/src/main/java/earlybird/earlybird/common/InstantUtil.java new file mode 100644 index 0000000..3657fee --- /dev/null +++ b/src/main/java/earlybird/earlybird/common/InstantUtil.java @@ -0,0 +1,15 @@ +package earlybird.earlybird.common; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +public class InstantUtil { + public static boolean checkTimeBeforeNow(Instant time) { + return (time.isBefore(Instant.now())); + } + + public static ZonedDateTime getZonedDateTimeFromInstant(Instant instant, ZoneId zoneId) { + return instant.atZone(zoneId); + } +} diff --git a/src/main/java/earlybird/earlybird/common/LocalDateTimeUtil.java b/src/main/java/earlybird/earlybird/common/LocalDateTimeUtil.java new file mode 100644 index 0000000..8128f1b --- /dev/null +++ b/src/main/java/earlybird/earlybird/common/LocalDateTimeUtil.java @@ -0,0 +1,18 @@ +package earlybird.earlybird.common; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; + +public class LocalDateTimeUtil { + public static LocalDateTime getLocalDateTimeNow() { + return LocalDateTime.now(ZoneId.of("Asia/Seoul")); + } + + public static LocalDateTime subtractDuration(LocalDateTime localDateTime, Duration duration) { + if (localDateTime == null || duration == null) { + throw new IllegalArgumentException("localDateTime and duration can't be null"); + } + return localDateTime.minus(duration); + } +} diff --git a/src/main/java/earlybird/earlybird/common/LocalDateUtil.java b/src/main/java/earlybird/earlybird/common/LocalDateUtil.java new file mode 100644 index 0000000..51fcdcd --- /dev/null +++ b/src/main/java/earlybird/earlybird/common/LocalDateUtil.java @@ -0,0 +1,10 @@ +package earlybird.earlybird.common; + +import java.time.LocalDate; +import java.time.ZoneId; + +public class LocalDateUtil { + public static LocalDate getLocalDateNow() { + return LocalDate.now(ZoneId.of("Asia/Seoul")); + } +} diff --git a/src/main/java/earlybird/earlybird/common/RetryConfig.java b/src/main/java/earlybird/earlybird/common/RetryConfig.java new file mode 100644 index 0000000..55bda92 --- /dev/null +++ b/src/main/java/earlybird/earlybird/common/RetryConfig.java @@ -0,0 +1,8 @@ +package earlybird.earlybird.common; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@EnableRetry +@Configuration +public class RetryConfig {} diff --git a/src/main/java/earlybird/earlybird/error/ErrorCode.java b/src/main/java/earlybird/earlybird/error/ErrorCode.java new file mode 100644 index 0000000..5435f7b --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/ErrorCode.java @@ -0,0 +1,33 @@ +package earlybird.earlybird.error; + +import lombok.Getter; + +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "올바르지 않은 입력값입니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "잘못된 HTTP 메서드를 호출했습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러가 발생했습니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 엔티티입니다."), + ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 아티클입니다."), + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 알림입니다."), + FCM_MESSAGE_TIME_BEFORE_NOW(HttpStatus.INTERNAL_SERVER_ERROR, "FCM 메시지 전송 희망 시간이 현재보다 과거입니다."), + FCM_NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 알림입니다."), + ALREADY_SENT_FCM_NOTIFICATION(HttpStatus.BAD_REQUEST, "이미 전송된 FCM 알림입니다."), + INCORRECT_REQUEST_BODY_FORMAT(HttpStatus.BAD_REQUEST, "잘못된 request body 형식입니다."), + INVALID_REQUEST_ARGUMENT(HttpStatus.BAD_REQUEST, "request argument 가 제약 조건을 만족하지 않습니다."), + FCM_DEVICE_TOKEN_MISMATCH( + HttpStatus.BAD_REQUEST, "요청한 알림 ID에 해당하는 디바이스 토큰과 요청한 디바이스 토큰이 일치하지 않습니다."), + APPOINTMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 약속입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."), + DELETED_APPOINTMENT_EXCEPTION(HttpStatus.NOT_FOUND, "삭제된 일정입니다."); + + private final HttpStatus status; + private final String message; + + ErrorCode(final HttpStatus status, final String message) { + this.status = status; + this.message = message; + } +} diff --git a/src/main/java/earlybird/earlybird/error/ErrorResponse.java b/src/main/java/earlybird/earlybird/error/ErrorResponse.java new file mode 100644 index 0000000..e1ec866 --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/ErrorResponse.java @@ -0,0 +1,32 @@ +package earlybird.earlybird.error; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ErrorResponse { + private HttpStatus status; + private String message; + + private ErrorResponse(final ErrorCode errorCode) { + this.status = errorCode.getStatus(); + this.message = errorCode.getMessage(); + } + + public ErrorResponse(final ErrorCode errorCode, final String message) { + this.status = errorCode.getStatus(); + this.message = errorCode.getMessage(); + } + + public static ErrorResponse of(final ErrorCode errorCode) { + return new ErrorResponse(errorCode); + } + + public static ErrorResponse of(final ErrorCode errorCode, final String message) { + return new ErrorResponse(errorCode, message); + } +} diff --git a/src/main/java/earlybird/earlybird/error/GlobalExceptionHandler.java b/src/main/java/earlybird/earlybird/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..bc78fc6 --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +package earlybird.earlybird.error; + +import earlybird.earlybird.error.exception.BusinessBaseException; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + protected ResponseEntity handleHttpRequestMethodNotSupportedException( + HttpRequestMethodNotSupportedException e) { + log.error("HttpRequestMethodNotSupportedException", e); + return createErrorResponseEntity(ErrorCode.METHOD_NOT_ALLOWED); + } + + @ExceptionHandler(BusinessBaseException.class) + protected ResponseEntity handleBusinessBaseException(BusinessBaseException e) { + log.error("BusinessException", e); + return createErrorResponseEntity(e.getErrorCode()); + } + + @ExceptionHandler(Exception.class) + protected ResponseEntity handleException(Exception e) { + log.error("Exception", e); + return createErrorResponseEntity(ErrorCode.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException e) { + log.error("HttpMessageNotReadableException", e); + return createErrorResponseEntity(ErrorCode.INCORRECT_REQUEST_BODY_FORMAT); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException e) { + log.error("MethodArgumentNotValidException", e); + return createErrorResponseEntity(ErrorCode.INVALID_REQUEST_ARGUMENT); + } + + private ResponseEntity createErrorResponseEntity(ErrorCode errorCode) { + return new ResponseEntity<>(ErrorResponse.of(errorCode), errorCode.getStatus()); + } +} diff --git a/src/main/java/earlybird/earlybird/error/exception/AlreadySentFcmNotificationException.java b/src/main/java/earlybird/earlybird/error/exception/AlreadySentFcmNotificationException.java new file mode 100644 index 0000000..8d0d9c8 --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/exception/AlreadySentFcmNotificationException.java @@ -0,0 +1,9 @@ +package earlybird.earlybird.error.exception; + +import static earlybird.earlybird.error.ErrorCode.ALREADY_SENT_FCM_NOTIFICATION; + +public class AlreadySentFcmNotificationException extends BusinessBaseException { + public AlreadySentFcmNotificationException() { + super(ALREADY_SENT_FCM_NOTIFICATION); + } +} diff --git a/src/main/java/earlybird/earlybird/error/exception/AppointmentNotFoundException.java b/src/main/java/earlybird/earlybird/error/exception/AppointmentNotFoundException.java new file mode 100644 index 0000000..d5a7851 --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/exception/AppointmentNotFoundException.java @@ -0,0 +1,9 @@ +package earlybird.earlybird.error.exception; + +import earlybird.earlybird.error.ErrorCode; + +public class AppointmentNotFoundException extends NotFoundException { + public AppointmentNotFoundException() { + super(ErrorCode.APPOINTMENT_NOT_FOUND); + } +} diff --git a/src/main/java/earlybird/earlybird/error/exception/ArticleNotFoundException.java b/src/main/java/earlybird/earlybird/error/exception/ArticleNotFoundException.java new file mode 100644 index 0000000..1d98a5f --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/exception/ArticleNotFoundException.java @@ -0,0 +1,9 @@ +package earlybird.earlybird.error.exception; + +import earlybird.earlybird.error.ErrorCode; + +public class ArticleNotFoundException extends NotFoundException { + public ArticleNotFoundException() { + super(ErrorCode.ARTICLE_NOT_FOUND); + } +} diff --git a/src/main/java/earlybird/earlybird/error/exception/BusinessBaseException.java b/src/main/java/earlybird/earlybird/error/exception/BusinessBaseException.java new file mode 100644 index 0000000..eba6026 --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/exception/BusinessBaseException.java @@ -0,0 +1,20 @@ +package earlybird.earlybird.error.exception; + +import earlybird.earlybird.error.ErrorCode; + +import lombok.Getter; + +@Getter +public abstract class BusinessBaseException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessBaseException(String message, ErrorCode errorCode) { + super(message); + this.errorCode = errorCode; + } + + public BusinessBaseException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/earlybird/earlybird/error/exception/DeletedAppointmentException.java b/src/main/java/earlybird/earlybird/error/exception/DeletedAppointmentException.java new file mode 100644 index 0000000..be3725d --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/exception/DeletedAppointmentException.java @@ -0,0 +1,9 @@ +package earlybird.earlybird.error.exception; + +import static earlybird.earlybird.error.ErrorCode.DELETED_APPOINTMENT_EXCEPTION; + +public class DeletedAppointmentException extends BusinessBaseException { + public DeletedAppointmentException() { + super(DELETED_APPOINTMENT_EXCEPTION); + } +} diff --git a/src/main/java/earlybird/earlybird/error/exception/FcmDeviceTokenMismatchException.java b/src/main/java/earlybird/earlybird/error/exception/FcmDeviceTokenMismatchException.java new file mode 100644 index 0000000..cdccd62 --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/exception/FcmDeviceTokenMismatchException.java @@ -0,0 +1,10 @@ +package earlybird.earlybird.error.exception; + +import static earlybird.earlybird.error.ErrorCode.FCM_DEVICE_TOKEN_MISMATCH; + +public class FcmDeviceTokenMismatchException extends BusinessBaseException { + + public FcmDeviceTokenMismatchException() { + super(FCM_DEVICE_TOKEN_MISMATCH); + } +} diff --git a/src/main/java/earlybird/earlybird/error/exception/FcmMessageTimeBeforeNowException.java b/src/main/java/earlybird/earlybird/error/exception/FcmMessageTimeBeforeNowException.java new file mode 100644 index 0000000..5176879 --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/exception/FcmMessageTimeBeforeNowException.java @@ -0,0 +1,10 @@ +package earlybird.earlybird.error.exception; + +import static earlybird.earlybird.error.ErrorCode.FCM_MESSAGE_TIME_BEFORE_NOW; + +public class FcmMessageTimeBeforeNowException extends BusinessBaseException { + + public FcmMessageTimeBeforeNowException() { + super(FCM_MESSAGE_TIME_BEFORE_NOW); + } +} diff --git a/src/main/java/earlybird/earlybird/error/exception/FcmNotificationNotFoundException.java b/src/main/java/earlybird/earlybird/error/exception/FcmNotificationNotFoundException.java new file mode 100644 index 0000000..b27d64c --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/exception/FcmNotificationNotFoundException.java @@ -0,0 +1,9 @@ +package earlybird.earlybird.error.exception; + +import earlybird.earlybird.error.ErrorCode; + +public class FcmNotificationNotFoundException extends NotFoundException { + public FcmNotificationNotFoundException() { + super(ErrorCode.NOTIFICATION_NOT_FOUND); + } +} diff --git a/src/main/java/earlybird/earlybird/error/exception/NotFoundException.java b/src/main/java/earlybird/earlybird/error/exception/NotFoundException.java new file mode 100644 index 0000000..f55f435 --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/exception/NotFoundException.java @@ -0,0 +1,13 @@ +package earlybird.earlybird.error.exception; + +import earlybird.earlybird.error.ErrorCode; + +public class NotFoundException extends BusinessBaseException { + public NotFoundException(ErrorCode errorCode) { + super(errorCode.getMessage(), errorCode); + } + + public NotFoundException() { + super((ErrorCode.NOT_FOUND)); + } +} diff --git a/src/main/java/earlybird/earlybird/error/exception/NotificationNotFoundException.java b/src/main/java/earlybird/earlybird/error/exception/NotificationNotFoundException.java new file mode 100644 index 0000000..4cbd18a --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/exception/NotificationNotFoundException.java @@ -0,0 +1,9 @@ +package earlybird.earlybird.error.exception; + +import earlybird.earlybird.error.ErrorCode; + +public class NotificationNotFoundException extends NotFoundException { + public NotificationNotFoundException() { + super(ErrorCode.NOTIFICATION_NOT_FOUND); + } +} diff --git a/src/main/java/earlybird/earlybird/error/exception/UserNotFoundException.java b/src/main/java/earlybird/earlybird/error/exception/UserNotFoundException.java new file mode 100644 index 0000000..fd85bdc --- /dev/null +++ b/src/main/java/earlybird/earlybird/error/exception/UserNotFoundException.java @@ -0,0 +1,9 @@ +package earlybird.earlybird.error.exception; + +import earlybird.earlybird.error.ErrorCode; + +public class UserNotFoundException extends NotFoundException { + public UserNotFoundException() { + super(ErrorCode.USER_NOT_FOUND); + } +} diff --git a/src/main/java/earlybird/earlybird/feedback/controller/CreateFeedbackController.java b/src/main/java/earlybird/earlybird/feedback/controller/CreateFeedbackController.java new file mode 100644 index 0000000..bc9a348 --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/controller/CreateFeedbackController.java @@ -0,0 +1,62 @@ +package earlybird.earlybird.feedback.controller; + +import earlybird.earlybird.feedback.controller.request.CreateFeedbackCommentRequest; +import earlybird.earlybird.feedback.controller.request.CreateFeedbackScoreRequest; +import earlybird.earlybird.feedback.service.anonymous.CreateAnonymousFeedbackCommentService; +import earlybird.earlybird.feedback.service.anonymous.CreateAnonymousFeedbackScoreService; +import earlybird.earlybird.feedback.service.anonymous.request.CreateAnonymousFeedbackCommentServiceRequest; +import earlybird.earlybird.feedback.service.anonymous.request.CreateAnonymousFeedbackScoreServiceRequest; +import earlybird.earlybird.feedback.service.auth.CreateAuthFeedbackCommentService; +import earlybird.earlybird.feedback.service.auth.request.CreateAuthFeedbackCommentServiceRequest; +import earlybird.earlybird.security.authentication.oauth2.user.OAuth2UserDetails; + +import jakarta.validation.Valid; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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/feedbacks") +@RestController +public class CreateFeedbackController { + + private final CreateAuthFeedbackCommentService createAuthFeedbackCommentService; + private final CreateAnonymousFeedbackCommentService createAnonymousFeedbackCommentService; + private final CreateAnonymousFeedbackScoreService createAnonymousFeedbackScoreService; + + @PostMapping("/comments") + public ResponseEntity createFeedbackComment( + @AuthenticationPrincipal OAuth2UserDetails oAuth2UserDetails, + @Valid @RequestBody CreateFeedbackCommentRequest request) { + + if (oAuth2UserDetails != null) { + CreateAuthFeedbackCommentServiceRequest serviceRequest = + CreateAuthFeedbackCommentServiceRequest.of(oAuth2UserDetails, request); + createAuthFeedbackCommentService.create(serviceRequest); + } else { + CreateAnonymousFeedbackCommentServiceRequest serviceRequest = + CreateAnonymousFeedbackCommentServiceRequest.of(request); + createAnonymousFeedbackCommentService.create(serviceRequest); + } + + return ResponseEntity.ok().build(); + } + + // TODO: 베타 테스트 이후 로그인 사용자의 피드백 받는 기능 구현 + @PostMapping("/scores") + public ResponseEntity createFeedbackScore( + @Valid @RequestBody CreateFeedbackScoreRequest request) { + + CreateAnonymousFeedbackScoreServiceRequest serviceRequest = + CreateAnonymousFeedbackScoreServiceRequest.of(request); + createAnonymousFeedbackScoreService.create(serviceRequest); + + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/earlybird/earlybird/feedback/controller/request/CreateFeedbackCommentRequest.java b/src/main/java/earlybird/earlybird/feedback/controller/request/CreateFeedbackCommentRequest.java new file mode 100644 index 0000000..4923543 --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/controller/request/CreateFeedbackCommentRequest.java @@ -0,0 +1,23 @@ +package earlybird.earlybird.feedback.controller.request; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class CreateFeedbackCommentRequest { + @NotBlank private String comment; + @NotBlank private String clientId; + + @NotNull + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + private LocalDateTime createdAt; +} diff --git a/src/main/java/earlybird/earlybird/feedback/controller/request/CreateFeedbackScoreRequest.java b/src/main/java/earlybird/earlybird/feedback/controller/request/CreateFeedbackScoreRequest.java new file mode 100644 index 0000000..df24be0 --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/controller/request/CreateFeedbackScoreRequest.java @@ -0,0 +1,23 @@ +package earlybird.earlybird.feedback.controller.request; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class CreateFeedbackScoreRequest { + @NotNull private Integer score; + @NotBlank private String clientId; + + @NotNull + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + private LocalDateTime createdAt; +} diff --git a/src/main/java/earlybird/earlybird/feedback/domain/comment/FeedbackComment.java b/src/main/java/earlybird/earlybird/feedback/domain/comment/FeedbackComment.java new file mode 100644 index 0000000..4227222 --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/domain/comment/FeedbackComment.java @@ -0,0 +1,35 @@ +package earlybird.earlybird.feedback.domain.comment; + +import earlybird.earlybird.common.BaseTimeEntity; +import earlybird.earlybird.user.entity.User; + +import jakarta.persistence.*; + +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +@Entity +public class FeedbackComment extends BaseTimeEntity { + + @Column(name = "feedback_comment_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long id; + + @Column(name = "feedback_comment", nullable = false) + private String comment; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + private String clientId; + + @Column(name = "feedback_comment_created_time_at_client", nullable = false) + private LocalDateTime createdTimeAtClient; +} diff --git a/src/main/java/earlybird/earlybird/feedback/domain/comment/FeedbackCommentRepository.java b/src/main/java/earlybird/earlybird/feedback/domain/comment/FeedbackCommentRepository.java new file mode 100644 index 0000000..83236e7 --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/domain/comment/FeedbackCommentRepository.java @@ -0,0 +1,5 @@ +package earlybird.earlybird.feedback.domain.comment; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FeedbackCommentRepository extends JpaRepository {} diff --git a/src/main/java/earlybird/earlybird/feedback/domain/score/FeedbackScore.java b/src/main/java/earlybird/earlybird/feedback/domain/score/FeedbackScore.java new file mode 100644 index 0000000..533b24b --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/domain/score/FeedbackScore.java @@ -0,0 +1,35 @@ +package earlybird.earlybird.feedback.domain.score; + +import earlybird.earlybird.common.BaseTimeEntity; +import earlybird.earlybird.user.entity.User; + +import jakarta.persistence.*; + +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +@Entity +public class FeedbackScore extends BaseTimeEntity { + + @Column(name = "feedback_score_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long id; + + @Column(name = "feedback_nps_score", nullable = false) + private Integer score; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + private String clientId; + + @Column(name = "feedback_created_time_at_client", nullable = false) + private LocalDateTime createdTimeAtClient; +} diff --git a/src/main/java/earlybird/earlybird/feedback/domain/score/FeedbackScoreRepository.java b/src/main/java/earlybird/earlybird/feedback/domain/score/FeedbackScoreRepository.java new file mode 100644 index 0000000..c5d8086 --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/domain/score/FeedbackScoreRepository.java @@ -0,0 +1,5 @@ +package earlybird.earlybird.feedback.domain.score; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FeedbackScoreRepository extends JpaRepository {} diff --git a/src/main/java/earlybird/earlybird/feedback/service/anonymous/CreateAnonymousFeedbackCommentService.java b/src/main/java/earlybird/earlybird/feedback/service/anonymous/CreateAnonymousFeedbackCommentService.java new file mode 100644 index 0000000..019f125 --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/service/anonymous/CreateAnonymousFeedbackCommentService.java @@ -0,0 +1,29 @@ +package earlybird.earlybird.feedback.service.anonymous; + +import earlybird.earlybird.feedback.domain.comment.FeedbackComment; +import earlybird.earlybird.feedback.domain.comment.FeedbackCommentRepository; +import earlybird.earlybird.feedback.service.anonymous.request.CreateAnonymousFeedbackCommentServiceRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class CreateAnonymousFeedbackCommentService { + + private final FeedbackCommentRepository feedbackCommentRepository; + + @Transactional + public void create(CreateAnonymousFeedbackCommentServiceRequest request) { + FeedbackComment feedbackComment = + FeedbackComment.builder() + .comment(request.getComment()) + .clientId(request.getClientId()) + .createdTimeAtClient(request.getCreatedAt()) + .build(); + + feedbackCommentRepository.save(feedbackComment); + } +} diff --git a/src/main/java/earlybird/earlybird/feedback/service/anonymous/CreateAnonymousFeedbackScoreService.java b/src/main/java/earlybird/earlybird/feedback/service/anonymous/CreateAnonymousFeedbackScoreService.java new file mode 100644 index 0000000..a93701a --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/service/anonymous/CreateAnonymousFeedbackScoreService.java @@ -0,0 +1,29 @@ +package earlybird.earlybird.feedback.service.anonymous; + +import earlybird.earlybird.feedback.domain.score.FeedbackScore; +import earlybird.earlybird.feedback.domain.score.FeedbackScoreRepository; +import earlybird.earlybird.feedback.service.anonymous.request.CreateAnonymousFeedbackScoreServiceRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class CreateAnonymousFeedbackScoreService { + + private final FeedbackScoreRepository feedbackScoreRepository; + + @Transactional + public void create(CreateAnonymousFeedbackScoreServiceRequest request) { + FeedbackScore feedbackScore = + FeedbackScore.builder() + .score(request.getScore()) + .clientId(request.getClientId()) + .createdTimeAtClient(request.getCreatedAt()) + .build(); + + feedbackScoreRepository.save(feedbackScore); + } +} diff --git a/src/main/java/earlybird/earlybird/feedback/service/anonymous/request/CreateAnonymousFeedbackCommentServiceRequest.java b/src/main/java/earlybird/earlybird/feedback/service/anonymous/request/CreateAnonymousFeedbackCommentServiceRequest.java new file mode 100644 index 0000000..99eb8d5 --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/service/anonymous/request/CreateAnonymousFeedbackCommentServiceRequest.java @@ -0,0 +1,32 @@ +package earlybird.earlybird.feedback.service.anonymous.request; + +import earlybird.earlybird.feedback.controller.request.CreateFeedbackCommentRequest; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class CreateAnonymousFeedbackCommentServiceRequest { + private String comment; + private String clientId; + private LocalDateTime createdAt; + + @Builder + private CreateAnonymousFeedbackCommentServiceRequest( + String comment, String clientId, LocalDateTime createdAt) { + this.comment = comment; + this.clientId = clientId; + this.createdAt = createdAt; + } + + public static CreateAnonymousFeedbackCommentServiceRequest of( + CreateFeedbackCommentRequest request) { + return CreateAnonymousFeedbackCommentServiceRequest.builder() + .comment(request.getComment()) + .clientId(request.getClientId()) + .createdAt(request.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/feedback/service/anonymous/request/CreateAnonymousFeedbackScoreServiceRequest.java b/src/main/java/earlybird/earlybird/feedback/service/anonymous/request/CreateAnonymousFeedbackScoreServiceRequest.java new file mode 100644 index 0000000..d07461e --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/service/anonymous/request/CreateAnonymousFeedbackScoreServiceRequest.java @@ -0,0 +1,32 @@ +package earlybird.earlybird.feedback.service.anonymous.request; + +import earlybird.earlybird.feedback.controller.request.CreateFeedbackScoreRequest; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class CreateAnonymousFeedbackScoreServiceRequest { + private int score; + private String clientId; + private LocalDateTime createdAt; + + @Builder + private CreateAnonymousFeedbackScoreServiceRequest( + int score, String clientId, LocalDateTime createdAt) { + this.score = score; + this.clientId = clientId; + this.createdAt = createdAt; + } + + public static CreateAnonymousFeedbackScoreServiceRequest of( + CreateFeedbackScoreRequest request) { + return CreateAnonymousFeedbackScoreServiceRequest.builder() + .score(request.getScore()) + .clientId(request.getClientId()) + .createdAt(request.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/feedback/service/auth/CreateAuthFeedbackCommentService.java b/src/main/java/earlybird/earlybird/feedback/service/auth/CreateAuthFeedbackCommentService.java new file mode 100644 index 0000000..532c087 --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/service/auth/CreateAuthFeedbackCommentService.java @@ -0,0 +1,38 @@ +package earlybird.earlybird.feedback.service.auth; + +import earlybird.earlybird.error.exception.UserNotFoundException; +import earlybird.earlybird.feedback.domain.comment.FeedbackComment; +import earlybird.earlybird.feedback.domain.comment.FeedbackCommentRepository; +import earlybird.earlybird.feedback.service.auth.request.CreateAuthFeedbackCommentServiceRequest; +import earlybird.earlybird.user.entity.User; +import earlybird.earlybird.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class CreateAuthFeedbackCommentService { + + private final FeedbackCommentRepository feedbackCommentRepository; + private final UserRepository userRepository; + + @Transactional + public void create(CreateAuthFeedbackCommentServiceRequest request) { + + Long userId = request.getUserAccountInfoDTO().getId(); + + User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + + FeedbackComment feedbackComment = + FeedbackComment.builder() + .comment(request.getComment()) + .user(user) + .createdTimeAtClient(request.getCreatedAt()) + .build(); + + feedbackCommentRepository.save(feedbackComment); + } +} diff --git a/src/main/java/earlybird/earlybird/feedback/service/auth/request/CreateAuthFeedbackCommentServiceRequest.java b/src/main/java/earlybird/earlybird/feedback/service/auth/request/CreateAuthFeedbackCommentServiceRequest.java new file mode 100644 index 0000000..ed90ae9 --- /dev/null +++ b/src/main/java/earlybird/earlybird/feedback/service/auth/request/CreateAuthFeedbackCommentServiceRequest.java @@ -0,0 +1,39 @@ +package earlybird.earlybird.feedback.service.auth.request; + +import earlybird.earlybird.feedback.controller.request.CreateFeedbackCommentRequest; +import earlybird.earlybird.security.authentication.oauth2.user.OAuth2UserDetails; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class CreateAuthFeedbackCommentServiceRequest { + private Long id; + private String comment; + private UserAccountInfoDTO userAccountInfoDTO; + private LocalDateTime createdAt; + + @Builder + private CreateAuthFeedbackCommentServiceRequest( + Long id, + String comment, + UserAccountInfoDTO userAccountInfoDTO, + LocalDateTime createdAt) { + this.id = id; + this.comment = comment; + this.userAccountInfoDTO = userAccountInfoDTO; + this.createdAt = createdAt; + } + + public static CreateAuthFeedbackCommentServiceRequest of( + OAuth2UserDetails oAuth2UserDetails, CreateFeedbackCommentRequest requestDTO) { + return CreateAuthFeedbackCommentServiceRequest.builder() + .comment(requestDTO.getComment()) + .userAccountInfoDTO(oAuth2UserDetails.getUserAccountInfoDTO()) + .createdAt(requestDTO.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/log/arrive/controller/ArriveOnTimeEventLogController.java b/src/main/java/earlybird/earlybird/log/arrive/controller/ArriveOnTimeEventLogController.java new file mode 100644 index 0000000..68bddf9 --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/arrive/controller/ArriveOnTimeEventLogController.java @@ -0,0 +1,42 @@ +package earlybird.earlybird.log.arrive.controller; + +import earlybird.earlybird.log.arrive.controller.request.ArriveOnTimeEventLoggingRequest; +import earlybird.earlybird.log.arrive.service.ArriveOnTimeEventLogService; +import earlybird.earlybird.log.arrive.service.request.ArriveOnTimeEventLoggingServiceRequest; +import earlybird.earlybird.scheduler.notification.service.update.UpdateNotificationAtArriveOnTimeService; +import earlybird.earlybird.scheduler.notification.service.update.request.UpdateNotificationAtArriveOnTimeServiceRequest; + +import jakarta.validation.Valid; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/log/arrive-on-time-event") +@RestController +public class ArriveOnTimeEventLogController { + + private final ArriveOnTimeEventLogService arriveOnTimeEventLogService; + private final UpdateNotificationAtArriveOnTimeService updateNotificationAtArriveOnTimeService; + + @PostMapping + public ResponseEntity arriveOnTimeEvent( + @Valid @RequestBody ArriveOnTimeEventLoggingRequest request) { + + UpdateNotificationAtArriveOnTimeServiceRequest updateServiceRequest = + UpdateNotificationAtArriveOnTimeServiceRequest.builder() + .appointmentId(request.getAppointmentId()) + .clientId(request.getClientId()) + .build(); + + updateNotificationAtArriveOnTimeService.update(updateServiceRequest); + + ArriveOnTimeEventLoggingServiceRequest loggingServiceRequest = + new ArriveOnTimeEventLoggingServiceRequest(request.getAppointmentId()); + arriveOnTimeEventLogService.create(loggingServiceRequest); + + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/earlybird/earlybird/log/arrive/controller/request/ArriveOnTimeEventLoggingRequest.java b/src/main/java/earlybird/earlybird/log/arrive/controller/request/ArriveOnTimeEventLoggingRequest.java new file mode 100644 index 0000000..2ca1807 --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/arrive/controller/request/ArriveOnTimeEventLoggingRequest.java @@ -0,0 +1,17 @@ +package earlybird.earlybird.log.arrive.controller.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ArriveOnTimeEventLoggingRequest { + + @NotNull private Long appointmentId; + + @NotBlank private String clientId; +} diff --git a/src/main/java/earlybird/earlybird/log/arrive/domain/ArriveOnTimeEventLog.java b/src/main/java/earlybird/earlybird/log/arrive/domain/ArriveOnTimeEventLog.java new file mode 100644 index 0000000..c82708f --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/arrive/domain/ArriveOnTimeEventLog.java @@ -0,0 +1,26 @@ +package earlybird.earlybird.log.arrive.domain; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.common.BaseTimeEntity; + +import jakarta.persistence.*; + +@Table(name = "arrive_on_time_event_log") +@Entity +public class ArriveOnTimeEventLog extends BaseTimeEntity { + + @Column(name = "arrive_on_time_event_log_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long id; + + @OneToOne + @JoinColumn(name = "appointment_id") + private Appointment appointment; + + public ArriveOnTimeEventLog() {} + + public ArriveOnTimeEventLog(Appointment appointment) { + this.appointment = appointment; + } +} diff --git a/src/main/java/earlybird/earlybird/log/arrive/domain/ArriveOnTimeEventLogRepository.java b/src/main/java/earlybird/earlybird/log/arrive/domain/ArriveOnTimeEventLogRepository.java new file mode 100644 index 0000000..6f81733 --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/arrive/domain/ArriveOnTimeEventLogRepository.java @@ -0,0 +1,5 @@ +package earlybird.earlybird.log.arrive.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ArriveOnTimeEventLogRepository extends JpaRepository {} diff --git a/src/main/java/earlybird/earlybird/log/arrive/service/ArriveOnTimeEventLogService.java b/src/main/java/earlybird/earlybird/log/arrive/service/ArriveOnTimeEventLogService.java new file mode 100644 index 0000000..b54e95e --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/arrive/service/ArriveOnTimeEventLogService.java @@ -0,0 +1,33 @@ +package earlybird.earlybird.log.arrive.service; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.domain.AppointmentRepository; +import earlybird.earlybird.error.exception.AppointmentNotFoundException; +import earlybird.earlybird.log.arrive.domain.ArriveOnTimeEventLog; +import earlybird.earlybird.log.arrive.domain.ArriveOnTimeEventLogRepository; +import earlybird.earlybird.log.arrive.service.request.ArriveOnTimeEventLoggingServiceRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class ArriveOnTimeEventLogService { + + private final ArriveOnTimeEventLogRepository arriveOnTimeEventLogRepository; + private final AppointmentRepository appointmentRepository; + + @Transactional + public void create(ArriveOnTimeEventLoggingServiceRequest request) { + Long appointmentId = request.getAppointmentId(); + Appointment appointment = + appointmentRepository + .findById(appointmentId) + .orElseThrow(AppointmentNotFoundException::new); + + ArriveOnTimeEventLog log = new ArriveOnTimeEventLog(appointment); + arriveOnTimeEventLogRepository.save(log); + } +} diff --git a/src/main/java/earlybird/earlybird/log/arrive/service/request/ArriveOnTimeEventLoggingServiceRequest.java b/src/main/java/earlybird/earlybird/log/arrive/service/request/ArriveOnTimeEventLoggingServiceRequest.java new file mode 100644 index 0000000..9b299db --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/arrive/service/request/ArriveOnTimeEventLoggingServiceRequest.java @@ -0,0 +1,10 @@ +package earlybird.earlybird.log.arrive.service.request; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class ArriveOnTimeEventLoggingServiceRequest { + private final Long appointmentId; +} diff --git a/src/main/java/earlybird/earlybird/log/visit/controller/VisitEventLogController.java b/src/main/java/earlybird/earlybird/log/visit/controller/VisitEventLogController.java new file mode 100644 index 0000000..05e4275 --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/visit/controller/VisitEventLogController.java @@ -0,0 +1,31 @@ +package earlybird.earlybird.log.visit.controller; + +import earlybird.earlybird.log.visit.controller.request.VisitEventLoggingRequest; +import earlybird.earlybird.log.visit.service.VisitEventLogService; +import earlybird.earlybird.log.visit.service.request.VisitEventLoggingServiceRequest; + +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/log") +@RestController +public class VisitEventLogController { + + private final VisitEventLogService visitEventLogService; + + @PostMapping("/visit-event") + public ResponseEntity visitEventLogging(@RequestBody VisitEventLoggingRequest request) { + if (request.getClientId() == null || request.getClientId().isEmpty()) + return ResponseEntity.badRequest().body("clientId is empty"); + VisitEventLoggingServiceRequest serviceRequest = + new VisitEventLoggingServiceRequest(request.getClientId()); + visitEventLogService.create(serviceRequest); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/earlybird/earlybird/log/visit/controller/request/VisitEventLoggingRequest.java b/src/main/java/earlybird/earlybird/log/visit/controller/request/VisitEventLoggingRequest.java new file mode 100644 index 0000000..190a651 --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/visit/controller/request/VisitEventLoggingRequest.java @@ -0,0 +1,12 @@ +package earlybird.earlybird.log.visit.controller.request; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class VisitEventLoggingRequest { + + private String clientId; +} diff --git a/src/main/java/earlybird/earlybird/log/visit/domain/VisitEventLog.java b/src/main/java/earlybird/earlybird/log/visit/domain/VisitEventLog.java new file mode 100644 index 0000000..f9c908e --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/visit/domain/VisitEventLog.java @@ -0,0 +1,24 @@ +package earlybird.earlybird.log.visit.domain; + +import earlybird.earlybird.common.BaseTimeEntity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +@Entity +public class VisitEventLog extends BaseTimeEntity { + + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long id; + + private String clientId; + + public VisitEventLog() {} + + public VisitEventLog(String clientId) { + this.clientId = clientId; + } +} diff --git a/src/main/java/earlybird/earlybird/log/visit/domain/VisitEventLogRepository.java b/src/main/java/earlybird/earlybird/log/visit/domain/VisitEventLogRepository.java new file mode 100644 index 0000000..d9a575a --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/visit/domain/VisitEventLogRepository.java @@ -0,0 +1,5 @@ +package earlybird.earlybird.log.visit.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VisitEventLogRepository extends JpaRepository {} diff --git a/src/main/java/earlybird/earlybird/log/visit/service/VisitEventLogService.java b/src/main/java/earlybird/earlybird/log/visit/service/VisitEventLogService.java new file mode 100644 index 0000000..6221371 --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/visit/service/VisitEventLogService.java @@ -0,0 +1,22 @@ +package earlybird.earlybird.log.visit.service; + +import earlybird.earlybird.log.visit.domain.VisitEventLog; +import earlybird.earlybird.log.visit.domain.VisitEventLogRepository; +import earlybird.earlybird.log.visit.service.request.VisitEventLoggingServiceRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class VisitEventLogService { + + private final VisitEventLogRepository visitEventLogRepository; + + @Transactional + public void create(VisitEventLoggingServiceRequest request) { + visitEventLogRepository.save(new VisitEventLog(request.getClientId())); + } +} diff --git a/src/main/java/earlybird/earlybird/log/visit/service/request/VisitEventLoggingServiceRequest.java b/src/main/java/earlybird/earlybird/log/visit/service/request/VisitEventLoggingServiceRequest.java new file mode 100644 index 0000000..c4203c7 --- /dev/null +++ b/src/main/java/earlybird/earlybird/log/visit/service/request/VisitEventLoggingServiceRequest.java @@ -0,0 +1,11 @@ +package earlybird.earlybird.log.visit.service.request; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class VisitEventLoggingServiceRequest { + + private final String clientId; +} diff --git a/src/main/java/earlybird/earlybird/messaging/MessagingService.java b/src/main/java/earlybird/earlybird/messaging/MessagingService.java new file mode 100644 index 0000000..00cf76e --- /dev/null +++ b/src/main/java/earlybird/earlybird/messaging/MessagingService.java @@ -0,0 +1,15 @@ +package earlybird.earlybird.messaging; + +import earlybird.earlybird.messaging.request.SendMessageByTokenServiceRequest; + +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; + +public interface MessagingService { + @Retryable(maxAttempts = 5, backoff = @Backoff(delay = 1000)) + void send(SendMessageByTokenServiceRequest request); + + @Recover + void recover(SendMessageByTokenServiceRequest request); +} diff --git a/src/main/java/earlybird/earlybird/messaging/MessagingServiceConfig.java b/src/main/java/earlybird/earlybird/messaging/MessagingServiceConfig.java new file mode 100644 index 0000000..ab1678b --- /dev/null +++ b/src/main/java/earlybird/earlybird/messaging/MessagingServiceConfig.java @@ -0,0 +1,27 @@ +package earlybird.earlybird.messaging; + +import earlybird.earlybird.messaging.firebase.FirebaseMessagingService; +import earlybird.earlybird.messaging.firebase.FirebaseMessagingServiceProxy; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepository; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class MessagingServiceConfig { + + @Bean + public MessagingService messagingService( + FcmNotificationRepository repository, + @Qualifier("taskExecutor") ThreadPoolTaskExecutor executor) { + return new FirebaseMessagingServiceProxy( + firebaseMessagingService(repository), repository, executor); + } + + @Bean + public FirebaseMessagingService firebaseMessagingService(FcmNotificationRepository repository) { + return new FirebaseMessagingService(repository); + } +} diff --git a/src/main/java/earlybird/earlybird/messaging/firebase/FirebaseMessagingService.java b/src/main/java/earlybird/earlybird/messaging/firebase/FirebaseMessagingService.java new file mode 100644 index 0000000..8660a48 --- /dev/null +++ b/src/main/java/earlybird/earlybird/messaging/firebase/FirebaseMessagingService.java @@ -0,0 +1,78 @@ +package earlybird.earlybird.messaging.firebase; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.*; + +import earlybird.earlybird.messaging.MessagingService; +import earlybird.earlybird.messaging.request.SendMessageByTokenServiceRequest; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepository; + +import jakarta.annotation.PostConstruct; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class FirebaseMessagingService implements MessagingService { + + @Value("${fcm.service-account-file}") + private String serviceAccountFilePath; + + @Value("${fcm.project-id}") + private String projectId; + + private final FcmNotificationRepository fcmNotificationRepository; + + @PostConstruct + private void init() throws IOException { + if (FirebaseApp.getApps().isEmpty()) { + ClassPathResource resource = new ClassPathResource(serviceAccountFilePath); + FirebaseOptions options = + FirebaseOptions.builder() + .setCredentials( + GoogleCredentials.fromStream( + new ClassPathResource(serviceAccountFilePath) + .getInputStream())) + .setProjectId(projectId) + .build(); + FirebaseApp.initializeApp(options); + } + } + + public void send(SendMessageByTokenServiceRequest request) { + log.info("send notification: {} {}", request.getNotificationId(), request.getTitle()); + try { + FirebaseMessaging.getInstance() + .send( + Message.builder() + .setNotification( + Notification.builder() + .setTitle(request.getTitle()) + .setBody(request.getBody()) + .build()) + .setToken(request.getDeviceToken()) + .build()); + } catch (FirebaseMessagingException e) { + throw new RuntimeException(e); + } + log.info("send success"); + } + + @Transactional + public void recover(SendMessageByTokenServiceRequest request) { + log.error("recover notification: {} {}", request.getNotificationId(), request.getTitle()); + fcmNotificationRepository + .findById(request.getNotificationId()) + .ifPresent(FcmNotification::onSendToFcmFailure); + } +} diff --git a/src/main/java/earlybird/earlybird/messaging/firebase/FirebaseMessagingServiceProxy.java b/src/main/java/earlybird/earlybird/messaging/firebase/FirebaseMessagingServiceProxy.java new file mode 100644 index 0000000..e56b22f --- /dev/null +++ b/src/main/java/earlybird/earlybird/messaging/firebase/FirebaseMessagingServiceProxy.java @@ -0,0 +1,48 @@ +package earlybird.earlybird.messaging.firebase; + +import earlybird.earlybird.messaging.MessagingService; +import earlybird.earlybird.messaging.request.SendMessageByTokenServiceRequest; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepository; +import earlybird.earlybird.scheduler.notification.domain.NotificationStatus; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +public class FirebaseMessagingServiceProxy implements MessagingService { + + private final FirebaseMessagingService target; + private final FcmNotificationRepository notificationRepository; + private final ThreadPoolTaskExecutor taskExecutor; + + public FirebaseMessagingServiceProxy( + FirebaseMessagingService target, + FcmNotificationRepository notificationRepository, + @Qualifier("taskExecutor") ThreadPoolTaskExecutor taskExecutor) { + this.target = target; + this.notificationRepository = notificationRepository; + this.taskExecutor = taskExecutor; + } + + @Transactional + @Override + public void send(SendMessageByTokenServiceRequest request) { + Long notificationId = request.getNotificationId(); + notificationRepository + .findByIdAndStatusForUpdate(notificationId, NotificationStatus.PENDING) + .ifPresent( + notification -> { + taskExecutor.execute(() -> target.send(request)); + notification.onSendToFcmSuccess(); + }); + } + + @Transactional + @Override + public void recover(SendMessageByTokenServiceRequest request) { + target.recover(request); + } +} diff --git a/src/main/java/earlybird/earlybird/messaging/request/SendMessageByTokenServiceRequest.java b/src/main/java/earlybird/earlybird/messaging/request/SendMessageByTokenServiceRequest.java new file mode 100644 index 0000000..e89b64b --- /dev/null +++ b/src/main/java/earlybird/earlybird/messaging/request/SendMessageByTokenServiceRequest.java @@ -0,0 +1,44 @@ +package earlybird.earlybird.messaging.request; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationStep.ONE_HOUR_BEFORE_PREPARATION_TIME; + +import earlybird.earlybird.scheduler.manager.request.AddNotificationToSchedulerServiceRequest; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class SendMessageByTokenServiceRequest { + private String title; + private String body; + private String deviceToken; + private Long notificationId; + + @Builder + private SendMessageByTokenServiceRequest( + String title, String body, String deviceToken, Long notificationId) { + this.title = title; + this.body = body; + this.deviceToken = deviceToken; + this.notificationId = notificationId; + } + + public static SendMessageByTokenServiceRequest from( + AddNotificationToSchedulerServiceRequest request) { + + NotificationStep notificationStep = request.getNotificationStep(); + String appointmentName = request.getAppointment().getAppointmentName(); + String title = + (notificationStep.equals(ONE_HOUR_BEFORE_PREPARATION_TIME)) + ? appointmentName + notificationStep.getTitle() + : notificationStep.getTitle(); + + return SendMessageByTokenServiceRequest.builder() + .title(title) + .body(notificationStep.getBody()) + .deviceToken(request.getDeviceToken()) + .notificationId(request.getNotificationId()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/config/SchedulerConfig.java b/src/main/java/earlybird/earlybird/scheduler/config/SchedulerConfig.java new file mode 100644 index 0000000..3f311d8 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/config/SchedulerConfig.java @@ -0,0 +1,21 @@ +package earlybird.earlybird.scheduler.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +// @EnableAsync +@Configuration +public class SchedulerConfig { + + private static final int POOL_SIZE = 2; // default: 1 + + @Bean + public ThreadPoolTaskScheduler threadPoolTaskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(POOL_SIZE); + scheduler.setThreadNamePrefix("EarlyBird-ThreadPool-Scheduler"); + scheduler.initialize(); + return scheduler; + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/manager/NotificationSchedulerManager.java b/src/main/java/earlybird/earlybird/scheduler/manager/NotificationSchedulerManager.java new file mode 100644 index 0000000..91fa987 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/manager/NotificationSchedulerManager.java @@ -0,0 +1,11 @@ +package earlybird.earlybird.scheduler.manager; + +import earlybird.earlybird.scheduler.manager.request.AddNotificationToSchedulerServiceRequest; + +public interface NotificationSchedulerManager { + void init(); + + void add(AddNotificationToSchedulerServiceRequest request); + + void remove(Long notificationId); +} diff --git a/src/main/java/earlybird/earlybird/scheduler/manager/aws/AwsNotificationSchedulerManagerService.java b/src/main/java/earlybird/earlybird/scheduler/manager/aws/AwsNotificationSchedulerManagerService.java new file mode 100644 index 0000000..418065c --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/manager/aws/AwsNotificationSchedulerManagerService.java @@ -0,0 +1,126 @@ +package earlybird.earlybird.scheduler.manager.aws; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationStatus.PENDING; + +import earlybird.earlybird.common.LocalDateTimeUtil; +import earlybird.earlybird.error.exception.FcmNotificationNotFoundException; +import earlybird.earlybird.scheduler.manager.NotificationSchedulerManager; +import earlybird.earlybird.scheduler.manager.request.AddNotificationToSchedulerServiceRequest; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepository; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; +import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Service +public class AwsNotificationSchedulerManagerService implements NotificationSchedulerManager { + private final DynamoDbClientFactory dynamoDbClientFactory; + private final String tableName = "earlybird-notification"; + private final String partitionKey = "notificationId"; + private final String sortKey = "targetTime"; + private final FcmNotificationRepository fcmNotificationRepository; + private final DateTimeFormatter targetTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + public AwsNotificationSchedulerManagerService( + DynamoDbClientFactory dynamoDbClientFactory, + FcmNotificationRepository fcmNotificationRepository) { + this.dynamoDbClientFactory = dynamoDbClientFactory; + this.fcmNotificationRepository = fcmNotificationRepository; + } + + @Override + @PostConstruct + public void init() { + fcmNotificationRepository.findAllByStatusIs(PENDING).stream() + .filter( + notification -> + notification + .getTargetTime() + .isAfter(LocalDateTimeUtil.getLocalDateTimeNow())) + .map(AddNotificationToSchedulerServiceRequest::of) + .forEach(this::add); + } + + @Override + @Transactional + public void add(AddNotificationToSchedulerServiceRequest request) { + Map notificationInfo = createNotificationAttributeMap(request); + + try (DynamoDbClient dynamoDbClient = dynamoDbClientFactory.create()) { + dynamoDbClient.putItem( + PutItemRequest.builder() + .tableName(this.tableName) + .item(notificationInfo) + .build()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + @Transactional + public void remove(Long notificationId) { + Map key = createNotificationAttributeMap(notificationId); + try (DynamoDbClient dynamoDbClient = dynamoDbClientFactory.create()) { + dynamoDbClient.deleteItem( + DeleteItemRequest.builder().tableName(tableName).key(key).build()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Map createNotificationAttributeMap(Long notificationId) { + FcmNotification notification = + fcmNotificationRepository + .findById(notificationId) + .orElseThrow(FcmNotificationNotFoundException::new); + String targetTime = notification.getTargetTime().format(targetTimeFormatter); + String notificationIdStr = String.valueOf(notificationId); + + Map key = new HashMap<>(); + key.put(this.partitionKey, AttributeValue.builder().s(notificationIdStr).build()); + key.put(this.sortKey, AttributeValue.builder().s(targetTime).build()); + return key; + } + + private Map createNotificationAttributeMap( + AddNotificationToSchedulerServiceRequest request) { + String notificationTitle = request.getNotificationStep().getTitle(); + String notificationBody = request.getNotificationStep().getBody(); + String targetTime = + LocalDateTime.ofInstant(request.getTargetTime(), ZoneId.of("Asia/Seoul")) + .format(targetTimeFormatter); + String deviceToken = request.getDeviceToken(); + String notificationId = String.valueOf(request.getNotificationId()); + + Map notificationInfo = new HashMap<>(); + notificationInfo.put("notificationTitle", createStringAttributeValue(notificationTitle)); + notificationInfo.put("notificationBody", createStringAttributeValue(notificationBody)); + notificationInfo.put(this.sortKey, createStringAttributeValue(targetTime)); + notificationInfo.put("deviceToken", createStringAttributeValue(deviceToken)); + notificationInfo.put(this.partitionKey, createStringAttributeValue(notificationId)); + + return notificationInfo; + } + + private AttributeValue createStringAttributeValue(String value) { + return AttributeValue.builder().s(value).build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/manager/aws/DefaultDynamoDbClientFactory.java b/src/main/java/earlybird/earlybird/scheduler/manager/aws/DefaultDynamoDbClientFactory.java new file mode 100644 index 0000000..f6142b5 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/manager/aws/DefaultDynamoDbClientFactory.java @@ -0,0 +1,30 @@ +package earlybird.earlybird.scheduler.manager.aws; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; + +@Service +public class DefaultDynamoDbClientFactory implements DynamoDbClientFactory { + private final AwsBasicCredentials credentials; + + public DefaultDynamoDbClientFactory( + @Value("${aws.access-key}") String awsAccessKey, + @Value("${aws.secret-access-key}") String awsSecretAccessKey) { + credentials = AwsBasicCredentials.create(awsAccessKey, awsSecretAccessKey); + } + + @Override + @Transactional + public DynamoDbClient create() { + return DynamoDbClient.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider(StaticCredentialsProvider.create(this.credentials)) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/manager/aws/DynamoDbClientFactory.java b/src/main/java/earlybird/earlybird/scheduler/manager/aws/DynamoDbClientFactory.java new file mode 100644 index 0000000..9e62f42 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/manager/aws/DynamoDbClientFactory.java @@ -0,0 +1,7 @@ +package earlybird.earlybird.scheduler.manager.aws; + +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; + +public interface DynamoDbClientFactory { + DynamoDbClient create(); +} diff --git a/src/main/java/earlybird/earlybird/scheduler/manager/request/AddNotificationToSchedulerServiceRequest.java b/src/main/java/earlybird/earlybird/scheduler/manager/request/AddNotificationToSchedulerServiceRequest.java new file mode 100644 index 0000000..3269851 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/manager/request/AddNotificationToSchedulerServiceRequest.java @@ -0,0 +1,34 @@ +package earlybird.earlybird.scheduler.manager.request; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; + +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; +import java.time.ZoneId; + +@Getter +@Builder +public class AddNotificationToSchedulerServiceRequest { + private Instant targetTime; + private NotificationStep notificationStep; + private String deviceToken; + private String clientId; + private Appointment appointment; + private Long notificationId; + + public static AddNotificationToSchedulerServiceRequest of(FcmNotification notification) { + return AddNotificationToSchedulerServiceRequest.builder() + .appointment(notification.getAppointment()) + .deviceToken(notification.getAppointment().getDeviceToken()) + .notificationStep(notification.getNotificationStep()) + .clientId(notification.getAppointment().getClientId()) + .targetTime( + notification.getTargetTime().atZone(ZoneId.of("Asia/Seoul")).toInstant()) + .notificationId(notification.getId()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/manager/spring/NotificationTaskSchedulerService.java b/src/main/java/earlybird/earlybird/scheduler/manager/spring/NotificationTaskSchedulerService.java new file mode 100644 index 0000000..b9e4e99 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/manager/spring/NotificationTaskSchedulerService.java @@ -0,0 +1,91 @@ +package earlybird.earlybird.scheduler.manager.spring; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationStatus.PENDING; + +import earlybird.earlybird.common.LocalDateTimeUtil; +import earlybird.earlybird.messaging.MessagingService; +import earlybird.earlybird.messaging.request.SendMessageByTokenServiceRequest; +import earlybird.earlybird.scheduler.manager.NotificationSchedulerManager; +import earlybird.earlybird.scheduler.manager.request.AddNotificationToSchedulerServiceRequest; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepository; + +import jakarta.annotation.PostConstruct; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.scheduling.TaskScheduler; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; + +@Slf4j +@RequiredArgsConstructor +// @Service +public class NotificationTaskSchedulerService implements NotificationSchedulerManager { + + private final TaskScheduler taskScheduler; + private final MessagingService messagingService; + private final FcmNotificationRepository fcmNotificationRepository; + + private final ConcurrentHashMap> notificationIdAndScheduleFutureMap = + new ConcurrentHashMap<>(); + + @PostConstruct + @Override + public void init() { + fcmNotificationRepository.findAllByStatusIs(PENDING).stream() + .filter( + notification -> + notification + .getTargetTime() + .isAfter(LocalDateTimeUtil.getLocalDateTimeNow())) + .map(AddNotificationToSchedulerServiceRequest::of) + .forEach(this::add); + } + + @Transactional + @Override + public void add(AddNotificationToSchedulerServiceRequest request) { + Long notificationId = request.getNotificationId(); + Instant targetTime = request.getTargetTime(); + + if (targetTime.isBefore(getNowInstant())) return; + + SendMessageByTokenServiceRequest sendRequest = + SendMessageByTokenServiceRequest.from(request); + + ScheduledFuture schedule = + taskScheduler.schedule( + () -> { + messagingService.send(sendRequest); + notificationIdAndScheduleFutureMap.remove(notificationId); + }, + targetTime); + + notificationIdAndScheduleFutureMap.put(notificationId, schedule); + } + + private Instant getNowInstant() { + return ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toInstant(); + } + + @Transactional + @Override + public void remove(Long notificationId) { + ScheduledFuture scheduledFuture = notificationIdAndScheduleFutureMap.get(notificationId); + if (scheduledFuture == null) { + return; + } + scheduledFuture.cancel(false); + notificationIdAndScheduleFutureMap.remove(notificationId); + } + + public boolean has(Long notificationId) { + return notificationIdAndScheduleFutureMap.containsKey(notificationId); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/controller/FcmSchedulerController.java b/src/main/java/earlybird/earlybird/scheduler/notification/controller/FcmSchedulerController.java new file mode 100644 index 0000000..c8f5f75 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/controller/FcmSchedulerController.java @@ -0,0 +1,77 @@ +package earlybird.earlybird.scheduler.notification.controller; + +import com.google.firebase.messaging.FirebaseMessagingException; + +import earlybird.earlybird.scheduler.notification.controller.request.DeregisterNotificationByTokenRequest; +import earlybird.earlybird.scheduler.notification.controller.request.RegisterNotificationByTokenRequest; +import earlybird.earlybird.scheduler.notification.controller.request.UpdateNotificationRequest; +import earlybird.earlybird.scheduler.notification.controller.response.RegisterNotificationByTokenResponse; +import earlybird.earlybird.scheduler.notification.controller.response.UpdateNotificationResponse; +import earlybird.earlybird.scheduler.notification.service.deregister.DeregisterNotificationService; +import earlybird.earlybird.scheduler.notification.service.register.RegisterNotificationAtSchedulerService; +import earlybird.earlybird.scheduler.notification.service.register.response.RegisterNotificationServiceResponse; +import earlybird.earlybird.scheduler.notification.service.update.UpdateNotificationService; +import earlybird.earlybird.scheduler.notification.service.update.response.UpdateFcmMessageServiceResponse; + +import jakarta.validation.Valid; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * Deprecated: 클라이언트에서 이 API 를 사용하는 코드가 전부 AppointmentController 의 API 로 변경되면 이 컨트롤러 삭제 TODO: 람다에서 + * 전송 성공하거나 실패했을 때 호출할 컨트롤러 만들기 + */ +@Deprecated +@RequiredArgsConstructor +@RequestMapping("/api/v1/message/fcm/token") +@RestController +public class FcmSchedulerController { + + private final RegisterNotificationAtSchedulerService registerService; + private final DeregisterNotificationService deregisterService; + private final UpdateNotificationService updateService; + + @PostMapping + public ResponseEntity registerTokenNotification( + @Valid @RequestBody RegisterNotificationByTokenRequest request) + throws FirebaseMessagingException { + RegisterNotificationServiceResponse serviceResponse = + registerService.registerFcmMessageForNewAppointment( + request.toRegisterFcmMessageForNewAppointmentAtSchedulerRequest()); + + RegisterNotificationByTokenResponse controllerResponse = + RegisterNotificationByTokenResponse.from(serviceResponse); + + return ResponseEntity.status(HttpStatus.OK).body(controllerResponse); + } + + @PatchMapping + public ResponseEntity updateNotification( + @Valid @RequestBody UpdateNotificationRequest request) { + UpdateFcmMessageServiceResponse serviceResponse = + updateService.update(request.toServiceRequest()); + UpdateNotificationResponse controllerResponse = + UpdateNotificationResponse.from(serviceResponse); + + return ResponseEntity.ok().body(controllerResponse); + } + + @DeleteMapping + public ResponseEntity deregisterTokenNotificationAtScheduler( + @RequestHeader("appointmentId") Long appointmentId, + @RequestHeader("clientId") String clientId) { + + DeregisterNotificationByTokenRequest request = + DeregisterNotificationByTokenRequest.builder() + .appointmentId(appointmentId) + .clientId(clientId) + .build(); + + deregisterService.deregister(request.toServiceRequest()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/controller/NotificationStatusController.java b/src/main/java/earlybird/earlybird/scheduler/notification/controller/NotificationStatusController.java new file mode 100644 index 0000000..44697ca --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/controller/NotificationStatusController.java @@ -0,0 +1,27 @@ +package earlybird.earlybird.scheduler.notification.controller; + +import earlybird.earlybird.scheduler.notification.service.update.UpdateNotificationStatusService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/api/v1/notification/status") +@RestController +public class NotificationStatusController { + + private final UpdateNotificationStatusService updateNotificationStatusService; + + // 쿼리 파라미터 이름 수정할 경우 AWS 람다 함수에서도 수정해야함 + @PostMapping + public ResponseEntity setNotificationStatus( + @RequestParam Long notificationId, @RequestParam Boolean sendSuccess) { + updateNotificationStatusService.update(notificationId, sendSuccess); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/controller/request/DeregisterNotificationByTokenRequest.java b/src/main/java/earlybird/earlybird/scheduler/notification/controller/request/DeregisterNotificationByTokenRequest.java new file mode 100644 index 0000000..16ab5a4 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/controller/request/DeregisterNotificationByTokenRequest.java @@ -0,0 +1,35 @@ +package earlybird.earlybird.scheduler.notification.controller.request; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationStatus.CANCELLED; + +import earlybird.earlybird.scheduler.notification.service.deregister.request.DeregisterFcmMessageAtSchedulerServiceRequest; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class DeregisterNotificationByTokenRequest { + + @NotBlank private String clientId; + @NotNull private Long appointmentId; + + @Builder + public DeregisterNotificationByTokenRequest(String clientId, Long appointmentId) { + this.clientId = clientId; + this.appointmentId = appointmentId; + } + + public DeregisterFcmMessageAtSchedulerServiceRequest toServiceRequest() { + return DeregisterFcmMessageAtSchedulerServiceRequest.builder() + .appointmentId(appointmentId) + .clientId(clientId) + .targetNotificationStatus(CANCELLED) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/controller/request/RegisterNotificationByTokenRequest.java b/src/main/java/earlybird/earlybird/scheduler/notification/controller/request/RegisterNotificationByTokenRequest.java new file mode 100644 index 0000000..f7e08f9 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/controller/request/RegisterNotificationByTokenRequest.java @@ -0,0 +1,57 @@ +package earlybird.earlybird.scheduler.notification.controller.request; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import earlybird.earlybird.scheduler.notification.service.register.request.RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +public class RegisterNotificationByTokenRequest { + @NotBlank private String clientId; + @NotBlank private String deviceToken; + @NotBlank private String appointmentName; + + @NotNull + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + private LocalDateTime appointmentTime; + + @NotNull + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + private LocalDateTime preparationTime; + + @NotNull + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + private LocalDateTime movingTime; + + public RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest + toRegisterFcmMessageForNewAppointmentAtSchedulerRequest() { + return RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest.builder() + .clientId(this.clientId) + .deviceToken(this.deviceToken) + .appointmentName(this.appointmentName) + .appointmentTime(this.appointmentTime) + .preparationTime(this.preparationTime) + .movingTime(this.movingTime) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/controller/request/UpdateNotificationRequest.java b/src/main/java/earlybird/earlybird/scheduler/notification/controller/request/UpdateNotificationRequest.java new file mode 100644 index 0000000..b823acf --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/controller/request/UpdateNotificationRequest.java @@ -0,0 +1,58 @@ +package earlybird.earlybird.scheduler.notification.controller.request; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import earlybird.earlybird.scheduler.notification.domain.NotificationUpdateType; +import earlybird.earlybird.scheduler.notification.service.update.request.UpdateFcmMessageServiceRequest; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class UpdateNotificationRequest { + + @NotNull private Long appointmentId; + @NotBlank private String appointmentName; + @NotBlank private String clientId; + @NotBlank private String deviceToken; + + @NotNull + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + private LocalDateTime appointmentTime; + + @NotNull + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + private LocalDateTime preparationTime; + + @NotNull + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + private LocalDateTime movingTime; + + @NotNull private NotificationUpdateType updateType; + + public UpdateFcmMessageServiceRequest toServiceRequest() { + return UpdateFcmMessageServiceRequest.builder() + .appointmentId(appointmentId) + .appointmentName(appointmentName) + .clientId(clientId) + .deviceToken(deviceToken) + .preparationTime(preparationTime) + .movingTime(movingTime) + .appointmentTime(appointmentTime) + .updateType(updateType) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/controller/response/RegisterNotificationByTokenResponse.java b/src/main/java/earlybird/earlybird/scheduler/notification/controller/response/RegisterNotificationByTokenResponse.java new file mode 100644 index 0000000..060ac5e --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/controller/response/RegisterNotificationByTokenResponse.java @@ -0,0 +1,46 @@ +package earlybird.earlybird.scheduler.notification.controller.response; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; +import earlybird.earlybird.scheduler.notification.service.register.response.RegisterNotificationServiceResponse; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +public class RegisterNotificationByTokenResponse { + private final Long appointmentId; + private final String appointmentName; + private final String clientId; + private final String deviceToken; + private final List notifications; + + @Builder + private RegisterNotificationByTokenResponse( + Long appointmentId, + String appointmentName, + String clientId, + String deviceToken, + List notifications) { + this.appointmentId = appointmentId; + this.appointmentName = appointmentName; + this.clientId = clientId; + this.deviceToken = deviceToken; + this.notifications = notifications; + } + + public static RegisterNotificationByTokenResponse from( + RegisterNotificationServiceResponse serviceResponse) { + Appointment appointment = serviceResponse.getAppointment(); + + return RegisterNotificationByTokenResponse.builder() + .appointmentId(appointment.getId()) + .appointmentName(appointment.getAppointmentName()) + .clientId(appointment.getClientId()) + .deviceToken(appointment.getDeviceToken()) + .notifications(serviceResponse.getNotifications()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/controller/response/UpdateNotificationResponse.java b/src/main/java/earlybird/earlybird/scheduler/notification/controller/response/UpdateNotificationResponse.java new file mode 100644 index 0000000..3e954fc --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/controller/response/UpdateNotificationResponse.java @@ -0,0 +1,37 @@ +package earlybird.earlybird.scheduler.notification.controller.response; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; +import earlybird.earlybird.scheduler.notification.service.update.response.UpdateFcmMessageServiceResponse; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +public class UpdateNotificationResponse { + + private final Long appointmentId; + private final String appointmentName; + private final String clientId; + private final String deviceToken; + private final List notifications; + + @Builder + public UpdateNotificationResponse( + Appointment appointment, List notifications) { + this.appointmentId = appointment.getId(); + this.appointmentName = appointment.getAppointmentName(); + this.clientId = appointment.getClientId(); + this.deviceToken = appointment.getDeviceToken(); + this.notifications = notifications; + } + + public static UpdateNotificationResponse from(UpdateFcmMessageServiceResponse serviceResponse) { + return UpdateNotificationResponse.builder() + .appointment(serviceResponse.getAppointment()) + .notifications(serviceResponse.getNotifications()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/domain/FcmNotification.java b/src/main/java/earlybird/earlybird/scheduler/notification/domain/FcmNotification.java new file mode 100644 index 0000000..97a116e --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/domain/FcmNotification.java @@ -0,0 +1,68 @@ +package earlybird.earlybird.scheduler.notification.domain; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationStatus.*; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.common.BaseTimeEntity; +import earlybird.earlybird.common.LocalDateTimeUtil; + +import jakarta.persistence.*; + +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class FcmNotification extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "fcm_notification_id") + private Long id; + + @Setter + @JsonIgnore + @ManyToOne + @JoinColumn(name = "appointment_id", nullable = false) + private Appointment appointment; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private NotificationStep notificationStep; + + @Column(nullable = false) + private LocalDateTime targetTime; + + @JsonIgnore + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private NotificationStatus status = PENDING; + + @JsonIgnore private LocalDateTime sentTime; + + @Builder + private FcmNotification( + Appointment appointment, NotificationStep notificationStep, LocalDateTime targetTime) { + this.appointment = appointment; + this.notificationStep = notificationStep; + this.targetTime = targetTime; + } + + public void onSendToFcmSuccess() { + this.sentTime = LocalDateTimeUtil.getLocalDateTimeNow(); + updateStatusTo(COMPLETED); + } + + public void onSendToFcmFailure() { + this.sentTime = null; + updateStatusTo(FAILED); + } + + public void updateStatusTo(NotificationStatus status) { + this.status = status; + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationRepository.java b/src/main/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationRepository.java new file mode 100644 index 0000000..4a4903f --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationRepository.java @@ -0,0 +1,21 @@ +package earlybird.earlybird.scheduler.notification.domain; + +import jakarta.persistence.LockModeType; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface FcmNotificationRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT n FROM FcmNotification n WHERE n.id = :id AND n.status = :status") + Optional findByIdAndStatusForUpdate( + @Param("id") Long id, @Param("status") NotificationStatus status); + + List findAllByStatusIs(NotificationStatus status); +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/domain/NotificationStatus.java b/src/main/java/earlybird/earlybird/scheduler/notification/domain/NotificationStatus.java new file mode 100644 index 0000000..89b06f8 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/domain/NotificationStatus.java @@ -0,0 +1,18 @@ +package earlybird.earlybird.scheduler.notification.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationStatus { + PENDING("전송 대기 중"), + COMPLETED("전송 완료"), + FAILED("전송 실패"), + MODIFIED("변경됨"), + POSTPONE("미뤄짐"), + CANCELLED("취소"), + CANCELLED_BY_ARRIVE_ON_TIME("정시 도착"); + + private final String text; +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/domain/NotificationStep.java b/src/main/java/earlybird/earlybird/scheduler/notification/domain/NotificationStep.java new file mode 100644 index 0000000..c4c3cab --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/domain/NotificationStep.java @@ -0,0 +1,21 @@ +package earlybird.earlybird.scheduler.notification.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationStep { + ONE_HOUR_BEFORE_PREPARATION_TIME(1L, " 준비 1시간 전!", "오늘의 준비사항을 확인해봐요 \uD83D\uDE0A"), + FIVE_MINUTES_BEFORE_PREPARATION_TIME(2L, "5분 후에 준비 시작해야 해요!", "허겁지겁 준비하면 후회해요! \uD83E\uDEE2"), + PREPARATION_TIME(3L, "지금 준비 시작 안하면 늦어요 ❗\uFE0F❗\uFE0F❗\uFE0F", "같이 5초 세고, 시작해봐요!"), + TEN_MINUTES_BEFORE_MOVING_TIME(4L, "10분 후에 이동해야 안 늦어요!", "교통정보를 미리 확인해보세요 \uD83D\uDEA5"), + MOVING_TIME(5L, "지금 출발해야 안 늦어요 ❗\uFE0F❗\uFE0F❗\uFE0F", "준비사항 다 체크하셨나요?"), + FIVE_MINUTES_BEFORE_APPOINTMENT_TIME(6L, "약속장소에 도착하셨나요?!", "도착하셨으면 확인버튼을 눌러주세요! \uD83E\uDD29"), + APPOINTMENT_TIME( + 7L, "1분 안에 확인버튼을 눌러주세요!!", "안 누르면 지각처리돼요!!! \uD83D\uDEAB\uD83D\uDEAB\uD83D\uDEAB"); + + private final Long stepNumber; + private final String title; + private final String body; +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/domain/NotificationUpdateType.java b/src/main/java/earlybird/earlybird/scheduler/notification/domain/NotificationUpdateType.java new file mode 100644 index 0000000..8513e56 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/domain/NotificationUpdateType.java @@ -0,0 +1,14 @@ +package earlybird.earlybird.scheduler.notification.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationUpdateType { + POSTPONE("약속 미루기"), + MODIFY("일정 수정하기"), + ARRIVE_ON_TIME("정시 도착"); + + private final String type; +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/NotificationInfoFactory.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/NotificationInfoFactory.java new file mode 100644 index 0000000..aa42cb2 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/NotificationInfoFactory.java @@ -0,0 +1,63 @@ +package earlybird.earlybird.scheduler.notification.service; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationStep.*; +import static earlybird.earlybird.scheduler.notification.domain.NotificationStep.APPOINTMENT_TIME; + +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; + +import lombok.Getter; + +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; + +@Service +public class NotificationInfoFactory { + @Getter private final List notificationStepList; + + public NotificationInfoFactory() { + this.notificationStepList = + List.of( + ONE_HOUR_BEFORE_PREPARATION_TIME, + FIVE_MINUTES_BEFORE_PREPARATION_TIME, + PREPARATION_TIME, + TEN_MINUTES_BEFORE_MOVING_TIME, + MOVING_TIME, + FIVE_MINUTES_BEFORE_APPOINTMENT_TIME, + APPOINTMENT_TIME); + } + + public Map createTargetTimeMap( + Instant preparationTimeInstant, + Instant movingTimeInstant, + Instant appointmentTimeInstant) { + return Map.of( + ONE_HOUR_BEFORE_PREPARATION_TIME, preparationTimeInstant.minus(1, ChronoUnit.HOURS), + FIVE_MINUTES_BEFORE_PREPARATION_TIME, + preparationTimeInstant.minus(5, ChronoUnit.MINUTES), + PREPARATION_TIME, preparationTimeInstant, + TEN_MINUTES_BEFORE_MOVING_TIME, movingTimeInstant.minus(10, ChronoUnit.MINUTES), + MOVING_TIME, movingTimeInstant, + FIVE_MINUTES_BEFORE_APPOINTMENT_TIME, + appointmentTimeInstant.minus(5, ChronoUnit.MINUTES), + APPOINTMENT_TIME, appointmentTimeInstant); + } + + public Map createTargetTimeMap( + LocalDateTime preparationTime, + LocalDateTime movingTime, + LocalDateTime appointmentTime) { + ZoneId seoul = ZoneId.of("Asia/Seoul"); + Instant preparationTimeInstant = preparationTime.atZone(seoul).toInstant(); + Instant movingTimeInstant = movingTime.atZone(seoul).toInstant(); + Instant appointmentTimeInstant = appointmentTime.atZone(seoul).toInstant(); + + return this.createTargetTimeMap( + preparationTimeInstant, movingTimeInstant, appointmentTimeInstant); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/deregister/DeregisterNotificationService.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/deregister/DeregisterNotificationService.java new file mode 100644 index 0000000..6bce574 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/deregister/DeregisterNotificationService.java @@ -0,0 +1,40 @@ +package earlybird.earlybird.scheduler.notification.service.deregister; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationStatus.CANCELLED; +import static earlybird.earlybird.scheduler.notification.domain.NotificationStatus.PENDING; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.service.FindAppointmentService; +import earlybird.earlybird.scheduler.manager.NotificationSchedulerManager; +import earlybird.earlybird.scheduler.notification.service.deregister.request.DeregisterFcmMessageAtSchedulerServiceRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class DeregisterNotificationService { + + private final NotificationSchedulerManager notificationSchedulerManager; + private final FindAppointmentService findAppointmentService; + + @Transactional + public void deregister(DeregisterFcmMessageAtSchedulerServiceRequest request) { + Appointment appointment = findAppointmentService.findBy(request); + + appointment.getFcmNotifications().stream() + .filter(notification -> notification.getStatus() == PENDING) + .forEach( + notification -> { + notificationSchedulerManager.remove(notification.getId()); + notification.updateStatusTo(request.getTargetNotificationStatus()); + }); + + if (request.getTargetNotificationStatus().equals(CANCELLED)) { + appointment.setRepeatingDaysEmpty(); + appointment.setDeleted(); + } + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/deregister/request/DeregisterFcmMessageAtSchedulerServiceRequest.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/deregister/request/DeregisterFcmMessageAtSchedulerServiceRequest.java new file mode 100644 index 0000000..2e05e95 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/deregister/request/DeregisterFcmMessageAtSchedulerServiceRequest.java @@ -0,0 +1,14 @@ +package earlybird.earlybird.scheduler.notification.service.deregister.request; + +import earlybird.earlybird.scheduler.notification.domain.NotificationStatus; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class DeregisterFcmMessageAtSchedulerServiceRequest { + private String clientId; + private Long appointmentId; + private NotificationStatus targetNotificationStatus; +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/deregister/request/DeregisterNotificationServiceRequestFactory.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/deregister/request/DeregisterNotificationServiceRequestFactory.java new file mode 100644 index 0000000..e1caa23 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/deregister/request/DeregisterNotificationServiceRequestFactory.java @@ -0,0 +1,46 @@ +package earlybird.earlybird.scheduler.notification.service.deregister.request; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationUpdateType.MODIFY; +import static earlybird.earlybird.scheduler.notification.domain.NotificationUpdateType.POSTPONE; + +import earlybird.earlybird.appointment.domain.AppointmentUpdateType; +import earlybird.earlybird.appointment.service.request.UpdateAppointmentServiceRequest; +import earlybird.earlybird.scheduler.notification.domain.NotificationStatus; +import earlybird.earlybird.scheduler.notification.domain.NotificationUpdateType; + +public class DeregisterNotificationServiceRequestFactory { + public static DeregisterFcmMessageAtSchedulerServiceRequest create( + Long appointmentId, String clientId, NotificationStatus notificationStatus) { + return DeregisterFcmMessageAtSchedulerServiceRequest.builder() + .appointmentId(appointmentId) + .clientId(clientId) + .targetNotificationStatus(notificationStatus) + .build(); + } + + public static DeregisterFcmMessageAtSchedulerServiceRequest create( + Long appointmentId, String clientId, NotificationUpdateType updateType) { + NotificationStatus targetStatus; + if (updateType.equals(POSTPONE)) targetStatus = NotificationStatus.POSTPONE; + else if (updateType.equals(MODIFY)) targetStatus = NotificationStatus.MODIFIED; + else throw new IllegalArgumentException("Invalid update type: " + updateType); + + return create(appointmentId, clientId, targetStatus); + } + + public static DeregisterFcmMessageAtSchedulerServiceRequest create( + UpdateAppointmentServiceRequest request) { + AppointmentUpdateType updateType = request.getUpdateType(); + NotificationStatus targetStatus = + switch (updateType) { + case POSTPONE -> NotificationStatus.POSTPONE; + case MODIFY -> NotificationStatus.MODIFIED; + }; + + return DeregisterFcmMessageAtSchedulerServiceRequest.builder() + .appointmentId(request.getAppointmentId()) + .clientId(request.getClientId()) + .targetNotificationStatus(targetStatus) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterAllNotificationAtSchedulerService.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterAllNotificationAtSchedulerService.java new file mode 100644 index 0000000..6ff8fd9 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterAllNotificationAtSchedulerService.java @@ -0,0 +1,37 @@ +package earlybird.earlybird.scheduler.notification.service.register; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.scheduler.manager.NotificationSchedulerManager; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepository; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Map; + +@Service +public class RegisterAllNotificationAtSchedulerService implements RegisterNotificationService { + + private final RegisterOneNotificationAtSchedulerService registerOneNotificationService; + + public RegisterAllNotificationAtSchedulerService( + FcmNotificationRepository fcmNotificationRepository, + NotificationSchedulerManager notificationSchedulerManager) { + this.registerOneNotificationService = + new RegisterOneNotificationAtSchedulerService( + fcmNotificationRepository, notificationSchedulerManager); + } + + @Override + @Transactional + public void register( + Appointment appointment, Map notificationStepAndTargetTime) { + for (NotificationStep notificationStep : notificationStepAndTargetTime.keySet()) { + registerOneNotificationService.register( + appointment, + Map.of(notificationStep, notificationStepAndTargetTime.get(notificationStep))); + } + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterNotificationAtSchedulerService.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterNotificationAtSchedulerService.java new file mode 100644 index 0000000..81987e9 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterNotificationAtSchedulerService.java @@ -0,0 +1,82 @@ +package earlybird.earlybird.scheduler.notification.service.register; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.domain.AppointmentRepository; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; +import earlybird.earlybird.scheduler.notification.service.NotificationInfoFactory; +import earlybird.earlybird.scheduler.notification.service.register.request.RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest; +import earlybird.earlybird.scheduler.notification.service.register.request.RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest; +import earlybird.earlybird.scheduler.notification.service.register.response.RegisterNotificationServiceResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Map; + +@Deprecated +@Slf4j +@RequiredArgsConstructor +@Service +public class RegisterNotificationAtSchedulerService { + + private final AppointmentRepository appointmentRepository; + private final RegisterAllNotificationAtSchedulerService + registerAllNotificationAtSchedulerService; + private final NotificationInfoFactory notificationInfoFactory; + + @Deprecated + @Transactional + public RegisterNotificationServiceResponse registerFcmMessageForNewAppointment( + RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest request) { + Appointment newAppointment = createAppointmentBy(request); + + return registerFcmMessageForExistingAppointment( + RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest.from( + request, newAppointment)); + } + + @Transactional + public RegisterNotificationServiceResponse registerFcmMessageForExistingAppointment( + RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest request) { + Appointment appointment = request.getAppointment(); + + Map targetTimeMap = + notificationInfoFactory.createTargetTimeMap( + request.getPreparationTimeInstant(), + request.getMovingTimeInstant(), + request.getAppointmentTimeInstant()); + + registerAllNotificationAtSchedulerService.register(appointment, targetTimeMap); + + return RegisterNotificationServiceResponse.builder() + .appointment(appointment) + .notifications(appointment.getFcmNotifications()) + .build(); + } + + private Appointment createAppointmentBy( + RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest request) { + Appointment appointment = + Appointment.builder() + .appointmentName(request.getAppointmentName()) + .clientId(request.getClientId()) + .deviceToken(request.getDeviceToken()) + .appointmentTime(request.getAppointmentTime().toLocalTime()) + .movingDuration( + Duration.between( + request.getMovingTime(), request.getAppointmentTime())) + .preparationDuration( + Duration.between( + request.getPreparationTime(), request.getMovingTime())) + .repeatingDayOfWeeks(new ArrayList<>()) + .build(); + + return appointmentRepository.save(appointment); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterNotificationService.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterNotificationService.java new file mode 100644 index 0000000..194988a --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterNotificationService.java @@ -0,0 +1,12 @@ +package earlybird.earlybird.scheduler.notification.service.register; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; + +import java.time.Instant; +import java.util.Map; + +public interface RegisterNotificationService { + void register( + Appointment appointment, Map notificationStepAndTargetTime); +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterOneNotificationAtSchedulerService.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterOneNotificationAtSchedulerService.java new file mode 100644 index 0000000..823ab86 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/RegisterOneNotificationAtSchedulerService.java @@ -0,0 +1,78 @@ +package earlybird.earlybird.scheduler.notification.service.register; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.common.InstantUtil; +import earlybird.earlybird.scheduler.manager.NotificationSchedulerManager; +import earlybird.earlybird.scheduler.manager.request.AddNotificationToSchedulerServiceRequest; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepository; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; + +import lombok.RequiredArgsConstructor; + +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.Map; + +@RequiredArgsConstructor +public class RegisterOneNotificationAtSchedulerService implements RegisterNotificationService { + + private final FcmNotificationRepository notificationRepository; + private final NotificationSchedulerManager notificationSchedulerManager; + + @Override + @Transactional + public void register( + Appointment appointment, Map notificationStepAndTargetTime) { + + if (notificationStepAndTargetTime.size() != 1) + throw new IllegalArgumentException("1개의 알림만 등록할 수 있습니다"); + + NotificationStep notificationStep = + notificationStepAndTargetTime.keySet().iterator().next(); + Instant targetTime = notificationStepAndTargetTime.get(notificationStep); + + if (InstantUtil.checkTimeBeforeNow(targetTime)) return; + + String deviceToken = appointment.getDeviceToken(); + + FcmNotification notification = + createNotification(notificationStep, targetTime, appointment); + + AddNotificationToSchedulerServiceRequest addTaskRequest = + createAddTaskRequest( + notificationStep, targetTime, appointment, notification, deviceToken); + + notificationSchedulerManager.add(addTaskRequest); + } + + private FcmNotification createNotification( + NotificationStep notificationStep, Instant targetTime, Appointment appointment) { + FcmNotification notification = + FcmNotification.builder() + .appointment(appointment) + .targetTime(targetTime.atZone(ZoneId.of("Asia/Seoul")).toLocalDateTime()) + .notificationStep(notificationStep) + .build(); + + appointment.addFcmNotification(notification); + return notificationRepository.save(notification); + } + + private AddNotificationToSchedulerServiceRequest createAddTaskRequest( + NotificationStep notificationStep, + Instant targetTime, + Appointment appointment, + FcmNotification notification, + String deviceToken) { + return AddNotificationToSchedulerServiceRequest.builder() + .notificationId(notification.getId()) + .targetTime(targetTime) + .appointment(appointment) + .notificationStep(notificationStep) + .deviceToken(deviceToken) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/register/repeating/RegisterNotificationForRepeatingAppointmentService.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/repeating/RegisterNotificationForRepeatingAppointmentService.java new file mode 100644 index 0000000..8fe2591 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/repeating/RegisterNotificationForRepeatingAppointmentService.java @@ -0,0 +1,79 @@ +package earlybird.earlybird.scheduler.notification.service.register.repeating; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationStatus.PENDING; +import static earlybird.earlybird.scheduler.notification.domain.NotificationStep.APPOINTMENT_TIME; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.domain.RepeatingDay; +import earlybird.earlybird.appointment.domain.RepeatingDayRepository; +import earlybird.earlybird.common.LocalDateTimeUtil; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; +import earlybird.earlybird.scheduler.notification.service.register.RegisterNotificationAtSchedulerService; +import earlybird.earlybird.scheduler.notification.service.register.request.RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.util.List; + +// TODO: AWS에 맞게 로직 수정 +@RequiredArgsConstructor +@Service +public class RegisterNotificationForRepeatingAppointmentService { + + private final RepeatingDayRepository repeatingDayRepository; + private final RegisterNotificationAtSchedulerService registerService; + + @Transactional + @Scheduled(cron = "0 0 0,23 * * ?", zone = "Asia/Seoul") // 매일 0시, 23시 + protected void registerEveryDay() { + switch (LocalDateTimeUtil.getLocalDateTimeNow().getHour()) { + case 0 -> registerAfterDay(0); + case 23 -> registerAfterDay(1); + } + } + + private void registerAfterDay(int plusDays) { + DayOfWeek afterTwoDayFromNow = + LocalDateTimeUtil.getLocalDateTimeNow().plusDays(plusDays).getDayOfWeek(); + List repeatingDays = + repeatingDayRepository.findAllByDayOfWeek(afterTwoDayFromNow); + repeatingDays.forEach( + repeatingDay -> { + Appointment appointment = repeatingDay.getAppointment(); + + RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest + registerRequest = + RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest + .from(appointment); + + boolean notificationIsNotRegistered = + appointment.getFcmNotifications().stream() + .filter( + notification -> + notification + .getNotificationStep() + .equals(APPOINTMENT_TIME)) + .filter( + notification -> + isValidTargetTime( + notification, + registerRequest.getAppointmentTime())) + .noneMatch( + notification -> + notification.getStatus().equals(PENDING)); + + if (notificationIsNotRegistered) + registerService.registerFcmMessageForExistingAppointment(registerRequest); + }); + } + + private boolean isValidTargetTime(FcmNotification notification, LocalDateTime appointmentTime) { + return !notification.getTargetTime().isBefore(appointmentTime); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/register/request/RegisterAllNotificationServiceRequest.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/request/RegisterAllNotificationServiceRequest.java new file mode 100644 index 0000000..576ad16 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/request/RegisterAllNotificationServiceRequest.java @@ -0,0 +1,17 @@ +package earlybird.earlybird.scheduler.notification.service.register.request; + +import earlybird.earlybird.appointment.domain.Appointment; + +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; + +@Getter +@Builder +public class RegisterAllNotificationServiceRequest { + private Instant preparationTimeInstant; + private Instant movingTimeInstant; + private Instant appointmentTimeInstant; + private Appointment appointment; +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/register/request/RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/request/RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest.java new file mode 100644 index 0000000..30f5590 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/request/RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest.java @@ -0,0 +1,119 @@ +package earlybird.earlybird.scheduler.notification.service.register.request; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.service.request.UpdateAppointmentServiceRequest; +import earlybird.earlybird.common.LocalDateTimeUtil; +import earlybird.earlybird.common.LocalDateUtil; +import earlybird.earlybird.scheduler.notification.service.update.request.UpdateFcmMessageServiceRequest; + +import lombok.Builder; +import lombok.Getter; + +import java.time.*; + +@Getter +public class RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest { + private String clientId; + private String deviceToken; + private LocalDateTime appointmentTime; + private LocalDateTime preparationTime; + private LocalDateTime movingTime; + private Appointment appointment; + + @Builder + private RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest( + String clientId, + String deviceToken, + LocalDateTime appointmentTime, + LocalDateTime preparationTime, + LocalDateTime movingTime, + Appointment appointment) { + this.clientId = clientId; + this.deviceToken = deviceToken; + this.appointmentTime = appointmentTime; + this.preparationTime = preparationTime; + this.movingTime = movingTime; + this.appointment = appointment; + } + + public Instant getAppointmentTimeInstant() { + return appointmentTime.atZone(ZoneId.of("Asia/Seoul")).toInstant(); + } + + public Instant getPreparationTimeInstant() { + return preparationTime.atZone(ZoneId.of("Asia/Seoul")).toInstant(); + } + + public Instant getMovingTimeInstant() { + return movingTime.atZone(ZoneId.of("Asia/Seoul")).toInstant(); + } + + public static RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest from( + RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest request, + Appointment appointment) { + return RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest.builder() + .clientId(request.getClientId()) + .deviceToken(request.getDeviceToken()) + .appointment(appointment) + .preparationTime(request.getPreparationTime()) + .movingTime(request.getMovingTime()) + .appointmentTime(request.getAppointmentTime()) + .build(); + } + + public static RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest from( + UpdateFcmMessageServiceRequest request, Appointment appointment) { + return RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest.builder() + .clientId(request.getClientId()) + .deviceToken(request.getDeviceToken()) + .appointment(appointment) + .preparationTime(request.getPreparationTime()) + .movingTime(request.getMovingTime()) + .appointmentTime(request.getAppointmentTime()) + .build(); + } + + public static RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest from( + Appointment appointment) { + + LocalDateTime appointmentTime = + appointment + .getAppointmentTime() + .atDate(LocalDateUtil.getLocalDateNow().plusDays(2)); + LocalDateTime movingTime = + LocalDateTimeUtil.subtractDuration( + appointmentTime, appointment.getMovingDuration()); + LocalDateTime preparationTime = + LocalDateTimeUtil.subtractDuration( + movingTime, appointment.getPreparationDuration()); + + return RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest.builder() + .clientId(appointment.getClientId()) + .deviceToken(appointment.getDeviceToken()) + .appointment(appointment) + .preparationTime(preparationTime) + .movingTime(movingTime) + .appointmentTime(appointmentTime) + .build(); + } + + public static RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest from( + UpdateAppointmentServiceRequest request, Appointment appointment) { + + LocalDateTime firstAppointmentTime = request.getFirstAppointmentTime(); + LocalDateTime movingTime = + LocalDateTimeUtil.subtractDuration( + firstAppointmentTime, request.getMovingDuration()); + LocalDateTime preparationTime = + LocalDateTimeUtil.subtractDuration(movingTime, request.getPreparationDuration()); + + return RegisterFcmMessageForExistingAppointmentAtSchedulerServiceRequest.builder() + .clientId(request.getClientId()) + .deviceToken(request.getDeviceToken()) + .appointment(appointment) + .preparationTime(preparationTime) + .movingTime(movingTime) + .appointmentTime(firstAppointmentTime) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/register/request/RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/request/RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest.java new file mode 100644 index 0000000..0ef4801 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/request/RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest.java @@ -0,0 +1,46 @@ +package earlybird.earlybird.scheduler.notification.service.register.request; + +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Getter +public class RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest { + private String clientId; + private String deviceToken; + private String appointmentName; + private LocalDateTime appointmentTime; + private LocalDateTime preparationTime; + private LocalDateTime movingTime; + + @Builder + private RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest( + String clientId, + String deviceToken, + String appointmentName, + LocalDateTime appointmentTime, + LocalDateTime preparationTime, + LocalDateTime movingTime) { + this.clientId = clientId; + this.deviceToken = deviceToken; + this.appointmentName = appointmentName; + this.appointmentTime = appointmentTime; + this.preparationTime = preparationTime; + this.movingTime = movingTime; + } + + public Instant getAppointmentTimeInstant() { + return appointmentTime.atZone(ZoneId.of("Asia/Seoul")).toInstant(); + } + + public Instant getPreparationTimeInstant() { + return preparationTime.atZone(ZoneId.of("Asia/Seoul")).toInstant(); + } + + public Instant getMovingTimeInstant() { + return movingTime.atZone(ZoneId.of("Asia/Seoul")).toInstant(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/register/response/RegisterNotificationServiceResponse.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/response/RegisterNotificationServiceResponse.java new file mode 100644 index 0000000..343e23c --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/register/response/RegisterNotificationServiceResponse.java @@ -0,0 +1,30 @@ +package earlybird.earlybird.scheduler.notification.service.register.response; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +public class RegisterNotificationServiceResponse { + + private final Appointment appointment; + private final List notifications; + + @Builder + private RegisterNotificationServiceResponse( + Appointment appointment, List notifications) { + this.appointment = appointment; + this.notifications = notifications; + } + + public static RegisterNotificationServiceResponse of(Appointment appointment) { + return RegisterNotificationServiceResponse.builder() + .appointment(appointment) + .notifications(appointment.getFcmNotifications()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationAtArriveOnTimeService.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationAtArriveOnTimeService.java new file mode 100644 index 0000000..3f46257 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationAtArriveOnTimeService.java @@ -0,0 +1,47 @@ +package earlybird.earlybird.scheduler.notification.service.update; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationStatus.CANCELLED_BY_ARRIVE_ON_TIME; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.domain.AppointmentRepository; +import earlybird.earlybird.error.exception.AppointmentNotFoundException; +import earlybird.earlybird.scheduler.notification.service.deregister.DeregisterNotificationService; +import earlybird.earlybird.scheduler.notification.service.deregister.request.DeregisterFcmMessageAtSchedulerServiceRequest; +import earlybird.earlybird.scheduler.notification.service.deregister.request.DeregisterNotificationServiceRequestFactory; +import earlybird.earlybird.scheduler.notification.service.update.request.UpdateNotificationAtArriveOnTimeServiceRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class UpdateNotificationAtArriveOnTimeService { + + private final AppointmentRepository appointmentRepository; + private final DeregisterNotificationService deregisterNotificationService; + + @Transactional + public void update(UpdateNotificationAtArriveOnTimeServiceRequest request) { + Appointment appointment = + appointmentRepository + .findById(request.getAppointmentId()) + .orElseThrow(AppointmentNotFoundException::new); + + if (!isValidClientId(appointment, request.getClientId())) + throw new AppointmentNotFoundException(); + + DeregisterFcmMessageAtSchedulerServiceRequest deregisterRequest = + DeregisterNotificationServiceRequestFactory.create( + appointment.getId(), + appointment.getClientId(), + CANCELLED_BY_ARRIVE_ON_TIME); + + deregisterNotificationService.deregister(deregisterRequest); + } + + private boolean isValidClientId(Appointment appointment, String requestedClientId) { + return appointment.getClientId().equals(requestedClientId); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationService.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationService.java new file mode 100644 index 0000000..286bf77 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationService.java @@ -0,0 +1,69 @@ +package earlybird.earlybird.scheduler.notification.service.update; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.service.FindAppointmentService; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; +import earlybird.earlybird.scheduler.notification.domain.NotificationUpdateType; +import earlybird.earlybird.scheduler.notification.service.NotificationInfoFactory; +import earlybird.earlybird.scheduler.notification.service.deregister.DeregisterNotificationService; +import earlybird.earlybird.scheduler.notification.service.deregister.request.DeregisterNotificationServiceRequestFactory; +import earlybird.earlybird.scheduler.notification.service.register.RegisterNotificationService; +import earlybird.earlybird.scheduler.notification.service.update.request.UpdateFcmMessageServiceRequest; +import earlybird.earlybird.scheduler.notification.service.update.response.UpdateFcmMessageServiceResponse; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Map; + +@RequiredArgsConstructor +@Service +public class UpdateNotificationService { + + private final RegisterNotificationService registerNotificationService; + private final DeregisterNotificationService deregisterNotificationService; + private final FindAppointmentService findAppointmentService; + private final NotificationInfoFactory notificationInfoFactory; + + @Transactional + public UpdateFcmMessageServiceResponse update(UpdateFcmMessageServiceRequest request) { + + Long appointmentId = request.getAppointmentId(); + String clientId = request.getClientId(); + Appointment appointment = + findAppointmentService.findBy(appointmentId, request.getClientId()); + NotificationUpdateType updateType = request.getUpdateType(); + + deregisterNotificationService.deregister( + DeregisterNotificationServiceRequestFactory.create( + appointmentId, clientId, updateType)); + + Map notificationInfo = + notificationInfoFactory.createTargetTimeMap( + request.getPreparationTime(), + request.getMovingTime(), + request.getAppointmentTime()); + + registerNotificationService.register(appointment, notificationInfo); + + changeDeviceTokenIfChanged(appointment, request.getDeviceToken()); + changeAppointmentNameIfChanged(appointment, request.getAppointmentName()); + + return UpdateFcmMessageServiceResponse.of(appointment); + } + + private void changeDeviceTokenIfChanged(Appointment appointment, String deviceToken) { + if (!appointment.getDeviceToken().equals(deviceToken)) { + appointment.changeDeviceToken(deviceToken); + } + } + + private void changeAppointmentNameIfChanged(Appointment appointment, String appointmentName) { + if (!appointment.getAppointmentName().equals(appointmentName)) { + appointment.changeAppointmentName(appointmentName); + } + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationStatusService.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationStatusService.java new file mode 100644 index 0000000..3d1bed1 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationStatusService.java @@ -0,0 +1,27 @@ +package earlybird.earlybird.scheduler.notification.service.update; + +import earlybird.earlybird.error.exception.NotificationNotFoundException; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class UpdateNotificationStatusService { + + private final FcmNotificationRepository notificationRepository; + + @Transactional + public void update(Long notificationId, Boolean sendSuccess) { + FcmNotification notification = + notificationRepository + .findById(notificationId) + .orElseThrow(NotificationNotFoundException::new); + if (sendSuccess) notification.onSendToFcmSuccess(); + else notification.onSendToFcmFailure(); + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/update/request/UpdateFcmMessageServiceRequest.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/request/UpdateFcmMessageServiceRequest.java new file mode 100644 index 0000000..95013b2 --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/request/UpdateFcmMessageServiceRequest.java @@ -0,0 +1,22 @@ +package earlybird.earlybird.scheduler.notification.service.update.request; + +import earlybird.earlybird.scheduler.notification.domain.NotificationUpdateType; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Builder +@Getter +public class UpdateFcmMessageServiceRequest { + + private Long appointmentId; + private String appointmentName; + private String clientId; + private String deviceToken; + private LocalDateTime preparationTime; + private LocalDateTime movingTime; + private LocalDateTime appointmentTime; + private NotificationUpdateType updateType; +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/update/request/UpdateNotificationAtArriveOnTimeServiceRequest.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/request/UpdateNotificationAtArriveOnTimeServiceRequest.java new file mode 100644 index 0000000..13e03fd --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/request/UpdateNotificationAtArriveOnTimeServiceRequest.java @@ -0,0 +1,17 @@ +package earlybird.earlybird.scheduler.notification.service.update.request; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class UpdateNotificationAtArriveOnTimeServiceRequest { + + private final Long appointmentId; + private final String clientId; + + @Builder + private UpdateNotificationAtArriveOnTimeServiceRequest(Long appointmentId, String clientId) { + this.appointmentId = appointmentId; + this.clientId = clientId; + } +} diff --git a/src/main/java/earlybird/earlybird/scheduler/notification/service/update/response/UpdateFcmMessageServiceResponse.java b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/response/UpdateFcmMessageServiceResponse.java new file mode 100644 index 0000000..7fc828d --- /dev/null +++ b/src/main/java/earlybird/earlybird/scheduler/notification/service/update/response/UpdateFcmMessageServiceResponse.java @@ -0,0 +1,32 @@ +package earlybird.earlybird.scheduler.notification.service.update.response; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; +import earlybird.earlybird.scheduler.notification.domain.NotificationStatus; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Builder +@Getter +public class UpdateFcmMessageServiceResponse { + + private final Appointment appointment; + private final List notifications; + + public static UpdateFcmMessageServiceResponse of(Appointment appointment) { + return UpdateFcmMessageServiceResponse.builder() + .appointment(appointment) + .notifications( + appointment.getFcmNotifications().stream() + .filter( + notification -> + notification + .getStatus() + .equals(NotificationStatus.PENDING)) + .toList()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/jwt/JWTAuthenticationFilter.java b/src/main/java/earlybird/earlybird/security/authentication/jwt/JWTAuthenticationFilter.java new file mode 100644 index 0000000..968ee42 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/jwt/JWTAuthenticationFilter.java @@ -0,0 +1,103 @@ +package earlybird.earlybird.security.authentication.jwt; + +import earlybird.earlybird.security.authentication.oauth2.user.OAuth2UserDetails; +import earlybird.earlybird.security.token.jwt.JWTUtil; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; +import earlybird.earlybird.user.entity.User; +import earlybird.earlybird.user.repository.UserRepository; + +import io.jsonwebtoken.ExpiredJwtException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.RequiredArgsConstructor; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +public class JWTAuthenticationFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + List passUriList = + Arrays.asList("/api/v1/login", "/api/v1/logout", "/api/v1/reissue"); + + if (passUriList.contains(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + String accessToken = request.getHeader("access"); + + if (accessToken == null) { + filterChain.doFilter(request, response); + return; + } + + try { + jwtUtil.isExpired(accessToken); + } catch (ExpiredJwtException e) { + PrintWriter writer = response.getWriter(); + writer.println("access token expired"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String category = jwtUtil.getCategory(accessToken); + + if (!category.equals("access")) { + PrintWriter writer = response.getWriter(); + writer.print("invalid access token"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String accountId = jwtUtil.getAccountId(accessToken); + String role = jwtUtil.getRole(accessToken); + + Optional optionalUser = userRepository.findByAccountId(accountId); + if (optionalUser.isEmpty()) { + filterChain.doFilter(request, response); + return; + } + + User user = optionalUser.get(); + + if (!user.getRole().equals(role)) { + filterChain.doFilter(request, response); + return; + } + + UserAccountInfoDTO userAccountInfoDTO = user.toUserAccountInfoDTO(); + List authorities = List.of(new SimpleGrantedAuthority(user.getRole())); + + OAuth2UserDetails oAuth2UserDetails = + new OAuth2UserDetails(userAccountInfoDTO, authorities); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + oAuth2UserDetails, null, oAuth2UserDetails.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/jwt/reissue/JWTReissueAuthenticationFilter.java b/src/main/java/earlybird/earlybird/security/authentication/jwt/reissue/JWTReissueAuthenticationFilter.java new file mode 100644 index 0000000..3a54c0a --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/jwt/reissue/JWTReissueAuthenticationFilter.java @@ -0,0 +1,104 @@ +package earlybird.earlybird.security.authentication.jwt.reissue; + +import earlybird.earlybird.security.token.jwt.access.CreateJWTAccessTokenService; +import earlybird.earlybird.security.token.jwt.refresh.CreateJWTRefreshTokenService; +import earlybird.earlybird.security.token.jwt.refresh.JWTRefreshTokenRepository; +import earlybird.earlybird.security.token.jwt.refresh.JWTRefreshTokenToCookieService; +import earlybird.earlybird.security.token.jwt.refresh.SaveJWTRefreshTokenService; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Optional; + +public class JWTReissueAuthenticationFilter extends AbstractAuthenticationProcessingFilter { + private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = + new AntPathRequestMatcher("/api/v1/reissue", "POST"); + private final CreateJWTRefreshTokenService createJWTRefreshTokenService; + private final CreateJWTAccessTokenService createJWTAccessTokenService; + private final JWTRefreshTokenRepository JWTRefreshTokenRepository; + private final SaveJWTRefreshTokenService saveJWTRefreshTokenService; + private final JWTRefreshTokenToCookieService JWTRefreshTokenToCookieService; + + public JWTReissueAuthenticationFilter( + CreateJWTAccessTokenService createJWTAccessTokenService, + CreateJWTRefreshTokenService createJWTRefreshTokenService, + JWTRefreshTokenRepository JWTRefreshTokenRepository, + SaveJWTRefreshTokenService saveJWTRefreshTokenService, + JWTRefreshTokenToCookieService JWTRefreshTokenToCookieService) { + super(DEFAULT_ANT_PATH_REQUEST_MATCHER); + this.createJWTAccessTokenService = createJWTAccessTokenService; + this.createJWTRefreshTokenService = createJWTRefreshTokenService; + this.JWTRefreshTokenRepository = JWTRefreshTokenRepository; + this.saveJWTRefreshTokenService = saveJWTRefreshTokenService; + this.JWTRefreshTokenToCookieService = JWTRefreshTokenToCookieService; + } + + @Override + public Authentication attemptAuthentication( + HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException, IOException, ServletException { + + Optional optionalRefreshCookie = + Arrays.stream(request.getCookies()) + .filter(cookie -> cookie.getName().equals("refresh")) + .findFirst(); + + if (optionalRefreshCookie.isEmpty()) { + throw new AuthenticationServiceException("refresh 토큰이 존재하지 않습니다."); + } + + String refreshToken = optionalRefreshCookie.get().getValue(); + JWTReissueAuthenticationToken authToken = new JWTReissueAuthenticationToken(refreshToken); + + return this.getAuthenticationManager().authenticate(authToken); + } + + @Override + protected void successfulAuthentication( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain, + Authentication authResult) + throws IOException, ServletException { + final int accessTokenExpiredMs = 60 * 60 * 60; // 60 * 60 * 60 ms = 36분 + final int refreshTokenExpiredMs = 86400000; // 86400000 ms = 24 h + + UserAccountInfoDTO userInfo = (UserAccountInfoDTO) authResult.getPrincipal(); + String accountId = userInfo.getAccountId(); + String role = userInfo.getRole(); + + String newAccessToken = + createJWTAccessTokenService.createAccessToken( + userInfo, (long) accessTokenExpiredMs); + String newRefreshToken = + createJWTRefreshTokenService.createRefreshToken( + userInfo, (long) refreshTokenExpiredMs); + + String oldRefreshToken = + Arrays.stream(request.getCookies()) + .filter(cookie -> cookie.getName().equals("refresh")) + .findFirst() + .get() + .getValue(); + + JWTRefreshTokenRepository.deleteByRefreshToken(oldRefreshToken); + + response.setHeader("access", newAccessToken); + response.addCookie( + JWTRefreshTokenToCookieService.createCookie( + newRefreshToken, refreshTokenExpiredMs)); + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/jwt/reissue/JWTReissueAuthenticationProvider.java b/src/main/java/earlybird/earlybird/security/authentication/jwt/reissue/JWTReissueAuthenticationProvider.java new file mode 100644 index 0000000..a670ca6 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/jwt/reissue/JWTReissueAuthenticationProvider.java @@ -0,0 +1,74 @@ +package earlybird.earlybird.security.authentication.jwt.reissue; + +import earlybird.earlybird.security.token.jwt.JWTUtil; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; +import earlybird.earlybird.user.entity.User; +import earlybird.earlybird.user.repository.UserRepository; + +import io.jsonwebtoken.ExpiredJwtException; + +import lombok.RequiredArgsConstructor; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +public class JWTReissueAuthenticationProvider implements AuthenticationProvider { + + private final JWTUtil jwtUtil; + private final UserRepository userRepository; + + @Override + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + String refreshToken = (String) authentication.getPrincipal(); + + if (refreshToken == null) { + throw new AuthenticationServiceException("refresh 토큰이 null 입니다."); + } + + try { + jwtUtil.isExpired(refreshToken); + } catch (ExpiredJwtException e) { + throw new AuthenticationServiceException("refresh 토큰이 만료되었습니다."); + } + + String category = jwtUtil.getCategory(refreshToken); + + if (!category.equals("refresh")) { + throw new AuthenticationServiceException("주어진 토큰이 refresh 토큰이 아닙니다."); + } + + String accountId = jwtUtil.getAccountId(refreshToken); + String role = jwtUtil.getRole(refreshToken); + + Optional optionalUser = userRepository.findByAccountId(accountId); + if (optionalUser.isEmpty()) { + throw new UsernameNotFoundException("refresh 토큰에 해당하는 사용자를 찾을 수 없습니다"); + } + + User user = optionalUser.get(); + + if (!user.getRole().equals(role)) { + throw new AuthenticationServiceException("refresh 토큰의 role과 저장된 role이 다릅니다."); + } + + UserAccountInfoDTO userAccountInfoDTO = user.toUserAccountInfoDTO(); + List authorities = List.of(new SimpleGrantedAuthority(role)); + + return new JWTReissueAuthenticationToken(authorities, userAccountInfoDTO); + } + + @Override + public boolean supports(Class authentication) { + return authentication.equals(JWTReissueAuthenticationToken.class); + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/jwt/reissue/JWTReissueAuthenticationToken.java b/src/main/java/earlybird/earlybird/security/authentication/jwt/reissue/JWTReissueAuthenticationToken.java new file mode 100644 index 0000000..b3794d1 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/jwt/reissue/JWTReissueAuthenticationToken.java @@ -0,0 +1,34 @@ +package earlybird.earlybird.security.authentication.jwt.reissue; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class JWTReissueAuthenticationToken extends AbstractAuthenticationToken { + /** 인증 전: refresh 토큰 인증 성공 후: UserAccountInfoDTO 객체 */ + private final Object principal; + + public JWTReissueAuthenticationToken( + Collection authorities, Object principal) { + super(authorities); + this.principal = principal; + setAuthenticated(true); + } + + public JWTReissueAuthenticationToken(Object principal) { + super(null); + this.principal = principal; + setAuthenticated(false); + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return principal; + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/oauth2/OAuth2AuthenticationFilter.java b/src/main/java/earlybird/earlybird/security/authentication/oauth2/OAuth2AuthenticationFilter.java new file mode 100644 index 0000000..06f822c --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/oauth2/OAuth2AuthenticationFilter.java @@ -0,0 +1,105 @@ +package earlybird.earlybird.security.authentication.oauth2; + +import earlybird.earlybird.security.authentication.oauth2.user.OAuth2UserDetails; +import earlybird.earlybird.security.enums.OAuth2ProviderName; +import earlybird.earlybird.security.token.jwt.access.CreateJWTAccessTokenService; +import earlybird.earlybird.security.token.jwt.refresh.CreateJWTRefreshTokenService; +import earlybird.earlybird.security.token.jwt.refresh.JWTRefreshTokenToCookieService; +import earlybird.earlybird.security.token.oauth2.OAuth2TokenDTO; +import earlybird.earlybird.security.token.oauth2.service.CreateOAuth2TokenService; +import earlybird.earlybird.security.token.oauth2.service.DeleteOAuth2TokenService; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +import java.io.IOException; + +public class OAuth2AuthenticationFilter extends AbstractAuthenticationProcessingFilter { + + private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = + new AntPathRequestMatcher("/api/v1/login", "POST"); + private final CreateJWTAccessTokenService createJWTAccessTokenService; + private final CreateJWTRefreshTokenService createJWTRefreshTokenService; + private final JWTRefreshTokenToCookieService jwtRefreshTokenToCookieService; + private final CreateOAuth2TokenService createOAuth2TokenService; + private final DeleteOAuth2TokenService deleteOAuth2TokenService; + + public OAuth2AuthenticationFilter( + CreateJWTAccessTokenService createJWTAccessTokenService, + CreateJWTRefreshTokenService createJWTRefreshTokenService, + JWTRefreshTokenToCookieService jwtRefreshTokenToCookieService, + CreateOAuth2TokenService createOAuth2TokenService, + DeleteOAuth2TokenService deleteOAuth2TokenService) { + super(DEFAULT_ANT_PATH_REQUEST_MATCHER); + this.createJWTAccessTokenService = createJWTAccessTokenService; + this.createJWTRefreshTokenService = createJWTRefreshTokenService; + this.jwtRefreshTokenToCookieService = jwtRefreshTokenToCookieService; + this.createOAuth2TokenService = createOAuth2TokenService; + this.deleteOAuth2TokenService = deleteOAuth2TokenService; + } + + @Override + public Authentication attemptAuthentication( + HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException, IOException, ServletException { + String oauth2ProviderName = request.getHeader("Provider-Name"); + String oauth2AccessToken = request.getHeader("OAuth2-Access"); + String oauth2RefreshToken = request.getHeader("OAuth2-Refresh"); + + if (oauth2ProviderName == null || oauth2AccessToken == null || oauth2RefreshToken == null) { + throw new AuthenticationServiceException( + "provider-name 또는 oauth2-access 값이 제공되지 않았습니다."); + } + + OAuth2AuthenticationToken token = + new OAuth2AuthenticationToken(oauth2ProviderName, oauth2AccessToken); + + return this.getAuthenticationManager().authenticate(token); + } + + @Override + protected void successfulAuthentication( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain, + Authentication authResult) + throws IOException, ServletException { + final int accessTokenExpiredMs = 60 * 60 * 60; // 60 * 60 * 60 ms = 36분 + final int refreshTokenExpiredMs = 86400000; // 86400000 ms = 24 h + + UserAccountInfoDTO userDTO = + ((OAuth2UserDetails) authResult.getPrincipal()).getUserAccountInfoDTO(); + String access = + createJWTAccessTokenService.createAccessToken(userDTO, (long) accessTokenExpiredMs); + String refresh = + createJWTRefreshTokenService.createRefreshToken( + userDTO, (long) refreshTokenExpiredMs); + + response.setHeader("access", access); + response.addCookie( + jwtRefreshTokenToCookieService.createCookie(refresh, refreshTokenExpiredMs)); + + deleteOAuth2TokenService.deleteByUserId(userDTO.getId()); + + OAuth2TokenDTO oAuth2TokenDTO = + OAuth2TokenDTO.builder() + .userDTO(userDTO) + .accessToken(request.getHeader("OAuth2-Access")) + .refreshToken(request.getHeader("OAuth2-Refresh")) + .oAuth2ProviderName( + OAuth2ProviderName.valueOf( + request.getHeader("Provider-Name").toUpperCase())) + .build(); + + createOAuth2TokenService.create(oAuth2TokenDTO); + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/oauth2/OAuth2AuthenticationProvider.java b/src/main/java/earlybird/earlybird/security/authentication/oauth2/OAuth2AuthenticationProvider.java new file mode 100644 index 0000000..6e48bf3 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/oauth2/OAuth2AuthenticationProvider.java @@ -0,0 +1,67 @@ +package earlybird.earlybird.security.authentication.oauth2; + +import earlybird.earlybird.security.authentication.oauth2.dto.OAuth2ServerResponse; +import earlybird.earlybird.security.authentication.oauth2.proxy.GoogleOAuth2UserInfoProxy; +import earlybird.earlybird.security.authentication.oauth2.proxy.OAuth2UserInfoProxy; +import earlybird.earlybird.security.authentication.oauth2.user.OAuth2UserJoinService; +import earlybird.earlybird.security.enums.OAuth2ProviderName; + +import lombok.RequiredArgsConstructor; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Map; + +@RequiredArgsConstructor +public class OAuth2AuthenticationProvider implements AuthenticationProvider { + + private static final Map oauth2UserInfoProxyList = + Map.of(OAuth2ProviderName.GOOGLE, new GoogleOAuth2UserInfoProxy()); + + private final UserDetailsService userDetailsService; + private final OAuth2UserJoinService oAuth2UserJoinService; + + @Override + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + String oauth2ProviderName = ((String) authentication.getPrincipal()).toLowerCase(); + String oauth2AccessToken = (String) authentication.getCredentials(); + + for (OAuth2ProviderName oAuth2ProviderName : oauth2UserInfoProxyList.keySet()) { + String proxyName = oAuth2ProviderName.name(); + if (proxyName.equalsIgnoreCase(oauth2ProviderName)) { + OAuth2UserInfoProxy oAuth2UserInfoProxy = + oauth2UserInfoProxyList.get(oAuth2ProviderName); + OAuth2ServerResponse oAuth2UserInfo = + oAuth2UserInfoProxy.getOAuth2UserInfo(oauth2AccessToken); + + String username = + oAuth2UserInfo.getProviderName() + " " + oAuth2UserInfo.getProviderId(); + + UserDetails userDetails; + try { + userDetails = userDetailsService.loadUserByUsername(username); + } catch (UsernameNotFoundException e) { + oAuth2UserJoinService.join(oAuth2UserInfo); + userDetails = userDetailsService.loadUserByUsername(username); + } + + return new OAuth2AuthenticationToken( + userDetails.getAuthorities(), userDetails, null); + } + } + + throw new AuthenticationServiceException("지원하지 않는 OAuth2 provider입니다."); + } + + @Override + public boolean supports(Class authentication) { + return authentication.equals(OAuth2AuthenticationToken.class); + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/oauth2/OAuth2AuthenticationToken.java b/src/main/java/earlybird/earlybird/security/authentication/oauth2/OAuth2AuthenticationToken.java new file mode 100644 index 0000000..2c2b3df --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/oauth2/OAuth2AuthenticationToken.java @@ -0,0 +1,52 @@ +package earlybird.earlybird.security.authentication.oauth2; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class OAuth2AuthenticationToken extends AbstractAuthenticationToken { + + /** + * 인증 전: provider 이름 (String)
+ * 인증 성공 후: UserDetails 객체 + */ + private final Object principal; + + /** + * 인증 전: OAuth2 액세스 토큰 (String)
+ * 인증 성공 후: null + */ + private final Object credentials; + + public OAuth2AuthenticationToken( + Collection authorities, + Object principal, + Object credentials) { + super(authorities); + this.principal = principal; + this.credentials = credentials; + setAuthenticated(true); + } + + /** + * @param principal provider 이름 + * @param credentials OAuth2 액세스 토큰 + */ + public OAuth2AuthenticationToken(Object principal, Object credentials) { + super(null); + this.principal = principal; + this.credentials = credentials; + setAuthenticated(false); + } + + @Override + public Object getCredentials() { + return credentials; + } + + @Override + public Object getPrincipal() { + return principal; + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/oauth2/dto/GoogleServerResponse.java b/src/main/java/earlybird/earlybird/security/authentication/oauth2/dto/GoogleServerResponse.java new file mode 100644 index 0000000..0b5894c --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/oauth2/dto/GoogleServerResponse.java @@ -0,0 +1,34 @@ +package earlybird.earlybird.security.authentication.oauth2.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class GoogleServerResponse implements OAuth2ServerResponse { + private String id; + private String name; + private String email; + + @Override + public String getProviderName() { + return "google"; + } + + @Override + public String getProviderId() { + return id; + } + + @Override + public String getEmail() { + return email; + } + + @Override + public String getName() { + return name; + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/oauth2/dto/OAuth2ServerResponse.java b/src/main/java/earlybird/earlybird/security/authentication/oauth2/dto/OAuth2ServerResponse.java new file mode 100644 index 0000000..02f7073 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/oauth2/dto/OAuth2ServerResponse.java @@ -0,0 +1,17 @@ +package earlybird.earlybird.security.authentication.oauth2.dto; + +/** OAuth2 서버(ex. Google, Apple ...)의 응답을 담은 객체 */ +public interface OAuth2ServerResponse { + + // 제공자 (Ex. naver, google, ...) + String getProviderName(); + + // 제공자에서 발급해주는 아이디(번호) + String getProviderId(); + + // 이메일 + String getEmail(); + + // 사용자 실명 (설정한 이름) + String getName(); +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/oauth2/proxy/GoogleOAuth2UserInfoProxy.java b/src/main/java/earlybird/earlybird/security/authentication/oauth2/proxy/GoogleOAuth2UserInfoProxy.java new file mode 100644 index 0000000..d7eaff7 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/oauth2/proxy/GoogleOAuth2UserInfoProxy.java @@ -0,0 +1,54 @@ +package earlybird.earlybird.security.authentication.oauth2.proxy; + +import earlybird.earlybird.security.authentication.oauth2.dto.GoogleServerResponse; +import earlybird.earlybird.security.authentication.oauth2.dto.OAuth2ServerResponse; + +import org.springframework.http.*; +import org.springframework.web.reactive.function.client.WebClient; + +import reactor.core.publisher.Mono; + +import javax.naming.AuthenticationException; + +public class GoogleOAuth2UserInfoProxy implements OAuth2UserInfoProxy { + @Override + public OAuth2ServerResponse getOAuth2UserInfo(String accessToken) { + + String authorization = "Bearer " + accessToken; + + Mono responseMono = + WebClient.create("https://www.googleapis.com") + .get() + .uri( + uriBuilder -> + uriBuilder + .scheme("https") + .path("/oauth2/v2/userinfo") + .build(false)) + .header("Authorization", authorization) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + clientResponse -> + Mono.error( + new AuthenticationException( + String.format( + "%s Error from google: %s", + clientResponse.statusCode(), + clientResponse.bodyToMono( + String.class))))) + .onStatus( + HttpStatusCode::is5xxServerError, + clientResponse -> + Mono.error( + new AuthenticationException( + String.format( + "%s Error from google: %s", + clientResponse.statusCode(), + clientResponse.bodyToMono( + String.class))))) + .bodyToMono(GoogleServerResponse.class); + + return responseMono.block(); + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/oauth2/proxy/OAuth2UserInfoProxy.java b/src/main/java/earlybird/earlybird/security/authentication/oauth2/proxy/OAuth2UserInfoProxy.java new file mode 100644 index 0000000..8a52f11 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/oauth2/proxy/OAuth2UserInfoProxy.java @@ -0,0 +1,14 @@ +package earlybird.earlybird.security.authentication.oauth2.proxy; + +import earlybird.earlybird.security.authentication.oauth2.dto.OAuth2ServerResponse; + +public interface OAuth2UserInfoProxy { + + /** + * OAuth2 인증 서버에 액세스 토큰으로 요청을 보내서 유저 정보 획득 & 반환 + * + * @param accessToken OAuth2 액세스 토큰 + * @return + */ + public OAuth2ServerResponse getOAuth2UserInfo(String accessToken); +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/oauth2/user/OAuth2UserDetails.java b/src/main/java/earlybird/earlybird/security/authentication/oauth2/user/OAuth2UserDetails.java new file mode 100644 index 0000000..07835cb --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/oauth2/user/OAuth2UserDetails.java @@ -0,0 +1,57 @@ +package earlybird.earlybird.security.authentication.oauth2.user; + +import earlybird.earlybird.user.dto.UserAccountInfoDTO; + +import lombok.RequiredArgsConstructor; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@RequiredArgsConstructor +public class OAuth2UserDetails implements UserDetails { + + private final UserAccountInfoDTO userAccountInfoDTO; + private final List roles; + + public UserAccountInfoDTO getUserAccountInfoDTO() { + return userAccountInfoDTO; + } + + @Override + public Collection getAuthorities() { + return roles; + } + + @Override + public String getPassword() { + return userAccountInfoDTO.getAccountId(); + } + + @Override + public String getUsername() { + return userAccountInfoDTO.getAccountId(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/oauth2/user/OAuth2UserDetailsService.java b/src/main/java/earlybird/earlybird/security/authentication/oauth2/user/OAuth2UserDetailsService.java new file mode 100644 index 0000000..790813d --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/oauth2/user/OAuth2UserDetailsService.java @@ -0,0 +1,34 @@ +package earlybird.earlybird.security.authentication.oauth2.user; + +import earlybird.earlybird.user.entity.User; +import earlybird.earlybird.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Service("userDetailsService") +public class OAuth2UserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Optional optionalUser = userRepository.findByUsername(username); + if (optionalUser.isEmpty()) { + throw new UsernameNotFoundException("username이 존재하지 않습니다. username: " + username); + } + User user = optionalUser.get(); + List authorities = List.of(new SimpleGrantedAuthority(user.getRole())); + return new OAuth2UserDetails(user.toUserAccountInfoDTO(), authorities); + } +} diff --git a/src/main/java/earlybird/earlybird/security/authentication/oauth2/user/OAuth2UserJoinService.java b/src/main/java/earlybird/earlybird/security/authentication/oauth2/user/OAuth2UserJoinService.java new file mode 100644 index 0000000..288e910 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/authentication/oauth2/user/OAuth2UserJoinService.java @@ -0,0 +1,21 @@ +package earlybird.earlybird.security.authentication.oauth2.user; + +import earlybird.earlybird.security.authentication.oauth2.dto.OAuth2ServerResponse; +import earlybird.earlybird.user.entity.User; +import earlybird.earlybird.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class OAuth2UserJoinService { + + private final UserRepository userRepository; + + public void join(OAuth2ServerResponse userInfo) { + User user = new User(userInfo); + userRepository.save(user); + } +} diff --git a/src/main/java/earlybird/earlybird/security/config/SecurityConfig.java b/src/main/java/earlybird/earlybird/security/config/SecurityConfig.java new file mode 100644 index 0000000..8acdff4 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/config/SecurityConfig.java @@ -0,0 +1,180 @@ +package earlybird.earlybird.security.config; + +import earlybird.earlybird.security.authentication.jwt.JWTAuthenticationFilter; +import earlybird.earlybird.security.authentication.jwt.reissue.JWTReissueAuthenticationFilter; +import earlybird.earlybird.security.authentication.jwt.reissue.JWTReissueAuthenticationProvider; +import earlybird.earlybird.security.authentication.oauth2.OAuth2AuthenticationFilter; +import earlybird.earlybird.security.authentication.oauth2.OAuth2AuthenticationProvider; +import earlybird.earlybird.security.authentication.oauth2.user.OAuth2UserDetails; +import earlybird.earlybird.security.authentication.oauth2.user.OAuth2UserJoinService; +import earlybird.earlybird.security.token.jwt.JWTUtil; +import earlybird.earlybird.security.token.jwt.access.CreateJWTAccessTokenService; +import earlybird.earlybird.security.token.jwt.refresh.CreateJWTRefreshTokenService; +import earlybird.earlybird.security.token.jwt.refresh.JWTRefreshTokenRepository; +import earlybird.earlybird.security.token.jwt.refresh.JWTRefreshTokenToCookieService; +import earlybird.earlybird.security.token.jwt.refresh.SaveJWTRefreshTokenService; +import earlybird.earlybird.security.token.oauth2.service.CreateOAuth2TokenService; +import earlybird.earlybird.security.token.oauth2.service.DeleteOAuth2TokenService; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; +import earlybird.earlybird.user.repository.UserRepository; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +import java.util.Collections; + +@RequiredArgsConstructor +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JWTRefreshTokenRepository JWTRefreshTokenRepository; + private final UserDetailsService userDetailsService; + private final OAuth2UserJoinService oAuth2UserJoinService; + private final CreateJWTAccessTokenService createJWTAccessTokenService; + private final CreateJWTRefreshTokenService createJWTRefreshTokenService; + private final JWTRefreshTokenToCookieService JWTRefreshTokenToCookieService; + private final JWTUtil jwtUtil; + private final UserRepository userRepository; + private final SaveJWTRefreshTokenService saveJWTRefreshTokenService; + private final CreateOAuth2TokenService createOAuth2TokenService; + private final DeleteOAuth2TokenService deleteOAuth2TokenService; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + AuthenticationManagerBuilder authenticationManagerBuilder = + http.getSharedObject(AuthenticationManagerBuilder.class); + authenticationManagerBuilder.authenticationProvider( + new OAuth2AuthenticationProvider(userDetailsService, oAuth2UserJoinService)); + AuthenticationManager authenticationManager = authenticationManagerBuilder.build(); + + http.authenticationManager(authenticationManager); + + http.authorizeHttpRequests( + auth -> + auth + // .anyRequest().authenticated() + .anyRequest() + .permitAll() + // TODO : 베타 테스트 기간에만 permitAll -> 로그인 기능 추가되면 authenticated()로 변경 + ); + + OAuth2AuthenticationFilter oAuth2AuthenticationFilter = + new OAuth2AuthenticationFilter( + createJWTAccessTokenService, + createJWTRefreshTokenService, + JWTRefreshTokenToCookieService, + createOAuth2TokenService, + deleteOAuth2TokenService); + oAuth2AuthenticationFilter.setAuthenticationManager(authenticationManager); + + http.addFilterAt(oAuth2AuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + JWTReissueAuthenticationFilter jwtReissueAuthenticationFilter = + new JWTReissueAuthenticationFilter( + createJWTAccessTokenService, + createJWTRefreshTokenService, + JWTRefreshTokenRepository, + saveJWTRefreshTokenService, + JWTRefreshTokenToCookieService); + ProviderManager jwtReissueAuthFilterProviderManager = + new ProviderManager(new JWTReissueAuthenticationProvider(jwtUtil, userRepository)); + jwtReissueAuthenticationFilter.setAuthenticationManager( + jwtReissueAuthFilterProviderManager); + + http.addFilterBefore(jwtReissueAuthenticationFilter, OAuth2AuthenticationFilter.class); + + JWTAuthenticationFilter jwtAuthenticationFilter = + new JWTAuthenticationFilter(jwtUtil, userRepository); + + // TODO: 베타 테스트 이후 살려놓기 + // http + // .addFilterAfter(jwtAuthenticationFilter, + // OAuth2AuthenticationFilter.class); + + http.logout( + logout -> + logout.logoutRequestMatcher( + new AntPathRequestMatcher("/api/v1/logout", "POST")) + .logoutSuccessHandler( + ((request, response, authentication) -> { + UserAccountInfoDTO userInfo = + ((OAuth2UserDetails) + authentication.getPrincipal()) + .getUserAccountInfoDTO(); + deleteOAuth2TokenService.deleteByUserAccountInfoDTO( + userInfo); + })) + .deleteCookies("JSESSIONID", "refresh") + .invalidateHttpSession(true) + .clearAuthentication(true) + .addLogoutHandler( + ((request, response, authentication) -> { + Cookie[] cookies = request.getCookies(); + + for (Cookie cookie : cookies) { + if (cookie.getName().equals("refresh")) { + String refresh = cookie.getValue(); + JWTRefreshTokenRepository.deleteByRefreshToken( + refresh); + } + } + }))); + + http.sessionManagement( + (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + http.formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable); + + http.cors( + corsCustomizer -> + corsCustomizer.configurationSource( + new CorsConfigurationSource() { + + @Override + public CorsConfiguration getCorsConfiguration( + HttpServletRequest request) { + + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOriginPatterns( + Collections.singletonList("*")); + configuration.setAllowedMethods( + Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders( + Collections.singletonList("*")); + configuration.setMaxAge(3600L); + + configuration.setExposedHeaders( + Collections.singletonList("Set-Cookie")); + configuration.setExposedHeaders( + Collections.singletonList("Authorization")); + + return configuration; + } + })); + + return http.build(); + } +} diff --git a/src/main/java/earlybird/earlybird/security/deregister/oauth2/OAuth2DeregisterController.java b/src/main/java/earlybird/earlybird/security/deregister/oauth2/OAuth2DeregisterController.java new file mode 100644 index 0000000..4748b85 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/deregister/oauth2/OAuth2DeregisterController.java @@ -0,0 +1,23 @@ +package earlybird.earlybird.security.deregister.oauth2; + +import earlybird.earlybird.security.authentication.oauth2.user.OAuth2UserDetails; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class OAuth2DeregisterController { + + private final OAuth2DeregisterService oAuth2DeregisterService; + + @DeleteMapping("/api/v1/users") + public ResponseEntity deregister(@AuthenticationPrincipal OAuth2UserDetails userDetails) { + oAuth2DeregisterService.deregister(userDetails); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/earlybird/earlybird/security/deregister/oauth2/OAuth2DeregisterService.java b/src/main/java/earlybird/earlybird/security/deregister/oauth2/OAuth2DeregisterService.java new file mode 100644 index 0000000..634a73c --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/deregister/oauth2/OAuth2DeregisterService.java @@ -0,0 +1,33 @@ +package earlybird.earlybird.security.deregister.oauth2; + +import earlybird.earlybird.security.authentication.oauth2.user.OAuth2UserDetails; +import earlybird.earlybird.security.deregister.oauth2.revoke.RevokeOAuth2TokenService; +import earlybird.earlybird.security.token.oauth2.OAuth2TokenDTO; +import earlybird.earlybird.security.token.oauth2.service.DeleteOAuth2TokenService; +import earlybird.earlybird.security.token.oauth2.service.FindOAuth2TokenService; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; +import earlybird.earlybird.user.service.DeleteUserService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class OAuth2DeregisterService { + + private final DeleteUserService deleteUserService; + private final FindOAuth2TokenService findOAuth2TokenService; + private final RevokeOAuth2TokenService revokeOAuth2TokenService; + private final DeleteOAuth2TokenService deleteOAuth2TokenService; + + public void deregister(OAuth2UserDetails userDetails) { + UserAccountInfoDTO userInfo = userDetails.getUserAccountInfoDTO(); + Long userId = userInfo.getId(); + OAuth2TokenDTO oAuth2TokenDTO = findOAuth2TokenService.findByUserId(userId); + + revokeOAuth2TokenService.revoke(oAuth2TokenDTO); + deleteOAuth2TokenService.deleteByUserAccountInfoDTO(userInfo); + deleteUserService.deleteUser(userInfo); + } +} diff --git a/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/RevokeOAuth2TokenService.java b/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/RevokeOAuth2TokenService.java new file mode 100644 index 0000000..bad0e8e --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/RevokeOAuth2TokenService.java @@ -0,0 +1,30 @@ +package earlybird.earlybird.security.deregister.oauth2.revoke; + +import earlybird.earlybird.security.deregister.oauth2.revoke.proxy.OAuth2TokenRevokeProxy; +import earlybird.earlybird.security.enums.OAuth2ProviderName; +import earlybird.earlybird.security.token.oauth2.OAuth2TokenDTO; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +import java.util.Map; + +@RequiredArgsConstructor +@Service +public class RevokeOAuth2TokenService { + + private final Map oAuth2TokenRevokeProxyMap; + + public void revoke(OAuth2TokenDTO oAuth2TokenDTO) { + OAuth2ProviderName oAuth2ProviderName = oAuth2TokenDTO.getOAuth2ProviderName(); + OAuth2TokenRevokeProxy oAuth2TokenRevokeProxy = + oAuth2TokenRevokeProxyMap.get(oAuth2ProviderName); + + String accessToken = oAuth2TokenDTO.getAccessToken(); + String refreshToken = oAuth2TokenDTO.getRefreshToken(); + + oAuth2TokenRevokeProxy.revoke(accessToken); + oAuth2TokenRevokeProxy.revoke(refreshToken); + } +} diff --git a/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/proxy/GoogleOAuth2TokenRevokeProxy.java b/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/proxy/GoogleOAuth2TokenRevokeProxy.java new file mode 100644 index 0000000..5caf8f1 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/proxy/GoogleOAuth2TokenRevokeProxy.java @@ -0,0 +1,38 @@ +package earlybird.earlybird.security.deregister.oauth2.revoke.proxy; + +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import reactor.core.publisher.Mono; + +@Component +public class GoogleOAuth2TokenRevokeProxy implements OAuth2TokenRevokeProxy { + @Override + public void revoke(String token) { + try { + WebClient.create("https://oauth2.googleapis.com") + .post() + .uri( + uriBuilder -> + uriBuilder + .scheme("https") + .path("/revoke") + .queryParam("token", token) + .build(false)) + .header("Content-type", MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + clientResponse -> Mono.error(new RuntimeException())) + .onStatus( + HttpStatusCode::is5xxServerError, + clientResponse -> Mono.error(new RuntimeException())) + .bodyToMono(String.class) + .block(); + } catch (RuntimeException e) { + System.out.println(e.getMessage()); + } + } +} diff --git a/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/proxy/OAuth2TokenRevokeProxy.java b/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/proxy/OAuth2TokenRevokeProxy.java new file mode 100644 index 0000000..832a1ea --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/proxy/OAuth2TokenRevokeProxy.java @@ -0,0 +1,11 @@ +package earlybird.earlybird.security.deregister.oauth2.revoke.proxy; + +public interface OAuth2TokenRevokeProxy { + + /** + * OAuth2 액세스 토큰 또는 리프레시 토큰을 만료시킴 + * + * @param token OAuth2 액세스 토큰 또는 리프레시 토큰 + */ + public void revoke(String token); +} diff --git a/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/proxy/OAuth2TokenRevokeProxyConfig.java b/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/proxy/OAuth2TokenRevokeProxyConfig.java new file mode 100644 index 0000000..e4e279d --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/deregister/oauth2/revoke/proxy/OAuth2TokenRevokeProxyConfig.java @@ -0,0 +1,18 @@ +package earlybird.earlybird.security.deregister.oauth2.revoke.proxy; + +import earlybird.earlybird.security.enums.OAuth2ProviderName; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Map; + +@Configuration +public class OAuth2TokenRevokeProxyConfig { + + @Bean + public Map providerNameAndProxyMap( + GoogleOAuth2TokenRevokeProxy googleOAuth2TokenRevokeProxy) { + return Map.of(OAuth2ProviderName.GOOGLE, googleOAuth2TokenRevokeProxy); + } +} diff --git a/src/main/java/earlybird/earlybird/security/enums/OAuth2ProviderName.java b/src/main/java/earlybird/earlybird/security/enums/OAuth2ProviderName.java new file mode 100644 index 0000000..fc93796 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/enums/OAuth2ProviderName.java @@ -0,0 +1,6 @@ +package earlybird.earlybird.security.enums; + +public enum OAuth2ProviderName { + GOOGLE, + APPLE +} diff --git a/src/main/java/earlybird/earlybird/security/token/jwt/JWTUtil.java b/src/main/java/earlybird/earlybird/security/token/jwt/JWTUtil.java new file mode 100644 index 0000000..b7af788 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/jwt/JWTUtil.java @@ -0,0 +1,80 @@ +package earlybird.earlybird.security.token.jwt; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +@Component +public class JWTUtil { + + private final SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}") String secret) { + + secretKey = + new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getAccountId(String token) { + + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .get("accountId", String.class); + } + + public String getRole(String token) { + + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .get("role", String.class); + } + + public String getCategory(String token) { + + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .get("category", String.class); + } + + public Boolean isExpired(String token) throws ExpiredJwtException { + + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .getExpiration() + .before(new Date(System.currentTimeMillis())); + } + + public String createJwt(String category, String accountId, String role, Long expiredMs) { + + return Jwts.builder() + .claim("category", category) + .claim("accountId", accountId) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/earlybird/earlybird/security/token/jwt/access/CreateJWTAccessTokenService.java b/src/main/java/earlybird/earlybird/security/token/jwt/access/CreateJWTAccessTokenService.java new file mode 100644 index 0000000..38b83fa --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/jwt/access/CreateJWTAccessTokenService.java @@ -0,0 +1,22 @@ +package earlybird.earlybird.security.token.jwt.access; + +import earlybird.earlybird.security.token.jwt.JWTUtil; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class CreateJWTAccessTokenService { + + private final JWTUtil jwtUtil; + + public String createAccessToken(UserAccountInfoDTO userDTO, final Long expiredMs) { + String accountId = userDTO.getAccountId(); + String role = userDTO.getRole(); + + return jwtUtil.createJwt("access", accountId, role, expiredMs); + } +} diff --git a/src/main/java/earlybird/earlybird/security/token/jwt/refresh/CreateJWTRefreshTokenService.java b/src/main/java/earlybird/earlybird/security/token/jwt/refresh/CreateJWTRefreshTokenService.java new file mode 100644 index 0000000..520262d --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/jwt/refresh/CreateJWTRefreshTokenService.java @@ -0,0 +1,27 @@ +package earlybird.earlybird.security.token.jwt.refresh; + +import earlybird.earlybird.security.token.jwt.JWTUtil; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class CreateJWTRefreshTokenService { + + private final JWTUtil jwtUtil; + private final SaveJWTRefreshTokenService saveJWTRefreshTokenService; + + public String createRefreshToken(UserAccountInfoDTO userDTO, final Long expiredMs) { + String accountId = userDTO.getAccountId(); + String role = userDTO.getRole(); + + String refresh = jwtUtil.createJwt("refresh", accountId, role, expiredMs); + + saveJWTRefreshTokenService.saveJWTRefreshToken(accountId, refresh, expiredMs); + + return refresh; + } +} diff --git a/src/main/java/earlybird/earlybird/security/token/jwt/refresh/JWTRefreshToken.java b/src/main/java/earlybird/earlybird/security/token/jwt/refresh/JWTRefreshToken.java new file mode 100644 index 0000000..7768da9 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/jwt/refresh/JWTRefreshToken.java @@ -0,0 +1,26 @@ +package earlybird.earlybird.security.token.jwt.refresh; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +@Entity +public class JWTRefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String accountId; + private String refreshToken; + private String expiration; + + public JWTRefreshToken() {} + + public JWTRefreshToken(String accountId, String refreshToken, String expiration) { + this.accountId = accountId; + this.refreshToken = refreshToken; + this.expiration = expiration; + } +} diff --git a/src/main/java/earlybird/earlybird/security/token/jwt/refresh/JWTRefreshTokenRepository.java b/src/main/java/earlybird/earlybird/security/token/jwt/refresh/JWTRefreshTokenRepository.java new file mode 100644 index 0000000..e52e638 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/jwt/refresh/JWTRefreshTokenRepository.java @@ -0,0 +1,9 @@ +package earlybird.earlybird.security.token.jwt.refresh; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +public interface JWTRefreshTokenRepository extends JpaRepository { + @Transactional + void deleteByRefreshToken(String refresh); +} diff --git a/src/main/java/earlybird/earlybird/security/token/jwt/refresh/JWTRefreshTokenToCookieService.java b/src/main/java/earlybird/earlybird/security/token/jwt/refresh/JWTRefreshTokenToCookieService.java new file mode 100644 index 0000000..a678703 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/jwt/refresh/JWTRefreshTokenToCookieService.java @@ -0,0 +1,18 @@ +package earlybird.earlybird.security.token.jwt.refresh; + +import jakarta.servlet.http.Cookie; + +import org.springframework.stereotype.Service; + +@Service +public class JWTRefreshTokenToCookieService { + + public Cookie createCookie(String refresh, int expiredMs) { + Cookie cookie = new Cookie("refresh", refresh); + cookie.setMaxAge(expiredMs); + cookie.setPath("/"); + cookie.setHttpOnly(true); + + return cookie; + } +} diff --git a/src/main/java/earlybird/earlybird/security/token/jwt/refresh/SaveJWTRefreshTokenService.java b/src/main/java/earlybird/earlybird/security/token/jwt/refresh/SaveJWTRefreshTokenService.java new file mode 100644 index 0000000..9c8d168 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/jwt/refresh/SaveJWTRefreshTokenService.java @@ -0,0 +1,20 @@ +package earlybird.earlybird.security.token.jwt.refresh; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +import java.util.Date; + +@RequiredArgsConstructor +@Service +public class SaveJWTRefreshTokenService { + + private final JWTRefreshTokenRepository JWTRefreshTokenRepository; + + public void saveJWTRefreshToken(String accountId, String refresh, Long expiredMs) { + Date date = new Date(System.currentTimeMillis() + expiredMs); + JWTRefreshToken JWTRefreshToken = new JWTRefreshToken(accountId, refresh, date.toString()); + JWTRefreshTokenRepository.save(JWTRefreshToken); + } +} diff --git a/src/main/java/earlybird/earlybird/security/token/oauth2/OAuth2Token.java b/src/main/java/earlybird/earlybird/security/token/oauth2/OAuth2Token.java new file mode 100644 index 0000000..8c5246c --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/oauth2/OAuth2Token.java @@ -0,0 +1,45 @@ +package earlybird.earlybird.security.token.oauth2; + +import static lombok.AccessLevel.PRIVATE; + +import earlybird.earlybird.security.enums.OAuth2ProviderName; +import earlybird.earlybird.user.entity.User; + +import jakarta.persistence.*; + +import lombok.AllArgsConstructor; +import lombok.Builder; + +@Builder +@AllArgsConstructor(access = PRIVATE) +@Entity +public class OAuth2Token { + + @Column(name = "oauth2_token_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long id; + + private String accessToken; + private String refreshToken; + + @Enumerated(EnumType.STRING) + private OAuth2ProviderName oAuth2ProviderName; + + @OneToOne + @JoinColumn(name = "user_id") + private User user; + + public OAuth2Token() {} + + public OAuth2TokenDTO toOAuth2TokenDTO() { + + return OAuth2TokenDTO.builder() + .id(id) + .accessToken(accessToken) + .refreshToken(refreshToken) + .oAuth2ProviderName(oAuth2ProviderName) + .userDTO(user.toUserAccountInfoDTO()) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/security/token/oauth2/OAuth2TokenDTO.java b/src/main/java/earlybird/earlybird/security/token/oauth2/OAuth2TokenDTO.java new file mode 100644 index 0000000..92ff812 --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/oauth2/OAuth2TokenDTO.java @@ -0,0 +1,17 @@ +package earlybird.earlybird.security.token.oauth2; + +import earlybird.earlybird.security.enums.OAuth2ProviderName; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class OAuth2TokenDTO { + private Long id; + private String accessToken; + private String refreshToken; + private OAuth2ProviderName oAuth2ProviderName; + private UserAccountInfoDTO userDTO; +} diff --git a/src/main/java/earlybird/earlybird/security/token/oauth2/OAuth2TokenRepository.java b/src/main/java/earlybird/earlybird/security/token/oauth2/OAuth2TokenRepository.java new file mode 100644 index 0000000..75216eb --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/oauth2/OAuth2TokenRepository.java @@ -0,0 +1,16 @@ +package earlybird.earlybird.security.token.oauth2; + +import earlybird.earlybird.user.entity.User; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +public interface OAuth2TokenRepository extends JpaRepository { + + Optional findByUser(User user); + + @Transactional + void deleteByUser(User user); +} diff --git a/src/main/java/earlybird/earlybird/security/token/oauth2/service/CreateOAuth2TokenService.java b/src/main/java/earlybird/earlybird/security/token/oauth2/service/CreateOAuth2TokenService.java new file mode 100644 index 0000000..f07a44d --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/oauth2/service/CreateOAuth2TokenService.java @@ -0,0 +1,37 @@ +package earlybird.earlybird.security.token.oauth2.service; + +import earlybird.earlybird.error.exception.UserNotFoundException; +import earlybird.earlybird.security.token.oauth2.OAuth2Token; +import earlybird.earlybird.security.token.oauth2.OAuth2TokenDTO; +import earlybird.earlybird.security.token.oauth2.OAuth2TokenRepository; +import earlybird.earlybird.user.entity.User; +import earlybird.earlybird.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class CreateOAuth2TokenService { + + private final OAuth2TokenRepository oAuth2TokenRepository; + private final UserRepository userRepository; + + public void create(OAuth2TokenDTO oAuth2TokenDTO) { + User user = + userRepository + .findById(oAuth2TokenDTO.getUserDTO().getId()) + .orElseThrow(UserNotFoundException::new); + + OAuth2Token oAuth2Token = + OAuth2Token.builder() + .accessToken(oAuth2TokenDTO.getAccessToken()) + .refreshToken(oAuth2TokenDTO.getRefreshToken()) + .oAuth2ProviderName(oAuth2TokenDTO.getOAuth2ProviderName()) + .user(user) + .build(); + + oAuth2TokenRepository.save(oAuth2Token); + } +} diff --git a/src/main/java/earlybird/earlybird/security/token/oauth2/service/DeleteOAuth2TokenService.java b/src/main/java/earlybird/earlybird/security/token/oauth2/service/DeleteOAuth2TokenService.java new file mode 100644 index 0000000..820b0ae --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/oauth2/service/DeleteOAuth2TokenService.java @@ -0,0 +1,34 @@ +package earlybird.earlybird.security.token.oauth2.service; + +import earlybird.earlybird.error.exception.UserNotFoundException; +import earlybird.earlybird.security.token.oauth2.OAuth2TokenRepository; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; +import earlybird.earlybird.user.entity.User; +import earlybird.earlybird.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class DeleteOAuth2TokenService { + + private final OAuth2TokenRepository oAuth2TokenRepository; + private final UserRepository userRepository; + + public void deleteById(Long id) { + oAuth2TokenRepository.deleteById(id); + } + + public void deleteByUserId(Long userId) { + User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + oAuth2TokenRepository.deleteByUser(user); + } + + public void deleteByUserAccountInfoDTO(UserAccountInfoDTO userInfo) { + User user = + userRepository.findById(userInfo.getId()).orElseThrow(UserNotFoundException::new); + oAuth2TokenRepository.deleteByUser(user); + } +} diff --git a/src/main/java/earlybird/earlybird/security/token/oauth2/service/FindOAuth2TokenService.java b/src/main/java/earlybird/earlybird/security/token/oauth2/service/FindOAuth2TokenService.java new file mode 100644 index 0000000..2ea311d --- /dev/null +++ b/src/main/java/earlybird/earlybird/security/token/oauth2/service/FindOAuth2TokenService.java @@ -0,0 +1,30 @@ +package earlybird.earlybird.security.token.oauth2.service; + +import earlybird.earlybird.error.exception.UserNotFoundException; +import earlybird.earlybird.security.token.oauth2.OAuth2Token; +import earlybird.earlybird.security.token.oauth2.OAuth2TokenDTO; +import earlybird.earlybird.security.token.oauth2.OAuth2TokenRepository; +import earlybird.earlybird.user.entity.User; +import earlybird.earlybird.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class FindOAuth2TokenService { + + private final OAuth2TokenRepository oAuth2TokenRepository; + private final UserRepository userRepository; + + public OAuth2TokenDTO findByUserId(Long userId) { + Optional optionalUser = userRepository.findById(userId); + User user = optionalUser.orElseThrow(UserNotFoundException::new); + Optional optionalOAuth2Token = oAuth2TokenRepository.findByUser(user); + OAuth2Token oAuth2Token = optionalOAuth2Token.orElseThrow(); + return oAuth2Token.toOAuth2TokenDTO(); + } +} diff --git a/src/main/java/earlybird/earlybird/user/dto/UserAccountInfoDTO.java b/src/main/java/earlybird/earlybird/user/dto/UserAccountInfoDTO.java new file mode 100644 index 0000000..8600e2a --- /dev/null +++ b/src/main/java/earlybird/earlybird/user/dto/UserAccountInfoDTO.java @@ -0,0 +1,26 @@ +package earlybird.earlybird.user.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserAccountInfoDTO { + private Long id; + private String accountId; + private String name; + private String email; + private String role; + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof UserAccountInfoDTO)) return false; + UserAccountInfoDTO other = (UserAccountInfoDTO) obj; + + return this.id == other.getId() + && this.accountId.equals(other.getAccountId()) + && this.name.equals(other.getName()) + && this.email.equals(other.getEmail()) + && this.role.equals(other.getRole()); + } +} diff --git a/src/main/java/earlybird/earlybird/user/entity/User.java b/src/main/java/earlybird/earlybird/user/entity/User.java new file mode 100644 index 0000000..6d7ce28 --- /dev/null +++ b/src/main/java/earlybird/earlybird/user/entity/User.java @@ -0,0 +1,75 @@ +package earlybird.earlybird.user.entity; + +import earlybird.earlybird.common.LocalDateTimeUtil; +import earlybird.earlybird.security.authentication.oauth2.dto.OAuth2ServerResponse; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; + +import jakarta.persistence.*; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "users") +public class User { + + @Column(name = "user_id") + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 스프링 시큐리티에서 사용하는 값 */ + @Column(name = "user_account_id", nullable = false, unique = true) + private String accountId; + + @Column(name = "user_name", nullable = false) + private String name; + + @Column(name = "user_email", nullable = false) + private String email; + + @Column(name = "user_role", nullable = false) + private String role; + + @Column(name = "user_created_at", nullable = false) + private LocalDateTime createdAt; + + @Builder + private User( + Long id, + String accountId, + String name, + String email, + String role, + LocalDateTime createdAt) { + this.id = id; + this.accountId = accountId; + this.name = name; + this.email = email; + this.role = role; + this.createdAt = createdAt; + } + + public User(OAuth2ServerResponse userInfo) { + this.accountId = userInfo.getProviderName() + " " + userInfo.getProviderId(); + this.name = userInfo.getName(); + this.email = userInfo.getEmail(); + this.role = "USER"; + this.createdAt = LocalDateTimeUtil.getLocalDateTimeNow(); + } + + public User() {} + + public UserAccountInfoDTO toUserAccountInfoDTO() { + return UserAccountInfoDTO.builder() + .id(id) + .accountId(accountId) + .name(name) + .email(email) + .role(role) + .build(); + } +} diff --git a/src/main/java/earlybird/earlybird/user/repository/UserRepository.java b/src/main/java/earlybird/earlybird/user/repository/UserRepository.java new file mode 100644 index 0000000..51bc533 --- /dev/null +++ b/src/main/java/earlybird/earlybird/user/repository/UserRepository.java @@ -0,0 +1,19 @@ +package earlybird.earlybird.user.repository; + +import earlybird.earlybird.user.entity.User; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +/** + * findByAccountId와 findByUsername 함수는 서로 동일하지만 이름만 다른 함수
+ * 스프링 시큐리티에서는 accountId 대신 username 이라는 이름을 사용하기 때문에 가독성을 위해 이름이 다른 두 함수를 만들었음 + */ +public interface UserRepository extends JpaRepository { + Optional findByAccountId(String accountId); + + @Query("select u from User u where u.accountId=:username") + Optional findByUsername(String username); +} diff --git a/src/main/java/earlybird/earlybird/user/service/DeleteUserService.java b/src/main/java/earlybird/earlybird/user/service/DeleteUserService.java new file mode 100644 index 0000000..57f2202 --- /dev/null +++ b/src/main/java/earlybird/earlybird/user/service/DeleteUserService.java @@ -0,0 +1,29 @@ +package earlybird.earlybird.user.service; + +import earlybird.earlybird.error.exception.UserNotFoundException; +import earlybird.earlybird.user.dto.UserAccountInfoDTO; +import earlybird.earlybird.user.entity.User; +import earlybird.earlybird.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class DeleteUserService { + + private final UserRepository userRepository; + + public void deleteUser(UserAccountInfoDTO userInfo) { + String accountId = userInfo.getAccountId(); + Optional optionalUser = userRepository.findByAccountId(accountId); + if (optionalUser.isEmpty()) { + throw new UserNotFoundException(); + } + User user = optionalUser.get(); + userRepository.delete(user); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..be0da3c --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:mysql://localhost/earlybird + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + show_sql: true + format_sql: true \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..7047faf --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,17 @@ +spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + max-lifetime: 177000 + jpa: + hibernate: + ddl-auto: validate +server: + tomcat: + basedir: . + accesslog: + enabled: true + pattern: "%{yyyy-MM-dd HH:mm:ss}t %s %r %{User-Agent}i %{Referer}i %a %b %D" diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..27755cf --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,19 @@ +spring: + h2: + console: + enabled: true + path: /h2-console + datasource: + url: jdbc:mysql://localhost/earlybird_test + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + format_sql: true + show_sql: true + use_sql_comments: true \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 722ca83..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=earlybird diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..d214789 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,15 @@ +spring: + application: + name: earlybird + profiles: + active: ${SPRING_ACTIVE_PROFILE} + jwt: + secret: ${JWT_SECRET} + +fcm: + service-account-file: key/firebase_admin_sdk_private_key.json + project-id: ${FCM_PROJECT_ID} + +aws: + access-key: ${AWS_ACCESS_KEY} + secret-access-key: ${AWS_SECRET_ACCESS_KEY} \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..ef919d4 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,11 @@ + + + + + EarlyBird-API + + + + EarlyBird API Server + + diff --git a/src/main/resources/templates/showServerIp.html b/src/main/resources/templates/showServerIp.html new file mode 100644 index 0000000..5ff1522 --- /dev/null +++ b/src/main/resources/templates/showServerIp.html @@ -0,0 +1,11 @@ + + + + + EarlyBird-API + + + + server ip + + diff --git a/src/test/java/earlybird/earlybird/EarlybirdApplicationTests.java b/src/test/java/earlybird/earlybird/EarlybirdApplicationTests.java index b427454..8d7f610 100644 --- a/src/test/java/earlybird/earlybird/EarlybirdApplicationTests.java +++ b/src/test/java/earlybird/earlybird/EarlybirdApplicationTests.java @@ -6,8 +6,6 @@ @SpringBootTest class EarlybirdApplicationTests { - @Test - void contextLoads() { - } - + @Test + void contextLoads() {} } diff --git a/src/test/java/earlybird/earlybird/appointment/domain/AppointmentTest.java b/src/test/java/earlybird/earlybird/appointment/domain/AppointmentTest.java new file mode 100644 index 0000000..7cffb28 --- /dev/null +++ b/src/test/java/earlybird/earlybird/appointment/domain/AppointmentTest.java @@ -0,0 +1,79 @@ +// package earlybird.earlybird.appointment.domain; +// +// import org.assertj.core.api.Assertions; +// import org.junit.jupiter.api.BeforeEach; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +// import org.springframework.boot.test.context.SpringBootTest; +// import org.springframework.context.annotation.Profile; +// import org.springframework.test.context.ActiveProfiles; +// +// import javax.sql.DataSource; +// import java.sql.SQLException; +// import java.time.DayOfWeek; +// import java.time.Duration; +// import java.time.LocalDateTime; +// import java.time.LocalTime; +// import java.util.ArrayList; +// import java.util.List; +// import java.util.Optional; +// +// import static org.junit.jupiter.api.Assertions.*; +// +// @Profile({"local"}) +// @SpringBootTest +// class AppointmentTest { +// +// @Autowired +// private AppointmentRepository appointmentRepository; +// +// @Autowired +// private RepeatingDayRepository repeatingDayRepository; +// +// @Autowired +// private DataSource dataSource; +// +// @BeforeEach +// void setUp() { +// Appointment appointment = Appointment.builder() +// .appointmentTime(LocalTime.now()) +// .appointmentName("name") +// .deviceToken("token") +// .repeatingDayOfWeeks(List.of(DayOfWeek.MONDAY)) +// .preparationDuration(Duration.ZERO) +// .movingDuration(Duration.ZERO) +// .clientId("clientId") +// .build(); +// Appointment savedAppointment = appointmentRepository.save(appointment); +// appointment.getRepeatingDays().get(0).setDeleted(); +// } +// +// @DisplayName("") +// @Test +// void test() throws SQLException { +// // given +// System.out.println(dataSource.getConnection().getMetaData().getURL()); +// +//// RepeatingDay repeatingDay = savedAppointment.getRepeatingDays().get(0); +//// repeatingDay.setDeleted(); +//// appointmentRepository.save(savedAppointment); +// // when +// +// List all = appointmentRepository.findAll(); +//// List allByDayOfWeek = +// repeatingDayRepository.findAllByDayOfWeek(DayOfWeek.MONDAY); +// List repeatingDays = all.get(0).getRepeatingDays(); +// +// List all2 = repeatingDayRepository.findAll(); +//// savedAppointment.setDeleted(); +// List all1 = appointmentRepository.findAll(); +// Assertions.assertThat("ddd").isEqualTo("d"); +// +// // then +// +// +// } +// +// } diff --git a/src/test/java/earlybird/earlybird/appointment/domain/CreateTestAppointment.java b/src/test/java/earlybird/earlybird/appointment/domain/CreateTestAppointment.java new file mode 100644 index 0000000..5000ef4 --- /dev/null +++ b/src/test/java/earlybird/earlybird/appointment/domain/CreateTestAppointment.java @@ -0,0 +1,20 @@ +package earlybird.earlybird.appointment.domain; + +import java.time.Duration; +import java.time.LocalTime; +import java.util.ArrayList; + +public class CreateTestAppointment { + + public static Appointment create() { + return Appointment.builder() + .appointmentTime(LocalTime.now()) + .repeatingDayOfWeeks(new ArrayList<>()) + .movingDuration(Duration.ofMinutes(10)) + .preparationDuration(Duration.ofMinutes(10)) + .deviceToken("deviceToken") + .clientId("clientId") + .appointmentName("appointmentName") + .build(); + } +} diff --git a/src/test/java/earlybird/earlybird/appointment/domain/RepeatingDayRepositoryTest.java b/src/test/java/earlybird/earlybird/appointment/domain/RepeatingDayRepositoryTest.java new file mode 100644 index 0000000..3a49bbc --- /dev/null +++ b/src/test/java/earlybird/earlybird/appointment/domain/RepeatingDayRepositoryTest.java @@ -0,0 +1,45 @@ +package earlybird.earlybird.appointment.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalTime; +import java.util.List; + +@SpringBootTest +class RepeatingDayRepositoryTest { + + @Autowired private AppointmentRepository appointmentRepository; + + @Autowired private RepeatingDayRepository repeatingDayRepository; + + @DisplayName("") + @Test + void test() { + // given + + Appointment appointment = + Appointment.builder() + .appointmentTime(LocalTime.now()) + .appointmentName("name") + .deviceToken("token") + .repeatingDayOfWeeks(List.of(DayOfWeek.MONDAY)) + .preparationDuration(Duration.ZERO) + .movingDuration(Duration.ZERO) + .clientId("clientId") + .build(); + appointmentRepository.save(appointment); + repeatingDayRepository.findAllByDayOfWeek(DayOfWeek.MONDAY); + + // when + repeatingDayRepository.findAll(); + System.out.println("dd"); + + // then + + } +} diff --git a/src/test/java/earlybird/earlybird/feedback/service/CreateAuthFeedbackCommentServiceTest.java b/src/test/java/earlybird/earlybird/feedback/service/CreateAuthFeedbackCommentServiceTest.java new file mode 100644 index 0000000..a7d8008 --- /dev/null +++ b/src/test/java/earlybird/earlybird/feedback/service/CreateAuthFeedbackCommentServiceTest.java @@ -0,0 +1,101 @@ +// package earlybird.earlybird.feedback.service; +// +// import earlybird.earlybird.error.exception.UserNotFoundException; +// import earlybird.earlybird.feedback.domain.comment.FeedbackCommentRepository; +// import earlybird.earlybird.feedback.service.auth.CreateAuthFeedbackCommentService; +// import earlybird.earlybird.user.dto.UserAccountInfoDTO; +// import earlybird.earlybird.user.entity.User; +// import earlybird.earlybird.user.repository.UserRepository; +// import org.junit.jupiter.api.AfterEach; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.boot.test.context.SpringBootTest; +// +// import java.time.LocalDateTime; +// +// import static org.assertj.core.api.Assertions.*; +// +// @SpringBootTest +// class CreateAuthFeedbackCommentServiceTest { +// +// @Autowired +// private FeedbackCommentRepository feedbackCommentRepository; +// +// @Autowired +// private UserRepository userRepository; +// +// @Autowired +// private CreateAuthFeedbackCommentService createAuthFeedbackCommentService; +// +// +// @AfterEach +// void tearDown() { +// feedbackCommentRepository.deleteAllInBatch(); +// userRepository.deleteAllInBatch(); +// } +// +// @DisplayName("로그인한 유저의 피드백을 생성한다.") +// @Test +// void create() { +// // given +// LocalDateTime createdAt = LocalDateTime.of(2024, 10, 7, 9, 0); +// Long userId = 1L; +// User user = User.builder() +// .id(userId) +// .accountId("id") +// .email("email@email.com") +// .name("name") +// .role("USER") +// .createdAt(createdAt) +// .build(); +// +// User savedUser = userRepository.save(user); +// +// UserAccountInfoDTO userAccountInfoDTO = savedUser.toUserAccountInfoDTO(); +// +// Long feedbackId = 1L; +// String feedbackContent = "feedback content"; +// FeedbackDTO feedbackDTO = FeedbackDTO.builder() +// .id(feedbackId) +// .content(feedbackContent) +// .userAccountInfoDTO(userAccountInfoDTO) +// .createdAt(createdAt) +// .build(); +// +// // when +// FeedbackDTO createdFeedbackDTO = createAuthFeedbackCommentService.create(feedbackDTO); +// +// // then +// assertThat(feedbackCommentRepository.findAll()).hasSize(1); +// assertThat(createdFeedbackDTO) +// .extracting("id", "content", "createdAt") +// .contains( +// feedbackId, feedbackContent, createdAt +// ); +// assertThat(createdFeedbackDTO.getUserAccountInfoDTO()).isEqualTo(userAccountInfoDTO); +// } +// +// +// @DisplayName("찾을 수 없는 유저가 피드백 생성을 요청하면 예외가 발생한다.") +// @Test +// void createWithNotFoundUser() { +// // given +// UserAccountInfoDTO userAccountInfoDTO = UserAccountInfoDTO.builder() +// .accountId("id") +// .id(1L) +// .email("email@email.com") +// .name("name") +// .role("USER") +// .build(); +// +// FeedbackDTO feedbackDTO = FeedbackDTO.builder() +// .userAccountInfoDTO(userAccountInfoDTO) +// .build(); +// +// // when // then +// assertThatThrownBy(() -> createAuthFeedbackCommentService.create(feedbackDTO)) +// .isInstanceOf(UserNotFoundException.class); +// } +// +// } diff --git a/src/test/java/earlybird/earlybird/feedback/service/CreateAuthUserFeedbackServiceTest.java b/src/test/java/earlybird/earlybird/feedback/service/CreateAuthUserFeedbackServiceTest.java new file mode 100644 index 0000000..d4e6254 --- /dev/null +++ b/src/test/java/earlybird/earlybird/feedback/service/CreateAuthUserFeedbackServiceTest.java @@ -0,0 +1,101 @@ +// package earlybird.earlybird.feedback.service; +// +// import earlybird.earlybird.error.exception.UserNotFoundException; +// import earlybird.earlybird.feedback.dto.FeedbackDTO; +// import earlybird.earlybird.feedback.repository.FeedbackRepository; +// import earlybird.earlybird.user.dto.UserAccountInfoDTO; +// import earlybird.earlybird.user.entity.User; +// import earlybird.earlybird.user.repository.UserRepository; +// import org.junit.jupiter.api.AfterEach; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.boot.test.context.SpringBootTest; +// +// import java.time.LocalDateTime; +// +// import static org.assertj.core.api.Assertions.*; +// +// @SpringBootTest +// class CreateAuthUserFeedbackServiceTest { +// +// @Autowired +// private FeedbackRepository feedbackRepository; +// +// @Autowired +// private UserRepository userRepository; +// +// @Autowired +// private CreateAuthUserFeedbackService createAuthUserFeedbackService; +// +// +// @AfterEach +// void tearDown() { +// feedbackRepository.deleteAllInBatch(); +// userRepository.deleteAllInBatch(); +// } +// +// @DisplayName("로그인한 유저의 피드백을 생성한다.") +// @Test +// void create() { +// // given +// LocalDateTime createdAt = LocalDateTime.of(2024, 10, 7, 9, 0); +// Long userId = 1L; +// User user = User.builder() +// .id(userId) +// .accountId("id") +// .email("email@email.com") +// .name("name") +// .role("USER") +// .createdAt(createdAt) +// .build(); +// +// User savedUser = userRepository.save(user); +// +// UserAccountInfoDTO userAccountInfoDTO = savedUser.toUserAccountInfoDTO(); +// +// Long feedbackId = 1L; +// String feedbackContent = "feedback content"; +// FeedbackDTO feedbackDTO = FeedbackDTO.builder() +// .id(feedbackId) +// .content(feedbackContent) +// .userAccountInfoDTO(userAccountInfoDTO) +// .createdAt(createdAt) +// .build(); +// +// // when +// FeedbackDTO createdFeedbackDTO = createAuthUserFeedbackService.create(feedbackDTO); +// +// // then +// assertThat(feedbackRepository.findAll()).hasSize(1); +// assertThat(createdFeedbackDTO) +// .extracting("id", "content", "createdAt") +// .contains( +// feedbackId, feedbackContent, createdAt +// ); +// assertThat(createdFeedbackDTO.getUserAccountInfoDTO()).isEqualTo(userAccountInfoDTO); +// } +// +// +// @DisplayName("찾을 수 없는 유저가 피드백 생성을 요청하면 예외가 발생한다.") +// @Test +// void createWithNotFoundUser() { +// // given +// UserAccountInfoDTO userAccountInfoDTO = UserAccountInfoDTO.builder() +// .accountId("id") +// .id(1L) +// .email("email@email.com") +// .name("name") +// .role("USER") +// .build(); +// +// FeedbackDTO feedbackDTO = FeedbackDTO.builder() +// .userAccountInfoDTO(userAccountInfoDTO) +// .build(); +// +// // when // then +// assertThatThrownBy(() -> createAuthUserFeedbackService.create(feedbackDTO)) +// .isInstanceOf(UserNotFoundException.class); +// } +// +// } diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/domain/CreateTestFcmNotification.java b/src/test/java/earlybird/earlybird/scheduler/notification/domain/CreateTestFcmNotification.java new file mode 100644 index 0000000..5a6b9a8 --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/domain/CreateTestFcmNotification.java @@ -0,0 +1,16 @@ +package earlybird.earlybird.scheduler.notification.domain; + +import earlybird.earlybird.appointment.domain.CreateTestAppointment; + +import java.time.LocalDateTime; + +public class CreateTestFcmNotification { + + public static FcmNotification create() { + return FcmNotification.builder() + .appointment(CreateTestAppointment.create()) + .notificationStep(NotificationStep.APPOINTMENT_TIME) + .targetTime(LocalDateTime.now()) + .build(); + } +} diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationRepositoryStub.java b/src/test/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationRepositoryStub.java new file mode 100644 index 0000000..9a6b3fd --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationRepositoryStub.java @@ -0,0 +1,164 @@ +package earlybird.earlybird.scheduler.notification.domain; + +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.FluentQuery; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +public class FcmNotificationRepositoryStub implements FcmNotificationRepository { + + private final List fcmNotifications = new ArrayList<>(); + + @Override + public Optional findByIdAndStatusForUpdate( + Long id, NotificationStatus status) { + return Optional.empty(); + } + + @Override + public List findAllByStatusIs(NotificationStatus status) { + return List.of(); + } + + @Override + public void flush() {} + + @Override + public S saveAndFlush(S entity) { + return null; + } + + @Override + public List saveAllAndFlush(Iterable entities) { + return List.of(); + } + + @Override + public void deleteAllInBatch(Iterable entities) {} + + @Override + public void deleteAllByIdInBatch(Iterable longs) {} + + @Override + public void deleteAllInBatch() {} + + @Override + public FcmNotification getOne(Long aLong) { + return null; + } + + @Override + public FcmNotification getById(Long aLong) { + return null; + } + + @Override + public FcmNotification getReferenceById(Long aLong) { + return null; + } + + @Override + public Optional findOne(Example example) { + return Optional.empty(); + } + + @Override + public List findAll(Example example) { + return List.of(); + } + + @Override + public List findAll(Example example, Sort sort) { + return List.of(); + } + + @Override + public Page findAll(Example example, Pageable pageable) { + return null; + } + + @Override + public long count(Example example) { + return 0; + } + + @Override + public boolean exists(Example example) { + return false; + } + + @Override + public R findBy( + Example example, Function, R> queryFunction) { + return null; + } + + @Override + public S save(S entity) { + fcmNotifications.add(entity); + return entity; + } + + @Override + public List saveAll(Iterable entities) { + return List.of(); + } + + @Override + public Optional findById(Long aLong) { + return fcmNotifications.stream() + .filter(fcmNotification -> fcmNotification.getId().equals(aLong)) + .findFirst(); + } + + @Override + public boolean existsById(Long aLong) { + return false; + } + + @Override + public List findAll() { + return fcmNotifications; + } + + @Override + public List findAllById(Iterable longs) { + return List.of(); + } + + @Override + public long count() { + return 0; + } + + @Override + public void deleteById(Long aLong) {} + + @Override + public void delete(FcmNotification entity) {} + + @Override + public void deleteAllById(Iterable longs) {} + + @Override + public void deleteAll(Iterable entities) {} + + @Override + public void deleteAll() {} + + @Override + public List findAll(Sort sort) { + return List.of(); + } + + @Override + public Page findAll(Pageable pageable) { + return null; + } +} diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationRepositoryTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationRepositoryTest.java new file mode 100644 index 0000000..40d8389 --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationRepositoryTest.java @@ -0,0 +1,157 @@ +// package earlybird.earlybird.scheduler.notification.fcm.domain; +// +// import earlybird.earlybird.appointment.domain.Appointment; +// import earlybird.earlybird.appointment.domain.AppointmentRepository; +// import jakarta.persistence.EntityManager; +// import jakarta.persistence.EntityManagerFactory; +// import jakarta.persistence.PersistenceUnit; +// import org.junit.jupiter.api.AfterEach; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.beans.factory.annotation.Qualifier; +// import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +// import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +// import org.springframework.boot.test.context.SpringBootTest; +// import org.springframework.context.annotation.Import; +// import org.springframework.core.task.TaskExecutor; +// import org.springframework.dao.PessimisticLockingFailureException; +// import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +// import org.springframework.security.core.parameters.P; +// import org.springframework.stereotype.Component; +// import org.springframework.test.context.ActiveProfiles; +// import org.springframework.test.context.web.WebAppConfiguration; +// import org.springframework.transaction.annotation.Transactional; +// +// import java.time.LocalDateTime; +// import java.util.Optional; +// import java.util.concurrent.CompletableFuture; +// +// import static org.assertj.core.api.Assertions.assertThat; +// import static org.assertj.core.api.Assertions.assertThatThrownBy; +// import static org.junit.jupiter.api.Assertions.*; +// +// @Component +// class Transaction { +// +// @Transactional +// public void run(Runnable runnable) { +// try { +// runnable.run(); +// } catch (Exception e) { +// throw new RuntimeException(e); +// } +// } +// } +// +// @Import(Transaction.class) +// @ActiveProfiles("test") +// @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +// @SpringBootTest +// class FcmNotificationRepositoryTest { +// +// @Autowired +// private FcmNotificationRepository fcmNotificationRepository; +// +// @Autowired +// private AppointmentRepository appointmentRepository; +// +// @Autowired +// private Transaction transaction; +// +// @AfterEach +// void tearDown() { +// fcmNotificationRepository.deleteAllInBatch(); +// appointmentRepository.deleteAllInBatch(); +// } +// +// @DisplayName("ID와 전송 상태로 FCM 메시지를 조회한다.") +// @Test +// void findByIdAndStatus() { +// // given +// Appointment appointment = createAppointment(); +// FcmNotification notification = createNotification(appointment); +// appointment.addFcmNotification(notification); +// appointmentRepository.save(appointment); +// FcmNotification savedNotification = fcmNotificationRepository.save(notification); +// +// +// transaction.run(() -> { +// // when +// FcmNotification result = +// fcmNotificationRepository.findByIdAndStatusForUpdate(savedNotification.getId(), +// savedNotification.getStatus()).get(); +// +// // then +// assertThat(result.getId()).isEqualTo(savedNotification.getId()); +// assertThat(result.getStatus()).isEqualTo(savedNotification.getStatus()); +// }); +// } +// +// @DisplayName("findByIdAndStatusForUpdate 메서드를 실행하면 X-lock 락이 동작한다.") +// @Test +// void pessimisticWriteLock() { +// // given +// Appointment appointment = createAppointment(); +// FcmNotification notification = createNotification(appointment); +// appointment.addFcmNotification(notification); +// appointmentRepository.save(appointment); +// FcmNotification savedNotification = fcmNotificationRepository.save(notification); +// +// // when +// CompletableFuture transaction1 = CompletableFuture.runAsync(() -> transaction.run(() +// -> { +// FcmNotification fcmNotification = +// fcmNotificationRepository.findByIdAndStatusForUpdate(savedNotification.getId(), +// savedNotification.getStatus()).get(); +// threadSleep(5000); +// fcmNotification.updateStatusTo(NotificationStatus.CANCELLED); +// })); +// +// threadSleep(500); +// +// CompletableFuture transaction2 = CompletableFuture.runAsync(() -> { +// transaction.run(() -> { +// FcmNotification fcmNotification = +// fcmNotificationRepository.findById(savedNotification.getId()).get(); +// fcmNotification.updateStatusTo(NotificationStatus.COMPLETED); +// }); +// }); +// +// transaction1.join(); +// transaction2.join(); +// +// // then +// FcmNotification fcmNotification = +// fcmNotificationRepository.findById(savedNotification.getId()).get(); +// assertThat(fcmNotification.getStatus()).isEqualTo(NotificationStatus.COMPLETED); +// } +// +// +// private void threadSleep(int millis) { +// try { +// Thread.sleep(millis); +// } catch (InterruptedException e) { +// throw new RuntimeException(e); +// } +// } +// +// private Appointment createAppointment() { +// return appointmentRepository.save(Appointment.builder() +// .appointmentName("appointmentName") +// .deviceToken("deviceToken") +// .clientId("clientId") +// .build()); +// } +// +// private FcmNotification createNotification(Appointment appointment) { +// return FcmNotification.builder() +// .appointment(appointment) +// .targetTime(LocalDateTime.of(2024, 10, 11, 0, 0)) +// .notificationStep(NotificationStep.APPOINTMENT_TIME) +// .build(); +// } +// } +// +// +// diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationTest.java new file mode 100644 index 0000000..7a2f591 --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/domain/FcmNotificationTest.java @@ -0,0 +1,40 @@ +// package earlybird.earlybird.scheduler.notification.fcm.domain; +// +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// +// import java.time.LocalDateTime; +// +// import static earlybird.earlybird.scheduler.notification.fcm.domain.NotificationStatus.COMPLETED; +// import static org.assertj.core.api.Assertions.assertThat; +// +// class FcmNotificationTest { +// +// @DisplayName("알림 발송 성공 상태를 업데이트한다.") +// @Test +// void onSendToFcmSuccess() { +// // given +// FcmNotification notification = FcmNotification.builder() +// .build(); +// +// // when +// notification.onSendToFcmSuccess(); +// +// // then +// assertThat(notification.getStatus()).isEqualTo(COMPLETED); +// assertThat(notification.getSentTime()).isNotNull(); +// } +// +// @DisplayName("알림 상태를 수정한다.") +// @Test +// void updateStatusTo() { +// // given +// FcmNotification notification = FcmNotification.builder().build(); +// +// // when +// notification.updateStatusTo(COMPLETED); +// +// // then +// assertThat(notification.getStatus()).isEqualTo(COMPLETED); +// } +// } diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/DeregisterNotificationAtSchedulerServiceTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/DeregisterNotificationAtSchedulerServiceTest.java new file mode 100644 index 0000000..c5a8f73 --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/DeregisterNotificationAtSchedulerServiceTest.java @@ -0,0 +1,126 @@ +// package earlybird.earlybird.scheduler.notification.fcm.service; +// +// import earlybird.earlybird.error.exception.AlreadySentFcmNotificationException; +// import earlybird.earlybird.error.exception.FcmDeviceTokenMismatchException; +// import earlybird.earlybird.scheduler.notification.fcm.domain.FcmNotification; +// import earlybird.earlybird.scheduler.notification.fcm.domain.FcmNotificationRepository; +// import +// earlybird.earlybird.scheduler.notification.fcm.service.request.AddTaskToSchedulingTaskListServiceRequest; +// import +// earlybird.earlybird.scheduler.notification.fcm.service.deregister.request.DeregisterFcmMessageAtSchedulerServiceRequest; +// import org.junit.jupiter.api.AfterEach; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +// import org.springframework.boot.test.context.SpringBootTest; +// import org.springframework.test.context.ActiveProfiles; +// +// import java.time.LocalDateTime; +// import java.time.ZoneId; +// +// import static org.assertj.core.api.Assertions.assertThat; +// import static org.assertj.core.api.Assertions.assertThatThrownBy; +// import static org.junit.jupiter.api.Assertions.*; +// +// @ActiveProfiles("test") +// @SpringBootTest +// class DeregisterNotificationAtSchedulerServiceTest { +// +// @Autowired +// private DeregisterNotificationAtSchedulerService deregisterNotificationAtSchedulerService; +// +// @Autowired +// private FcmNotificationRepository fcmNotificationRepository; +// +// @Autowired +// private SchedulingTaskListService schedulingTaskListService; +// +// @AfterEach +// void tearDown() { +// fcmNotificationRepository.deleteAllInBatch(); +// } +// +// @DisplayName("스케줄러에 등록된 알림을 스케줄러와 DB 에서 삭제한다.") +// @Test +// void deregister() { +// // given +// LocalDateTime targetTime = LocalDateTime.now().plusDays(1); +// FcmNotification notification = createNotification(targetTime); +// +// FcmNotification savedNotification = fcmNotificationRepository.save(notification); +// +// DeregisterFcmMessageAtSchedulerServiceRequest request +// = createDeregisterRequest(savedNotification.getId(), "deviceToken"); +// +// AddTaskToSchedulingTaskListServiceRequest addRequest = +// AddTaskToSchedulingTaskListServiceRequest.builder() +// .targetTime(targetTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()) +// .uuid("uuid") +// .title("title") +// .body("body") +// .deviceToken("deviceToken") +// .build(); +// +// schedulingTaskListService.add(addRequest); +// +// // when +// deregisterNotificationAtSchedulerService.deregister(request); +// +// // then +// assertThat(schedulingTaskListService.has("uuid")).isFalse(); +// assertThat(fcmNotificationRepository.findById(savedNotification.getId())).isEmpty(); +// } +// +// @DisplayName("저장되어 있는 알림의 디바이스 토큰 값과 요청에 담긴 디바이스 토큰 값이 일치하지 않으면 예외가 발생한다.") +// @Test +// void deregisterWithMismatchDeviceToken() { +// // given +// FcmNotification notification = createNotification(LocalDateTime.now().plusDays(1)); +// FcmNotification savedNotification = fcmNotificationRepository.save(notification); +// +// DeregisterFcmMessageAtSchedulerServiceRequest request +// = createDeregisterRequest(savedNotification.getId(), "mismatchedDeviceToken"); +// +// // when // then +// assertThatThrownBy(() -> deregisterNotificationAtSchedulerService.deregister(request)) +// .isInstanceOf(FcmDeviceTokenMismatchException.class); +// } +// +// private DeregisterFcmMessageAtSchedulerServiceRequest createDeregisterRequest(Long +// notificationId, String deviceToken) { +// return DeregisterFcmMessageAtSchedulerServiceRequest.builder() +// .notificationId(notificationId) +// .deviceToken(deviceToken) +// .build(); +// } +// +// @DisplayName("이미 전송된 알림을 삭제하려고 하면 예외가 발생한다.") +// @Test +// void test() { +// // given +// FcmNotification notification = createNotification(LocalDateTime.now()); +// notification.onSendToFcmSuccess("fcmMessageId"); +// FcmNotification savedNotification = fcmNotificationRepository.save(notification); +// +// DeregisterFcmMessageAtSchedulerServiceRequest request +// = createDeregisterRequest(savedNotification.getId(), "deviceToken"); +// +// // when // then +// assertThatThrownBy(() -> deregisterNotificationAtSchedulerService.deregister(request)) +// .isInstanceOf(AlreadySentFcmNotificationException.class); +// } +// +// +// private static FcmNotification createNotification(LocalDateTime targetTime) { +// return FcmNotification.builder() +// .uuid("uuid") +// .title("title") +// .body("body") +// .targetTime(targetTime) +// .deviceToken("deviceToken") +// .build(); +// } +// +// +// } diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/RegisterNotificationAtSchedulerServiceTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/RegisterNotificationAtSchedulerServiceTest.java new file mode 100644 index 0000000..2b8c14c --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/RegisterNotificationAtSchedulerServiceTest.java @@ -0,0 +1,120 @@ +// package earlybird.earlybird.scheduler.notification.fcm.service; +// +// import earlybird.earlybird.scheduler.notification.fcm.domain.FcmNotificationRepository; +// import +// earlybird.earlybird.scheduler.notification.fcm.service.register.request.RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest; +// import earlybird.earlybird.messaging.request.SendMessageByTokenServiceRequest; +// import +// earlybird.earlybird.scheduler.notification.fcm.service.register.response.RegisterFcmMessageAtSchedulerServiceResponse; +// import org.awaitility.Awaitility; +// import org.junit.jupiter.api.AfterEach; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.boot.test.context.SpringBootTest; +// import org.springframework.boot.test.mock.mockito.MockBean; +// import org.springframework.test.context.ActiveProfiles; +// +// import java.time.LocalDateTime; +// import java.util.concurrent.ExecutionException; +// import java.util.concurrent.TimeUnit; +// +// import static org.assertj.core.api.Assertions.assertThat; +// import static org.mockito.ArgumentMatchers.any; +// import static org.mockito.Mockito.*; +// +// @ActiveProfiles("test") +// @SpringBootTest +// class RegisterNotificationAtSchedulerServiceTest { +// +// @MockBean +// private SendMessageToFcmService sendMessageToFcmService; +// +// @Autowired +// private RegisterNotificationAtSchedulerService registerNotificationAtSchedulerService; +// +// @Autowired +// private SchedulingTaskListService schedulingTaskListService; +// +// @Autowired +// private FcmNotificationRepository fcmNotificationRepository; +// +// @AfterEach +// void tearDown() { +// fcmNotificationRepository.deleteAllInBatch(); +// } +// +// @DisplayName("FCM 메시지를 스케줄러에 등록하면 등록된 시간에 FCM 메시지 전송이 실행된다.") +// @Test +// void schedulerExecute() throws ExecutionException, InterruptedException { +// // given +// int targetSecond = 2; +// LocalDateTime targetTime = LocalDateTime.now().plusSeconds(targetSecond); +// RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest request = +// RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest.builder() +// .clientId("clientId") +// .deviceToken("deviceToken") +// .appointmentName("appointmentName") +// .preparationTime(LocalDateTime.now().minusHours(1)) +// .movingTime(LocalDateTime.now().minusHours(1)) +// .appointmentTime(LocalDateTime.now().plusSeconds(targetSecond)) +// .build(); +// +// // when +// registerNotificationAtSchedulerService.registerFcmMessageForNewAppointment(request); +// +// // then +// Awaitility.await() +// .atMost(targetSecond + 1, TimeUnit.SECONDS) +// .untilAsserted(() -> { +// verify(sendMessageToFcmService, +// +// times(1)).sendMessageByToken(any(SendMessageByTokenServiceRequest.class)); +// }); +// } +// +// @DisplayName("요청한 준비 시간, 이동 시간, 약속 시간에 따라 최대 7개의 알림이 스케줄러에 등록된다.") +// @Test +// void registerFcmMessageForNewAppointment() throws ExecutionException, InterruptedException { +// // given +// RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest request = +// RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest.builder() +// .clientId("clientId") +// .deviceToken("deviceToken") +// .appointmentName("appointmentName") +// .preparationTime(LocalDateTime.now().plusHours(2)) +// .movingTime(LocalDateTime.now().plusHours(3)) +// .appointmentTime(LocalDateTime.now().plusHours(4)) +// .build(); +// +// // when +// RegisterFcmMessageAtSchedulerServiceResponse response = +// registerNotificationAtSchedulerService.registerFcmMessageForNewAppointment(request); +// +// // then +// assertThat(response.getNotifications()).hasSize(7); +// } +// +// @DisplayName("알림 목표 시간이 현재보다 과거이면 알림이 등록되지 않는다.") +// @Test +// void targetTimeIsBeforeNow() { +// // given +// RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest request = +// RegisterFcmMessageForNewAppointmentAtSchedulerServiceRequest.builder() +// .clientId("clientId") +// .deviceToken("deviceToken") +// .appointmentName("appointmentName") +// .preparationTime(LocalDateTime.now().minusHours(1)) +// .movingTime(LocalDateTime.now().minusHours(1)) +// .appointmentTime(LocalDateTime.now().minusHours(1)) +// .build(); +// +// // when +// RegisterFcmMessageAtSchedulerServiceResponse response = +// registerNotificationAtSchedulerService.registerFcmMessageForNewAppointment(request); +// +// // then +// assertThat(response.getNotifications()).hasSize(0); +// } +// +// } diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/SchedulingTaskListServiceTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/SchedulingTaskListServiceTest.java new file mode 100644 index 0000000..cf04e69 --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/SchedulingTaskListServiceTest.java @@ -0,0 +1,10 @@ +package earlybird.earlybird.scheduler.notification.fcm.service; + +import static org.junit.jupiter.api.Assertions.*; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest +class SchedulingTaskListServiceTest {} diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/SendMessageToFcmServiceTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/SendMessageToFcmServiceTest.java new file mode 100644 index 0000000..b1eb16a --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/SendMessageToFcmServiceTest.java @@ -0,0 +1,120 @@ +// package earlybird.earlybird.scheduler.notification.fcm.service; +// +// import com.google.firebase.messaging.FirebaseMessagingException; +// import earlybird.earlybird.appointment.domain.Appointment; +// import earlybird.earlybird.appointment.domain.AppointmentRepository; +// import earlybird.earlybird.scheduler.notification.fcm.domain.FcmNotification; +// import earlybird.earlybird.scheduler.notification.fcm.domain.FcmNotificationRepository; +// import earlybird.earlybird.scheduler.notification.fcm.domain.NotificationStep; +// import earlybird.earlybird.messaging.request.SendMessageByTokenServiceRequest; +// import org.awaitility.Awaitility; +// import org.junit.jupiter.api.AfterEach; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.boot.test.context.SpringBootTest; +// import org.springframework.boot.test.mock.mockito.MockBean; +// import org.springframework.test.context.ActiveProfiles; +// +// import java.time.LocalDateTime; +// import java.util.Optional; +// import java.util.concurrent.ExecutionException; +// import java.util.concurrent.TimeUnit; +// +// import static earlybird.earlybird.scheduler.notification.fcm.domain.NotificationStatus.COMPLETED; +// import static +// earlybird.earlybird.scheduler.notification.fcm.domain.NotificationStep.APPOINTMENT_TIME; +// import static org.assertj.core.api.Assertions.assertThat; +// import static org.mockito.Mockito.doReturn; +// +// @ActiveProfiles("test") +// @SpringBootTest +// class SendMessageToFcmServiceTest { +// +// @Autowired +// private FcmNotificationRepository fcmNotificationRepository; +// +// @Autowired +// private AppointmentRepository appointmentRepository; +// +// @Autowired +// private SendMessageToFcmService sendMessageToFcmService; +// +// @MockBean +// private MessagingService messagingService; +// +// @AfterEach +// void tearDown() { +// fcmNotificationRepository.deleteAllInBatch(); +// appointmentRepository.deleteAllInBatch(); +// } +// +// @DisplayName("FCM 으로 메시지를 보내고 전송 정보를 DB에 업데이트한다.") +// @Test +// void sendMessageByToken() throws FirebaseMessagingException, ExecutionException, +// InterruptedException { +// // given +// String title = "title"; +// String body = "body"; +// String uuid = "uuid"; +// String deviceToken = "deviceToken"; +// LocalDateTime targetTime = LocalDateTime.of(2024, 10, 9, 0, 0); +// +// +// Appointment appointment = createAppointment(); +// FcmNotification fcmNotification = createFcmNotification(appointment, targetTime, +// APPOINTMENT_TIME); +// appointment.addFcmNotification(fcmNotification); +// Appointment savedAppointment = appointmentRepository.save(appointment); +// FcmNotification savedNotification = fcmNotificationRepository.save(fcmNotification); +// +// SendMessageByTokenServiceRequest request = createRequest(title, body, deviceToken, +// savedNotification.getId()); +// +// String fcmMessageId = "fcm-message-id"; +// doReturn(fcmMessageId).when(messagingService).send(request); +// +// // when +// sendMessageToFcmService.sendMessageByToken(request); +// +// // then +// Awaitility.await() +// .atMost(5, TimeUnit.SECONDS) +// .until(() -> +// fcmNotificationRepository.findById(savedNotification.getId()).get().getStatus().equals(COMPLETED)); +// +// Optional optional = +// fcmNotificationRepository.findById(savedNotification.getId()); +// assertThat(optional).isPresent(); +// assertThat(optional.get().getStatus()).isEqualTo(COMPLETED); +// assertThat(optional.get().getSentTime()).isNotNull(); +// } +// +// private Appointment createAppointment() { +// return appointmentRepository.save(Appointment.builder() +// .appointmentName("appointmentName") +// .clientId("clientId") +// .deviceToken("deviceToken") +// .build()); +// } +// +// private FcmNotification createFcmNotification(Appointment appointment, LocalDateTime +// targetTime, NotificationStep notificationStep) { +// return FcmNotification.builder() +// .appointment(appointment) +// .targetTime(targetTime) +// .notificationStep(notificationStep) +// .build(); +// } +// +// private SendMessageByTokenServiceRequest createRequest(String title, String body, String +// deviceToken, Long notificationId) { +// return SendMessageByTokenServiceRequest.builder() +// .title(title) +// .body(body) +// .deviceToken(deviceToken) +// .notificationId(notificationId) +// .build(); +// } +// +// } diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/UpdateNotificationServiceTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/UpdateNotificationServiceTest.java new file mode 100644 index 0000000..dee4112 --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/UpdateNotificationServiceTest.java @@ -0,0 +1,165 @@ +// package earlybird.earlybird.scheduler.notification.fcm.service; +// +// import com.google.firebase.messaging.FirebaseMessagingException; +// import earlybird.earlybird.error.exception.AlreadySentFcmNotificationException; +// import earlybird.earlybird.error.exception.FcmDeviceTokenMismatchException; +// import earlybird.earlybird.error.exception.FcmMessageTimeBeforeNowException; +// import earlybird.earlybird.scheduler.notification.fcm.domain.FcmNotification; +// import earlybird.earlybird.scheduler.notification.fcm.domain.FcmNotificationRepository; +// import +// earlybird.earlybird.scheduler.notification.fcm.service.request.AddTaskToSchedulingTaskListServiceRequest; +// import +// earlybird.earlybird.scheduler.notification.fcm.service.update.request.UpdateFcmMessageServiceRequest; +// import +// earlybird.earlybird.scheduler.notification.fcm.service.update.response.UpdateFcmMessageServiceResponse; +// import org.junit.jupiter.api.AfterEach; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +// import org.springframework.boot.test.context.SpringBootTest; +// import org.springframework.boot.test.mock.mockito.MockBean; +// import org.springframework.test.context.ActiveProfiles; +// +// import java.time.LocalDateTime; +// import java.time.ZoneId; +// +// import static org.assertj.core.api.Assertions.assertThat; +// import static org.assertj.core.api.Assertions.assertThatThrownBy; +// import static org.junit.jupiter.api.Assertions.*; +// import static org.mockito.ArgumentMatchers.any; +// import static org.mockito.Mockito.doReturn; +// +// @ActiveProfiles("test") +// @SpringBootTest +// class UpdateNotificationServiceTest { +// +// @Autowired +// private UpdateNotificationService service; +// +// @Autowired +// private FcmNotificationRepository repository; +// +// @Autowired +// private SchedulingTaskListService schedulingTaskListService; +// +// @MockBean +// private FirebaseMessagingService firebaseMessagingService; +// +// @AfterEach +// void tearDown() { +// repository.deleteAllInBatch(); +// } +// +// @DisplayName("알림 정보와 스케줄링 정보를 업데이트한다.") +// @Test +// void update() throws FirebaseMessagingException { +// // given +// String deviceToken = "deviceToken"; +// LocalDateTime savedTargetTime = LocalDateTime.now().plusDays(1); +// +// FcmNotification notification = createFcmNotification(deviceToken, savedTargetTime); +// FcmNotification savedNotification = repository.save(notification); +// +// AddTaskToSchedulingTaskListServiceRequest addRequest = +// AddTaskToSchedulingTaskListServiceRequest.builder() +// .body(savedNotification.getBody()) +// .deviceToken(deviceToken) +// .title(notification.getTitle()) +// .uuid(notification.getUuid()) +// .targetTime(savedTargetTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()) +// .build(); +// schedulingTaskListService.add(addRequest); +// +// LocalDateTime updatedTargetTime = LocalDateTime.now().plusDays(2).withNano(0); +// +// UpdateFcmMessageServiceRequest request = UpdateFcmMessageServiceRequest.builder() +// .notificationId(savedNotification.getId()) +// .deviceToken(deviceToken) +// .targetTime(updatedTargetTime) +// .build(); +// +// String fcmMessageId = "fcm-message-id"; +// doReturn(fcmMessageId).when(firebaseMessagingService).send(any()); +// +// // when +// UpdateFcmMessageServiceResponse response = service.update(request); +// +// // then +// assertThat(response.getNotificationId()).isEqualTo(savedNotification.getId()); +// assertThat(response.getUpdatedTargetTime()).isEqualTo(updatedTargetTime); +// +// FcmNotification updatedNotification = +// repository.findById(response.getNotificationId()).get(); +// assertThat(updatedNotification.getTargetTime()).isEqualTo(updatedTargetTime); +// } +// +// @DisplayName("현재 시간보다 과거의 시간으로 알림 시간을 수정하려고 하면 예외가 발생한다.") +// @Test +// void updateTargetTimeBeforeNow() { +// // given +// UpdateFcmMessageServiceRequest request = +// UpdateFcmMessageServiceRequest.builder().targetTime(LocalDateTime.now().minusSeconds(1)).build(); +// +// // when // then +// assertThatThrownBy(() -> service.update(request)) +// .isInstanceOf(FcmMessageTimeBeforeNowException.class); +// } +// +// @DisplayName("알림을 수정하려고 할 때 저장되어 있는 알림의 디바이스 토큰과 요청 디바이스 토큰이 다르면 예외가 발생한다.") +// @Test +// void deviceTokenMismatch() { +// // given +// String deviceToken = "deviceToken"; +// LocalDateTime savedTargetTime = LocalDateTime.now(); +// +// FcmNotification notification = createFcmNotification(deviceToken, savedTargetTime); +// FcmNotification savedNotification = repository.save(notification); +// +// String mismatchDeviceToken = "mismatchDeviceToken"; +// UpdateFcmMessageServiceRequest request = UpdateFcmMessageServiceRequest.builder() +// .deviceToken(mismatchDeviceToken) +// .targetTime(LocalDateTime.now().plusDays(1)) +// .notificationId(savedNotification.getId()) +// .build(); +// +// // when // then +// assertThatThrownBy(() -> service.update(request)) +// .isInstanceOf(FcmDeviceTokenMismatchException.class); +// } +// +// @DisplayName("이미 전송한 알림을 수정하려고 하면 예외가 발생한다.") +// @Test +// void alreadySentFcmNotification() { +// // given +// FcmNotification notification = createFcmNotification("deviceToken", +// LocalDateTime.now().minusDays(1)); +// notification.onSendToFcmSuccess("fcmMessageId"); +// repository.save(notification); +// +// UpdateFcmMessageServiceRequest request = UpdateFcmMessageServiceRequest.builder() +// .notificationId(notification.getId()) +// .targetTime(LocalDateTime.now().plusDays(1)) +// .deviceToken(notification.getDeviceToken()) +// .build(); +// +// // when // then +// assertThatThrownBy(() -> service.update(request)) +// .isInstanceOf(AlreadySentFcmNotificationException.class); +// +// } +// +// +// private FcmNotification createFcmNotification(String deviceToken, LocalDateTime +// savedTargetTime) { +// return FcmNotification.builder() +// .body("바디") +// .uuid("uuid") +// .title("제목") +// .deviceToken(deviceToken) +// .targetTime(savedTargetTime) +// .build(); +// } +// +// +// } diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/manager/NotificationSchedulerManagerStub.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/manager/NotificationSchedulerManagerStub.java new file mode 100644 index 0000000..2d757dc --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/manager/NotificationSchedulerManagerStub.java @@ -0,0 +1,30 @@ +package earlybird.earlybird.scheduler.notification.service.manager; + +import earlybird.earlybird.scheduler.manager.NotificationSchedulerManager; +import earlybird.earlybird.scheduler.manager.request.AddNotificationToSchedulerServiceRequest; + +import java.util.ArrayList; +import java.util.List; + +public class NotificationSchedulerManagerStub implements NotificationSchedulerManager { + private final List notificationIds = new ArrayList(); + + @Override + public void init() { + return; + } + + @Override + public void add(AddNotificationToSchedulerServiceRequest request) { + this.notificationIds.add(request.getNotificationId()); + } + + @Override + public void remove(Long notificationId) { + this.notificationIds.remove(notificationId); + } + + public List getNotificationIds() { + return notificationIds; + } +} diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/register/AppointmentRepositoryStub.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/register/AppointmentRepositoryStub.java new file mode 100644 index 0000000..909857f --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/register/AppointmentRepositoryStub.java @@ -0,0 +1,149 @@ +package earlybird.earlybird.scheduler.notification.service.register; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.domain.AppointmentRepository; + +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.FluentQuery; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +public class AppointmentRepositoryStub implements AppointmentRepository { + @Override + public void flush() {} + + @Override + public S saveAndFlush(S entity) { + return null; + } + + @Override + public List saveAllAndFlush(Iterable entities) { + return List.of(); + } + + @Override + public void deleteAllInBatch(Iterable entities) {} + + @Override + public void deleteAllByIdInBatch(Iterable longs) {} + + @Override + public void deleteAllInBatch() {} + + @Override + public Appointment getOne(Long aLong) { + return null; + } + + @Override + public Appointment getById(Long aLong) { + return null; + } + + @Override + public Appointment getReferenceById(Long aLong) { + return null; + } + + @Override + public Optional findOne(Example example) { + return Optional.empty(); + } + + @Override + public List findAll(Example example) { + return List.of(); + } + + @Override + public List findAll(Example example, Sort sort) { + return List.of(); + } + + @Override + public Page findAll(Example example, Pageable pageable) { + return null; + } + + @Override + public long count(Example example) { + return 0; + } + + @Override + public boolean exists(Example example) { + return false; + } + + @Override + public R findBy( + Example example, Function, R> queryFunction) { + return null; + } + + @Override + public S save(S entity) { + return null; + } + + @Override + public List saveAll(Iterable entities) { + return List.of(); + } + + @Override + public Optional findById(Long aLong) { + return Optional.empty(); + } + + @Override + public boolean existsById(Long aLong) { + return false; + } + + @Override + public List findAll() { + return List.of(); + } + + @Override + public List findAllById(Iterable longs) { + return List.of(); + } + + @Override + public long count() { + return 0; + } + + @Override + public void deleteById(Long aLong) {} + + @Override + public void delete(Appointment entity) {} + + @Override + public void deleteAllById(Iterable longs) {} + + @Override + public void deleteAll(Iterable entities) {} + + @Override + public void deleteAll() {} + + @Override + public List findAll(Sort sort) { + return List.of(); + } + + @Override + public Page findAll(Pageable pageable) { + return null; + } +} diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/register/RegisterAllNotificationAtSchedulerServiceTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/register/RegisterAllNotificationAtSchedulerServiceTest.java new file mode 100644 index 0000000..d44ec45 --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/register/RegisterAllNotificationAtSchedulerServiceTest.java @@ -0,0 +1,52 @@ +package earlybird.earlybird.scheduler.notification.service.register; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.domain.CreateTestAppointment; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepository; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepositoryStub; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; +import earlybird.earlybird.scheduler.notification.service.manager.NotificationSchedulerManagerStub; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +class RegisterAllNotificationAtSchedulerServiceTest { + + private FcmNotificationRepository fcmNotificationRepository; + private NotificationSchedulerManagerStub schedulerManager; + + @BeforeEach + void setUp() { + fcmNotificationRepository = new FcmNotificationRepositoryStub(); + schedulerManager = new NotificationSchedulerManagerStub(); + } + + @DisplayName("등록 시점보다 미래의 다수 알림을 등록한다.") + @Test + void register() { + // given + RegisterAllNotificationAtSchedulerService service = + new RegisterAllNotificationAtSchedulerService( + fcmNotificationRepository, schedulerManager); + + Appointment appointment = CreateTestAppointment.create(); + Map notificationInfo = + Map.of( + NotificationStep.APPOINTMENT_TIME, Instant.now().plusSeconds(10), + NotificationStep.MOVING_TIME, Instant.now().plusSeconds(10)); + + // when + service.register(appointment, notificationInfo); + + // then + assertThat(appointment.getFcmNotifications()).hasSize(notificationInfo.size()); + assertThat(schedulerManager.getNotificationIds()).hasSize(notificationInfo.size()); + } +} diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/register/RegisterOneNotificationAtSchedulerServiceTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/register/RegisterOneNotificationAtSchedulerServiceTest.java new file mode 100644 index 0000000..03bf207 --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/register/RegisterOneNotificationAtSchedulerServiceTest.java @@ -0,0 +1,89 @@ +package earlybird.earlybird.scheduler.notification.service.register; + +import static earlybird.earlybird.scheduler.notification.domain.NotificationStep.*; + +import static org.assertj.core.api.Assertions.*; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.domain.CreateTestAppointment; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepository; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepositoryStub; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; +import earlybird.earlybird.scheduler.notification.service.manager.NotificationSchedulerManagerStub; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.*; +import java.util.Map; + +class RegisterOneNotificationAtSchedulerServiceTest { + private FcmNotificationRepository fcmNotificationRepository; + private NotificationSchedulerManagerStub schedulerManager; + + @BeforeEach + void setUp() { + fcmNotificationRepository = new FcmNotificationRepositoryStub(); + schedulerManager = new NotificationSchedulerManagerStub(); + } + + @DisplayName("알림 1개를 스케줄러에 등록한다.") + @Test + void register() { + // given + RegisterOneNotificationAtSchedulerService service = + new RegisterOneNotificationAtSchedulerService( + fcmNotificationRepository, schedulerManager); + + Appointment appointment = CreateTestAppointment.create(); + + Map notificationInfo = + Map.of(APPOINTMENT_TIME, Instant.now().plusSeconds(10)); + + // when + service.register(appointment, notificationInfo); + + // then + assertThat(appointment.getFcmNotifications()).hasSize(1); + assertThat(schedulerManager.getNotificationIds()).hasSize(1); + } + + @DisplayName("1개 이상의 알림 등록을 요청하면 예외가 발생한다.") + @Test + void registerMoreThanOne() { + // given + RegisterOneNotificationAtSchedulerService service = + new RegisterOneNotificationAtSchedulerService( + fcmNotificationRepository, schedulerManager); + + Appointment appointment = CreateTestAppointment.create(); + Map notificationInfo = + Map.of( + APPOINTMENT_TIME, Instant.now().plusSeconds(10), + MOVING_TIME, Instant.now().plusSeconds(10)); + + // when // then + assertThatThrownBy(() -> service.register(appointment, notificationInfo)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("등록 시점보다 과거의 알림은 등록되지 않는다.") + @Test + void test() { + // given + RegisterOneNotificationAtSchedulerService service = + new RegisterOneNotificationAtSchedulerService( + fcmNotificationRepository, schedulerManager); + Appointment appointment = CreateTestAppointment.create(); + Map notificationInfo = + Map.of(APPOINTMENT_TIME, Instant.now().minusSeconds(1)); + + // when + service.register(appointment, notificationInfo); + + // then + assertThat(appointment.getFcmNotifications()).hasSize(0); + assertThat(schedulerManager.getNotificationIds()).hasSize(0); + } +} diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/request/RegisterFcmMessageAtSchedulerServiceRequestTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/request/RegisterFcmMessageAtSchedulerServiceRequestTest.java new file mode 100644 index 0000000..b1c4898 --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/request/RegisterFcmMessageAtSchedulerServiceRequestTest.java @@ -0,0 +1,91 @@ +// package earlybird.earlybird.scheduler.notification.fcm.service.request; +// +// import earlybird.earlybird.scheduler.notification.fcm.domain.FcmNotification; +// import org.junit.jupiter.api.DisplayName; +// import org.junit.jupiter.api.Test; +// import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +// import org.springframework.test.context.ActiveProfiles; +// +// import java.time.Instant; +// import java.time.LocalDateTime; +// import java.time.ZoneId; +// import java.time.format.DateTimeFormatter; +// +// import static earlybird.earlybird.scheduler.notification.fcm.domain.FcmNotificationStatus.*; +// import static org.assertj.core.api.Assertions.assertThat; +// +// class RegisterFcmMessageAtSchedulerServiceRequestTest { +// +// @DisplayName("LocalDateTime 으로 저장되어 있는 전송 목표 시간을 Instant 로 변환해서 가져온다.") +// @Test +// void getTargetTimeInstant() { +// // given +// LocalDateTime targetTime = LocalDateTime.of(2024, 10, 11, 1, 2, 3); +// RegisterFcmMessageAtSchedulerServiceRequest request = +// RegisterFcmMessageAtSchedulerServiceRequest.builder() +// .targetTime(targetTime) +// .build(); +// +// // when +// Instant result = request.getTargetTimeInstant(); +// +// // then +// assertThat(result.atZone(ZoneId.of("Asia/Seoul")).toString().split("\\+")[0]) +// .isEqualTo(targetTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); +// } +// +// @DisplayName("RegisterFcmMessageAtSchedulerServiceRequest 객체를 FcmNotification 객체로 변환한다.") +// @Test +// void toFcmNotification() { +// // given +// LocalDateTime targetTime = LocalDateTime.of(2024, 10, 11, 1, 2, 3); +// RegisterFcmMessageAtSchedulerServiceRequest request = +// RegisterFcmMessageAtSchedulerServiceRequest.builder() +// .title("제목") +// .body("바디") +// .deviceToken("디바이스 토큰") +// .targetTime(targetTime) +// .build(); +// +// // when +// FcmNotification result = request.toFcmNotification(); +// +// // then +// assertThat(result).extracting("uuid", "title", "body", "deviceToken", "targetTime", +// "status") +// .containsExactly( +// request.getUuid(), request.getTitle(), request.getBody(), +// request.getDeviceToken(), +// targetTime, PENDING +// ); +// +// } +// +// @DisplayName("Builder 패턴을 이용해 객체를 생성할 수 있다.") +// @Test +// void builder() { +// // given +// String title = "title"; +// String body = "body"; +// String deviceToken = "deviceToken"; +// LocalDateTime targetTime = LocalDateTime.of(2024, 10, 11, 1, 2, 3); +// +// // when +// RegisterFcmMessageAtSchedulerServiceRequest result = +// RegisterFcmMessageAtSchedulerServiceRequest.builder() +// .title(title) +// .body(body) +// .deviceToken(deviceToken) +// .targetTime(targetTime) +// .build(); +// +// // then +// assertThat(result).extracting("title", "body", "deviceToken", "targetTime") +// .containsExactly(title, body, deviceToken, targetTime); +// assertThat(result.getUuid()).isNotNull(); +// +// } +// +// +// +// } diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationServiceTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationServiceTest.java new file mode 100644 index 0000000..5614aae --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationServiceTest.java @@ -0,0 +1,136 @@ +package earlybird.earlybird.scheduler.notification.service.update; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import earlybird.earlybird.appointment.domain.Appointment; +import earlybird.earlybird.appointment.domain.CreateTestAppointment; +import earlybird.earlybird.appointment.service.FindAppointmentService; +import earlybird.earlybird.scheduler.notification.domain.NotificationStep; +import earlybird.earlybird.scheduler.notification.domain.NotificationUpdateType; +import earlybird.earlybird.scheduler.notification.service.NotificationInfoFactory; +import earlybird.earlybird.scheduler.notification.service.deregister.DeregisterNotificationService; +import earlybird.earlybird.scheduler.notification.service.register.RegisterNotificationService; +import earlybird.earlybird.scheduler.notification.service.update.request.UpdateFcmMessageServiceRequest; +import earlybird.earlybird.scheduler.notification.service.update.response.UpdateFcmMessageServiceResponse; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.HashMap; + +@ExtendWith(MockitoExtension.class) +class UpdateNotificationServiceTest { + + @Mock private RegisterNotificationService registerNotificationService; + + @Mock private DeregisterNotificationService deregisterNotificationService; + + @Mock private FindAppointmentService findAppointmentService; + + @Mock private NotificationInfoFactory notificationInfoFactory; + + @InjectMocks private UpdateNotificationService updateNotificationService; + + private Appointment appointment; + private Long appointmentId; + private NotificationUpdateType updateType; + private final HashMap notificationInfo = new HashMap<>(); + + @BeforeEach + void setUp() { + this.appointment = CreateTestAppointment.create(); + this.appointmentId = 1L; + this.updateType = NotificationUpdateType.MODIFY; + + when(findAppointmentService.findBy(appointmentId, appointment.getClientId())) + .thenReturn(appointment); + when(notificationInfoFactory.createTargetTimeMap(any(LocalDateTime.class), any(), any())) + .thenReturn(notificationInfo); + } + + @DisplayName("업데이트를 하면 등록되어 있던 알림을 해제하고 새로운 알림을 등록한다.") + @Test + void updateThenDeregisterAndRegisterNotification() { + // given + + UpdateFcmMessageServiceRequest request = + UpdateFcmMessageServiceRequest.builder() + .appointmentId(appointmentId) + .appointmentName(appointment.getAppointmentName()) + .clientId(appointment.getClientId()) + .deviceToken(appointment.getDeviceToken()) + .preparationTime(LocalDateTime.now()) + .movingTime(LocalDateTime.now()) + .appointmentTime(LocalDateTime.now()) + .updateType(updateType) + .build(); + + // when + updateNotificationService.update(request); + + // then + verify(registerNotificationService).register(appointment, notificationInfo); + verify(deregisterNotificationService).deregister(any()); + } + + @DisplayName("디바이스 토큰이 저장되어 있는 값과 업데이트시 요청 값이 다르면 업데이트한다.") + @Test + void changeDeviceTokenWhenRequestDeviceTokenIsDifferent() { + // given + String newDeviceToken = "newDeviceToken"; + + UpdateFcmMessageServiceRequest request = + UpdateFcmMessageServiceRequest.builder() + .appointmentId(appointmentId) + .appointmentName(appointment.getAppointmentName()) + .clientId(appointment.getClientId()) + .deviceToken(newDeviceToken) + .preparationTime(LocalDateTime.now()) + .movingTime(LocalDateTime.now()) + .appointmentTime(LocalDateTime.now()) + .updateType(updateType) + .build(); + + // when + UpdateFcmMessageServiceResponse response = updateNotificationService.update(request); + + // then + assertThat(appointment.getDeviceToken()).isEqualTo(newDeviceToken); + assertThat(response.getAppointment().getDeviceToken()).isEqualTo(newDeviceToken); + } + + @DisplayName("약속 이름이 저장되어 있는 값과 업데이트시 요청 값이 다르면 업데이트한다.") + @Test + void changeAppointmentNameWhenRequestDeviceTokenIsDifferent() { + // given + String newAppointmentName = "newAppointmentName"; + + UpdateFcmMessageServiceRequest request = + UpdateFcmMessageServiceRequest.builder() + .appointmentId(appointmentId) + .appointmentName(newAppointmentName) + .clientId(appointment.getClientId()) + .deviceToken(appointment.getDeviceToken()) + .preparationTime(LocalDateTime.now()) + .movingTime(LocalDateTime.now()) + .appointmentTime(LocalDateTime.now()) + .updateType(updateType) + .build(); + + // when + UpdateFcmMessageServiceResponse response = updateNotificationService.update(request); + + // then + + } +} diff --git a/src/test/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationStatusServiceTest.java b/src/test/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationStatusServiceTest.java new file mode 100644 index 0000000..ff1b892 --- /dev/null +++ b/src/test/java/earlybird/earlybird/scheduler/notification/service/update/UpdateNotificationStatusServiceTest.java @@ -0,0 +1,74 @@ +package earlybird.earlybird.scheduler.notification.service.update; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import earlybird.earlybird.error.exception.NotificationNotFoundException; +import earlybird.earlybird.scheduler.notification.domain.CreateTestFcmNotification; +import earlybird.earlybird.scheduler.notification.domain.FcmNotification; +import earlybird.earlybird.scheduler.notification.domain.FcmNotificationRepository; +import earlybird.earlybird.scheduler.notification.domain.NotificationStatus; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +@ExtendWith(MockitoExtension.class) +class UpdateNotificationStatusServiceTest { + + @Mock private FcmNotificationRepository notificationRepository; + + @InjectMocks private UpdateNotificationStatusService service; + + @DisplayName("푸시 알림 전송 성공 시 푸시 알림 정보를 성공으로 업데이트한다.") + @Test + void updateToSuccess() { + // given + FcmNotification notification = CreateTestFcmNotification.create(); + + Long notificationId = 1L; + + when(notificationRepository.findById(notificationId)).thenReturn(Optional.of(notification)); + + // when + service.update(notificationId, true); + + // then + assertThat(notification.getStatus()).isEqualTo(NotificationStatus.COMPLETED); + } + + @DisplayName("푸시 알림 전송 실패 시 푸시 알림 정보를 실패로 업데이트한다.") + @Test + void updateToFailure() { + // given + FcmNotification notification = CreateTestFcmNotification.create(); + Long notificationId = 1L; + when(notificationRepository.findById(notificationId)).thenReturn(Optional.of(notification)); + + // when + service.update(notificationId, false); + + // then + assertThat(notification.getStatus()).isEqualTo(NotificationStatus.FAILED); + } + + @DisplayName("업데이트 대상 알림이 존재하지 않으면 예외가 발생한다.") + @Test + void throwExceptionWhenNotificationNotFound() { + // given + Long notificationId = 1L; + when(notificationRepository.findById(notificationId)).thenReturn(Optional.empty()); + + // when // then + assertThatThrownBy(() -> service.update(notificationId, true)) + .isInstanceOf(NotificationNotFoundException.class); + assertThatThrownBy(() -> service.update(notificationId, false)) + .isInstanceOf(NotificationNotFoundException.class); + } +}