Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/alarm #51

Merged
merged 2 commits into from
Sep 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.swygbro.trip.backend.domain.alarm.api;

import com.swygbro.trip.backend.domain.alarm.application.AlarmService;
import com.swygbro.trip.backend.domain.alarm.dto.AlarmDto;
import com.swygbro.trip.backend.domain.user.domain.User;
import com.swygbro.trip.backend.global.jwt.CurrentUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class AlarmController {

private final AlarmService alarmService;

@GetMapping("/alarm/subscribe")
@PreAuthorize("isAuthenticated() and hasRole('USER') and #user.id == principal.id")
@SecurityRequirement(name = "access-token")
@Operation(summary = "알람 SSE 연결", description = """
# 알람 SSE 연결

알람 서비스를 이용하기 위해선 SSE 연결 api입니다.
SSE는 연결 후 1시간 이후 연결이 종료 됩니다.

SSE 연결 시 Event name은 'open' 입니다.
SSE 연결 후 SSE 미연결 시 수신받은 알람이 존재할 경우 Event name은 'alarm' 입니다.

## 응답

- 연결 성공 시 'connect completed' 를 반환합니다.
- SSE 미연결 시 수신받은 알람이 존재할 경우 연결 성공 시 수신받은 알람을 전송합니다.
- 연결 실패 시 '500' 에러를 반환합니다.
""", tags = "Alarm")
@ApiResponse(
responseCode = "200",
description = "알람 연결 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = SseEmitter.class),
examples = {@ExampleObject(name = "알람 연결 성공", value = "{ \"connect completed\"}"),
@ExampleObject(name = "수신 받지 못한 알람 전송",
value = "{\"AlarmId\" : \"알람 ID\", \"FromUserId\" : \"알람을 보낸 유저 ID\", \"TargetId\" : \"타깃 ID\", \"AlarmType\" : \"리뷰, 예약등 알람 타입\", \"isRead\" : \"알람 읽음 여부\" }"
)}
)
)
@ApiResponse(
responseCode = "500",
description = "알람 연결 실패",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ApiResponse.class),
examples = @ExampleObject(
name = "알람 연결 실패",
value = "{ \"status\" : \"INTERNAL_SERVER_ERROR\", \"message\" : \"해당 유저와 연결에 실패했습니다\"}"
)
)
)
public SseEmitter subscribe(@CurrentUser User user) {
return alarmService.connectAlarm(user.getId());
}

@GetMapping("/alarm")
@PreAuthorize("isAuthenticated() and hasRole('USER') and #user.id == principal.id")
@SecurityRequirement(name = "access-token")
@Operation(summary = "알람 조회", description = """
# 알람 조회

수신 받은 알람 리스트를 조회합니다.

size = 5, sort = createdAt, 내림차순 방식으로 페이징 합니다.

## 각 필드의 제약 조건은 다음과 같습니다.
| 필드명 | 설명 | 제약조건 | 예시 |
|--------|------|----------|----------|
|isRead| 알람 확인 유무 | Integer(required = false) | 1 |

알람 확인 유무 조건 없이 모든 리스트를 불러오고 싶을땐 isRead 입력X
isRead가 0일 경우 알람 확인을 안한 알람 리스트 조회
isRead가 1일 경우 알람 확인을 한 알람 리스트 조회

## 응답

- 조회 성공 시 `200` 코드와 함께 알람 리스트 정보를 json 형태로 반환합니다.
""", tags = "Alarm")
@ApiResponse(
responseCode = "200",
description = "알람 조회 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = AlarmDto.class)
)
)
public Page<AlarmDto> getAlarmList(@CurrentUser User user,
@RequestParam(required = false) Integer isRead,
@PageableDefault(size = 5, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
return alarmService.getAlarmList(user.getId(), isRead, pageable);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.swygbro.trip.backend.domain.alarm.api;

import com.swygbro.trip.backend.domain.alarm.application.AlarmService;
import com.swygbro.trip.backend.domain.alarm.dto.AlarmDto;
import com.swygbro.trip.backend.domain.reservation.domain.Reservation;
import com.swygbro.trip.backend.domain.review.domain.Review;
import com.swygbro.trip.backend.global.exception.BaseException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class ConnectAlarmController {

private final AlarmService alarmService;

@PostMapping("/alarm/{alarmId}")
@PreAuthorize("isAuthenticated() and hasRole('USER')")
@SecurityRequirement(name = "access-token")
@Operation(summary = "알람 클릭 시 타깃으로 이동", description = """
# 알람 클릭 시 타깃으로 이동

알람 클릭 시 해당 알람 타깃으로 이동합니다.
리뷰 알람일 경우 클릭 시 해당 리뷰로 이동.
예약 알람일 경우 클릭 시 해당 알람으로 이동.

## 각 필드의 제약 조건은 다음과 같습니다.
| 필드명 | 설명 | 제약조건 | 예시 |
|--------|------|----------|----------|
|alarmId| 알람 id | 숫자 | 1 |

## 응답

- 해당 타깃 정보를 조회해 줍니다.
- 잘못된 alarmType 입력 시 '404' 에러를 반환합니다.
- 리뷰, 예약 조회 시 발생하는 에러는 해당 api를 확인 해주세요.
""", tags = "Alarm")
@ApiResponse(
responseCode = "200",
description = "타깃 리뷰 조회 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = Review.class)
)
)
@ApiResponse(
responseCode = "200",
description = "타깃 예약 조회 성공",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = Reservation.class)
)
)
@ApiResponse(
responseCode = "404",
description = "조회 실패",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = BaseException.class),
examples = @ExampleObject(
name = "가이드 상품 조회 실패",
value = "{ \"status\" : \"NOT_FOUND\", \"message\" : \"해당 알람을 찾을 수 없습니다.\"}")
)
)
public String getAlarm(@PathVariable Long alarmId) {

AlarmDto alarmDto = alarmService.getAlarm(alarmId);
String path = null;
switch (alarmDto.getAlarmType().getType()) {
case "reservation" -> {
path = "redirect:/api/v1/reservation/" + alarmDto.getArgs().getTargetId();
}
case "review" -> {
path = "redirect:/api/v1/reviews/" + alarmDto.getArgs().getTargetId();
}
}

return path;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.swygbro.trip.backend.domain.alarm.application;

import com.swygbro.trip.backend.domain.alarm.domain.Alarm;
import com.swygbro.trip.backend.domain.alarm.domain.AlarmRepository;
import com.swygbro.trip.backend.domain.alarm.domain.AlarmStorage;
import com.swygbro.trip.backend.domain.alarm.domain.EmitterRepository;
import com.swygbro.trip.backend.domain.alarm.dto.AlarmDto;
import com.swygbro.trip.backend.domain.alarm.exception.AlarmNotConnectException;
import com.swygbro.trip.backend.domain.alarm.exception.NotFoundAlarmException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class AlarmService {
private final static Long DEFAULT_TIMEOUT = 60L * 1000 * 60;
private final EmitterRepository emitterRepository;
private final AlarmRepository alarmRepository;
private final AlarmStorage alarmStorage = new AlarmStorage();

public SseEmitter connectAlarm(Long userId) {
SseEmitter sseEmitter = new SseEmitter(DEFAULT_TIMEOUT);
emitterRepository.save(userId, sseEmitter);

sseEmitter.onCompletion(() -> emitterRepository.delete(userId));
sseEmitter.onTimeout(() -> emitterRepository.delete(userId));

try {
sseEmitter.send(SseEmitter.event().id("id").name("open").data("connect completed"));
} catch (IOException e) {
throw new AlarmNotConnectException(userId);
}
log.info("connect sse");
sendPendingAlarm(userId, sseEmitter);

return sseEmitter;
}

public void send(Long alarmId, Long receiveUserId, AlarmDto alarm) {
emitterRepository.get(receiveUserId).ifPresentOrElse(sseEmitter -> {
try {
sseEmitter.send(SseEmitter.event().id(alarmId.toString()).name("alarm").data(alarm.toString()));
log.info("send alarm");
} catch (IOException e) {
alarmStorage.addAlarm(receiveUserId, alarm);
throw new AlarmNotConnectException(receiveUserId);
}
}, () -> {
alarmStorage.addAlarm(receiveUserId, alarm);
log.info("No emitter founded");
});
}

public void sendPendingAlarm(Long userId, SseEmitter emitter) {
List<AlarmDto> alarms = alarmStorage.getAlarms(userId);
if (alarms != null) {
alarms.forEach(alarm -> {
try {
emitter.send(SseEmitter.event().id(alarm.getId().toString()).name("alarm").data(alarm.toString()));
} catch (IOException e) {
alarmStorage.addAlarm(userId, alarm);
throw new AlarmNotConnectException(userId);
}
});
}
}

public Page<AlarmDto> getAlarmList(Long userId, Integer isRead, Pageable pageable) {
return alarmRepository.findAllByUserId(userId, isRead, pageable);
}

public AlarmDto getAlarm(Long alarmId) {
Alarm alarm = alarmRepository.findById(alarmId).orElseThrow(NotFoundAlarmException::new);

alarm.setIsRead();
Alarm modifyAlarm = alarmRepository.saveAndFlush(alarm);

return AlarmDto.fromEntity(modifyAlarm);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.swygbro.trip.backend.domain.alarm.domain;

import com.swygbro.trip.backend.domain.user.domain.User;
import com.swygbro.trip.backend.global.entity.BaseEntity;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Type;

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class Alarm extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;

@Enumerated(EnumType.STRING)
@Column(name = "alarm_type", nullable = false)
private AlarmType alarmType;

@Type(JsonType.class)
@Column(columnDefinition = "longtext", nullable = false)
private AlarmArgs args;

@Column(name = "is_read", columnDefinition = "TINYINT(1)", nullable = false)
private Boolean isRead;

public void setIsRead() {
this.isRead = true;
}

public static Alarm of(User user, AlarmType alarmType, AlarmArgs args, boolean isRead) {
return Alarm
.builder()
.user(user)
.alarmType(alarmType)
.args(args)
.isRead(isRead)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.swygbro.trip.backend.domain.alarm.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class AlarmArgs<T> {
private Long fromUserId;
private T targetId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.swygbro.trip.backend.domain.alarm.domain;

import com.swygbro.trip.backend.domain.alarm.dto.AlarmDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface AlarmCustomRepository {
Page<AlarmDto> findAllByUserId(Long userId, Integer isRead, Pageable pageable);
}
Loading
Loading