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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ dependencies {
// h2
runtimeOnly 'com.h2database:h2'

//querydsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

// mysql
runtimeOnly 'com.mysql:mysql-connector-j'

Expand Down Expand Up @@ -105,14 +111,20 @@ dependencies {

// prometheus
implementation 'io.micrometer:micrometer-registry-prometheus'

// email
implementation 'org.springframework.boot:spring-boot-starter-mail'

// Logging
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
}

dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}


tasks.named('test') {
useJUnitPlatform()
}
}
2 changes: 2 additions & 0 deletions src/main/java/devkor/com/teamcback/TeamCBackApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@OpenAPIDefinition(servers = {@Server(url = "/")})
@EnableScheduling
@EnableFeignClients
@EnableAsync
@SpringBootApplication
public class TeamCBackApplication {

Expand Down
123 changes: 123 additions & 0 deletions src/main/java/devkor/com/teamcback/domain/common/EmailService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package devkor.com.teamcback.domain.common;

import devkor.com.teamcback.domain.suggestion.entity.SuggestionImage;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
public class EmailService {

private static final String SUGGESTION_TITLE = "[고대로] 건의사항 추가됨 - 제목: ";
private static final String SENDER = "고대로팀 <leeyejin113@gmail.com>";

@Value("${staff.emails}")
private String notifyEmails;

@Value("${metrics.environment}")
private String env;

private final JavaMailSender mailSender;

public EmailService(JavaMailSender mailSender) {
this.mailSender = mailSender;
}

/**
* 건의사항 이메일 전송
*/
@Async
public void sendNotificationMessage(String title, String writer, String type, String content, List<SuggestionImage> images) throws MessagingException {

// 내용 설정
Map<String, String> contents = new HashMap<>();
contents.put("건의제목", title);
contents.put("건의종류", type);
contents.put("건의내용", content);

// 전송
sendHtmlEmail(SUGGESTION_TITLE + title, "건의", writer, contents, images);
}

/**
* 일반 이메일 전송
*/
private void sendSimpleEmail(String title, String type, String writer, Map<String, String> contents, List<SuggestionImage> images) {

for(String notifyEmail : notifyEmails.split(",")) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(SENDER); // 보내는 사람
message.setTo(notifyEmail); // 받는 사람
message.setSubject(title); // 제목

// 내용
String content = writer +"(으)로부터" + type + "가 추가되었습니다!";
for(String key : contents.keySet()) {
content += "\n - " + key + ": " + contents.get(key);
}

// 첨부파일
if(images != null) {
content += "\n - 첨부 파일:";
for (int i = 0; i < images.size(); i++) {
content += "\n[" + (i+1) + "] link: " + images.get(i).getImageUrl();
}
}

content += "\n* 서버 환경: " + env;

message.setText(content);

mailSender.send(message);
}

}

/**
* HTML 이메일 전송
*/
private void sendHtmlEmail(String title, String type, String writer, Map<String, String> contents, List<SuggestionImage> images) throws MessagingException {

for(String notifyEmail : notifyEmails.split(",")) {

MimeMessage message = mailSender.createMimeMessage();

// true: multipart 메시지 (첨부파일 가능), "UTF-8": 문자 인코딩
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

helper.setFrom(SENDER);
helper.setTo(notifyEmail);
helper.setSubject(title);

// 내용
String html = "<h2 style='color:green;'>" + writer +"(으)로부터" + type + "가 추가되었습니다!</h2>";
for(String key : contents.keySet()) {
html += "<p><strong>" + key + "</strong><br>" + contents.get(key) + "</p>";
}

// 첨부파일
if(images != null) {
html += "<p><strong>첨부 파일:</strong><br>";
for (int i = 0; i < images.size(); i++) {
html += "<a href='" + images.get(i).getImageUrl() + "'>[" + (i+1) + "]</a> ";
}
html += "</p>";
}

html += "<p>* 서버 환경: " + env + "</p>";

helper.setText(html, true); // HTML 모드

mailSender.send(message);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package devkor.com.teamcback.domain.place.repository;

import devkor.com.teamcback.domain.place.entity.Place;
import devkor.com.teamcback.domain.place.entity.PlaceType;

import java.util.List;

public interface CustomPlaceRepository {
List<Place> getFacilitiesByBuildingAndTypesWithPage(Long buildingId, List<PlaceType> mainFacilityTypes, Place lastPlace, int size);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package devkor.com.teamcback.domain.place.repository;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import devkor.com.teamcback.domain.place.entity.Place;
import devkor.com.teamcback.domain.place.entity.PlaceType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

import static devkor.com.teamcback.domain.place.entity.QPlace.place;

@Repository
@RequiredArgsConstructor
public class CustomPlaceRepositoryImpl implements CustomPlaceRepository{

private final JPAQueryFactory jpaQueryFactory;

@Override
public List<Place> getFacilitiesByBuildingAndTypesWithPage(Long buildingId, List<PlaceType> mainFacilityTypes, Place lastPlace, int size) {
return jpaQueryFactory
.selectFrom(place)
.where(
place.building.id.eq(buildingId),
place.type.in(mainFacilityTypes),
gtPlaceCursor(lastPlace)
)
.orderBy(
place.floor.asc(),
place.id.asc()
)
.limit(size)
.fetch();
}

private BooleanExpression gtPlaceCursor(Place lastPlace) {
if (lastPlace== null) return null;
return place.floor.gt(lastPlace.getFloor())
.or(place.floor.eq(lastPlace.getFloor()).and(place.id.gt(lastPlace.getId())));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
import devkor.com.teamcback.domain.place.entity.Place;
import devkor.com.teamcback.domain.place.entity.PlaceType;
import devkor.com.teamcback.domain.routes.entity.Node;
import java.util.List;

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

public interface PlaceRepository extends JpaRepository<Place, Long> {
import java.util.List;

public interface PlaceRepository extends JpaRepository<Place, Long>, CustomPlaceRepository {

List<Place> findAllByBuildingAndType(Building building, PlaceType type);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import devkor.com.teamcback.domain.search.dto.response.*;
import devkor.com.teamcback.domain.search.service.SearchService;
import devkor.com.teamcback.global.response.CommonResponse;
import devkor.com.teamcback.global.response.CursorPageRes;
import devkor.com.teamcback.global.security.UserDetailsImpl;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
Expand Down Expand Up @@ -196,6 +197,29 @@ public CommonResponse<SearchBuildingDetailRes> searchBuildingDetail(
return CommonResponse.success(searchService.searchBuildingDetail(userId, buildingId));
}

/**
* 건물 대표 편의시설 목록 조회 (no-offset)
* @param buildingId 건물 id
* @param lastPlaceId 마지막 편의시설 id
* @param size 한 번에 가져올 크기
*/
@Operation(summary = "건물 주요 편의시설 조회", description = "건물 주요 편의시설 조회")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."),
@ApiResponse(responseCode = "404", description = "Not Found",
content = @Content(schema = @Schema(implementation = CommonResponse.class))),
})
@GetMapping("/buildings/{buildingId}/main-facilities")
public CommonResponse<CursorPageRes<SearchMainFacilityRes>> searchBuildingMainFacilityList(
@Parameter(name = "buildingId", description = "건물 id", example = "1", required = true)
@PathVariable Long buildingId,
@Parameter(description = "마지막 편의시설 ID")
@RequestParam(required = false) Long lastPlaceId,
@Parameter(description = "한 번에 가져올 크기")
@RequestParam(defaultValue = "8") int size) {
return CommonResponse.success(searchService.searchBuildingMainFacilityList(buildingId, lastPlaceId, size));
}

/**
* 장소 상세 정보 조회
* @param placeId 장소 id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import devkor.com.teamcback.domain.user.entity.User;
import devkor.com.teamcback.domain.user.repository.UserRepository;
import devkor.com.teamcback.global.exception.exception.GlobalException;
import devkor.com.teamcback.global.response.CursorPageRes;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
Expand Down Expand Up @@ -64,23 +65,27 @@ public class SearchService {
static final int BASE_SCORE_OUTDOOR_TAG_DEFAULT = 750;

// 건물 상세 모달 아이콘으로 표시할 편의시설 종류
private final List<PlaceType> iconTypes = Arrays.asList(PlaceType.VENDING_MACHINE, PlaceType.PRINTER, PlaceType.LOUNGE,
private static final List<PlaceType> iconTypes = Arrays.asList(PlaceType.VENDING_MACHINE, PlaceType.PRINTER, PlaceType.LOUNGE,
PlaceType.READING_ROOM, PlaceType.STUDY_ROOM, PlaceType.CAFE, PlaceType.CONVENIENCE_STORE, PlaceType.CAFETERIA,
PlaceType.SLEEPING_ROOM, PlaceType.SHOWER_ROOM, PlaceType.BANK, PlaceType.GYM);

// 통합 검색 결과에 표시하지 않을 편의시설 종류
//TODO: 자전거보관소, 벤치 디자인 요청 후 List에서 제거
private final List<String> excludedTypes = Arrays.asList(PlaceType.CLASSROOM.getName(), PlaceType.TOILET.getName(),
private static final List<String> excludedTypes = Arrays.asList(PlaceType.CLASSROOM.getName(), PlaceType.TOILET.getName(),
PlaceType.MEN_TOILET.getName(), PlaceType.WOMEN_TOILET.getName(), PlaceType.MEN_HANDICAPPED_TOILET.getName(),
PlaceType.WOMEN_HANDICAPPED_TOILET.getName(), PlaceType.LOCKER.getName(), PlaceType.TRASH_CAN.getName(),
PlaceType.BICYCLE_RACK.getName(), PlaceType.BENCH.getName());

// 통합 검색 결과에서 "건물명 + 기본편의시설명"의 형태로 제공되어야 하는 편의시설 종류
private final List<PlaceType> outerTagTypes = Arrays.asList(PlaceType.CAFE, PlaceType.CAFETERIA, PlaceType.CONVENIENCE_STORE,
private static final List<PlaceType> outerTagTypes = Arrays.asList(PlaceType.CAFE, PlaceType.CAFETERIA, PlaceType.CONVENIENCE_STORE,
PlaceType.READING_ROOM, PlaceType.STUDY_ROOM, PlaceType.BOOK_RETURN_MACHINE, PlaceType.LOUNGE, PlaceType.WATER_PURIFIER,
PlaceType.VENDING_MACHINE, PlaceType.PRINTER, PlaceType.TUMBLER_WASHER, PlaceType.ONESTOP_AUTO_MACHINE, PlaceType.BANK,
PlaceType.SMOKING_BOOTH, PlaceType.SHOWER_ROOM, PlaceType.GYM, PlaceType.SLEEPING_ROOM, PlaceType.HEALTH_OFFICE, PlaceType.DISABLED_PARKING);

// 건물 상세 조회 : 대표 편의시설 종류
private static final List<PlaceType> mainFacilityTypes = Arrays.asList(PlaceType.LOUNGE, PlaceType.CAFE, PlaceType.CONVENIENCE_STORE, PlaceType.CAFETERIA,
PlaceType.READING_ROOM, PlaceType.STUDY_ROOM, PlaceType.GYM, PlaceType.SLEEPING_ROOM, PlaceType.SHOWER_ROOM);

/**
* 통합 검색
*/
Expand Down Expand Up @@ -264,10 +269,8 @@ public SearchMaskIndexByPlaceRes searchMaskIndexByPlace(Long placeId) {
public SearchBuildingDetailRes searchBuildingDetail(Long userId, Long buildingId) {
Building building = findBuilding(buildingId);

//(임의) 가져올 대표 시설 정보 List (라운지, 카페, 편의점, 식당, 헬스장, 열람실, 스터디룸, 수면실, 샤워실)
//자세한 회의 후 List 수정하기
List<PlaceType> types = Arrays.asList(PlaceType.LOUNGE, PlaceType.CAFE, PlaceType.CONVENIENCE_STORE, PlaceType.CAFETERIA, PlaceType.READING_ROOM, PlaceType.STUDY_ROOM, PlaceType.GYM, PlaceType.SLEEPING_ROOM, PlaceType.SHOWER_ROOM);
List<Place> mainFacilities = getFacilitiesByBuildingAndTypes(building, types);
// TODO: 배포 버전 수정 후 편의시설 조회 제거
List<Place> mainFacilities = getFacilitiesByBuildingAndTypes(building, mainFacilityTypes);
List<SearchMainFacilityRes> res = new ArrayList<>();

for (Place place : mainFacilities) {
Expand All @@ -292,10 +295,22 @@ public SearchBuildingDetailRes searchBuildingDetail(Long userId, Long buildingId
bookmarked = true;
}
}
return new SearchBuildingDetailRes(res, containPlaceTypes, building, bookmarked);
}

@Transactional(readOnly = true)
public CursorPageRes<SearchMainFacilityRes> searchBuildingMainFacilityList(Long buildingId, Long lastPlaceId, int size) {
Place lastPlace = (lastPlaceId == null) ? null : findPlace(lastPlaceId);

// TODO: 커뮤니티 구상 완료되면 커뮤니티 정보 넣기
List<SearchMainFacilityRes> mainFacilities = placeRepository.getFacilitiesByBuildingAndTypesWithPage(buildingId, mainFacilityTypes, lastPlace, size + 1)
.stream().map(SearchMainFacilityRes::new).collect(Collectors.toList());

return new SearchBuildingDetailRes(res, containPlaceTypes, building, bookmarked);
boolean hasNext = mainFacilities.size() > size;
if (hasNext) mainFacilities.remove(size);

Long lastCursorId = mainFacilities.isEmpty() ? null : mainFacilities.get(mainFacilities.size() - 1).getPlaceId();

return new CursorPageRes<>(mainFacilities, hasNext, lastCursorId);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.mail.MessagingException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
Expand Down Expand Up @@ -43,8 +44,7 @@ public CommonResponse<CreateSuggestionRes> createSuggestion(
@AuthenticationPrincipal UserDetailsImpl userDetail,
@Parameter(description = "건의 제목, 분류, 내용, 이메일", required = true)
@RequestPart(value = "req") CreateSuggestionReq req,
@Parameter(description = "건의 사진") @RequestPart(value = "images", required = false) List<MultipartFile> images
) {
@Parameter(description = "건의 사진") @RequestPart(value = "images", required = false) List<MultipartFile> images) {
Long userId = userDetail == null ? null : userDetail.getUser().getUserId();
return CommonResponse.success(suggestionService.createSuggestion(userId, req, images));
}
Expand Down
Loading
Loading