Skip to content

Commit

Permalink
Feat: disaster tab (#99)
Browse files Browse the repository at this point in the history
* Feat: 재난 발생 시 fcm 발송 및 notification 저장

* Fix: resolved merge conflict

* Feat: 재난 발생 시 fcm 전송 handler 구현

* Feat: 재난 탭 조회 구현

* Feat: 재난 발생 이벤트 핸들러 기초 구현

* Feat: 단일 fcm 전송을 동기로 변경

* Feat: npe fix

* Feat: fix small bug

* Feat: fix small bug

* Feat: add docs

* Feat: small bug fix

* Feat: small bug fix

* Feat: small bug fix
  • Loading branch information
versatile0010 authored Nov 22, 2023
1 parent 590e425 commit f71a204
Show file tree
Hide file tree
Showing 24 changed files with 594 additions and 34 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/jib-build-depoly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:

- name: 🐧 create application.yml
run: |
mkdir ./src/main/resources
mkdir -p ./src/main/resources
cd ./src/main/resources
touch ./application.yml
echo "${{ secrets.PROPERTIES_PROD }}" | base64 --decode > ./application.yml
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

@SpringBootApplication
public class BackendApplication {

public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
package com.numberone.backend.domain.admin.controller;

import com.numberone.backend.domain.admin.dto.request.CreateDisasterEventDto;
import com.numberone.backend.domain.admin.dto.response.GetAddressResponse;
import com.numberone.backend.domain.admin.service.AdminService;
import com.numberone.backend.domain.disaster.dto.request.SaveDisasterRequest;
import com.numberone.backend.domain.disaster.event.DisasterEvent;
import com.numberone.backend.domain.disaster.service.DisasterService;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.List;
Expand All @@ -19,6 +21,7 @@
public class AdminController {

private final AdminService adminService;
private final DisasterService disasterService;

@Operation(summary = "서버에 지역별 대피소 정보 Json 파일로 업로드하기", description =
"""
Expand Down Expand Up @@ -58,4 +61,53 @@ public ResponseEntity<List<GetAddressResponse>> getAllAddressInfo() {
return ResponseEntity.ok(adminService.getAllAddressInfo());
}

@Operation(summary = "(테스트용) 재난 발생시키기", description = """
지원되는 재난 유형:
DROUGHT("가뭄"),
STRONG_WIND("강풍"),
DRYNESS("건조"),
HEAVY_SNOWFALL("대설"),
TIDAL_WAVE("대조기"),
FINE_DUST("미세먼지"),
WILDFIRE("산불"),
LANDSLIDE("산사태"),
FOG("안개"),
EARTHQUAKE("지진"),
TYPHOON("태풍"),
HEATWAVE("폭염"),
ROUGH_SEA("풍랑"),
COLD_WAVE("한파"),
HEAVY_RAIN("호우"),
FLOOD("홍수"),
GAS("가스"),
TRAFFIC("교통"),
FINANCE("금융"),
COLLAPSE("붕괴"),
WATER_SUPPLY("수도"),
ENERGY("에너지"),
MEDICAL("의료"),
INFECTIOUS_DISEASE("전염병"),
POWER_OUTAGE("정전"),
COMMUNICATION("통신"),
EXPLOSION("폭발"),
FIRE("화재"),
ENVIRONMENTAL_POLLUTION("환경오염사고"),
AI("AI"),
EMERGENCY("비상사태"),
TERROR("테러"),
CHEMICAL("화생방사고"),
MISSING("실종"),
OTHERS("기타"),
""")
@PostMapping("/disaster")
public ResponseEntity<SaveDisasterRequest> createDisaster(@RequestBody SaveDisasterRequest request){
disasterService.save(request);
return ResponseEntity.ok(request);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.numberone.backend.domain.admin.dto.request;

import com.numberone.backend.domain.disaster.util.DisasterType;
import lombok.*;

@ToString
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CreateDisasterEventDto {
DisasterType type;
String location;
String message;
Long disasterNum;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.numberone.backend.domain.disaster.dto.request;

import com.numberone.backend.domain.disaster.util.DisasterType;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;

@Getter
Expand All @@ -12,6 +13,7 @@ public class SaveDisasterRequest {
private String location;
private String msg;
private Long disasterNum;
@Schema(defaultValue = "2023/11/21 23:22:00")
private String createdAt;

public static SaveDisasterRequest of(DisasterType disasterType, String location, String msg, Long disasterNum, String createdAt) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.numberone.backend.domain.disaster.entity;

import com.numberone.backend.config.basetime.BaseTimeEntity;
import com.numberone.backend.domain.conversation.entity.Conversation;
import com.numberone.backend.domain.disaster.util.DisasterType;
import jakarta.persistence.*;
Expand All @@ -13,7 +14,7 @@
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Comment("재난 정보")
public class Disaster {
public class Disaster extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.numberone.backend.domain.disaster.event;

import com.numberone.backend.domain.disaster.entity.Disaster;
import com.numberone.backend.domain.disaster.util.DisasterType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;

@ToString
@Builder
@Getter
@AllArgsConstructor
public class DisasterEvent {
DisasterType type;
String location;
String message;
Long disasterNum;
public static DisasterEvent of (Disaster disaster){
return DisasterEvent.builder()
.type(disaster.getDisasterType())
.location(disaster.getLocation())
.message(disaster.getMsg())
.disasterNum(disaster.getDisasterNum())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.numberone.backend.domain.disaster.service;

import com.numberone.backend.domain.disaster.event.DisasterEvent;
import com.numberone.backend.domain.friendship.entity.Friendship;
import com.numberone.backend.domain.member.entity.Member;
import com.numberone.backend.domain.member.repository.MemberRepository;
import com.numberone.backend.domain.notification.entity.NotificationEntity;
import com.numberone.backend.domain.notification.entity.NotificationTag;
import com.numberone.backend.domain.notification.repository.NotificationRepository;
import com.numberone.backend.domain.notificationregion.repository.NotificationRegionRepository;
import com.numberone.backend.exception.notfound.NotFoundMemberException;
import com.numberone.backend.support.fcm.service.FcmMessageProvider;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionalEventListener;

import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;

@Slf4j
@RequiredArgsConstructor
@Service
public class DisasterEventHandler {
private final MemberRepository memberRepository;
private final FcmMessageProvider fcmMessageProvider;
private final NotificationRepository notificationRepository;
private final NotificationRegionRepository notificationRegionRepository;

@Transactional(jakarta.transaction.Transactional.TxType.REQUIRES_NEW)
@TransactionalEventListener
public void sendFcmMessagesByPresentLocation(DisasterEvent disasterEvent) {
log.info("[신규 재난 발생! Disaster event handler 가 동작합니다.]");
log.info("[sendFcmMessagesByPresentLocation]");

String type = disasterEvent.getType().code2kor();
String location = disasterEvent.getLocation();
Long disasterNum = disasterEvent.getDisasterNum();
String title = String.format("[긴급] %s %s 발생", location, type);
String message = "대피로에 접속하여 행동요령을 확인하세요!";

// 현재 재난 위치에 있는 회원에게 알림을 보낸다.
log.info("현재 재난 위치에 있는 회원에게 알림을 전송합니다.");
List<Long> memberIdListByPresentLocation = memberRepository.findAllByLocation(location);

List<String> targetMemberFcmTokens = memberIdListByPresentLocation.stream().map(memberId -> {
Member member = memberRepository.findById(memberId)
.orElseThrow(NotFoundMemberException::new);
NotificationEntity savedNotificationEntity = notificationRepository.save(
new NotificationEntity(member, disasterEvent.getType(), disasterEvent.getMessage(), true)
);
member.updateSafety(false);
log.info("received member id: {} Notification id: {} ", member.getId(), savedNotificationEntity.getId());
log.info(title);
log.info(message);
return member.getFcmToken();
}).filter(Objects::nonNull).toList();

// fcm 메세지 일괄 전송
fcmMessageProvider.sendFcmToMembers(targetMemberFcmTokens, title, message, NotificationTag.DISASTER);

log.info("위험 지역에 위치한 회원의 가족에게 알림을 보냅니다.");
// 해당 회원의 가족에게 알림을 보낸다.
String messageToFriend = "";
String titleToFriend = "";
memberIdListByPresentLocation.forEach(memberId -> {
Member member = memberRepository.findById(memberId)
.orElseThrow(NotFoundMemberException::new);

List<Member> friendList = member.getFriendships().stream()
.map(Friendship::getFriend).distinct().toList();

List<String> friendFcmTokens = friendList.stream().map(Member::getFcmToken).filter(Objects::nonNull).toList();


String memberName = member.getRealName() != null ? member.getRealName() : member.getNickName();
fcmMessageProvider.sendFcmToMembers(
friendFcmTokens,
String.format("가족 위험상태 변경 알림"),
String.format("""
%s님이 위험 지역에 있어요.
지금 바로 %s님에게 안부를 물어보세요!
""", memberName, memberName),
NotificationTag.FAMILY
);

friendList.forEach(friend ->
notificationRepository.save(
new NotificationEntity(friend, NotificationTag.FAMILY, titleToFriend, messageToFriend, true)
)
);
});

// 중복 알람 방지
List<Long> memberIdListByOnboardingRegions = memberRepository.findAll()
.stream().map(Member::getId)
.filter(id -> !memberIdListByPresentLocation.contains(id))
.toList();

log.info("회원이 재난문자 알림을 받고자 하는 지역에 대한 푸시알람을 중복을 제거하여 보냅니다.");
// 해당 회원의 온보딩 리스트를 기준으로 알림을 보낸다.
List<String> targetFcmListByOnboardingRegions = memberIdListByOnboardingRegions.stream()
.flatMap(memberId -> {
Member member = memberRepository.findById(memberId)
.orElseThrow(NotFoundMemberException::new);
boolean isMatched = notificationRegionRepository.findByMemberId(memberId)
.stream().anyMatch(
region -> region.getLocation().contains(location)
);
notificationRepository.save(
new NotificationEntity(member, disasterEvent.getType(), disasterEvent.getMessage(), true)
);
return isMatched ? Stream.of(member.getFcmToken()) : Stream.empty();
}).filter(Objects::nonNull).toList();
fcmMessageProvider.sendFcmToMembers(targetFcmListByOnboardingRegions, title, message, NotificationTag.DISASTER);

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.numberone.backend.domain.disaster.dto.response.SituationHomeResponse;
import com.numberone.backend.domain.disaster.dto.response.SituationResponse;
import com.numberone.backend.domain.disaster.entity.Disaster;
import com.numberone.backend.domain.disaster.event.DisasterEvent;
import com.numberone.backend.domain.disaster.repository.DisasterRepository;
import com.numberone.backend.domain.disaster.util.DisasterType;
import com.numberone.backend.domain.member.entity.Member;
Expand All @@ -22,6 +23,7 @@
import com.numberone.backend.util.LocationProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -42,6 +44,7 @@ public class DisasterService {
private final MemberService memberService;
private final ConversationService conversationService;
private final ConversationRepository conversationRepository;
private final ApplicationEventPublisher eventPublisher;

public LatestDisasterResponse getLatestDisaster(String email, LatestDisasterRequest latestDisasterRequest) {
String address = locationProvider.pos2address(latestDisasterRequest.getLatitude(), latestDisasterRequest.getLongitude());
Expand All @@ -53,7 +56,7 @@ public LatestDisasterResponse getLatestDisaster(String email, LatestDisasterRequ
}
disasters.removeIf(disaster -> !isValidDisasterType(disaster.getDisasterType(), member.getNotificationDisasters()));

if(disasters.isEmpty())
if (disasters.isEmpty())
return LatestDisasterResponse.notExist();

return LatestDisasterResponse.of(disasters.stream()
Expand All @@ -66,14 +69,18 @@ public LatestDisasterResponse getLatestDisaster(String email, LatestDisasterRequ
public void save(SaveDisasterRequest saveDisasterRequest) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
LocalDateTime dateTime = LocalDateTime.parse(saveDisasterRequest.getCreatedAt(), formatter);
Disaster disaster = Disaster.of(
saveDisasterRequest.getDisasterType(),
saveDisasterRequest.getLocation(),
saveDisasterRequest.getMsg(),
saveDisasterRequest.getDisasterNum(),
dateTime
);
disasterRepository.save(disaster);
Disaster savedDisaster =
disasterRepository.save(
Disaster.of(
saveDisasterRequest.getDisasterType(),
saveDisasterRequest.getLocation(),
saveDisasterRequest.getMsg(),
saveDisasterRequest.getDisasterNum(),
dateTime
)
);
log.info("재난 발생 이벤트 발행");
eventPublisher.publishEvent(DisasterEvent.of(savedDisaster)); // 신규 재난 발생 이벤트
}

private boolean isValidDisasterType(DisasterType disasterType, List<NotificationDisaster> notificationDisasters) {
Expand All @@ -96,13 +103,13 @@ public SituationHomeResponse getSituationHome(String email) {

List<SituationResponse> situationResponses = new ArrayList<>();
for (Disaster disaster : disasters) {
Long conversationCnt=0L;
Long conversationCnt = 0L;
List<GetConversationResponse> conversationResponses = new ArrayList<>();
conversationCnt+=conversationRepository.countByDisaster(disaster);
List<Conversation> conversations = conversationRepository.findAllByDisasterOrderByLikeCntDesc(disaster, PageRequest.of(0,3));
conversationCnt += conversationRepository.countByDisaster(disaster);
List<Conversation> conversations = conversationRepository.findAllByDisasterOrderByLikeCntDesc(disaster, PageRequest.of(0, 3));
for (Conversation conversation : conversations) {
conversationResponses.add(conversationService.getExceptChild(email, conversation.getId()));
conversationCnt+=conversationRepository.countByParent(conversation);
conversationCnt += conversationRepository.countByParent(conversation);
}
situationResponses.add(SituationResponse.of(disaster, conversationResponses, conversationCnt));
}
Expand All @@ -115,9 +122,9 @@ public SituationDetailResponse getSituationDetail(String email, Long disasterId,
.orElseThrow(NotFoundDisasterException::new);
List<GetConversationResponse> conversationResponses = new ArrayList<>();
List<Conversation> conversations;
if(sort.equals("popularity"))
if (sort.equals("popularity"))
conversations = conversationRepository.findAllByDisasterOrderByLikeCntDesc(disaster);
else if(sort.equals("time"))
else if (sort.equals("time"))
conversations = conversationRepository.findAllByDisasterOrderByCreatedAtDesc(disaster);
else
throw new BadRequestConversationSortException();
Expand Down
Loading

0 comments on commit f71a204

Please sign in to comment.