diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index f1483296..c3e1fce1 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -63,7 +63,7 @@ jobs: with: host: ${{ secrets.HOST_NAME }} username: ${{ secrets.USER_NAME }} - password: ${{ secrets.USER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} port: ${{ secrets.PORT }} script: | docker pull ${{ secrets.DOCKER_USER }}/kodaero:latest diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 5fd7a0a3..ba0cc698 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -63,7 +63,7 @@ jobs: with: host: ${{ secrets.HOST_NAME }} username: ${{ secrets.USER_NAME }} - password: ${{ secrets.USER_PASSWORD }} + key: ${{ secrets.SSH_KEY }} port: ${{ secrets.PORT }} script: | docker pull ${{ secrets.DOCKER_USER }}/kodaero:latest diff --git a/.gitignore b/.gitignore index 6ff3d5de..9b084d21 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ out/ ### 환경 변수 ### .env + +###claude### +.claude +.serena diff --git a/src/main/java/devkor/com/teamcback/domain/ble/dto/response/BLEDeviceListRes.java b/src/main/java/devkor/com/teamcback/domain/ble/dto/response/BLEDeviceListRes.java index 67b68d35..8484a8df 100644 --- a/src/main/java/devkor/com/teamcback/domain/ble/dto/response/BLEDeviceListRes.java +++ b/src/main/java/devkor/com/teamcback/domain/ble/dto/response/BLEDeviceListRes.java @@ -1,5 +1,6 @@ package devkor.com.teamcback.domain.ble.dto.response; +import devkor.com.teamcback.domain.place.entity.Place; import lombok.Getter; import lombok.Setter; @@ -11,11 +12,13 @@ public class BLEDeviceListRes { private String deviceName; private Long placeId; private Integer capacity; + private String imageUrl; - public BLEDeviceListRes(Long id, String deviceName, Long placeId, int capacity) { + public BLEDeviceListRes(Long id, String deviceName, Long placeId, int capacity, String imageUrl) { this.id = id; this.deviceName = deviceName; this.placeId = placeId; this.capacity = capacity; + this.imageUrl = imageUrl; } } \ No newline at end of file diff --git a/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java b/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java index 9df6c4eb..b1573f3a 100644 --- a/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java +++ b/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java @@ -175,11 +175,13 @@ public List getBLEDeviceList() { for (BLEDevice device : devices) { Long placeId = null; + String imageUrl = null; if (device.getPlace() != null) { placeId = device.getPlace().getId(); + imageUrl = device.getPlace().getImageUrl(); } - BLEDeviceListRes dto = new BLEDeviceListRes(device.getId(), device.getDeviceName(), placeId, device.getCapacity()); + BLEDeviceListRes dto = new BLEDeviceListRes(device.getId(), device.getDeviceName(), placeId, device.getCapacity(), imageUrl); result.add(dto); } diff --git a/src/main/java/devkor/com/teamcback/domain/bookmark/dto/response/CreateBookmarkRes.java b/src/main/java/devkor/com/teamcback/domain/bookmark/dto/response/CreateBookmarkRes.java index 739c2e46..9bc06b9a 100644 --- a/src/main/java/devkor/com/teamcback/domain/bookmark/dto/response/CreateBookmarkRes.java +++ b/src/main/java/devkor/com/teamcback/domain/bookmark/dto/response/CreateBookmarkRes.java @@ -1,9 +1,6 @@ package devkor.com.teamcback.domain.bookmark.dto.response; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import devkor.com.teamcback.domain.bookmark.entity.Bookmark; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; @JsonIgnoreProperties public class CreateBookmarkRes { diff --git a/src/main/java/devkor/com/teamcback/domain/bookmark/service/BookmarkService.java b/src/main/java/devkor/com/teamcback/domain/bookmark/service/BookmarkService.java index 2045c924..e98282a1 100644 --- a/src/main/java/devkor/com/teamcback/domain/bookmark/service/BookmarkService.java +++ b/src/main/java/devkor/com/teamcback/domain/bookmark/service/BookmarkService.java @@ -45,7 +45,6 @@ public class BookmarkService { * 즐겨찾기 업데이트 */ @Transactional - @UpdateScore(addScore = 1) public CreateBookmarkRes createBookmark(Long userId, CreateBookmarkReq req) { User user = findUser(userId); diff --git a/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java b/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java index 807eb29d..928507f8 100644 --- a/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/common/repository/FileRepository.java @@ -1,12 +1,21 @@ package devkor.com.teamcback.domain.common.repository; import devkor.com.teamcback.domain.common.entity.File; +import devkor.com.teamcback.domain.review.repository.CustomFileRepository; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; -public interface FileRepository extends JpaRepository { +public interface FileRepository extends JpaRepository, CustomFileRepository { List findAllByFileUuid(String fileUuid); + List findTop10AllByFileUuidOrderBySortNumAsc(String fileUuid); + + List findTop5AllByFileUuidOrderBySortNumAsc(String fileUuid); + + List findTop3AllByFileUuidOrderBySortNumAsc(String fileUuid); + File findByFileUuidAndSortNum(String fileUuid, Long sortNum); + + boolean existsByFileUuid(String fileUuid); } diff --git a/src/main/java/devkor/com/teamcback/domain/common/util/FileUtil.java b/src/main/java/devkor/com/teamcback/domain/common/util/FileUtil.java index 53334460..f2734f7a 100644 --- a/src/main/java/devkor/com/teamcback/domain/common/util/FileUtil.java +++ b/src/main/java/devkor/com/teamcback/domain/common/util/FileUtil.java @@ -137,7 +137,7 @@ public void uploadThumb(File savedFile, MultipartFile file, Integer width, Integ InputStream inputStream = new ByteArrayInputStream(os.toByteArray()); // 저장 - String thumbSavedName = s3Util.uploadFile(inputStream, savedFile.getFileUuid(), filePath, file.getContentType()); + String thumbSavedName = s3Util.uploadFile(inputStream, savedFile.getFileOriginalName(), filePath, file.getContentType()); savedFile.setThumbSavedName(thumbSavedName); } @@ -167,6 +167,22 @@ public void deleteFile(String fileUuid) { fileRepository.deleteAll(savedFileList); } + public List getFiles(String fileUuid) { + return fileRepository.findAllByFileUuid(fileUuid); + } + + public List getTop10Files(String fileUuid) { + return fileRepository.findTop10AllByFileUuidOrderBySortNumAsc(fileUuid); + } + + public List getTop5Files(String fileUuid) { + return fileRepository.findTop5AllByFileUuidOrderBySortNumAsc(fileUuid); + } + + public List getTop3Files(String fileUuid) { + return fileRepository.findTop3AllByFileUuidOrderBySortNumAsc(fileUuid); + } + public List getOriginalFiles(String fileUuid) { List fileList = fileRepository.findAllByFileUuid(fileUuid).stream().map(File::getFileSavedName).toList(); diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/controller/AdminOperatingTimeController.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/controller/AdminOperatingTimeController.java new file mode 100644 index 00000000..a227daee --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/controller/AdminOperatingTimeController.java @@ -0,0 +1,66 @@ +package devkor.com.teamcback.domain.operatingtime.controller; + +import devkor.com.teamcback.domain.operatingtime.dto.request.SavePlaceOperatingTimeConditionReq; +import devkor.com.teamcback.domain.operatingtime.dto.request.SavePlaceOperatingTimeReq; +import devkor.com.teamcback.domain.operatingtime.dto.response.GetPlaceOperatingTimeRes; +import devkor.com.teamcback.domain.operatingtime.dto.response.SavePlaceOperatingTimeConditionRes; +import devkor.com.teamcback.domain.operatingtime.dto.response.SavePlaceOperatingTimeRes; +import devkor.com.teamcback.domain.operatingtime.service.AdminOperatingTimeService; +import devkor.com.teamcback.global.response.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +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.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/operating-time") +public class AdminOperatingTimeController { + private final AdminOperatingTimeService adminOperatingTimeService; + + @GetMapping("/places/{placeId}") + @Operation(summary = "장소의 운영시간 검색", + description = "장소 id로 운영시간 검색") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "장소를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + public CommonResponse getPlaceOperatingTime( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId) { + return CommonResponse.success(adminOperatingTimeService.getPlaceOperatingTime(placeId)); + } + + @PutMapping("/places/{placeId}") + @Operation(summary = "장소의 운영시간 저장", + description = "장소가 각 요일별로 고정된 운영시간을 가지는 경우(00:00-00:00 형식 포함)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "장소를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + public CommonResponse savePlaceOperatingTime( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId, + @Parameter(description = "요일별 운영 시간") @RequestBody SavePlaceOperatingTimeReq req) { + return CommonResponse.success(adminOperatingTimeService.savePlaceOperatingTime(placeId, req)); + } + + @PostMapping("/places/{placeId}") + @Operation(summary = "장소의 조건 및 운영시간 저장(별도로 요일별 대표 운영 시간도 저장해야 함)", + description = "장소가 조건에 따라 유동적인 운영시간을 가지는 경우") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "장소를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + public CommonResponse savePlaceOperatingTimeCondition( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId, + @Parameter(description = "조건 및 운영 시간") @RequestBody @Valid SavePlaceOperatingTimeConditionReq req) { + return CommonResponse.success(adminOperatingTimeService.savePlaceOperatingTimeCondition(placeId, req)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/request/SavePlaceOperatingTimeConditionReq.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/request/SavePlaceOperatingTimeConditionReq.java new file mode 100644 index 00000000..122d8934 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/request/SavePlaceOperatingTimeConditionReq.java @@ -0,0 +1,26 @@ +package devkor.com.teamcback.domain.operatingtime.dto.request; + +import devkor.com.teamcback.domain.operatingtime.entity.DayOfWeek; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Schema(description = "저장할 운영시간 정보(상관없는 조건은 null로 표시)") +@Getter +@Setter +public class SavePlaceOperatingTimeConditionReq { + @Schema(description = "요일", example = "WEEKDAY") + private DayOfWeek dayOfWeek; + @Schema(description = "짝수주 여부", example = "null") + private Boolean isEvenWeek; + @Schema(description = "공휴일 여부", example = "false") + private Boolean isHoliday; + @Schema(description = "방학 여부", example = "null") + private Boolean isVacation; + @Schema(description = "조건에 해당하는 운영시간 목록") + @NotEmpty(message = "최소 1개의 운영시간이 있어야 합니다.") + private List timeList; +} diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/request/SavePlaceOperatingTimeConditionTimeReq.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/request/SavePlaceOperatingTimeConditionTimeReq.java new file mode 100644 index 00000000..99b8450b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/request/SavePlaceOperatingTimeConditionTimeReq.java @@ -0,0 +1,23 @@ +package devkor.com.teamcback.domain.operatingtime.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SavePlaceOperatingTimeConditionTimeReq { + @Min(value = 0) + @Max(value = 23) + private int StartHour; + @Min(value = 0) + @Max(value = 59) + private int StartMinute; + @Min(value = 0) + @Max(value = 23) + private int endHour; + @Min(value = 0) + @Max(value = 59) + private int endMinute; +} diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/request/SavePlaceOperatingTimeReq.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/request/SavePlaceOperatingTimeReq.java new file mode 100644 index 00000000..2da7ef19 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/request/SavePlaceOperatingTimeReq.java @@ -0,0 +1,27 @@ +package devkor.com.teamcback.domain.operatingtime.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Schema(description = "저장할 운영시간 정보") +@Getter +@Setter +public class SavePlaceOperatingTimeReq { + @Schema(description = "평일 운영시간", example = "09:00-21:00") + private String weekDayOperatingTime; + @Schema(description = "월요일 운영시간(평일 모두 같다면 없어도 됨)", example = "09:00-21:00") + private String mondayOperatingTime; + @Schema(description = "화요일 운영시간(평일 모두 같다면 없어도 됨)", example = "09:00-21:00") + private String tuesdayOperatingTime; + @Schema(description = "수요일 운영시간(평일 모두 같다면 없어도 됨)", example = "09:00-21:00") + private String wednesdayOperatingTime; + @Schema(description = "목요일 운영시간(평일 모두 같다면 없어도 됨)", example = "09:00-21:00") + private String thursdayOperatingTime; + @Schema(description = "금요일 운영시간(평일 모두 같다면 없어도 됨)", example = "09:00-21:00") + private String fridayOperatingTime; + @Schema(description = "토요일 운영시간", example = "09:00-20:00") + private String saturdayOperatingTime; + @Schema(description = "일요일 운영시간", example = "휴무") + private String sundayOperatingTime; +} diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/GetPlaceOperatingTimeRes.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/GetPlaceOperatingTimeRes.java new file mode 100644 index 00000000..c218ddb0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/GetPlaceOperatingTimeRes.java @@ -0,0 +1,34 @@ +package devkor.com.teamcback.domain.operatingtime.dto.response; + +import devkor.com.teamcback.domain.place.entity.Place; +import lombok.Getter; + +@Getter +public class GetPlaceOperatingTimeRes { + private Long placeId; + private String placeName; + private String todayOperatingTime; + private String weekDayOperatingTime; + private String mondayOperatingTime; + private String tuesdayOperatingTime; + private String wednesdayOperatingTime; + private String thursdayOperatingTime; + private String fridayOperatingTime; + private String saturdayOperatingTime; + private String sundayOperatingTime; + + + public GetPlaceOperatingTimeRes(Place place) { + this.placeId = place.getId(); + this.placeName = place.getName(); + this.todayOperatingTime = place.getOperatingTime(); + this.weekDayOperatingTime = place.getWeekdayOperatingTime(); + this.mondayOperatingTime = place.getMondayOperatingTime(); + this.tuesdayOperatingTime = place.getTuesdayOperatingTime(); + this.wednesdayOperatingTime = place.getWednesdayOperatingTime(); + this.thursdayOperatingTime = place.getThursdayOperatingTime(); + this.fridayOperatingTime = place.getFridayOperatingTime(); + this.saturdayOperatingTime = place.getSaturdayOperatingTime(); + this.sundayOperatingTime = place.getSundayOperatingTime(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/HolidayResDto.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/HolidayResDto.java similarity index 84% rename from src/main/java/devkor/com/teamcback/domain/operatingtime/dto/HolidayResDto.java rename to src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/HolidayResDto.java index f8a0251b..56dddf8e 100644 --- a/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/HolidayResDto.java +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/HolidayResDto.java @@ -1,4 +1,4 @@ -package devkor.com.teamcback.domain.operatingtime.dto; +package devkor.com.teamcback.domain.operatingtime.dto.response; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -16,7 +16,7 @@ public class HolidayResDto { public HolidayResDto(JSONObject itemJson) { this.name = itemJson.getString("dateName"); String holidayOrNot = itemJson.getString("isHoliday"); - this.isHoliday = holidayOrNot.equals("Y") ? true : false; + this.isHoliday = holidayOrNot.equals("Y"); String dateString = itemJson.getBigInteger("locdate").toString(); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd"); this.date = LocalDate.parse(dateString, formatter); diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/SavePlaceOperatingTimeConditionRes.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/SavePlaceOperatingTimeConditionRes.java new file mode 100644 index 00000000..6dd95d2d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/SavePlaceOperatingTimeConditionRes.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.operatingtime.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "장소 운영시간 저장 완료") +@JsonIgnoreProperties +public class SavePlaceOperatingTimeConditionRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/SavePlaceOperatingTimeRes.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/SavePlaceOperatingTimeRes.java new file mode 100644 index 00000000..2ffc8dd3 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/dto/response/SavePlaceOperatingTimeRes.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.operatingtime.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "장소 운영시간 저장 완료") +@JsonIgnoreProperties +public class SavePlaceOperatingTimeRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/DayOfWeek.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/DayOfWeek.java index 1c169df7..af5beed5 100644 --- a/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/DayOfWeek.java +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/DayOfWeek.java @@ -24,6 +24,89 @@ public String getOperatingTime(Place place) { return place.getWeekdayOperatingTime(); } }, + + + MONDAY { + @Override + public DayOfWeek findNext() { + return TUESDAY; + } + + @Override + public String getOperatingTime(Building building) { + return building.getWeekdayOperatingTime(); + } + + @Override + public String getOperatingTime(Place place) { + return place.getMondayOperatingTime() == null ? place.getWeekdayOperatingTime() : place.getMondayOperatingTime(); + } + }, + TUESDAY { + @Override + public DayOfWeek findNext() { + return WEDNESDAY; + } + + @Override + public String getOperatingTime(Building building) { + return building.getWeekdayOperatingTime(); + } + + @Override + public String getOperatingTime(Place place) { + return place.getTuesdayOperatingTime() == null ? place.getWeekdayOperatingTime() : place.getTuesdayOperatingTime(); + } + }, + WEDNESDAY { + @Override + public DayOfWeek findNext() { + return THURSDAY; + } + + @Override + public String getOperatingTime(Building building) { + return building.getWeekdayOperatingTime(); + } + + @Override + public String getOperatingTime(Place place) { + return place.getWednesdayOperatingTime() == null ? place.getWeekdayOperatingTime() : place.getWednesdayOperatingTime(); + } + }, + THURSDAY { + @Override + public DayOfWeek findNext() { + return FRIDAY; + } + + @Override + public String getOperatingTime(Building building) { + return building.getWeekdayOperatingTime(); + } + + @Override + public String getOperatingTime(Place place) { + return place.getThursdayOperatingTime() == null ? place.getWeekdayOperatingTime() : place.getThursdayOperatingTime(); + } + }, + FRIDAY { + @Override + public DayOfWeek findNext() { + return SATURDAY; + } + + @Override + public String getOperatingTime(Building building) { + return building.getWeekdayOperatingTime(); + } + + @Override + public String getOperatingTime(Place place) { + return place.getFridayOperatingTime() == null ? place.getWeekdayOperatingTime() : place.getFridayOperatingTime(); + } + }, + SATURDAY { @Override public DayOfWeek findNext() { @@ -43,7 +126,7 @@ public String getOperatingTime(Place place) { SUNDAY { @Override public DayOfWeek findNext() { - return WEEKDAY; + return MONDAY; } @Override diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/Holiday.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/Holiday.java index 916e7972..9e9339c9 100644 --- a/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/Holiday.java +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/Holiday.java @@ -1,6 +1,6 @@ package devkor.com.teamcback.domain.operatingtime.entity; -import devkor.com.teamcback.domain.operatingtime.dto.HolidayResDto; +import devkor.com.teamcback.domain.operatingtime.dto.response.HolidayResDto; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/OperatingCondition.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/OperatingCondition.java index 57afb1e6..01705217 100644 --- a/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/OperatingCondition.java +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/OperatingCondition.java @@ -1,6 +1,7 @@ package devkor.com.teamcback.domain.operatingtime.entity; import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.operatingtime.dto.request.SavePlaceOperatingTimeConditionReq; import devkor.com.teamcback.domain.place.entity.Place; import jakarta.persistence.*; import lombok.Getter; @@ -31,4 +32,11 @@ public class OperatingCondition extends BaseEntity { @ManyToOne @JoinColumn(name = "place_id") private Place place; + + public OperatingCondition(SavePlaceOperatingTimeConditionReq req) { + this.dayOfWeek = req.getDayOfWeek(); + this.isEvenWeek = req.getIsEvenWeek(); + this.isHoliday = req.getIsHoliday(); + this.isVacation = req.getIsVacation(); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/OperatingTime.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/OperatingTime.java index 15bdc87a..cb3b01e7 100644 --- a/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/OperatingTime.java +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/entity/OperatingTime.java @@ -1,6 +1,7 @@ package devkor.com.teamcback.domain.operatingtime.entity; import devkor.com.teamcback.domain.building.entity.Building; +import devkor.com.teamcback.domain.operatingtime.dto.request.SavePlaceOperatingTimeConditionTimeReq; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -31,4 +32,10 @@ public class OperatingTime { @ManyToOne @JoinColumn(name = "operating_condition_id") private OperatingCondition operatingCondition; + + public OperatingTime(OperatingCondition operatingCondition, LocalTime startTime, LocalTime endTime) { + this.operatingCondition = operatingCondition; + this.startTime = startTime; + this.endTime = endTime; + } } diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/repositoy/OperatingConditionRepository.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/repositoy/OperatingConditionRepository.java index 591889c7..9d47d52c 100644 --- a/src/main/java/devkor/com/teamcback/domain/operatingtime/repositoy/OperatingConditionRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/repositoy/OperatingConditionRepository.java @@ -15,5 +15,5 @@ public interface OperatingConditionRepository extends JpaRepository { List findAllByOperatingCondition(OperatingCondition operatingCondition); + + void deleteAllByOperatingCondition(OperatingCondition operatingCondition); } diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/scheduler/OperatingScheduler.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/scheduler/OperatingScheduler.java index a86bbc05..9b545e0f 100644 --- a/src/main/java/devkor/com/teamcback/domain/operatingtime/scheduler/OperatingScheduler.java +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/scheduler/OperatingScheduler.java @@ -34,7 +34,8 @@ public class OperatingScheduler { private static Boolean isVacation = null; private static Boolean isEvenWeek = null; - @Scheduled(cron = "0 0 0 * * *") // 매일 자정마다 + // 매일 자정마다 요일 등 조건에 맞는 운영 시간 지정해 저장 + @Scheduled(cron = "0 0 0 * * *") @EventListener(ApplicationReadyEvent.class) public void updateOperatingTime() { @@ -55,8 +56,9 @@ public void updateOperatingTime() { } - // @EventListener(ApplicationReadyEvent.class) // 테스트용 - @Scheduled(cron = "0 */10 9-18 * * *") // 10분마다 + // 10분마다 운영 여부 확인 + //@EventListener(ApplicationReadyEvent.class) // 테스트용 + @Scheduled(cron = "0 */10 9-18 * * *") public void updateOperatingDuringPeakHour() { // 배포 서버에서만 실행 @@ -73,6 +75,7 @@ public void updateOperatingDuringPeakHour() { } } + // 수업 시간 외에는 30분마다 운영 여부 확인 @Scheduled(cron = "0 0,30 0-8,19-23 * * *") // 30분마다 public void updateOperating() { @@ -126,6 +129,21 @@ private void setState() { private DayOfWeek findDayOfWeek(LocalDate date) { switch (date.getDayOfWeek()) { + case MONDAY -> { + return DayOfWeek.MONDAY; + } + case TUESDAY -> { + return DayOfWeek.TUESDAY; + } + case WEDNESDAY -> { + return DayOfWeek.WEDNESDAY; + } + case THURSDAY -> { + return DayOfWeek.THURSDAY; + } + case FRIDAY -> { + return DayOfWeek.FRIDAY; + } case SATURDAY -> { return DayOfWeek.SATURDAY; } diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/service/AdminOperatingTimeService.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/service/AdminOperatingTimeService.java new file mode 100644 index 00000000..9f7ff439 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/service/AdminOperatingTimeService.java @@ -0,0 +1,98 @@ +package devkor.com.teamcback.domain.operatingtime.service; + +import devkor.com.teamcback.domain.operatingtime.dto.request.SavePlaceOperatingTimeConditionReq; +import devkor.com.teamcback.domain.operatingtime.dto.request.SavePlaceOperatingTimeConditionTimeReq; +import devkor.com.teamcback.domain.operatingtime.dto.request.SavePlaceOperatingTimeReq; +import devkor.com.teamcback.domain.operatingtime.dto.response.GetPlaceOperatingTimeRes; +import devkor.com.teamcback.domain.operatingtime.dto.response.SavePlaceOperatingTimeConditionRes; +import devkor.com.teamcback.domain.operatingtime.dto.response.SavePlaceOperatingTimeRes; +import devkor.com.teamcback.domain.operatingtime.entity.DayOfWeek; +import devkor.com.teamcback.domain.operatingtime.entity.OperatingCondition; +import devkor.com.teamcback.domain.operatingtime.entity.OperatingTime; +import devkor.com.teamcback.domain.operatingtime.repositoy.OperatingConditionRepository; +import devkor.com.teamcback.domain.operatingtime.repositoy.OperatingTimeRepository; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminOperatingTimeService { + private final OperatingConditionRepository operatingConditionRepository; + private final OperatingTimeRepository operatingTimeRepository; + private final PlaceRepository placeRepository; + + @Transactional(readOnly = true) + public GetPlaceOperatingTimeRes getPlaceOperatingTime(Long placeId) { + Place place = findPlace(placeId); + + return new GetPlaceOperatingTimeRes(place); + } + + @Transactional + public SavePlaceOperatingTimeRes savePlaceOperatingTime(Long placeId, SavePlaceOperatingTimeReq req) { + Place place = findPlace(placeId); + + // 운영시간 수정 + place.updateOperatingTime(req); + + return new SavePlaceOperatingTimeRes(); + } + + @Transactional + public SavePlaceOperatingTimeConditionRes savePlaceOperatingTimeCondition(Long placeId, SavePlaceOperatingTimeConditionReq req) { + Place place = findPlace(placeId); + + // 운영조건 찾기 + OperatingCondition operatingCondition = findOperatingConditionOfPlace(req.getDayOfWeek(), req.getIsHoliday(), req.getIsVacation(), req.getIsEvenWeek(), place); + + // 기존 운영조건이 존재하면 해당하는 운영시간 모두 삭제 + if(operatingCondition != null) { + operatingTimeRepository.deleteAllByOperatingCondition(operatingCondition); + } + // 존재하지 않으면 새로 생성 + else { + operatingCondition = operatingConditionRepository.save(new OperatingCondition(req)); + } + + // 운영시간 저장 + for(SavePlaceOperatingTimeConditionTimeReq timeReq : req.getTimeList()) { + LocalTime startTime = LocalTime.of(timeReq.getStartHour(), timeReq.getStartMinute()); + LocalTime endTime = LocalTime.of(timeReq.getEndHour(), timeReq.getEndMinute()); + + operatingTimeRepository.save(new OperatingTime(operatingCondition, startTime, endTime)); + } + + return new SavePlaceOperatingTimeConditionRes(); + } + + /** + * 장소 id에 해당하는 장소 찾기 + */ + private Place findPlace(Long placeId) { + return placeRepository.findById(placeId).orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_PLACE)); + } + + /** + * 해당 장소와 조건에 맞는 운영 조건 찾기 + */ + private OperatingCondition findOperatingConditionOfPlace(DayOfWeek dayOfWeek, Boolean isHoliday, Boolean isVacation, Boolean isEvenWeek, Place place) { + OperatingCondition operatingCondition = operatingConditionRepository.findByDayOfWeekAndIsHolidayAndIsVacationAndPlace(dayOfWeek, isHoliday, isVacation, place); + + if(operatingCondition == null) return null; + + if(dayOfWeek == DayOfWeek.SATURDAY) { // 토요일인 경우 + if(operatingCondition.getIsEvenWeek() == null || operatingCondition.getIsEvenWeek() == isEvenWeek) return operatingCondition; + else return null; + } + + return operatingCondition; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/service/HolidayService.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/service/HolidayService.java index 70aae4d2..d7dac623 100644 --- a/src/main/java/devkor/com/teamcback/domain/operatingtime/service/HolidayService.java +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/service/HolidayService.java @@ -1,6 +1,6 @@ package devkor.com.teamcback.domain.operatingtime.service; -import devkor.com.teamcback.domain.operatingtime.dto.HolidayResDto; +import devkor.com.teamcback.domain.operatingtime.dto.response.HolidayResDto; import devkor.com.teamcback.domain.operatingtime.entity.Holiday; import devkor.com.teamcback.domain.operatingtime.repositoy.HolidayRepository; import java.net.URI; diff --git a/src/main/java/devkor/com/teamcback/domain/operatingtime/service/OperatingService.java b/src/main/java/devkor/com/teamcback/domain/operatingtime/service/OperatingService.java index 5dd84d0e..672f9e37 100644 --- a/src/main/java/devkor/com/teamcback/domain/operatingtime/service/OperatingService.java +++ b/src/main/java/devkor/com/teamcback/domain/operatingtime/service/OperatingService.java @@ -19,6 +19,7 @@ import java.time.format.DateTimeFormatter; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; @@ -77,9 +78,12 @@ public void updateOperatingTime(DayOfWeek dayOfWeek, boolean isHoliday, boolean @Transactional public void updateIsOperating(LocalTime now, DayOfWeek dayOfWeek, boolean isHoliday, boolean isVacation, boolean isEvenWeek) { List buildings = buildingRepository.findAll(); - List placesWithCondition = operatingConditionRepository.findAll().stream().map(OperatingCondition::getPlace).distinct().toList(); + List placesWithCondition = findOperatingConditionList(dayOfWeek, isHoliday, isVacation, isEvenWeek).stream().map(OperatingCondition::getPlace).distinct().toList(); for(Building building : buildings) { + // 건물에 해당하는 장소 목록 + List places = placeRepository.findAllByBuilding(building); + // 상시 개방이 아닌 건물만 확인 if(!alwaysOpenBuildings.contains(building.getId())) { boolean isOperating = checkBuildingIsOperating(building, now, dayOfWeek); @@ -91,10 +95,32 @@ public void updateIsOperating(LocalTime now, DayOfWeek dayOfWeek, boolean isHoli changeNodeIsOperating(isOperating, building); // 건물 운영 여부 변경 building.setOperating(isOperating); - // 건물에 속하면서 장소만의 운영 시간이 없는 경우 운영 여부 동기화 - List places = placeRepository.findAllByBuilding(building); + // 건물 내 장소 운영 여부 변경 for(Place place : places) { - if(!placesWithCondition.contains(place)) place.setOperating(isOperating); + // 장소만의 시간이 없는 경우 건물과 동기화 + if(dayOfWeek.getOperatingTime(place) == null && !placesWithCondition.contains(place)) { + place.setOperating(isOperating); + } + } + } + } + + // 건물에 해당하는 장소에 운영 시간이 있는 경우 운영 여부 설정 + for(Place place : places) { + String todayOperatingTime = dayOfWeek.getOperatingTime(place); + + // 장소에 운영시간이 존재 + if(todayOperatingTime != null) { + // 설정된 오늘 시간과 일치하지 않으면(예: 휴무이면 다음 운영 시간으로 설정됨) 운영 x + if(!isTimeRangePattern(todayOperatingTime)) { + place.setOperating(true); + } + else if(!Objects.equals(place.getOperatingTime(), todayOperatingTime)) { + place.setOperating(false); + } + // 일치하면 운영 여부 확인 + else { + place.setOperating(isInOperatingTime(now, todayOperatingTime)); } } } @@ -144,15 +170,37 @@ private String findOtherBuildingOperatingTime(Building building, DayOfWeek dayOf private String findPlaceOperatingTime(Place place, DayOfWeek dayOfWeek) { String operatingTime = dayOfWeek.getOperatingTime(place); - // 장소의 운영 시간이 시간 형식이 아니면 건물의 운영 시간을 따라감 + // 장소의 운영 시간이 시간 형식이 아니면 if(!isTimeRangePattern(operatingTime)) { - if(place.getBuilding() == null) return DEFAULT_OPERATING_TIME; - operatingTime = place.getBuilding().getOperatingTime(); + // 장소의 다음 운영 시간 + operatingTime = findOtherPlaceOperatingTime(place, dayOfWeek); + if(operatingTime == null) { + // 건물이 있으면 건물 운영 시간으로 + if(place.getBuilding() != null) operatingTime = place.getBuilding().getOperatingTime(); + // 없으면 기본 시간 + else operatingTime = DEFAULT_OPERATING_TIME; + } } return operatingTime; } + /** + * 시간 형식인 다음 운영 시간을 찾음 + */ + private String findOtherPlaceOperatingTime(Place place, DayOfWeek dayOfWeek) { + DayOfWeek nextDay = dayOfWeek.findNext(); + String operatingTime = nextDay.getOperatingTime(place); + + while(dayOfWeek != nextDay) { + if(isTimeRangePattern(operatingTime)) return operatingTime; + nextDay = nextDay.findNext(); + operatingTime = nextDay.getOperatingTime(place); + } + + return null; + } + /** * 운영 조건에 해당하는 장소의 운영 시간 찾기 */ @@ -266,6 +314,8 @@ private OperatingCondition findOperatingConditionOfPlace(DayOfWeek dayOfWeek, bo * 오늘에 맞는 운영 조건 목록 찾기 */ private List findOperatingConditionList(DayOfWeek dayOfWeek, boolean isHoliday, boolean isVacation, boolean isEvenWeek) { + if(dayOfWeek != DayOfWeek.SATURDAY && dayOfWeek != DayOfWeek.SUNDAY) dayOfWeek = DayOfWeek.WEEKDAY; + List operatingConditionList = operatingConditionRepository.findByDayOfWeekAndIsHolidayAndIsVacationOrNot(dayOfWeek, isHoliday, isVacation); if(dayOfWeek == DayOfWeek.SATURDAY) { // 토요일인 경우 diff --git a/src/main/java/devkor/com/teamcback/domain/place/controller/AdminPlaceController.java b/src/main/java/devkor/com/teamcback/domain/place/controller/AdminPlaceController.java index e53d939a..bf91b19c 100644 --- a/src/main/java/devkor/com/teamcback/domain/place/controller/AdminPlaceController.java +++ b/src/main/java/devkor/com/teamcback/domain/place/controller/AdminPlaceController.java @@ -1,14 +1,9 @@ package devkor.com.teamcback.domain.place.controller; -import devkor.com.teamcback.domain.building.dto.response.SaveBuildingMainImageRes; import devkor.com.teamcback.domain.place.dto.request.CreatePlaceReq; import devkor.com.teamcback.domain.place.dto.request.ModifyPlaceReq; -import devkor.com.teamcback.domain.place.dto.response.CreatePlaceRes; -import devkor.com.teamcback.domain.place.dto.response.DeletePlaceRes; -import devkor.com.teamcback.domain.place.dto.response.GetPlaceListRes; -import devkor.com.teamcback.domain.place.dto.response.ModifyPlaceRes; +import devkor.com.teamcback.domain.place.dto.response.*; import devkor.com.teamcback.domain.place.service.AdminPlaceService; -import devkor.com.teamcback.domain.suggestion.dto.response.SavePlaceImageRes; import devkor.com.teamcback.global.response.CommonResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -18,11 +13,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.util.List; @RestController @RequiredArgsConstructor @@ -91,25 +82,4 @@ public CommonResponse deletePlace( @Parameter(description = "삭제할 편의시설 ID") @PathVariable Long placeId) { return CommonResponse.success(adminPlaceService.deletePlace(placeId)); } - - @PostMapping(value = "/{placeId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "장소 사진 저장", description = "장소 사진 저장(첫 사진이 대표 사진)") - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), - @ApiResponse(responseCode = "404", description = "장소를 찾을 수 없습니다.", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - @ApiResponse(responseCode = "401", description = "권한이 없습니다.", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - @ApiResponse(responseCode = "403", description = "잘못된 입력입니다.", - content = @Content(schema = @Schema(implementation = CommonResponse.class))), - }) - public CommonResponse savePlaceImages( - @Parameter(name = "placeId", description = "장소 ID") - @PathVariable Long placeId, - @Parameter(description = "저장할 사진 파일") - @RequestPart("images") List images - ) { - return CommonResponse.success( - adminPlaceService.savePlaceImage(placeId, images)); - } } diff --git a/src/main/java/devkor/com/teamcback/domain/place/controller/AdminPlaceImageController.java b/src/main/java/devkor/com/teamcback/domain/place/controller/AdminPlaceImageController.java new file mode 100644 index 00000000..8e4203aa --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/place/controller/AdminPlaceImageController.java @@ -0,0 +1,112 @@ +package devkor.com.teamcback.domain.place.controller; + +import devkor.com.teamcback.domain.place.dto.response.*; +import devkor.com.teamcback.domain.place.service.AdminPlaceImageService; +import devkor.com.teamcback.global.response.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +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 lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/places") +public class AdminPlaceImageController { + private final AdminPlaceImageService adminPlaceImageService; + + @PostMapping(value = "/{placeId}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "장소 사진 1장 추가", + description = "장소 사진 1장 추가 저장") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "장소를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + public CommonResponse savePlaceImage( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId, + @Parameter(description = "저장할 사진 파일") @RequestPart("image") MultipartFile image + ) { + return CommonResponse.success(adminPlaceImageService.savePlaceImage(placeId, image)); + } + + @PostMapping(value = "/{placeId}/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "장소 사진 여러장 새로 저장", + description = "기존 장소 사진 전체 삭제 후 사진 여러장 새로 저장") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "장소를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + public CommonResponse savePlaceImageList( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId, + @Parameter(description = "저장할 사진 파일 목록") @RequestPart("image") List images + ) { + return CommonResponse.success(adminPlaceImageService.savePlaceImageList(placeId, images)); + } + + @DeleteMapping(value = "/{placeId}/images") + @Operation(summary = "장소 사진 전체 삭제", + description = "장소 사진 전체 삭제") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "장소를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + public CommonResponse deletePlaceImage( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId + ) { + return CommonResponse.success(adminPlaceImageService.deletePlaceImage(placeId)); + } + + @GetMapping("/{placeId}/image") + @Operation(summary = "장소 사진 1장 검색", + description = "장소 사진 1장 검색") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "장소를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + public CommonResponse getPlaceImage( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId + ) { + return CommonResponse.success(adminPlaceImageService.searchPlaceImage(placeId)); + } + + @GetMapping("/{placeId}/images") + @Operation(summary = "장소 사진 여러 장 검색", + description = "장소 사진 여러 장 검색") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "장소를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + public CommonResponse getPlaceImageList( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId + ) { + return CommonResponse.success(adminPlaceImageService.searchPlaceImageList(placeId)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/place/dto/response/DeletePlaceImageRes.java b/src/main/java/devkor/com/teamcback/domain/place/dto/response/DeletePlaceImageRes.java new file mode 100644 index 00000000..ccb97f80 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/place/dto/response/DeletePlaceImageRes.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.place.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "장소 사진 삭제 응답 dto") +@JsonIgnoreProperties +public class DeletePlaceImageRes { + +} diff --git a/src/main/java/devkor/com/teamcback/domain/place/dto/response/GetPlaceImageRes.java b/src/main/java/devkor/com/teamcback/domain/place/dto/response/GetPlaceImageRes.java new file mode 100644 index 00000000..c6b42dd0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/place/dto/response/GetPlaceImageRes.java @@ -0,0 +1,25 @@ +package devkor.com.teamcback.domain.place.dto.response; + +import devkor.com.teamcback.domain.place.entity.Place; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "장소 사진 검색 응답 dto") +@Getter +public class GetPlaceImageRes { + private Long placeId; + private String placeName; + private String image; + + public GetPlaceImageRes(Place place) { + this.placeId = place.getId(); + this.placeName = place.getName(); + this.image = place.getImageUrl(); + } + + public GetPlaceImageRes(Place place, String image) { + this.placeId = place.getId(); + this.placeName = place.getName(); + this.image = image; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/place/dto/response/ModifyPlaceImageRes.java b/src/main/java/devkor/com/teamcback/domain/place/dto/response/ModifyPlaceImageRes.java new file mode 100644 index 00000000..6b296705 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/place/dto/response/ModifyPlaceImageRes.java @@ -0,0 +1,17 @@ +package devkor.com.teamcback.domain.place.dto.response; + +import devkor.com.teamcback.domain.place.entity.Place; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "장소 사진 수정 완료") +@Getter +public class ModifyPlaceImageRes { + private Long placeId; + private String imageUrl; + + public ModifyPlaceImageRes(Place place, String imageUrl) { + this.placeId = place.getId(); + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/place/dto/response/SavePlaceImageRes.java b/src/main/java/devkor/com/teamcback/domain/place/dto/response/SavePlaceImageRes.java new file mode 100644 index 00000000..1f0c907d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/place/dto/response/SavePlaceImageRes.java @@ -0,0 +1,17 @@ +package devkor.com.teamcback.domain.place.dto.response; + +import devkor.com.teamcback.domain.place.entity.Place; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "장소 사진 저장 완료") +@Getter +public class SavePlaceImageRes { + private Long placeId; + private String imageUrl; + + public SavePlaceImageRes(Place place, String imageUrl) { + this.placeId = place.getId(); + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/place/dto/response/SearchPlaceImageListRes.java b/src/main/java/devkor/com/teamcback/domain/place/dto/response/SearchPlaceImageListRes.java new file mode 100644 index 00000000..2a8a7e26 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/place/dto/response/SearchPlaceImageListRes.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.place.dto.response; + +import devkor.com.teamcback.domain.place.entity.Place; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.util.List; + +@Schema(description = "장소 사진 리스트 응답 dto") +@Getter +public class SearchPlaceImageListRes { + private Long placeId; + private String placeName; + private List imageList; + + public SearchPlaceImageListRes(Place place, List imageList) { + this.placeId = place.getId(); + this.placeName = place.getName(); + this.imageList = imageList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/place/dto/response/SearchPlaceImageRes.java b/src/main/java/devkor/com/teamcback/domain/place/dto/response/SearchPlaceImageRes.java new file mode 100644 index 00000000..12a286be --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/place/dto/response/SearchPlaceImageRes.java @@ -0,0 +1,22 @@ +package devkor.com.teamcback.domain.place.dto.response; + +import devkor.com.teamcback.domain.place.entity.Place; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "장소 사진 검색 응답 dto") +@Getter +public class SearchPlaceImageRes { + private String image; + private Long sortNum; + + public SearchPlaceImageRes(Place place) { + this.image = place.getImageUrl(); + this.sortNum = 1L; + } + + public SearchPlaceImageRes(String image, Long sortNum) { + this.image = image; + this.sortNum = sortNum; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/place/entity/Place.java b/src/main/java/devkor/com/teamcback/domain/place/entity/Place.java index 0d2e8c41..8de937a6 100644 --- a/src/main/java/devkor/com/teamcback/domain/place/entity/Place.java +++ b/src/main/java/devkor/com/teamcback/domain/place/entity/Place.java @@ -1,5 +1,6 @@ package devkor.com.teamcback.domain.place.entity; +import devkor.com.teamcback.domain.operatingtime.dto.request.SavePlaceOperatingTimeReq; import devkor.com.teamcback.domain.place.dto.request.CreatePlaceReq; import devkor.com.teamcback.domain.place.dto.request.ModifyPlaceReq; import devkor.com.teamcback.domain.building.entity.Building; @@ -52,6 +53,21 @@ public class Place extends BaseEntity { @Setter private String weekdayOperatingTime; + @Setter + private String mondayOperatingTime; + + @Setter + private String tuesdayOperatingTime; + + @Setter + private String wednesdayOperatingTime; + + @Setter + private String thursdayOperatingTime; + + @Setter + private String fridayOperatingTime; + @Setter private String saturdayOperatingTime; @@ -65,14 +81,17 @@ public class Place extends BaseEntity { private Integer maskIndex; @Column(nullable = false) - private Integer starSum = 0; + private double starSum = 0; @Column(nullable = false) - private Integer starNum = 0; + private int starNum = 0; @Column private String contact; // 연락처 등 + @Column + private String foodType; + @ManyToOne @JoinColumn(name = "building_id") private Building building; @@ -118,4 +137,15 @@ public void update(ModifyPlaceReq req, Building building, Node node) { this.node = node; this.description = req.getDescription(); } + + public void updateOperatingTime(SavePlaceOperatingTimeReq req) { + this.setWeekdayOperatingTime(req.getWeekDayOperatingTime()); + this.setMondayOperatingTime(req.getMondayOperatingTime()); + this.setTuesdayOperatingTime(req.getTuesdayOperatingTime()); + this.setWednesdayOperatingTime(req.getWednesdayOperatingTime()); + this.setThursdayOperatingTime(req.getThursdayOperatingTime()); + this.setFridayOperatingTime(req.getFridayOperatingTime()); + this.setSaturdayOperatingTime(req.getSaturdayOperatingTime()); + this.setSundayOperatingTime(req.getSundayOperatingTime()); + } } diff --git a/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceImage.java b/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceImage.java deleted file mode 100644 index 42bddecf..00000000 --- a/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceImage.java +++ /dev/null @@ -1,30 +0,0 @@ -package devkor.com.teamcback.domain.place.entity; - -import devkor.com.teamcback.domain.common.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@Table(name = "tb_place_image") -@NoArgsConstructor -public class PlaceImage extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private String image; // 추후 삭제 - - @ManyToOne - @JoinColumn(name = "place_id") - private Place place; -} diff --git a/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceImageRepository.java b/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceImageRepository.java deleted file mode 100644 index 677dbe73..00000000 --- a/src/main/java/devkor/com/teamcback/domain/place/repository/PlaceImageRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package devkor.com.teamcback.domain.place.repository; - -import devkor.com.teamcback.domain.place.entity.Place; -import devkor.com.teamcback.domain.place.entity.PlaceImage; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface PlaceImageRepository extends JpaRepository { - - List findAllByPlace(Place place); -} diff --git a/src/main/java/devkor/com/teamcback/domain/place/service/AdminPlaceImageService.java b/src/main/java/devkor/com/teamcback/domain/place/service/AdminPlaceImageService.java new file mode 100644 index 00000000..be3a80c7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/place/service/AdminPlaceImageService.java @@ -0,0 +1,138 @@ +package devkor.com.teamcback.domain.place.service; + +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_FILE; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_PLACE; + +import devkor.com.teamcback.domain.common.entity.File; +import devkor.com.teamcback.domain.common.util.FileUtil; +import devkor.com.teamcback.domain.place.dto.response.*; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.infra.s3.FilePath; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminPlaceImageService { + private final PlaceRepository placeRepository; + private final FileUtil fileUtil; + + // 장소 사진 1장 추가 저장 + @Transactional + public SavePlaceImageRes savePlaceImage(Long placeId, MultipartFile image) { + Place place = findPlace(placeId); + + String fileUuid = place.getFileUuid(); + long sortNum = 1L; + + // fileUuid 확인 + if (fileUuid == null) { + // 새로 생성하여 저장 + fileUuid = fileUtil.createFileUuid(); + place.setFileUuid(fileUuid); + } + else { + sortNum = fileUtil.getFiles(fileUuid).size() + 1; + } + + // 파일 업로드 + fileUtil.upload(image, fileUuid, FilePath.PLACE, sortNum); + + String imageUrl = fileUtil.getOriginalFile(fileUuid); + + return new SavePlaceImageRes(place, imageUrl); + } + + // 장소 사진 여러 장 새로 저장 + @Transactional + public SavePlaceImageRes savePlaceImageList(Long placeId, List images) { + Place place = findPlace(placeId); + + String fileUuid = place.getFileUuid(); + + // fileUuid 확인 + if (fileUuid == null) { + // 새로 생성하여 저장 + fileUuid = fileUtil.createFileUuid(); + place.setFileUuid(fileUuid); + } + + // 파일 업로드 + fileUtil.upload(images, fileUuid, null, FilePath.PLACE); + + String imageUrl = fileUtil.getOriginalFile(fileUuid); + + return new SavePlaceImageRes(place, imageUrl); + } + + // 장소 사진 삭제 + @Transactional + public DeletePlaceImageRes deletePlaceImage(Long placeId) { + Place place = findPlace(placeId); + + if (place.getFileUuid() == null) { + throw new GlobalException(NOT_FOUND_FILE); + } + + // 기존 파일 삭제 + fileUtil.deleteFile(place.getFileUuid()); + + return new DeletePlaceImageRes(); + } + + // 장소 사진 1장 검색 + @Transactional(readOnly = true) + public GetPlaceImageRes searchPlaceImage(Long placeId) { + Place place = findPlace(placeId); + + String fileUuid = place.getFileUuid(); + + // 사진 없을 때 + if (fileUuid == null) { + return new GetPlaceImageRes(place); + } + + String fileName = fileUtil.getOriginalFile(fileUuid); + if (fileName == null) { + return new GetPlaceImageRes(place); + } + + // 사진 있을 때 + return new GetPlaceImageRes(place, fileName); + } + + // 장소 사진 여러 장 검색 + @Transactional(readOnly = true) + public SearchPlaceImageListRes searchPlaceImageList(Long placeId) { + Place place = findPlace(placeId); + + String fileUuid = place.getFileUuid(); + + List images = new ArrayList<>(); + + // 사진 없을 때 + if (fileUuid == null || fileUtil.getFiles(fileUuid).isEmpty()) { + images.add(new SearchPlaceImageRes(place)); + return new SearchPlaceImageListRes(place, images); + } + + // 사진 있을 때 + for(File file : fileUtil.getFiles(fileUuid)) { + images.add(new SearchPlaceImageRes(file.getFileSavedName(), file.getSortNum())); + } + + return new SearchPlaceImageListRes(place, images); + } + + private Place findPlace(Long placeId) { + return placeRepository.findById(placeId) + .orElseThrow(() -> new GlobalException(NOT_FOUND_PLACE)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/place/service/AdminPlaceService.java b/src/main/java/devkor/com/teamcback/domain/place/service/AdminPlaceService.java index b04ea9b3..48792fd1 100644 --- a/src/main/java/devkor/com/teamcback/domain/place/service/AdminPlaceService.java +++ b/src/main/java/devkor/com/teamcback/domain/place/service/AdminPlaceService.java @@ -1,27 +1,19 @@ package devkor.com.teamcback.domain.place.service; -import devkor.com.teamcback.domain.building.dto.response.SaveBuildingMainImageRes; import devkor.com.teamcback.domain.common.util.FileUtil; import devkor.com.teamcback.domain.place.dto.request.CreatePlaceReq; import devkor.com.teamcback.domain.place.dto.request.ModifyPlaceReq; import devkor.com.teamcback.domain.building.entity.Building; import devkor.com.teamcback.domain.building.repository.BuildingRepository; -import devkor.com.teamcback.domain.place.dto.response.CreatePlaceRes; -import devkor.com.teamcback.domain.place.dto.response.DeletePlaceRes; -import devkor.com.teamcback.domain.place.dto.response.GetPlaceListRes; -import devkor.com.teamcback.domain.place.dto.response.GetPlaceRes; -import devkor.com.teamcback.domain.place.dto.response.ModifyPlaceRes; +import devkor.com.teamcback.domain.place.dto.response.*; import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.repository.PlaceRepository; import devkor.com.teamcback.domain.routes.entity.Node; import devkor.com.teamcback.domain.routes.repository.NodeRepository; -import devkor.com.teamcback.domain.suggestion.dto.response.SavePlaceImageRes; import devkor.com.teamcback.global.exception.exception.GlobalException; -import devkor.com.teamcback.infra.s3.FilePath; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; import java.util.ArrayList; import java.util.List; @@ -85,21 +77,6 @@ public DeletePlaceRes deletePlace(Long facilityId) { return new DeletePlaceRes(); } - // 장소 사진 저장 - @Transactional - public SavePlaceImageRes savePlaceImage(Long placeId, List images) { - - Place place = findPlace(placeId); - - if(place.getFileUuid() == null) { - place.setFileUuid(fileUtil.createFileUuid()); - } - - fileUtil.upload(images, place.getFileUuid(), null, FilePath.PLACE); - - return new SavePlaceImageRes(); - } - private Building findBuilding(Long buildingId) { return buildingRepository.findById(buildingId).orElseThrow(() -> new GlobalException(NOT_FOUND_BUILDING)); } diff --git a/src/main/java/devkor/com/teamcback/domain/report/controller/AdminReportController.java b/src/main/java/devkor/com/teamcback/domain/report/controller/AdminReportController.java new file mode 100644 index 00000000..091bf2b7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/controller/AdminReportController.java @@ -0,0 +1,52 @@ +package devkor.com.teamcback.domain.report.controller; + +import devkor.com.teamcback.domain.report.dto.request.UpdateReportStatusReq; +import devkor.com.teamcback.domain.report.dto.response.GetReportListRes; +import devkor.com.teamcback.domain.report.dto.response.UpdateReportStatusRes; +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import devkor.com.teamcback.domain.report.service.ReportService; +import devkor.com.teamcback.global.response.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +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 lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/reports") +public class AdminReportController { + private final ReportService reportService; + + @Operation(summary = "신고 관리를 위한 목록 조회", + description = "신고 상태에 따라 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping + public CommonResponse getReportList( + @Parameter(name = "reportStatus", description = "신고 상태 종류") @RequestParam(required = false) ReportStatus reportStatus + ) { + return CommonResponse.success(reportService.getReportList(reportStatus)); + } + + @Operation(summary = "신고 상태 변경", + description = "신고 상태 변경") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PutMapping("/{reportId}") + public CommonResponse updateReportStatus( + @Parameter(description = "변경할 신고 id") @PathVariable Long reportId, + @Parameter(description = "신고의 변경할 생태") @RequestBody UpdateReportStatusReq req + ) { + return CommonResponse.success(reportService.updateReportStatus(reportId, req)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/controller/ReportController.java b/src/main/java/devkor/com/teamcback/domain/report/controller/ReportController.java new file mode 100644 index 00000000..dd2b7930 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/controller/ReportController.java @@ -0,0 +1,56 @@ +package devkor.com.teamcback.domain.report.controller; + +import devkor.com.teamcback.domain.report.dto.request.CreateReviewReportReq; +import devkor.com.teamcback.domain.report.dto.response.CreateReviewReportRes; +import devkor.com.teamcback.domain.report.dto.response.GetUserReviewReportStatusRes; +import devkor.com.teamcback.domain.report.service.ReportService; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +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.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/reports") +public class ReportController { + + private final ReportService reportService; + + @Operation(summary = "신고 작성", + description = "리뷰에 대한 신고를 작성") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping(value = "/reviews/{reviewId}") + public CommonResponse createReviewReport( + @Parameter(description = "사용자정보") @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(name = "reviewId", description = "리뷰 ID") @PathVariable Long reviewId, + @Parameter(description = "리뷰 작성 내용", required = true) @Valid @RequestBody CreateReviewReportReq req) { + + return CommonResponse.success(reportService.createReviewReport(userDetail == null ? null : userDetail.getUser().getUserId(), reviewId, req)); + } + + @Operation(summary = "사용자 리뷰 신고 여부 조회", + description = "사용자 신고된 상태인지 확인하고 알림(비회원은 조회 x)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping(value = "/status") + public CommonResponse getUserReviewReportStatus( + @Parameter(description = "사용자정보", required = true) @AuthenticationPrincipal UserDetailsImpl userDetail) + { + return CommonResponse.success(reportService.getUserReviewReportStatus(userDetail.getUser().getUserId())); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/dto/request/CreateReviewReportReq.java b/src/main/java/devkor/com/teamcback/domain/report/dto/request/CreateReviewReportReq.java new file mode 100644 index 00000000..1a7f3d9f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/dto/request/CreateReviewReportReq.java @@ -0,0 +1,17 @@ +package devkor.com.teamcback.domain.report.dto.request; + +import devkor.com.teamcback.domain.report.entity.ReasonCategory; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Schema(description = "저장할 리뷰 신고 내용") +@Getter +@Setter +public class CreateReviewReportReq { + @NotNull + private ReasonCategory reasonCategory; // 신고 이유 + private String content; // 신고 내용 + +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/dto/request/UpdateReportStatusReq.java b/src/main/java/devkor/com/teamcback/domain/report/dto/request/UpdateReportStatusReq.java new file mode 100644 index 00000000..47e0d9a3 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/dto/request/UpdateReportStatusReq.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.report.dto.request; + +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Schema(description = "수정할 신고 내용") +@Getter +@Setter +public class UpdateReportStatusReq { + @NotNull + private ReportStatus status; +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/dto/response/CreateReviewReportRes.java b/src/main/java/devkor/com/teamcback/domain/report/dto/response/CreateReviewReportRes.java new file mode 100644 index 00000000..7813a622 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/dto/response/CreateReviewReportRes.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.report.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "리뷰 신고 저장 완료") +@JsonIgnoreProperties +public class CreateReviewReportRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetReportListRes.java b/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetReportListRes.java new file mode 100644 index 00000000..ee07da82 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetReportListRes.java @@ -0,0 +1,14 @@ +package devkor.com.teamcback.domain.report.dto.response; + +import lombok.Getter; + +import java.util.List; + +@Getter +public class GetReportListRes { + List reportList; + + public GetReportListRes(List reportList) { + this.reportList = reportList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetReportRes.java b/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetReportRes.java new file mode 100644 index 00000000..ab3c71d5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetReportRes.java @@ -0,0 +1,31 @@ +package devkor.com.teamcback.domain.report.dto.response; + +import devkor.com.teamcback.domain.report.entity.Report; +import lombok.Getter; + +@Getter +public class GetReportRes { + private Long reportId; + private Long targetId; + private String targetType; + private String reasonCategory; + private String content; + private String status; + private String effectiveAt; + private String createdAt; + private Long reporterId; + private Long reporterUserId; + + public GetReportRes(Report report) { + this.reportId = report.getId(); + this.targetId = report.getTargetId(); + this.targetType = report.getTargetType().toString(); + this.reasonCategory = report.getReasonCategory().toString(); + this.content = report.getContent(); + this.status = report.getStatus().toString(); + this.effectiveAt = report.getEffectiveAt() != null ? report.getEffectiveAt().toString() : null; + this.createdAt = report.getCreatedAt() != null ? report.getCreatedAt().toString() : null; + this.reporterId = report.getReporter().getUserId(); + this.reporterUserId = report.getReportedUser().getUserId(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetReportedReviewRes.java b/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetReportedReviewRes.java new file mode 100644 index 00000000..de5524e7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetReportedReviewRes.java @@ -0,0 +1,29 @@ +package devkor.com.teamcback.domain.report.dto.response; + +import devkor.com.teamcback.domain.review.entity.Review; +import lombok.Getter; + +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +@Getter +public class GetReportedReviewRes { + private Long reviewId; + private String comment; + private Long placeId; + private String placeName; + private String reviewCreatedAt; + private String reasonCategory; + + public GetReportedReviewRes(Review review, String reasonCategory) { + this.reviewId = review.getId(); + this.placeId = review.getPlace().getId(); + this.placeName = review.getPlace().getName(); + this.comment = review.getComment(); + this.reasonCategory = reasonCategory; + + // 작성일 변환 + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yy.MM.dd(E)", Locale.KOREAN); + this.reviewCreatedAt = review.getCreatedAt() != null ? review.getCreatedAt().format(formatter): ""; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetUserReviewReportStatusRes.java b/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetUserReviewReportStatusRes.java new file mode 100644 index 00000000..04072949 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/dto/response/GetUserReviewReportStatusRes.java @@ -0,0 +1,23 @@ +package devkor.com.teamcback.domain.report.dto.response; + +import devkor.com.teamcback.domain.user.entity.User; +import lombok.Getter; + +import java.util.List; + +@Getter +public class GetUserReviewReportStatusRes { + private Long userId; + private String userName; + private boolean isReported; + private List reviewList; + private List reportReasons; + + public GetUserReviewReportStatusRes(User user, List reviewList) { + this.userId = user.getUserId(); + this.userName = user.getUsername(); + this.isReported = !reviewList.isEmpty(); + this.reviewList = reviewList; + this.reportReasons = reviewList.stream().map(GetReportedReviewRes::getReasonCategory).toList(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/dto/response/UpdateReportStatusRes.java b/src/main/java/devkor/com/teamcback/domain/report/dto/response/UpdateReportStatusRes.java new file mode 100644 index 00000000..1fe7bcf8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/dto/response/UpdateReportStatusRes.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.report.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +@Getter +@JsonIgnoreProperties +public class UpdateReportStatusRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/entity/ReasonCategory.java b/src/main/java/devkor/com/teamcback/domain/report/entity/ReasonCategory.java new file mode 100644 index 00000000..869de81c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/entity/ReasonCategory.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.report.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ReasonCategory { + ABUSE_OR_DISCRIMINATION("욕설/비하"), + DEFAMATION("명예훼손"), + SPAM_OR_ADVERTISING("홍보/도배"), + INAPPROPRIATE_CONTENT("음란/선정"); + + private final String label; +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/entity/Report.java b/src/main/java/devkor/com/teamcback/domain/report/entity/Report.java new file mode 100644 index 00000000..6d0f0e0d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/entity/Report.java @@ -0,0 +1,61 @@ +package devkor.com.teamcback.domain.report.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +@Entity +@Getter +@Table(name = "tb_report") +@NoArgsConstructor +public class Report extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + @Enumerated(EnumType.STRING) + private TargetType targetType; + + @Column + private Long targetId; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ReasonCategory reasonCategory; + + @Column(length = 300) + private String content; + + @Setter + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ReportStatus status; + + @Setter + @Column + private LocalDate effectiveAt; // 신고 정지 시작일 + + @ManyToOne + @JoinColumn(name = "reporter_id") + private User reporter; + + @ManyToOne + @JoinColumn(name = "reported_user_id") + private User reportedUser; + + public Report(TargetType targetType, Long targetId, ReasonCategory reasonCategory, String content, ReportStatus status, User reporter, User reportedUser) { + this.targetType = targetType; + this.targetId = targetId; + this.reasonCategory = reasonCategory; + this.content = content; + this.status = status; + this.reporter = reporter; + this.reportedUser = reportedUser; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/entity/ReportStatus.java b/src/main/java/devkor/com/teamcback/domain/report/entity/ReportStatus.java new file mode 100644 index 00000000..9b4f2f43 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/entity/ReportStatus.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.report.entity; + +public enum ReportStatus { + PENDING, + RESOLVED, + REJECTED, + EXPIRED +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/entity/TargetType.java b/src/main/java/devkor/com/teamcback/domain/report/entity/TargetType.java new file mode 100644 index 00000000..c32dfad8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/entity/TargetType.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.report.entity; + +public enum TargetType { + REVIEW +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/repository/CustomReportRepository.java b/src/main/java/devkor/com/teamcback/domain/report/repository/CustomReportRepository.java new file mode 100644 index 00000000..de42fc0d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/repository/CustomReportRepository.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.report.repository; + +import devkor.com.teamcback.domain.report.entity.Report; +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import devkor.com.teamcback.domain.report.entity.TargetType; +import devkor.com.teamcback.domain.user.entity.User; + +import java.util.List; + +public interface CustomReportRepository { + List findUniqueReportsForUserReviewReportStatus(User user, TargetType type, ReportStatus status); +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/repository/CustomReportRepositoryImpl.java b/src/main/java/devkor/com/teamcback/domain/report/repository/CustomReportRepositoryImpl.java new file mode 100644 index 00000000..1f27dc56 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/repository/CustomReportRepositoryImpl.java @@ -0,0 +1,46 @@ +package devkor.com.teamcback.domain.report.repository; + +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import devkor.com.teamcback.domain.report.entity.Report; +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import devkor.com.teamcback.domain.report.entity.TargetType; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.vote.dto.response.QGetVoteOptionRes; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static devkor.com.teamcback.domain.report.entity.QReport.report; +import static devkor.com.teamcback.domain.vote.entity.QVote.vote; +import static devkor.com.teamcback.domain.vote.entity.QVoteOption.voteOption; +import static devkor.com.teamcback.domain.vote.entity.QVoteRecord.voteRecord; + +@Repository +@RequiredArgsConstructor +public class CustomReportRepositoryImpl implements CustomReportRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findUniqueReportsForUserReviewReportStatus(User user, TargetType type, ReportStatus status) { + // 서브쿼리로 각 그룹의 최신 ID 추출 + JPQLQuery subQuery = JPAExpressions + .select(report.id.max()) + .from(report) + .where( + report.reportedUser.eq(user) + .and(report.targetType.eq(type)) + .and(report.status.eq(status)) + ) + .groupBy(report.targetId, report.reasonCategory); + + return jpaQueryFactory + .selectFrom(report) + .where(report.id.in(subQuery)) + .orderBy(report.effectiveAt.asc()) + .fetch(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/repository/ReportRepository.java b/src/main/java/devkor/com/teamcback/domain/report/repository/ReportRepository.java new file mode 100644 index 00000000..6dc63f41 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/repository/ReportRepository.java @@ -0,0 +1,16 @@ +package devkor.com.teamcback.domain.report.repository; + +import devkor.com.teamcback.domain.report.entity.Report; +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReportRepository extends JpaRepository, CustomReportRepository { + + List findByStatusOrderByCreatedAtDesc(ReportStatus status); + + List findAllByOrderByCreatedAtDesc(); + + List findAllByStatus(ReportStatus reportStatus); +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/scheduler/ReportScheduler.java b/src/main/java/devkor/com/teamcback/domain/report/scheduler/ReportScheduler.java new file mode 100644 index 00000000..89867c5a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/scheduler/ReportScheduler.java @@ -0,0 +1,41 @@ +package devkor.com.teamcback.domain.report.scheduler; + +import devkor.com.teamcback.domain.report.service.ReportService; +import devkor.com.teamcback.global.redis.RedisLockUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j(topic = "Report status Scheduler") +@Component +@RequiredArgsConstructor +public class ReportScheduler { + + @Value("${metrics.environment}") + private String env; + private final RedisLockUtil redisLockUtil; + private final ReportService reportService; + + @Scheduled(cron = "0 1 0 * * *") // 매일 자정마다 + @EventListener(ApplicationReadyEvent.class) + public void updateReportStatus() { + + // 배포 서버에서만 실행 + if(!env.equals("prod")) return; + + try{ + log.info("신고 상태 업데이트"); + + redisLockUtil.executeWithLock("report-lock", 1, 300, () -> { + reportService.updateExpiredReportStatus(); + return null; + }); + } catch (Exception e) { + log.info("updateReportStatus() 작업 실패: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java b/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java new file mode 100644 index 00000000..ad81dfbd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java @@ -0,0 +1,159 @@ +package devkor.com.teamcback.domain.report.service; + +import devkor.com.teamcback.domain.report.dto.request.CreateReviewReportReq; +import devkor.com.teamcback.domain.report.dto.request.UpdateReportStatusReq; +import devkor.com.teamcback.domain.report.dto.response.*; +import devkor.com.teamcback.domain.report.entity.Report; +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import devkor.com.teamcback.domain.report.entity.TargetType; +import devkor.com.teamcback.domain.report.repository.ReportRepository; +import devkor.com.teamcback.domain.review.entity.Review; +import devkor.com.teamcback.domain.review.repository.ReviewRepository; +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static devkor.com.teamcback.domain.report.entity.ReportStatus.*; +import static devkor.com.teamcback.global.response.ResultCode.*; + +@Service +@RequiredArgsConstructor +public class ReportService { + + private final ReportRepository reportRepository; + private final ReviewRepository reviewRepository; + private final UserRepository userRepository; + + /** + * 리뷰에 대한 신고 작성 + */ + @Transactional + public CreateReviewReportRes createReviewReport(Long userId, Long reviewId, @Valid CreateReviewReportReq req) { + // 신고한 사용자(비회원 가능) + User user = userId == null ? null : findUser(userId); + + // 신고된 게시물 + Review review = findReview(reviewId); + + // TODO: 정책 설정 후 다시 - 신고되자마자 처리하는 경우 + // review.setReported(true); + + // 신고 저장 + Report report = new Report(TargetType.REVIEW, reviewId, req.getReasonCategory(), req.getContent(), PENDING, user, review.getUser()); + reportRepository.save(report); + + return new CreateReviewReportRes(); + } + + /** + * 사용자가 신고되었는지 확인 + */ + @Transactional(readOnly = true) + public GetUserReviewReportStatusRes getUserReviewReportStatus(Long userId) { + // 사용자 검색 + User user = findUser(userId); + + // 신고 검색 + List reportList = reportRepository.findUniqueReportsForUserReviewReportStatus(user, TargetType.REVIEW, RESOLVED); + + // 신고된 리뷰 목록 조회 + List reviewList = new ArrayList<>(); + for(Report report : reportList) { + reviewList.add(new GetReportedReviewRes(findReview(report.getTargetId()), report.getReasonCategory().getLabel())); + } + + return new GetUserReviewReportStatusRes(user, reviewList); + } + + /** + * 신고 목록 조회 + */ + @Transactional(readOnly = true) + public GetReportListRes getReportList(ReportStatus status) { + List reportList; + + // 신고 상태에 따라 조회 + if (status == null) { + reportList = reportRepository.findAllByOrderByCreatedAtDesc(); + } + else { + reportList = reportRepository.findByStatusOrderByCreatedAtDesc(status); + } + + return new GetReportListRes(reportList.stream().map(GetReportRes::new).toList()); + } + + /** + * 신고 상태 변경 + */ + @Transactional + public UpdateReportStatusRes updateReportStatus(Long reportId, UpdateReportStatusReq req) { + // 신고 + Report report = findReport(reportId); + + // 신고 상태 수정 + report.setStatus(req.getStatus()); + + // 신고가 받아들여진 경우 + if(req.getStatus().equals(RESOLVED)) { + // 신고 유효일 설정 + report.setEffectiveAt(LocalDate.now()); + + // 신고 대상이 리뷰인 경우 + if(report.getTargetType().equals(TargetType.REVIEW)) { + Review review = findReview(report.getTargetId()); + // 신고된 리뷰 설정(신고가 만료되거나 해도 이 필드는 그대로 유지됨) + review.setReported(true); + } + } + + return new UpdateReportStatusRes(); + } + + /** + * 신고 유효일 체크하고 상태 수정 + */ + @Transactional + public void updateExpiredReportStatus() { + // 유효한 신고 내역 조회 + List reportList = reportRepository.findAllByStatus(RESOLVED); + + for(Report report : reportList) { + LocalDate thirtyDaysAfter = report.getEffectiveAt().plusDays(30); + + if (LocalDate.now().isAfter(thirtyDaysAfter)) { + // 30일이 지났으면 상태 변경 + report.setStatus(EXPIRED); + } + } + } + + /** + * 사용자 찾기 + */ + private User findUser(Long userId) { + return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); + } + + /** + * 리뷰 찾기 + */ + private Review findReview(Long reviewId) { + return reviewRepository.findById(reviewId).orElseThrow(() -> new GlobalException(NOT_FOUND_REVIEW)); + } + + /** + * 신고 찾기 + */ + private Report findReport(Long reportId) { + return reportRepository.findById(reportId).orElseThrow(() -> new GlobalException(NOT_FOUND_REPORT)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/controller/ReviewController.java b/src/main/java/devkor/com/teamcback/domain/review/controller/ReviewController.java new file mode 100644 index 00000000..9ab4d4e8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/controller/ReviewController.java @@ -0,0 +1,129 @@ +package devkor.com.teamcback.domain.review.controller; + +import devkor.com.teamcback.domain.review.dto.request.CreateReviewReq; +import devkor.com.teamcback.domain.review.dto.request.ModifyReviewReq; +import devkor.com.teamcback.domain.review.dto.response.*; +import devkor.com.teamcback.domain.review.service.ReviewService; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +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.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/reviews") +public class ReviewController { + + private final ReviewService reviewService; + + @Operation(summary = "리뷰 태그 종류 검색") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("/tags") + public CommonResponse getReviewTagList() { + return CommonResponse.success(reviewService.getReviewTagList()); + } + + @Operation(summary = "리뷰가 있는 장소의 리뷰 목록 포함 상세 검색", + description = "식당, 카페") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("/places/{placeId}") + public CommonResponse getReviewPlaceDetail( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId) { + + return CommonResponse.success(reviewService.getReviewPlaceDetail(placeId)); + } + + @Operation(summary = "리뷰가 있는 장소의 리뷰 목록 포함 상세 검색 - 무한스크롤로 리뷰 사진 추가 조회", + description = "식당, 카페") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("/places/{placeId}/images") + public CommonResponse> getReviewPlaceDetailImages( + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId, + @Parameter(name = "lastFileId", description = "마지막 조회한 사진 id") @RequestParam Long lastFileId) { + + return CommonResponse.success(reviewService.getReviewPlaceDetailImages(placeId, lastFileId)); + } + + @Operation(summary = "리뷰 작성", + description = "식당, 카페에 대한 리뷰를 작성") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping(value = "/places/{placeId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse createReview( + @Parameter(description = "사용자정보", required = true) @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(name = "placeId", description = "장소 ID") @PathVariable Long placeId, + @Parameter(description = "리뷰 작성 내용", required = true) @Valid @ModelAttribute CreateReviewReq createReviewReq) { + + return CommonResponse.success(reviewService.createReview(userDetail.getUser().getUserId(), placeId, createReviewReq)); + } + + @Operation(summary = "리뷰 조회", + description = "식당, 카페에 대한 리뷰 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("/{reviewId}") + public CommonResponse getReview( + @Parameter(name = "reviewId", description = "리뷰 ID") @PathVariable Long reviewId) { + return CommonResponse.success(reviewService.getReview(reviewId)); + } + + @Operation(summary = "리뷰 수정", + description = "식당, 카페에 대한 리뷰를 수정") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PutMapping(value = "/{reviewId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse modifyReview( + @Parameter(description = "사용자정보", required = true) @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(name = "reviewId", description = "리뷰 ID") @PathVariable Long reviewId, + @Parameter(description = "리뷰 작성 내용", required = true) @Valid @ModelAttribute ModifyReviewReq modifyReviewReq) { + + return CommonResponse.success(reviewService.modifyReview(userDetail.getUser().getUserId(), reviewId, modifyReviewReq)); + } + + @Operation(summary = "리뷰 삭제", + description = "식당, 카페에 대한 리뷰를 삭제") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "401", description = "권한이 없습니다.", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @DeleteMapping( "/{reviewId}") + public CommonResponse deleteReview( + @Parameter(description = "사용자정보", required = true) @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(name = "reviewId", description = "리뷰 ID") @PathVariable Long reviewId) { + return CommonResponse.success(reviewService.deleteReview(userDetail.getUser().getUserId(), reviewId)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/request/CreateReviewReq.java b/src/main/java/devkor/com/teamcback/domain/review/dto/request/CreateReviewReq.java new file mode 100644 index 00000000..1dbc376c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/request/CreateReviewReq.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.review.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; + +@Schema(description = "저장할 리뷰 정보") +@Getter +@Setter +public class CreateReviewReq { + + @Schema(description = "별점", example = "1.5") + @DecimalMin(value = "0.5", message = "별점을 입력해주세요.") + private double score; + + @Schema(description = "재방문 여부", example = "false") + private boolean isRevisit = false; + + @Schema(description = "리뷰 태그 리스트") + @Size(max = 5, message = "태그는 최대 5개까지 가능합니다.") + private List tagIds = new ArrayList<>(); + + @Schema(description = "한줄평", example = "맛있고 좋아요.") + private String comment; + + @Size(max = 3) + @Schema(description = "첨부 사진") + private List images = new ArrayList<>(); +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/request/ModifyReviewReq.java b/src/main/java/devkor/com/teamcback/domain/review/dto/request/ModifyReviewReq.java new file mode 100644 index 00000000..77818cae --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/request/ModifyReviewReq.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.review.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; + +@Schema(description = "수정할 리뷰 정보") +@Getter +@Setter +public class ModifyReviewReq { + + @Schema(description = "별점", example = "1.5") + @DecimalMin(value = "0.5", message = "별점을 입력해주세요.") + private double score; + + @Schema(description = "재방문 여부", example = "false") + private boolean isRevisit = false; + + @Schema(description = "리뷰 태그 리스트") + @Size(max = 5, message = "태그는 최대 5개까지 가능합니다.") + private List tagIds = new ArrayList<>(); + + @Schema(description = "한줄평", example = "맛있고 좋아요.") + private String comment; + + @Size(max = 3) + @Schema(description = "첨부 사진") + private List images = new ArrayList<>(); +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/CreateReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/CreateReviewRes.java new file mode 100644 index 00000000..25be1dd5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/CreateReviewRes.java @@ -0,0 +1,28 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import devkor.com.teamcback.global.response.ScoreUpdateResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Schema(description = "리뷰 생성 응답 dto") +@Getter +public class CreateReviewRes implements ScoreUpdateResponse { + private Long reviewId; + + @Setter + @Schema(description = "레벨업 여부") + private boolean isLevelUp; + + @Setter + @Schema(description = "현재 점수") + private Long currentScore; + + @Setter + @Schema(description = "점수 획득 여부") + private boolean scoreGained; + + public CreateReviewRes(Long id) { + this.reviewId = id; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/DeleteReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/DeleteReviewRes.java new file mode 100644 index 00000000..91b6aa67 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/DeleteReviewRes.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import devkor.com.teamcback.global.response.ScoreUpdateResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Schema(description = "리뷰 삭제 응답 dto") +@Getter +public class DeleteReviewRes implements ScoreUpdateResponse { + + @Schema(description = "레벨업 여부") + private boolean isLevelUp; + + @Schema(description = "현재 점수") + private Long currentScore; + + @Schema(description = "점수 획득 여부 (삭제 시에는 항상 false)") + private boolean scoreGained; +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewPlaceDetailRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewPlaceDetailRes.java new file mode 100644 index 00000000..692c918f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewPlaceDetailRes.java @@ -0,0 +1,60 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.entity.PlaceType; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceImageRes; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceReviewTagRes; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.util.List; + +@Getter +public class GetReviewPlaceDetailRes { + + private Long placeId; + private Long nodeId; + private Integer maskIndex; + private PlaceType type; + private String name; + private String detail; + private String operatingTime; + private boolean availability; + private boolean plugAvailability; + private boolean isOperating; + private String description; + + @Schema(description = "별점 평균") + private String starAverage; + @Schema(description = "음식 카테고리", example = "한식") + private String foodType; + + @Schema(description = "장소 대표 사진 목록") + private List placeImages; + @Schema(description = "리뷰 태그 목록") + private List tagList; + @Schema(description = "리뷰 목록") + private List reviewList; + @Schema(description = "리뷰 사진 목록") + private List reviewImageList; + + public GetReviewPlaceDetailRes(Place place, List placeImageList, List reviewTagList, List reviewList, List reviewImageList) { + this.placeId = place.getId(); + this.nodeId = place.getNode().getId(); + this.maskIndex = place.getMaskIndex(); + this.type = place.getType(); + this.name = place.getName(); + this.detail = place.getDetail(); + this.operatingTime = place.getOperatingTime(); + this.availability = place.isAvailability(); + this.plugAvailability = place.isPlugAvailability(); + this.isOperating = place.isOperating(); + this.description = place.getDescription(); + this.starAverage = String.format("%.2f", ((double) place.getStarSum()) / place.getStarNum()); + this.foodType = place.getFoodType(); + this.placeImages = placeImageList; + this.tagList = reviewTagList; + this.reviewList = reviewList; + this.reviewImageList = reviewImageList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewRes.java new file mode 100644 index 00000000..10918d5a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewRes.java @@ -0,0 +1,53 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import devkor.com.teamcback.domain.review.entity.Review; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; + +@Getter +public class GetReviewRes { + @Schema(description = "리뷰 id") + private Long reviewId; + @Schema(description = "리뷰 사용자 fineUuid") + private String fileUuid; + @Schema(description = "별점") + private double score; + @Schema(description = "재방문 여부") + private boolean isRevisit; + @Schema(description = "한줄평") + private String comment; + @Schema(description = "리뷰 장소 id") + private Long placeId; + @Schema(description = "리뷰 사용자 id") + private Long userId; + @Schema(description = "리뷰 작성일") + private String createdAt; + @Schema(description = "리뷰 태그 목록") + private List tagList; + @Schema(description = "리뷰 사진 목록") + private List reviewImageList; + + public GetReviewRes(Review review, List tagList, List reviewImageRes) { + this.userId = review.getUser() != null ? review.getUser().getUserId() : null; + this.reviewId = review.getId(); + this.placeId = review.getPlace().getId(); + this.score = review.getScore(); + this.fileUuid = review.getFileUuid(); + this.isRevisit = review.isRevisit(); + this.comment = review.getComment(); + + // 작성일 변환 + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yy.MM.dd(E)", Locale.KOREAN); + this.createdAt = review.getCreatedAt() != null ? review.getCreatedAt().format(formatter): ""; + + // 리뷰 태그 + this.tagList = tagList; + + // 리뷰 사진 + this.reviewImageList = reviewImageRes; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagListRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagListRes.java new file mode 100644 index 00000000..048c086f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagListRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +@Getter +public class GetReviewTagListRes { + private Map> reviewTags; + + public GetReviewTagListRes(Map> reviewTags) { + this.reviewTags = reviewTags; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagRes.java new file mode 100644 index 00000000..906d8a44 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/GetReviewTagRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import devkor.com.teamcback.domain.review.entity.ReviewTag; +import lombok.Getter; + +@Getter +public class GetReviewTagRes { + private Long reviewTagId; + private String tag; + + public GetReviewTagRes(ReviewTag reviewTag) { + this.reviewTagId = reviewTag.getId(); + this.tag = reviewTag.getTag(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/ModifyReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/ModifyReviewRes.java new file mode 100644 index 00000000..ccf2d5fb --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/ModifyReviewRes.java @@ -0,0 +1,28 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import devkor.com.teamcback.global.response.ScoreUpdateResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Schema(description = "리뷰 수정 응답 dto") +@Getter +public class ModifyReviewRes implements ScoreUpdateResponse { + private Long reviewId; + + @Setter + @Schema(description = "레벨업 여부") + private boolean isLevelUp; + + @Setter + @Schema(description = "현재 점수") + private Long currentScore; + + @Setter + @Schema(description = "점수 획득 여부") + private boolean scoreGained; + + public ModifyReviewRes(Long id) { + this.reviewId = id; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java new file mode 100644 index 00000000..b9f71187 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java @@ -0,0 +1,45 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import devkor.com.teamcback.domain.review.entity.Review; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; + +@Getter +public class SearchPlaceReviewRes { + + @Schema(description = "리뷰 사용자 id") + private Long userId; + + @Schema(description = "리뷰 id") + private Long reviewId; + + @Schema(description = "재방문 여부") + private boolean isRevisit; + + @Schema(description = "한줄평") + private String comment; + + @Schema(description = "리뷰 작성 시기") + private String createdAt; + + @Schema(description = "리뷰별 사진 목록") + private List reviewImageRes; + + public SearchPlaceReviewRes(Review review, List reviewImageRes) { + this.userId = review.getUser() != null ? review.getUser().getUserId() : null; + this.reviewId = review.getId(); + this.isRevisit = review.isRevisit(); + this.comment = review.getComment(); + + // 작성일 변환 + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yy.MM.dd(E)", Locale.KOREAN); + this.createdAt = review.getCreatedAt() != null ? review.getCreatedAt().format(formatter): ""; + + // 리뷰별 사진 + this.reviewImageRes = reviewImageRes; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchReviewImageRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchReviewImageRes.java new file mode 100644 index 00000000..dd06776b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchReviewImageRes.java @@ -0,0 +1,18 @@ +package devkor.com.teamcback.domain.review.dto.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class SearchReviewImageRes { + private Long imageId; + private String image; + + public SearchReviewImageRes(Long imageId, String image) { + this.imageId = imageId; + this.image = image; + } +} + + diff --git a/src/main/java/devkor/com/teamcback/domain/review/entity/PlaceReviewTagMap.java b/src/main/java/devkor/com/teamcback/domain/review/entity/PlaceReviewTagMap.java new file mode 100644 index 00000000..e94ddc17 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/entity/PlaceReviewTagMap.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.review.entity; + +import devkor.com.teamcback.domain.place.entity.Place; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Table(name = "tb_place_review_tag_map") +@NoArgsConstructor +public class PlaceReviewTagMap { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "place_id") + private Place place; + + @ManyToOne + @JoinColumn(name = "review_tag_id") + private ReviewTag reviewTag; + + @Setter + @Column(nullable = false) + private int num = 0; + + public PlaceReviewTagMap(Place place, ReviewTag tag) { + this.place = place; + this.reviewTag = tag; + this.num = 1; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/entity/Review.java b/src/main/java/devkor/com/teamcback/domain/review/entity/Review.java new file mode 100644 index 00000000..ae963792 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/entity/Review.java @@ -0,0 +1,69 @@ +package devkor.com.teamcback.domain.review.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.review.dto.request.CreateReviewReq; +import devkor.com.teamcback.domain.review.dto.request.ModifyReviewReq; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.*; +import jakarta.validation.Valid; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Getter +@Table(name = "tb_review") +@NoArgsConstructor +public class Review extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private String fileUuid; // 3장까지 업로드 가능 + + @Column(nullable = false) + private double score = 0; + + @Column(nullable = false) + private boolean isRevisit; // 재방문 여부 + + @Column(nullable = false, length = 500) + private String comment = ""; // 500자까지 작성 가능 + + @Setter + @Column(nullable = false) + private boolean isReported = false; // 신고 여부 + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne + @JoinColumn(name = "place_id") + private Place place; // 식당, 카페 등의 장소 + + @Setter + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL) + private List reviewTagMaps = new ArrayList<>(); // 선택한 후기 태그 + + public Review(CreateReviewReq createReviewReq, User user, Place place) { + this.fileUuid = UUID.randomUUID().toString(); + this.score = createReviewReq.getScore(); + this.isRevisit = createReviewReq.isRevisit(); + this.comment = createReviewReq.getComment(); + this.user = user; + this.place = place; + } + + public void modify(@Valid ModifyReviewReq modifyReviewReq) { + this.score = modifyReviewReq.getScore(); + this.isRevisit = modifyReviewReq.isRevisit(); + this.comment = modifyReviewReq.getComment(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTag.java b/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTag.java new file mode 100644 index 00000000..55ea1d5a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTag.java @@ -0,0 +1,22 @@ +package devkor.com.teamcback.domain.review.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "tb_review_tag") +@NoArgsConstructor +public class ReviewTag { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private TagType type; + + @Column(nullable = false) + private String tag; +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTagMap.java b/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTagMap.java new file mode 100644 index 00000000..87e87af1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/entity/ReviewTagMap.java @@ -0,0 +1,29 @@ +package devkor.com.teamcback.domain.review.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "tb_review_tag_map") +@NoArgsConstructor +public class ReviewTagMap { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 하나의 리뷰에 태그 최대 5개 + @ManyToOne + @JoinColumn(name = "review_id") + private Review review; + + @ManyToOne + @JoinColumn(name = "review_tag_id") + private ReviewTag reviewTag; + + public ReviewTagMap(Review savedReview, ReviewTag tag) { + this.review = savedReview; + this.reviewTag = tag; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/entity/TagType.java b/src/main/java/devkor/com/teamcback/domain/review/entity/TagType.java new file mode 100644 index 00000000..0da4cb27 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/entity/TagType.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.review.entity; + +public enum TagType { + TASTE, MOOD; +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepository.java b/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepository.java new file mode 100644 index 00000000..8d670bcd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepository.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.review.repository; + +import devkor.com.teamcback.domain.common.entity.File; + +import java.util.List; + +public interface CustomFileRepository { + List getReviewFilesByPlaceWithPage(Long placeId, Long lastFileId, int size); +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepositoryImpl.java b/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepositoryImpl.java new file mode 100644 index 00000000..c4706ecb --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/CustomFileRepositoryImpl.java @@ -0,0 +1,37 @@ +package devkor.com.teamcback.domain.review.repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import devkor.com.teamcback.domain.common.entity.File; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static devkor.com.teamcback.domain.common.entity.QFile.file; +import static devkor.com.teamcback.domain.review.entity.QReview.review; + +@Repository +@RequiredArgsConstructor +public class CustomFileRepositoryImpl implements CustomFileRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List getReviewFilesByPlaceWithPage(Long placeId, Long lastFileId, int size) { + return jpaQueryFactory + .select(file) + .from(file) + .join(review).on(file.fileUuid.eq(review.fileUuid)) + .where( + review.place.id.eq(placeId), + file.id.gt(lastFileId) + ) + .orderBy( + review.createdAt.desc(), + file.sortNum.asc() + ) + .limit(size) + .fetch(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/PlaceReviewTagMapRepository.java b/src/main/java/devkor/com/teamcback/domain/review/repository/PlaceReviewTagMapRepository.java new file mode 100644 index 00000000..9a8f8741 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/PlaceReviewTagMapRepository.java @@ -0,0 +1,14 @@ +package devkor.com.teamcback.domain.review.repository; + +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.review.entity.PlaceReviewTagMap; +import devkor.com.teamcback.domain.review.entity.ReviewTag; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PlaceReviewTagMapRepository extends JpaRepository { + List findByPlaceOrderByNumDesc(Place place); + + PlaceReviewTagMap findByPlaceAndReviewTag(Place place, ReviewTag reviewTag); +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewRepository.java b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewRepository.java new file mode 100644 index 00000000..c1172621 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewRepository.java @@ -0,0 +1,20 @@ +package devkor.com.teamcback.domain.review.repository; + +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.review.entity.Review; +import devkor.com.teamcback.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface ReviewRepository extends JpaRepository { + + List findAllByPlaceAndIsReportedOrderByCreatedAtDesc(Place place, boolean reported); + + /** + * 특정 사용자가 특정 장소에 특정 기간 내 작성한 리뷰 존재 여부 확인 + * (같은 장소 하루 1개 리뷰 제한용) + */ + boolean existsByUserAndPlaceAndCreatedAtBetween(User user, Place place, LocalDateTime start, LocalDateTime end); +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagMapRepository.java b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagMapRepository.java new file mode 100644 index 00000000..8cfdbfa2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagMapRepository.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.review.repository; + +import devkor.com.teamcback.domain.review.entity.ReviewTagMap; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewTagMapRepository extends JpaRepository { +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagRepository.java b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagRepository.java new file mode 100644 index 00000000..216d6a01 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/repository/ReviewTagRepository.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.review.repository; + +import devkor.com.teamcback.domain.review.entity.ReviewTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewTagRepository extends JpaRepository { +} diff --git a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java new file mode 100644 index 00000000..19e5df9a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java @@ -0,0 +1,368 @@ +package devkor.com.teamcback.domain.review.service; + +import devkor.com.teamcback.domain.common.entity.File; +import devkor.com.teamcback.domain.common.repository.FileRepository; +import devkor.com.teamcback.domain.common.util.FileUtil; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.entity.PlaceType; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import devkor.com.teamcback.domain.review.dto.request.CreateReviewReq; +import devkor.com.teamcback.domain.review.dto.request.ModifyReviewReq; +import devkor.com.teamcback.domain.review.dto.response.*; +import devkor.com.teamcback.domain.review.entity.*; +import devkor.com.teamcback.domain.review.repository.PlaceReviewTagMapRepository; +import devkor.com.teamcback.domain.review.repository.ReviewRepository; +import devkor.com.teamcback.domain.review.repository.ReviewTagMapRepository; +import devkor.com.teamcback.domain.review.repository.ReviewTagRepository; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceImageRes; +import devkor.com.teamcback.domain.search.dto.response.SearchPlaceReviewTagRes; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.annotation.UpdateScore; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import devkor.com.teamcback.infra.s3.FilePath; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final PlaceRepository placeRepository; + private final ReviewRepository reviewRepository; + private final ReviewTagRepository reviewTagRepository; + private final ReviewTagMapRepository reviewTagMapRepository; + private final PlaceReviewTagMapRepository placeReviewTagMapRepository; + private final FileRepository fileRepository; + private final UserRepository userRepository; + private final FileUtil fileUtil; + + /** + * 리뷰 태그 종류 검색 + */ + public GetReviewTagListRes getReviewTagList() { + Map> reviewTagMap = new HashMap<>(); + + // 태그 종류 + for(TagType tagType : TagType.values()) { + reviewTagMap.put(tagType.name(), new ArrayList<>()); + } + + // 태그 종류별 내용 + List reviewTagList = reviewTagRepository.findAll(); + for(ReviewTag reviewTag : reviewTagList) { + reviewTagMap.get(reviewTag.getType().name()).add(new GetReviewTagRes(reviewTag)); + } + + return new GetReviewTagListRes(reviewTagMap); + } + + /** + * 리뷰 기능있는 장소 상세 조회 + */ + @Transactional(readOnly = true) + public GetReviewPlaceDetailRes getReviewPlaceDetail(Long placeId) { + // 장소 검색 + Place place = findPlaceById(placeId); + + // 식당, 카페만 조회 가능하도록 제한 + checkReviewPlaceType(place); + + // 장소 대표 사진 조회 (사진 5장 제한 - 원본 사진) + List placeImageList = new ArrayList<>(); + if(place.getFileUuid() != null) { + placeImageList = fileUtil.getTop5Files(place.getFileUuid()).stream().map(file -> new SearchPlaceImageRes(file.getId(), file.getFileSavedName())).toList(); + } + + // 장소 리뷰 태그 조회 + List reviewTagList = findPlaceReviewTagList(place); + + // 리뷰 최신순 조회 + List reviewList = reviewRepository.findAllByPlaceAndIsReportedOrderByCreatedAtDesc(place, false); + + // 리뷰별 이미지 조회(썸네일) + List reviewImageList = new ArrayList<>(); + + // 리뷰별 이미지 조회 + for(Review review : reviewList) { + // 리뷰별 이미지 + List reviewFiles = fileUtil.getFiles(review.getFileUuid()); + + // 썸네일 이미지 추출 + List imageResList = reviewFiles.stream().map(file -> new SearchReviewImageRes(file.getId(), file.getThumbSavedName())).toList(); + reviewImageList.add(new SearchPlaceReviewRes(review, imageResList)); + } + + // 리뷰 전체 이미지 조회(원본 - 10장까지) + List totalReviewImageList = fileRepository.getReviewFilesByPlaceWithPage(placeId, 0L, 10).stream().map(file -> new SearchReviewImageRes(file.getId(), file.getFileSavedName())).toList(); + + return new GetReviewPlaceDetailRes(place, placeImageList, reviewTagList, reviewImageList, totalReviewImageList); + } + + /** + * 리뷰 기능있는 장소 상세 조회 - 리뷰 사진 추가 조회 + */ + @Transactional(readOnly = true) + public List getReviewPlaceDetailImages(Long placeId, Long lastFileId) { + // 장소 검색 + Place place = findPlaceById(placeId); + + // 식당, 카페만 조회 가능하도록 제한 + checkReviewPlaceType(place); + + // 리뷰 전체 이미지 조회(원본 - 10장까지) + return fileRepository.getReviewFilesByPlaceWithPage(placeId, lastFileId, 10).stream().map(file -> new SearchReviewImageRes(file.getId(), file.getFileSavedName())).toList(); + + } + + /** + * 리뷰 작성 + * - 기본 점수: +3점 (별점) + * - 한줄평 10글자 이상: +7점 + * - 사진 1장 이상: +3점 + * - 같은 장소 하루 1개 리뷰 제한 + */ + @Transactional + @UpdateScore(dynamic = true) + public CreateReviewRes createReview(Long userId, Long placeId, CreateReviewReq createReviewReq) { + // 한줄평 길이 검증 (작성했다면 10글자 이상) + validateCommentLength(createReviewReq.getComment()); + + // 사용자 검색 + User user = findUserById(userId); + + // 장소 검색 + Place place = findPlaceById(placeId); + + // 리뷰 저장 + Review savedReview = reviewRepository.save(new Review(createReviewReq, user, place)); + + // 사진 저장 + fileUtil.upload(createReviewReq.getImages(), savedReview.getFileUuid(), null, FilePath.REVIEW); + + // 리뷰 태그 저장 + saveReviewTagMap(savedReview, createReviewReq.getTagIds()); + + // 장소 별점 추가 + place.setStarNum(place.getStarNum() + 1); + place.setStarSum(place.getStarSum() + createReviewReq.getScore()); + + return new CreateReviewRes(savedReview.getId()); + } + + /** + * 리뷰 조회 + */ + @Transactional(readOnly = true) + public GetReviewRes getReview(Long reviewId) { + // 리뷰 검색 + Review review = findReviewById(reviewId); + + // 리뷰 태그 조회 + List tagResList = new ArrayList<>(); + for(ReviewTagMap reviewTagMap : review.getReviewTagMaps()) { + tagResList.add(new GetReviewTagRes(reviewTagMap.getReviewTag())); + } + + // 리뷰 이미지 조회 + List imageResList = fileUtil.getFiles(review.getFileUuid()).stream().map(file -> new SearchReviewImageRes(file.getId(), file.getFileSavedName())).toList(); + + return new GetReviewRes(review, tagResList, imageResList); + } + + /** + * 리뷰 수정 + * - 한줄평/사진 추가 시 추가 점수 부여 + * - 한줄평/사진 제거 시 해당 점수 차감 + */ + @Transactional + @UpdateScore(dynamic = true) + public ModifyReviewRes modifyReview(Long userId, Long reviewId, @Valid ModifyReviewReq modifyReviewReq) { + // 한줄평 길이 검증 (작성했다면 10글자 이상) + validateCommentLength(modifyReviewReq.getComment()); + + // 사용자 검색 + User user = findUserById(userId); + + // 리뷰 검색 + Review review = findReviewById(reviewId); + + // 권한 검사 (자신이 작성한 리뷰만 수정 가능) + validateUser(user, review); + + // 사진 삭제 + fileUtil.deleteFile(review.getFileUuid()); + + // 사진 저장 + fileUtil.upload(modifyReviewReq.getImages(), review.getFileUuid(), null, FilePath.REVIEW); + + // 리뷰 수정 + review.modify(modifyReviewReq); + + // 기존 태그 삭제 + deleteReviewTagMap(review); + + // 태그 저장 + saveReviewTagMap(review, modifyReviewReq.getTagIds()); + + // 장소 평점 수정 + review.getPlace().setStarSum(review.getPlace().getStarSum() - review.getScore() + modifyReviewReq.getScore()); + + return new ModifyReviewRes(review.getId()); + } + + /** + * 리뷰 삭제 + * - 삭제한 리뷰의 점수만큼 차감 (최소 0점) + */ + @Transactional + @UpdateScore(dynamic = true) + public DeleteReviewRes deleteReview(Long userId, Long reviewId) { + // 사용자 검색 + User user = findUserById(userId); + + // 리뷰 검색 + Review review = findReviewById(reviewId); + + // 권한 검사 (자신이 작성한 리뷰만 삭제 가능) + validateUser(user, review); + + // 리뷰 태그 삭제 + deleteReviewTagMap(review); + + // 사진 삭제 + fileUtil.deleteFile(review.getFileUuid()); + + // 장소 평정 수정 + review.getPlace().setStarNum(review.getPlace().getStarNum() - 1); + review.getPlace().setStarSum(review.getPlace().getStarSum() - review.getScore()); + + // 리뷰 삭제 + reviewRepository.delete(review); + + return new DeleteReviewRes(); + } + + /** + * 장소 검색 + */ + private Place findPlaceById(Long id) { + return placeRepository.findById(id).orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_PLACE)); + } + + /** + * 사용자 검색 + */ + private User findUserById(Long userId) { + return userRepository.findById(userId).orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_USER)); + } + + /** + * 리뷰 검색 + */ + private Review findReviewById(Long reviewId) { + return reviewRepository.findById(reviewId).orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_REVIEW)); + } + + /** + * 리뷰에 해당하는 장소 타입인지 확인 + */ + private void checkReviewPlaceType(Place place) { + // 식당, 카페만 조회 가능하도록 제한 + if(place.getType() != PlaceType.CAFETERIA && place.getType() != PlaceType.CAFE + && place.getType() != PlaceType.CAFE_TEMP && place.getType() != PlaceType.CAFT_TEMP && place.getType() != PlaceType.CONV_TEMP) { + throw new GlobalException(ResultCode.NOT_SUPPORTED_PLACE_TYPE); + } + } + + /** + * 장소별 리뷰 태그 조회 + */ + private List findPlaceReviewTagList(Place place) { + List reviewTagMaps = placeReviewTagMapRepository.findByPlaceOrderByNumDesc(place); + + List tagResList = new ArrayList<>(); + for(PlaceReviewTagMap reviewTagMap : reviewTagMaps) { + tagResList.add(new SearchPlaceReviewTagRes(reviewTagMap.getReviewTag().getId(), reviewTagMap.getReviewTag().getTag(), reviewTagMap.getNum())); + } + + return tagResList; + } + + /** + * 리뷰 권한 검사 + */ + private void validateUser(User user, Review review) { + // 권한 검사 (자신이 작성한 리뷰만 수정 가능) + if(!user.getUserId().equals(review.getUser().getUserId())) { + throw new GlobalException(ResultCode.UNAUTHORIZED); + } + } + + /** + * 한줄평 길이 검증 (작성했다면 10글자 이상) + */ + private void validateCommentLength(String comment) { + if(comment != null && !comment.isBlank() && comment.length() < 10) { + throw new GlobalException(ResultCode.COMMENT_TOO_SHORT); + } + } + + /** + * 리뷰 태그 저장 + */ + private void saveReviewTagMap(Review review, List reviewTagIds) { + + // 태그 저장 + List reviewTagMaps = new ArrayList<>(); + for(Long tagId : reviewTagIds) { + ReviewTag tag = reviewTagRepository.findById(tagId).orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_REVIEW_TAG)); + + // 리뷰 태그 저장 + ReviewTagMap reviewTagMap = reviewTagMapRepository.save(new ReviewTagMap(review, tag)); + reviewTagMaps.add(reviewTagMap); + + // 장소 태그 저장 + PlaceReviewTagMap placeReviewTagMap = placeReviewTagMapRepository.findByPlaceAndReviewTag(review.getPlace(), tag); + + if(placeReviewTagMap != null) { + placeReviewTagMap.setNum(placeReviewTagMap.getNum() + 1); + } + else { + placeReviewTagMapRepository.save(new PlaceReviewTagMap(review.getPlace(), tag)); + } + } + + // 리뷰에 태그 목록 저장 + review.setReviewTagMaps(reviewTagMaps); + } + + /** + * 기존 리뷰 태그 삭제 + */ + private void deleteReviewTagMap(Review review) { + // 기존 태그 삭제 + List reviewTagMaps = review.getReviewTagMaps(); + review.setReviewTagMaps(null); + + for(ReviewTagMap reviewTagMap : reviewTagMaps) { + // 장소 태그 수정 + PlaceReviewTagMap placeReviewTagMap = placeReviewTagMapRepository.findByPlaceAndReviewTag(review.getPlace(), reviewTagMap.getReviewTag()); + placeReviewTagMap.setNum(placeReviewTagMap.getNum() - 1); + if(placeReviewTagMap.getNum() <= 0) { + placeReviewTagMapRepository.delete(placeReviewTagMap); + } + + // 리뷰 태그 삭제 + reviewTagMapRepository.delete(reviewTagMap); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchFacilityRes.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchFacilityRes.java index 5c875df7..d5a539fd 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchFacilityRes.java +++ b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchFacilityRes.java @@ -4,6 +4,10 @@ import devkor.com.teamcback.domain.place.entity.PlaceType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; @Schema(description = "편의시설 정보") @Getter @@ -44,6 +48,11 @@ public class SearchFacilityRes { private String description; @Schema(description = "편의시설 별점", example = "NaN 또는 3.6666666666666665") private String starAverage; + @Schema(description = "식당인 경우 음식 카테고리", example = "한식") + private String foodType; + @Setter + @Schema(description = "식당인 경우 리뷰 태그 목록") + private List tagList; public SearchFacilityRes(Place place, String imageUrl) { this.id = place.getId(); @@ -64,5 +73,6 @@ public SearchFacilityRes(Place place, String imageUrl) { this.floor = place.getFloor().intValue(); this.description = place.getDescription(); this.starAverage = String.format("%.2f", ((double) place.getStarSum()) / place.getStarNum()); + this.foodType = place.getFoodType(); } } diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceImageRes.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceImageRes.java index 1ad73458..8c8ad4f2 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceImageRes.java +++ b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceImageRes.java @@ -1,6 +1,5 @@ package devkor.com.teamcback.domain.search.dto.response; -import devkor.com.teamcback.domain.place.entity.PlaceImage; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,11 +9,6 @@ public class SearchPlaceImageRes { private Long imageId; private String image; - public SearchPlaceImageRes(PlaceImage placeImage) { - this.imageId = placeImage.getId(); - this.image = placeImage.getImage(); - } - public SearchPlaceImageRes(Long imageId, String image) { this.imageId = imageId; this.image = image; diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceRes.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceRes.java index 54e7228f..f2c851df 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceRes.java +++ b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceRes.java @@ -6,6 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; @Schema(description = "건물 및 강의실 조회 결과") @Getter @@ -55,6 +58,11 @@ public class SearchPlaceRes { private String description; @Schema(description = "편의시설 별점", example = "NaN 또는 3.6666666666666665") private String starAverage; + @Schema(description = "식당인 경우 음식 카테고리", example = "한식") + private String foodType; + @Setter + @Schema(description = "식당인 경우 리뷰 태그 목록") + private List tagList; public SearchPlaceRes(Place place, String imageUrl) { this.id = place.getId(); @@ -79,5 +87,6 @@ public SearchPlaceRes(Place place, String imageUrl) { this.placeType = place.getType(); this.description = place.getDescription(); this.starAverage = String.format("%.2f", ((double) place.getStarSum()) / place.getStarNum()); + this.foodType = place.getFoodType(); } } diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceReviewTagRes.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceReviewTagRes.java new file mode 100644 index 00000000..4a064ff0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchPlaceReviewTagRes.java @@ -0,0 +1,25 @@ +package devkor.com.teamcback.domain.search.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Schema(description = "장소의 리뷰 태그 목록") +@Getter +@NoArgsConstructor +public class SearchPlaceReviewTagRes { + @Schema(description = "리뷰 태그 id", example = "1") + private Long id; + + @Schema(description = "태그 내용", example = "맛있어요") + private String tag; + + @Schema(description = "태그 개수", example = "5") + private int num; + + public SearchPlaceReviewTagRes(Long id, String tag, int num) { + this.id = id; + this.tag = tag; + this.num = num; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchRoomDetailRes.java b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchRoomDetailRes.java index 13bbc735..8ec72be7 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchRoomDetailRes.java +++ b/src/main/java/devkor/com/teamcback/domain/search/dto/response/SearchRoomDetailRes.java @@ -7,8 +7,11 @@ import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.entity.PlaceType; import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; import java.util.regex.Pattern; import lombok.Getter; +import lombok.Setter; @Getter public class SearchRoomDetailRes { @@ -33,6 +36,9 @@ public class SearchRoomDetailRes { private String description; private String starAverage; private String nextPlaceTime; + private String foodType; + @Setter + private List tagList; public SearchRoomDetailRes(Place place, String imageUrl) { this.id = place.getId(); @@ -62,5 +68,6 @@ else if(place.isOperating()) { // 운영 중이면 종료 시간 else { // 운영 종료인 경우 여는 시간 this.nextPlaceTime = place.getOperatingTime().substring(0, 5); } + this.foodType = place.getFoodType(); } } diff --git a/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java b/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java index e4287db0..00f1c16d 100644 --- a/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java +++ b/src/main/java/devkor/com/teamcback/domain/search/service/SearchService.java @@ -12,9 +12,11 @@ import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.entity.PlaceNickname; import devkor.com.teamcback.domain.place.entity.PlaceType; -import devkor.com.teamcback.domain.place.repository.PlaceImageRepository; import devkor.com.teamcback.domain.place.repository.PlaceNicknameRepository; import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import devkor.com.teamcback.domain.review.entity.PlaceReviewTagMap; +import devkor.com.teamcback.domain.review.repository.PlaceReviewTagMapRepository; +import devkor.com.teamcback.domain.review.repository.ReviewTagRepository; import devkor.com.teamcback.domain.routes.repository.NodeRepository; import devkor.com.teamcback.domain.search.dto.request.SaveSearchLogReq; import devkor.com.teamcback.domain.search.dto.response.*; @@ -54,8 +56,9 @@ public class SearchService { private final UserRepository userRepository; private final CategoryRepository categoryRepository; private final BookmarkRepository bookmarkRepository; - private final PlaceImageRepository placeImageRepository; private final NodeRepository nodeRepository; + private final PlaceReviewTagMapRepository placeReviewTagMapRepository; + private final ReviewTagRepository reviewTagRepository; private final LogUtil logUtil; private final FileUtil fileUtil; @@ -196,7 +199,15 @@ public SearchBuildingFacilityListRes searchBuildingFacilityByType(Long buildingI if(place.getFileUuid() != null) { imageUrl = fileUtil.getThumbnail(place.getFileUuid()); } - map.get(place.getFloor()).add(new SearchFacilityRes(place, imageUrl)); + + SearchFacilityRes facilityRes = new SearchFacilityRes(place, imageUrl); + + // 리뷰 내용 추가 + if(place.getType() == PlaceType.CAFE || place.getType() == PlaceType.CAFETERIA) { + facilityRes.setTagList(findPlaceReviewTagList(place)); + } + + map.get(place.getFloor()).add(facilityRes); } res.setFacilities(map); @@ -236,7 +247,14 @@ public SearchFloorInfoRes searchPlaceByBuildingFloor(Long buildingId, int floor) imageUrl = fileUtil.getThumbnail(place.getFileUuid()); } - placeResList.add(new SearchRoomDetailRes(place, imageUrl)); + SearchRoomDetailRes detailRes = new SearchRoomDetailRes(place, imageUrl); + + // 리뷰 내용 추가 + if(place.getType() == PlaceType.CAFE || place.getType() == PlaceType.CAFETERIA) { + detailRes.setTagList(findPlaceReviewTagList(place)); + } + + placeResList.add(detailRes); } List nodeList = nodeRepository.findAllByBuildingAndFloorAndTypeIn(building, floor, List.of(ENTRANCE, STAIR, ELEVATOR)) @@ -276,7 +294,14 @@ public SearchFacilityListRes searchFacilitiesWithType(PlaceType placeType) { imageUrl = fileUtil.getThumbnail(place.getFileUuid()); } - placeResList.add(new SearchPlaceRes(place, imageUrl)); + SearchPlaceRes placeRes = new SearchPlaceRes(place, imageUrl); + + // 리뷰 내용 추가 + if(place.getType() == PlaceType.CAFE || place.getType() == PlaceType.CAFETERIA) { + placeRes.setTagList(findPlaceReviewTagList(place)); + } + + placeResList.add(placeRes); } logUtil.logClick(null, null, null, placeType.toString()); return new SearchFacilityListRes(placeResList); @@ -345,7 +370,7 @@ public SearchBuildingDetailRes searchBuildingDetail(Long userId, Long buildingId // 건물 대표 이미지 확인 String imageUrl = null; if(building.getFileUuid() != null) { - imageUrl = fileUtil.getThumbnail(building.getFileUuid()); + imageUrl = fileUtil.getOriginalFile(building.getFileUuid()); } logUtil.logClick(building.getName(), null, null, null); return new SearchBuildingDetailRes(res, containPlaceTypes, building, imageUrl, bookmarked); @@ -395,16 +420,12 @@ public SearchPlaceDetailRes searchPlaceDetail(Long userId, Long placeId) { Place place = findPlace(placeId); // 장소 사진 - // TODO: 나중에 수정 String imageUrl = null; - List placeImageList; + List placeImageList = new ArrayList<>(); if(place.getFileUuid() != null) { - placeImageList = fileUtil.getThumbnailFiles(place.getFileUuid()).stream().map(image -> new SearchPlaceImageRes(0L, image)).toList(); + placeImageList = fileUtil.getFiles(place.getFileUuid()).stream().map(file -> new SearchPlaceImageRes(file.getId(), file.getFileSavedName())).toList(); imageUrl = placeImageList.isEmpty() ? null : placeImageList.get(0).getImage(); } - else { - placeImageList = new ArrayList<>();placeImageRepository.findAllByPlace(place).stream().map(SearchPlaceImageRes::new).toList(); - } // 즐겨찾기 if(bookmarkRepository.existsByLocationIdAndLocationTypeAndCategoryBookmarkList_CategoryIn(place.getId(), LocationType.PLACE, categories)) { @@ -705,4 +726,15 @@ private Building findBuilding(Long buildingId) { private Place findPlace(Long placeId) { return placeRepository.findById(placeId).orElseThrow(() -> new GlobalException(NOT_FOUND_PLACE)); } + + private List findPlaceReviewTagList(Place place) { + List reviewTagMaps = placeReviewTagMapRepository.findByPlaceOrderByNumDesc(place); + + List tagResList = new ArrayList<>(); + for(PlaceReviewTagMap reviewTagMap : reviewTagMaps) { + tagResList.add(new SearchPlaceReviewTagRes(reviewTagMap.getReviewTag().getId(), reviewTagMap.getReviewTag().getTag(), reviewTagMap.getNum())); + } + + return tagResList; + } } diff --git a/src/main/java/devkor/com/teamcback/domain/suggestion/dto/response/CreateSuggestionRes.java b/src/main/java/devkor/com/teamcback/domain/suggestion/dto/response/CreateSuggestionRes.java index 5c55cbb1..983c9cda 100644 --- a/src/main/java/devkor/com/teamcback/domain/suggestion/dto/response/CreateSuggestionRes.java +++ b/src/main/java/devkor/com/teamcback/domain/suggestion/dto/response/CreateSuggestionRes.java @@ -1,13 +1,25 @@ package devkor.com.teamcback.domain.suggestion.dto.response; import devkor.com.teamcback.domain.suggestion.entity.Suggestion; +import devkor.com.teamcback.global.response.ScoreUpdateResponse; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +import lombok.Setter; @Schema(description = "건의 생성 완료") @Getter -public class CreateSuggestionRes { +public class CreateSuggestionRes implements ScoreUpdateResponse { private Long suggestionId; + @Setter + @Schema(description = "레벨업 여부", example = "false") + private boolean isLevelUp; + @Setter + @Schema(description = "현재 점수", example = "15") + private Long currentScore; + @Setter + @Schema(description = "점수 획득 여부", example = "true") + private boolean scoreGained; + public CreateSuggestionRes(Suggestion suggestion) { this.suggestionId = suggestion.getId(); } diff --git a/src/main/java/devkor/com/teamcback/domain/suggestion/dto/response/SavePlaceImageRes.java b/src/main/java/devkor/com/teamcback/domain/suggestion/dto/response/SavePlaceImageRes.java deleted file mode 100644 index 3000f2ca..00000000 --- a/src/main/java/devkor/com/teamcback/domain/suggestion/dto/response/SavePlaceImageRes.java +++ /dev/null @@ -1,9 +0,0 @@ -package devkor.com.teamcback.domain.suggestion.dto.response; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "건물 사진 저장 완료") -@JsonIgnoreProperties -public class SavePlaceImageRes { -} diff --git a/src/main/java/devkor/com/teamcback/domain/suggestion/repository/SuggestionRepository.java b/src/main/java/devkor/com/teamcback/domain/suggestion/repository/SuggestionRepository.java index 826ffb7d..9672f763 100644 --- a/src/main/java/devkor/com/teamcback/domain/suggestion/repository/SuggestionRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/suggestion/repository/SuggestionRepository.java @@ -3,6 +3,7 @@ import devkor.com.teamcback.domain.suggestion.entity.Suggestion; import devkor.com.teamcback.domain.suggestion.entity.SuggestionType; import devkor.com.teamcback.domain.user.entity.User; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -16,4 +17,6 @@ public interface SuggestionRepository extends JpaRepository { Page findBySuggestionTypeAndIsSolved(Pageable pageable, SuggestionType type, Boolean isSolved); List findByUser(User user); + + long countByUserAndCreatedAtBetween(User user, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/devkor/com/teamcback/domain/suggestion/service/SuggestionService.java b/src/main/java/devkor/com/teamcback/domain/suggestion/service/SuggestionService.java index 6ac266c3..285b3672 100644 --- a/src/main/java/devkor/com/teamcback/domain/suggestion/service/SuggestionService.java +++ b/src/main/java/devkor/com/teamcback/domain/suggestion/service/SuggestionService.java @@ -45,7 +45,7 @@ public class SuggestionService { * 건의 생성 */ @Transactional - @UpdateScore(addScore = 3) + @UpdateScore(addScore = 10) public CreateSuggestionRes createSuggestion(Long userId, CreateSuggestionReq req, List images) { User user = null; if(userId != null) user = findUser(userId); diff --git a/src/main/java/devkor/com/teamcback/domain/vote/dto/response/SaveVoteRecordRes.java b/src/main/java/devkor/com/teamcback/domain/vote/dto/response/SaveVoteRecordRes.java index 2b08c45d..65908cc9 100644 --- a/src/main/java/devkor/com/teamcback/domain/vote/dto/response/SaveVoteRecordRes.java +++ b/src/main/java/devkor/com/teamcback/domain/vote/dto/response/SaveVoteRecordRes.java @@ -1,9 +1,22 @@ package devkor.com.teamcback.domain.vote.dto.response; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import devkor.com.teamcback.global.response.ScoreUpdateResponse; import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +@Getter @Schema(description = "투표 내용 저장 완료") @JsonIgnoreProperties -public class SaveVoteRecordRes { +public class SaveVoteRecordRes implements ScoreUpdateResponse { + @Setter + @Schema(description = "레벨업 여부", example = "false") + private boolean isLevelUp; + @Setter + @Schema(description = "현재 점수", example = "15") + private Long currentScore; + @Setter + @Schema(description = "점수 획득 여부", example = "true") + private boolean scoreGained; } diff --git a/src/main/java/devkor/com/teamcback/domain/vote/service/VoteService.java b/src/main/java/devkor/com/teamcback/domain/vote/service/VoteService.java index 3608b0d3..9d4f508b 100644 --- a/src/main/java/devkor/com/teamcback/domain/vote/service/VoteService.java +++ b/src/main/java/devkor/com/teamcback/domain/vote/service/VoteService.java @@ -60,7 +60,7 @@ public GetVoteRes getVoteByPlace(Long voteTopicId, Long placeId) { /** * 투표 저장 */ - @UpdateScore(addScore = 3) + @UpdateScore(addScore = 5) @Transactional public SaveVoteRecordRes saveVoteRecord(Long userId, SaveVoteRecordReq req) { if(userId == null) throw new GlobalException(FORBIDDEN); diff --git a/src/main/java/devkor/com/teamcback/global/annotation/UpdateScore.java b/src/main/java/devkor/com/teamcback/global/annotation/UpdateScore.java index 50fcba2d..b9360de4 100644 --- a/src/main/java/devkor/com/teamcback/global/annotation/UpdateScore.java +++ b/src/main/java/devkor/com/teamcback/global/annotation/UpdateScore.java @@ -9,5 +9,12 @@ @Retention(value = RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface UpdateScore { - int addScore(); + int addScore() default 0; + + /** + * 동적 점수 계산 여부 + * true인 경우 addScore 무시하고 Request 내용 기반으로 점수 계산 + * (리뷰 생성/수정/삭제 등에서 사용) + */ + boolean dynamic() default false; } diff --git a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java index 9eb9c9d0..c0ba2a22 100644 --- a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java +++ b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java @@ -1,7 +1,14 @@ package devkor.com.teamcback.global.aop; -import devkor.com.teamcback.domain.bookmark.dto.request.CreateBookmarkReq; -import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.common.repository.FileRepository; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import devkor.com.teamcback.domain.review.dto.request.CreateReviewReq; +import devkor.com.teamcback.domain.review.dto.request.ModifyReviewReq; +import devkor.com.teamcback.domain.review.entity.Review; +import devkor.com.teamcback.domain.review.repository.ReviewRepository; +import devkor.com.teamcback.domain.suggestion.dto.request.CreateSuggestionReq; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; import devkor.com.teamcback.domain.user.entity.Level; import devkor.com.teamcback.domain.user.entity.User; import devkor.com.teamcback.domain.user.repository.UserRepository; @@ -9,6 +16,7 @@ import devkor.com.teamcback.domain.vote.repository.VoteRecordRepository; import devkor.com.teamcback.global.annotation.UpdateScore; import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ScoreUpdateResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; @@ -17,10 +25,12 @@ import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.Arrays; import java.util.Comparator; -import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; +import static devkor.com.teamcback.global.response.ResultCode.*; @Slf4j @Aspect @@ -29,22 +39,45 @@ public class UpdateScoreAspect { private final UserRepository userRepository; - private final UserBookmarkLogRepository userBookmarkLogRepository; + private final SuggestionRepository suggestionRepository; private final VoteRecordRepository voteRecordRepository; + private final ReviewRepository reviewRepository; + private final PlaceRepository placeRepository; + private final FileRepository fileRepository; + + // 리뷰 점수 상수 + private static final int REVIEW_BASE_SCORE = 3; // 기본 점수 (별점) + private static final int REVIEW_COMMENT_SCORE = 7; // 한줄평 추가 점수 + private static final int REVIEW_IMAGE_SCORE = 3; // 사진 추가 점수 + private static final int REVIEW_COMMENT_MIN_LENGTH = 10; // 한줄평 최소 글자 수 @Around("@annotation(updateScore)") public Object updateScore(ProceedingJoinPoint joinPoint, UpdateScore updateScore) throws Throwable { - int addScore = updateScore.addScore(); - - Object[] args = joinPoint.getArgs(); // 변수값 - String[] paramNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames(); // 변수명 + Object[] args = joinPoint.getArgs(); + String[] paramNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames(); + String methodName = joinPoint.getSignature().getName(); // User 정보 찾기 User user = getUser(args, paramNames); + // 동적 점수 계산 모드 + if (updateScore.dynamic()) { + return handleDynamicScore(joinPoint, user, args, paramNames, methodName); + } + + // 기존 고정 점수 모드 + return handleFixedScore(joinPoint, updateScore.addScore(), user, args); + } + + /** + * 기존 고정 점수 처리 (Vote, Suggestion 등) + */ + private Object handleFixedScore(ProceedingJoinPoint joinPoint, int addScore, User user, Object[] args) throws Throwable { // 점수 갱신 불가 확인 - if(!checkUpdatable(user, args)) { - return joinPoint.proceed(); + if (!checkUpdatable(user, args)) { + Object result = joinPoint.proceed(); + injectScoreInfo(result, user, false, false); + return result; } // 비지니스 로직 수행 @@ -53,15 +86,203 @@ public Object updateScore(ProceedingJoinPoint joinPoint, UpdateScore updateScore result = joinPoint.proceed(); } catch (Exception e) { log.info("비지니스 로직에서 예외가 발생했습니다."); - throw e; // 기존 흐름 유지 + throw e; + } + + // 점수 증가 + increaseScore(user, addScore); + + // 점수 정보 주입 + injectScoreInfo(result, user, user.isUpgraded(), true); + + return result; + } + + /** + * 동적 점수 처리 (Review 등) + */ + private Object handleDynamicScore(ProceedingJoinPoint joinPoint, User user, Object[] args, String[] paramNames, String methodName) throws Throwable { + if (user == null) { + log.warn("User 정보를 찾을 수 없습니다."); + Object result = joinPoint.proceed(); + injectScoreInfo(result, null, false, false); + return result; + } + + // 리뷰 생성 + for (int i = 0; i < args.length; i++) { + if (args[i] instanceof CreateReviewReq req) { + return handleReviewCreate(joinPoint, user, args, paramNames, req); + } + } + + // 리뷰 수정 + for (int i = 0; i < args.length; i++) { + if (args[i] instanceof ModifyReviewReq req) { + Long reviewId = getReviewId(args, paramNames); + return handleReviewModify(joinPoint, user, reviewId, req); + } + } + + // 리뷰 삭제 (메서드 이름으로 판단) + if (methodName.equals("deleteReview")) { + Long reviewId = getReviewId(args, paramNames); + return handleReviewDelete(joinPoint, user, reviewId); + } + + // 기타 동적 점수 케이스 (확장용) + Object result = joinPoint.proceed(); + injectScoreInfo(result, user, false, false); + return result; + } + + /** + * 리뷰 생성 시 점수 처리 + */ + private Object handleReviewCreate(ProceedingJoinPoint joinPoint, User user, Object[] args, String[] paramNames, CreateReviewReq req) throws Throwable { + // placeId 추출 + Long placeId = getPlaceId(args, paramNames); + Place place = placeRepository.findById(placeId) + .orElseThrow(() -> new GlobalException(NOT_FOUND_PLACE)); + + // 중복 체크: 같은 장소에 오늘 이미 리뷰 작성했는지 + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = startOfDay.plusDays(1); + if (reviewRepository.existsByUserAndPlaceAndCreatedAtBetween(user, place, startOfDay, endOfDay)) { + // throw new GlobalException(ALREADY_REVIEWED_TODAY); + return joinPoint.proceed(); + } + + // 비즈니스 로직 수행 + Object result; + try { + result = joinPoint.proceed(); + } catch (Exception e) { + log.info("비지니스 로직에서 예외가 발생했습니다."); + throw e; } + // 동적 점수 계산 + int addScore = calculateReviewScore(req.getComment(), req.getImages() != null && !req.getImages().isEmpty()); + // 점수 증가 increaseScore(user, addScore); + // 점수 정보 주입 + injectScoreInfo(result, user, user.isUpgraded(), true); + return result; } + /** + * 리뷰 수정 시 점수 처리 + */ + private Object handleReviewModify(ProceedingJoinPoint joinPoint, User user, Long reviewId, ModifyReviewReq req) throws Throwable { + // 기존 리뷰 조회 + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new GlobalException(NOT_FOUND_REVIEW)); + + // 기존 점수 계산 + boolean hadImages = fileRepository.existsByFileUuid(review.getFileUuid()); + int oldScore = calculateReviewScore(review.getComment(), hadImages); + + // 비즈니스 로직 수행 + Object result; + try { + result = joinPoint.proceed(); + } catch (Exception e) { + log.info("비지니스 로직에서 예외가 발생했습니다."); + throw e; + } + + // 새 점수 계산 + int newScore = calculateReviewScore(req.getComment(), req.getImages() != null && !req.getImages().isEmpty()); + + // 점수 차이 계산 및 적용 + int scoreDiff = newScore - oldScore; + if (scoreDiff != 0) { + updateScoreWithDiff(user, scoreDiff); + injectScoreInfo(result, user, user.isUpgraded(), scoreDiff > 0); + } else { + injectScoreInfo(result, user, false, false); + } + + return result; + } + + /** + * 리뷰 삭제 시 점수 처리 + */ + private Object handleReviewDelete(ProceedingJoinPoint joinPoint, User user, Long reviewId) throws Throwable { + // 삭제 전 리뷰 조회 (삭제 후에는 조회 불가) + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new GlobalException(NOT_FOUND_REVIEW)); + + // 기존 점수 계산 + boolean hadImages = fileRepository.existsByFileUuid(review.getFileUuid()); + int scoreToDeduct = calculateReviewScore(review.getComment(), hadImages); + + // 비즈니스 로직 수행 (리뷰 삭제) + Object result; + try { + result = joinPoint.proceed(); + } catch (Exception e) { + log.info("비지니스 로직에서 예외가 발생했습니다."); + throw e; + } + + // 점수 감소 + updateScoreWithDiff(user, -scoreToDeduct); + + // 점수 정보 주입 (삭제 시 scoreGained는 항상 false) + injectScoreInfo(result, user, user.isUpgraded(), false); + + return result; + } + + /** + * 리뷰 점수 계산 + */ + private int calculateReviewScore(String comment, boolean hasImages) { + int score = REVIEW_BASE_SCORE; // 기본 점수 (별점) + + // 한줄평 점수 (10글자 이상) + if (comment != null && comment.length() >= REVIEW_COMMENT_MIN_LENGTH) { + score += REVIEW_COMMENT_SCORE; + } + + // 사진 점수 + if (hasImages) { + score += REVIEW_IMAGE_SCORE; + } + + return score; + } + + /** + * 파라미터에서 placeId 추출 + */ + private Long getPlaceId(Object[] args, String[] paramNames) { + for (int i = 0; i < args.length; i++) { + if (paramNames[i].equals("placeId") && args[i] instanceof Long) { + return (Long) args[i]; + } + } + throw new GlobalException(NOT_FOUND_PLACE); + } + + /** + * 파라미터에서 reviewId 추출 + */ + private Long getReviewId(Object[] args, String[] paramNames) { + for (int i = 0; i < args.length; i++) { + if (paramNames[i].equals("reviewId") && args[i] instanceof Long) { + return (Long) args[i]; + } + } + throw new GlobalException(NOT_FOUND_REVIEW); + } + private User getUser(Object[] args, String[] paramNames) { User user = null; for (int i = 0; i < args.length; i++) { @@ -85,9 +306,12 @@ private boolean checkUpdatable(User user, Object[] args) { // 기타 조건 확인 for (Object arg : args) { - // 북마크 추가 시 점수 증가 여부 확인 (중복 로그 확인) - if (arg instanceof CreateBookmarkReq req) { - if (userBookmarkLogRepository.existsByUserAndLocationIdAndLocationType(user, req.getLocationId(), req.getLocationType())) { + // 건의 작성 시 하루 2회까지만 점수 지급 + if (arg instanceof CreateSuggestionReq) { + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime endOfDay = startOfDay.plusDays(1); + long todayCount = suggestionRepository.countByUserAndCreatedAtBetween(user, startOfDay, endOfDay); + if (todayCount >= 2) { return false; } } @@ -101,6 +325,14 @@ private boolean checkUpdatable(User user, Object[] args) { return true; } + private void injectScoreInfo(Object result, User user, boolean isLevelUp, boolean scoreGained) { + if (result instanceof ScoreUpdateResponse response && user != null) { + response.setLevelUp(isLevelUp); + response.setCurrentScore(user.getScore()); + response.setScoreGained(scoreGained); + } + } + public void increaseScore(User user, int addScore) { long newScore = user.getScore() + addScore; // 전후 레벨 계산 @@ -112,6 +344,21 @@ public void increaseScore(User user, int addScore) { user.updateScore(newScore, isChanged); } + /** + * 점수 차이만큼 업데이트 (증가 또는 감소) + */ + private void updateScoreWithDiff(User user, int scoreDiff) { + long newScore = Math.max(0, user.getScore() + scoreDiff); // 최소 0점 + + // 전후 레벨 계산 + Level beforeLv = getLevel(user.getScore()); + Level afterLv = getLevel(newScore); + + // 변했으면 true + boolean isChanged = beforeLv != afterLv; + user.updateScore(newScore, isChanged); + } + private Level getLevel(Long score) { // score >= minScore 인 경우 중 가장 높은 레벨 반환 return Arrays.stream(Level.values()) diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index 504d7e7b..bad5cd71 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -24,6 +24,7 @@ public enum ResultCode { FORBIDDEN(HttpStatus.FORBIDDEN, 1013, "권한이 없는 사용자입니다."), DB_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 1014, "DB 데이터 문제가 발생했습니다."), UNSUPPORTED_REQUEST(HttpStatus.NOT_FOUND, 1015, "존재하지 않는 요청입니다."), + EXCEEDED_MAXIMUM_FILE_COUNT(HttpStatus.BAD_REQUEST, 1016, "최대 파일 개수를 초과했습니다."), // 사용자 2000번대 NOT_FOUND_USER(HttpStatus.NOT_FOUND, 2000, "사용자를 찾을 수 없습니다."), @@ -45,6 +46,7 @@ public enum ResultCode { NOT_FOUND_PLACE(HttpStatus.NOT_FOUND, 4000, "장소를 찾을 수 없습니다."), NOT_FOUND_PLACE_NICKNAME(HttpStatus.NOT_FOUND, 4001, "장소 별명을 찾을 수 없습니다."), NOT_SUPPORTED_PLACE_TYPE(HttpStatus.BAD_REQUEST, 4002, "지원하지 않는 장소 타입입니다."), + DUPLICATED_PLACE_IMAGE(HttpStatus.CONFLICT, 4003, "해당 시설의 이미지가 이미 존재합니다."), // 길찾기 6000번대 NOT_FOUND_NODE(HttpStatus.NOT_FOUND, 6000, "노드를 찾을 수 없습니다."), @@ -91,8 +93,16 @@ public enum ResultCode { EXISTING_PLACE_FOR_DEVICE(HttpStatus.CONFLICT, 13004, "중복되는 device placeId입니다."), // 강의 14000번대 - NOT_FOUND_COURSE(HttpStatus.NOT_FOUND, 14000, "강의를 찾을 수 없습니다.") - ; + NOT_FOUND_COURSE(HttpStatus.NOT_FOUND, 14000, "강의를 찾을 수 없습니다."), + + // 리뷰 15000번대 + NOT_FOUND_REVIEW_TAG(HttpStatus.NOT_FOUND, 15000, "리뷰 태그를 찾을 수 없습니다."), + NOT_FOUND_REVIEW(HttpStatus.NOT_FOUND, 15001, "리뷰를 찾을 수 없습니다."), + ALREADY_REVIEWED_TODAY(HttpStatus.CONFLICT, 15002, "오늘 이미 해당 장소에 리뷰를 작성했습니다."), + COMMENT_TOO_SHORT(HttpStatus.BAD_REQUEST, 15003, "한줄평은 10글자 이상 작성해주세요."), + + // 신고 16000번대 + NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."); private final HttpStatus status; private final int code; diff --git a/src/main/java/devkor/com/teamcback/global/response/ScoreUpdateResponse.java b/src/main/java/devkor/com/teamcback/global/response/ScoreUpdateResponse.java new file mode 100644 index 00000000..f28559fd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/global/response/ScoreUpdateResponse.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.global.response; + +public interface ScoreUpdateResponse { + void setLevelUp(boolean isLevelUp); + void setCurrentScore(Long currentScore); + void setScoreGained(boolean scoreGained); +} diff --git a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java index c9b1a62c..77695172 100644 --- a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java +++ b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java @@ -90,6 +90,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자인 경우에만 허용 .requestMatchers("/api/categories/**").authenticated() .requestMatchers("/api/bookmarks/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() // 리뷰는 로그인 필요 + .requestMatchers("/api/reports/status").authenticated() // 신고 상태 확인은 로그인 필요 .anyRequest().permitAll() ).exceptionHandling(ex -> ex .accessDeniedHandler(customAccessDeniedHandler()) // 인가 실패 시 diff --git a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java index 18d7c776..4c75cbbe 100644 --- a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java +++ b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java @@ -10,7 +10,8 @@ public enum FilePath { // 파일 경로를 나타내는 상수를 정의 BUILDING("building/"), PLACE("place/"), BUILDING_IMAGE("buildingImage/"), - SUGGESTION("suggestion/"); + SUGGESTION("suggestion/"), + REVIEW("review/"); private final String path; // 경로를 저장하는 final 필드 } diff --git a/src/main/java/devkor/com/teamcback/infra/s3/S3Util.java b/src/main/java/devkor/com/teamcback/infra/s3/S3Util.java index 64280467..a2dd731f 100644 --- a/src/main/java/devkor/com/teamcback/infra/s3/S3Util.java +++ b/src/main/java/devkor/com/teamcback/infra/s3/S3Util.java @@ -50,12 +50,8 @@ private static ObjectMetadata setObjectMetadata(InputStream inputStream, String return metadata; } - // TODO: 추후 정리 - public String uploadFile(MultipartFile file, FilePath filePath) { - return uploadFile(file, filePath, UUID.randomUUID().toString()); - } - public String uploadFile(MultipartFile multipartFile, FilePath filePath, String fileUuid) { + public String uploadFile(MultipartFile multipartFile, FilePath filePath) { // 업로드할 파일이 존재하지 않거나 비어있으면 null 반환 if (multipartFile == null || multipartFile.isEmpty()) { return null; @@ -73,8 +69,13 @@ public String uploadFile(MultipartFile multipartFile, FilePath filePath, String throw new GlobalException(MAXIMUM_UPLOAD_FILE_SIZE); } + String originalName = multipartFile.getOriginalFilename(); + if(originalName == null || originalName.isEmpty()) { + originalName = UUID.randomUUID().toString(); + } + // 파일명을 UTF-8로 디코딩 - String fileName = URLDecoder.decode(fileUuid, StandardCharsets.UTF_8); + String fileName = URLDecoder.decode(originalName, StandardCharsets.UTF_8); // 업로드할 파일의 메타데이터 생성 ObjectMetadata metadata = setObjectMetadata(multipartFile); @@ -91,9 +92,9 @@ public String uploadFile(MultipartFile multipartFile, FilePath filePath, String return getFileUrl(fileName, filePath); } - public String uploadFile(InputStream inputStream, String fileUuid, FilePath filePath, String contentType) { + public String uploadFile(InputStream inputStream, String originalFileName, FilePath filePath, String contentType) { // 파일명을 UTF-8로 디코딩 - String fileName = URLDecoder.decode(fileUuid, StandardCharsets.UTF_8); + String fileName = URLDecoder.decode(originalFileName, StandardCharsets.UTF_8); try { // 업로드할 파일의 메타데이터 생성 @@ -139,7 +140,6 @@ public void deleteFile(String fileUrl, String filePath) { amazonS3Client.deleteObject(bucketName, filePath + fileName); } - // TODO: 추후 정리 public boolean exists(String fileUrl, FilePath filePath) { // 주어진 파일 URL로부터 파일명을 추출 String fileName = getFileNameFromFileUrl(fileUrl, filePath); @@ -166,7 +166,6 @@ public boolean exists(String fileUrl, String filePath) { return true; } - // TODO: 추후 정리 private String getFileUrl(String fileName, FilePath filePath) { // AWS S3 클라이언트를 사용하여 주어진 버킷, 파일 경로 및 파일명에 해당하는 파일의 URL을 얻어옴 return amazonS3Client.getUrl(bucketName, filePath.getPath() + fileName).toString(); @@ -177,7 +176,6 @@ private String getFileUrl(String fileName, String filePath) { return amazonS3Client.getUrl(bucketName, filePath+ fileName).toString(); } - // TODO: 추후 정리 private String getFileNameFromFileUrl(String fileUrl, FilePath filePath) { // 파일 URL에서 파일 경로 다음의 문자열부터 파일명의 끝까지 추출하여 반환 return fileUrl.substring(fileUrl.lastIndexOf(filePath.getPath()) + filePath.getPath().length()); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 625c517d..0922602f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,7 +26,7 @@ spring: servlet: multipart: - location: /app/temp + location: /tmp max-file-size: 5MB max-request-size: 50MB