diff --git a/.github/pull_request_templete.md b/.github/PULL_REQUEST_TEMPLATE.md
similarity index 100%
rename from .github/pull_request_templete.md
rename to .github/PULL_REQUEST_TEMPLATE.md
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 00000000..4e45f799
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,64 @@
+name: CD with Gradle
+
+on:
+ push:
+ branches: [ "main", "develop" ]
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'oracle'
+
+ - name: Make env file
+ run: |
+ echo "${{ secrets.ENV }}" | base64 --decode > .env
+
+ - name: Make docker-compose file
+ run: |
+ echo "\n" >> ./docker-compose.yml
+ echo "${{ secrets.DOCKER_COMPOSE }}" | base64 --decode >> ./docker-compose.yml
+ echo "\n" >> ./docker-compose.yml
+
+ - name: Grant execute permission for gradlew
+ run: |
+ chmod +x gradlew
+
+ - name: Build with Gradle
+ run: ./gradlew build -x test
+
+ - name: Docker Login
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_PASSWORD }}
+
+ - name: Docker build & push to Docker repo
+ run: |
+ docker build -f Dockerfile -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPO }} .
+ docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPO }}
+
+ - name: Deploy to server
+ uses: appleboy/ssh-action@master
+ id: deploy
+ with:
+ host: ${{ secrets.AWS_HOST }}
+ username: ubuntu
+ key: ${{ secrets.AWS_SSH_KEY }}
+ envs: GITHUB_SHA
+ script: |
+ sudo docker rm -f $(docker ps -qa)
+ sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPO }}
+ sudo docker-compose up -d --build
+ sudo docker image prune -f
diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
new file mode 100644
index 00000000..711d0f02
--- /dev/null
+++ b/.github/workflows/gradle.yml
@@ -0,0 +1,57 @@
+name: CI
+
+on:
+ push:
+ branches: [ "main", "develop" ]
+ pull_request:
+ branches: [ "main", "develop" ]
+
+permissions:
+ contents: read
+ checks: write
+ pull-requests: write
+
+jobs:
+ build:
+ name: Build and test project
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout the code
+ uses: actions/checkout@v3
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'oracle'
+
+ - name: Make env file
+ run: |
+ echo "${{ secrets.ENV }}" | base64 --decode > .env
+
+ - name: Make docker-compose file
+ run: |
+ echo "\n" >> ./docker-compose.yml
+ echo "${{ secrets.DOCKER_COMPOSE }}" | base64 --decode >> ./docker-compose.yml
+ echo "\n" >> ./docker-compose.yml
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Build with Gradle
+ run: ./gradlew build -x test
+
+ - name: Publish result of unit test
+ uses: EnricoMi/publish-unit-test-result-action@v2
+ if: always()
+ with:
+ files: "**/build/test-results/test/TEST-*.xml"
+ github_token: ${{ github.token }}
+
+ - name: Publish failure of unit test
+ uses: mikepenz/action-junit-report@v3
+ if: always()
+ with:
+ report_paths: '**/build/test-results/test/TEST-*.xml'
+ github_token: ${{ github.token }}
diff --git a/.gitignore b/.gitignore
index c2065bc2..2c0c9410 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,13 @@ out/
### VS Code ###
.vscode/
+
+### ENV FILE ###
+.env
+
+### DATABASE/DATA Directory ###
+database/data/
+
+### REDIS/DATA Directory ###
+redis/data/
+/src/main/generated/com/haejwo/tripcometrue
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..3094db35
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,8 @@
+FROM openjdk:17-jdk-slim
+WORKDIR /app
+COPY . .
+COPY .env .env
+ARG JAR_FILE_PATH=build/libs/*.jar
+COPY ${JAR_FILE_PATH} app.jar
+ENV TZ=Asia/Seoul
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/README.md b/README.md
index 5f526cd7..6e45af46 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,64 @@
-# TripComeTrue_BE
+### ๐คํ์ ์๊ฐ
+
+| Backend | Backend |Backend| Backend | Backend |
+|:----------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------:|
+|
|
|
|
|
|
+| [์ด์ ์](https://github.com/liyusang1) | [์ด์ฃผ์ฐ](https://github.com/jo0oy) |[๋ฐฑ์ธ๊ถ](https://github.com/BackInGone)| [๊น๋๋ฏผ](https://github.com/meena2003) | [๋ฐ์ค๋ชจ](https://github.com/junmo95) |
+| ๐กspring security
๐กํ์๊ฐ์
๋ฐ ๋ก๊ทธ์ธ
๐กSNS ๋ก๊ทธ์ธ (์นด์นด์ค,๋ค์ด๋ฒ,๊ตฌ๊ธ)
๐ก์ฌํ ํ๊ธฐ ์์ฑ
๐ก์ฌํ ๊ณํ ์์ฑ ๋ฐ ์กฐํ | ๐กGithub Actions CI/CD, EC2, Docker
๐กํ ๋ฉ์ธ ํผ๋ ๊ด๋ จ api
๐กํ ๊ฒ์ ํผ๋ ๊ด๋ จ api
๐ก๋์ ์์ธ ๊ด๋ จ api
๐กํ์จ open api ์ค์ผ์ค๋ฌ ํธ์ถ ๋ฐ ํ์จ ์ ๋ณด redis ์ ์ฅ
|๐ก๋ง์ด ํ์ด์ง ๊ด๋ จ api
๐ก๋ณด๊ด ๊ธฐ๋ฅ ๊ด๋ จ api
๐ก์ข์์ ๊ด๋ จ api | ๐กS3 ์ด๋ฏธ์ง ์
๋ก๋ ์ญ์ ๊ด๋ จ api
๐ก ์ฌํ ํ๊ธฐ ๋ฆฌ๋ทฐ ๋ฐ ๋ฆฌ๋ทฐ๋๊ธ ๊ด๋ จ api
๐ก์ฌํ์ง ๋ฆฌ๋ทฐ ๋ฐ ๋ฆฌ๋ทฐ๋๊ธ ๊ด๋ จ api
| ๐ก์ฌํ ํ๊ธฐ ๋ฉ์ธํ์ด์ง api
๐ก์ฌํ ํ๊ธฐ ์์ธํ์ด์ง api
๐ก์ฌํ์ง ์์ธํ์ด์ง api |
+
+-----------------------
+### ๐๊ธฐ์ ์คํ & ๊ตฌํํ๊ฒฝ
+> - Java : ![Java](https://img.shields.io/badge/java-17-red.svg)
+> - FrameWork : ![Spring Boot](https://img.shields.io/badge/springboot-3.2.1-brightgreen.svg) ![Spring Security](https://img.shields.io/badge/springsecurity-brightgreen.svg) ![Spring Data JPA](https://img.shields.io/badge/spring%20data%20JPA-brightgreen.svg) ![Spring Web](https://img.shields.io/badge/spring%20web-brightgreen.svg)
+> - Build : ![Gradle](https://img.shields.io/badge/Build-Gradle-blue.svg)
+> - VCS : ![Git](https://img.shields.io/badge/VCS-Git-orange.svg) ![GitHub](https://img.shields.io/badge/Github-black.svg)
+> - Database : ![GCP Cloud SQL](https://img.shields.io/badge/Database-AmazonEC2-yellow.svg)
+> - DBMS : ![MySQL](https://img.shields.io/badge/DBMS-MySQL-blue.svg)
+> - ๋ฐฐํฌํ๊ฒฝ : ![GCP VM](https://img.shields.io/badge/๋ฐฐํฌ%20ํ๊ฒฝ-AmazonEc2-blue.svg)
+> - ์ปจ๋ฒค์
: ![Code Convention](https://img.shields.io/badge/Code%20Convention-IntelliJ%20Java%20Google%20Style-brightgreen.svg)
+> - ๋ธ๋์น ์ ๋ต : ![GitFlow](https://img.shields.io/badge/GitFlow-Workflow-orange.svg)
+
+### ๐API ๋ช
์ธ์ & ์๋น์ค ๋ฐฐํฌ ์ฃผ์
+- ์๋น์ค ๋ฐฐํฌ ์ฃผ์ : [https://tripcometrue.vercel.app](https://tripcometrue.vercel.app)
+- API ๋ช
์ธ์:
+ - [Postman 1](https://documenter.getpostman.com/view/14269013/2s9YsJCYY9#47909ddc-026a-4731-b0ca-5088b8e8574f)
+ - [Postman 2](https://documenter.getpostman.com/view/24478928/2s9YsRaUDD)
+ - [Notion 1](https://arrow-halibut-e8d.notion.site/API-9d3aa3736a764af6a513efda552211b5?pvs=4)
+ - [Notion 2](https://immense-soarer-ecc.notion.site/TripComeTrue-API-4f9c4ca0580e47e49adfe3b62ec39957?pvs=4)
+
+
+### ๐ํจํค์ง ๊ตฌ์กฐ
+```
+ com.example.yanolja
+ โโโ domain
+ โ โโโ alarm
+ โ โโโ city
+ โ โโโ comment
+ โ โโโ likes
+ โ โโโ member
+ โ โโโ place
+ โ โโโ review
+ โ โโโ store
+ โ โโโ tripplan
+ โ โโโ triprecord
+ โ ...
+ โโโ global
+ โโโ config
+ โโโ entity
+ โโโ enums
+ โโโ exception
+ โโโ jwt
+ โโโ s3
+ โโโ springsecurity
+ โโโ util
+ โโโ validator
+```
+
+--------------------
+
+### โญERD
+![erd](https://github.com/TripComeTrue/TripComeTrue_BE/assets/65541248/33ff36cc-0e04-4285-82d4-b2939e77b5ab)
+
+
+### โญProject Architecture
+![image](https://github.com/TripComeTrue/TripComeTrue_BE/assets/65541248/a6a64b92-2d77-4240-a64e-fe6770e1703e)
diff --git a/build.gradle b/build.gradle
index 375ad6ab..7a6e3b9b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -26,19 +26,80 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.security:spring-security-test'
compileOnly 'org.projectlombok:lombok'
- //mysql connector
+ // mysql connector
runtimeOnly 'com.mysql:mysql-connector-j'
+ // queryDSL ์ค์
+ implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
+ annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
+ annotationProcessor "jakarta.annotation:jakarta.annotation-api"
+ annotationProcessor "jakarta.persistence:jakarta.persistence-api"
+
+ // redis
+ implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+
+ // testcontainers
+ testImplementation 'org.springframework.boot:spring-boot-testcontainers'
+ testImplementation 'org.testcontainers:mysql'
+
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
+ testAnnotationProcessor 'org.projectlombok:lombok'
+ testImplementation 'org.projectlombok:lombok'
// yml
implementation 'org.yaml:snakeyaml:+'
+
+ // dotenv-java
+ implementation 'io.github.cdimascio:java-dotenv:+'
+
+
+ // aws S3
+ implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
+
+ //oauth
+ implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
+
+ // jjwt
+ implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
+ runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
+ runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'
+
+ // httpclient5
+ implementation 'org.apache.httpcomponents.client5:httpclient5:5.3'
+}
+
+jar {
+ enabled = false
}
tasks.named('test') {
useJUnitPlatform()
}
+
+
+// Querydsl ์ค์ ๋ถ
+// ์๋ ๊ฒ๋ค์ด ์์ด๋ ๊ธฐ๋ณธ์ ์ธ querydsl ๋์์ ํ๋ ์ธํ
๋ฆฌ์ ์ด์์ ๋น๋ ์ ๋ฐ์ํ ๋ฌธ์ ๋ฅผ ์๋ฐฉ
+def generated = 'src/main/generated'
+
+// querydsl QClass ํ์ผ ์์ฑ ์์น๋ฅผ ์ง์
+// ์๋ build ๋๋ ํ ๋ฆฌ ์์ ์์ด์ ๋์ ์๋ณด์์ง๋ง ๊บผ๋ค์ ๋ด๊ฐ ์ง์ ํ ๋๋ ํ ๋ฆฌ์ ๊บผ๋ด์ด
+// ์ธํ
๋ฆฌ์ ์ด IDE์์ ๋ฌธ์ ์ธ๋ฐ, ๋น๋ gradle ํ ๋ ์ค์บ ์์ญ์ด ๋ฌ๋ผ์ ์ค๋ณต ์ค์บ์ด ๋ฐ์ํ ์ ์๋ค.
+tasks.withType(JavaCompile) {
+ options.getGeneratedSourceOutputDirectory().set(file(generated))
+}
+
+// java source set ์ querydsl QClass ์์น ์ถ๊ฐ
+sourceSets {
+ main.java.srcDirs += [ generated ]
+}
+
+// gradle clean ์์ QClass ๋๋ ํ ๋ฆฌ ์ญ์
+clean {
+ delete file(generated)
+}
diff --git a/database/Dockerfile b/database/Dockerfile
index 53bb0f93..f79306ab 100644
--- a/database/Dockerfile
+++ b/database/Dockerfile
@@ -4,8 +4,6 @@ MAINTAINER liyusang799@gmail.com
COPY init.sql /docker-entrypoint-initdb.d
-ENV MYSQL_ROOT_PASSWORD=root
-
VOLUME /var/lib/mysql
EXPOSE 3307
\ No newline at end of file
diff --git a/database/init.sql b/database/init.sql
index a383a8ae..0e756bf7 100644
--- a/database/init.sql
+++ b/database/init.sql
@@ -1,6 +1,3 @@
-alter user 'root'@'localhost' identified with caching_sha2_password by 'root';
-flush privileges;
-
create database if not exists tripcometrue;
-use tripcometrue;
\ No newline at end of file
+use tripcometrue;
diff --git a/docker-compose.yml b/docker-compose.yml
index 9223b645..9f732e2f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,12 +1,23 @@
version: '3'
+
+networks:
+ network:
+
services:
- travel-db:
+ tripcometrue-db:
container_name: tripcometrue-db
- build:
- context: ./database
- dockerfile: Dockerfile
+ image: jo0oy/tripcometrue-mysql
ports:
- "3307:3306"
+ environment:
+ - MYSQL_DATABASE=tripcometrue
+ - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
+ - TZ=Asia/Seoul
+ volumes:
+ - ./database/data:/var/lib/mysql
+ command:
+ - --character-set-server=utf8mb4
+ - --collation-server=utf8mb4_unicode_ci
restart: always
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
@@ -15,5 +26,20 @@ services:
networks:
- network
-networks:
- network:
\ No newline at end of file
+ tripcometrue-redis:
+ container_name: tripcometrue-redis
+ image: redis:6
+ hostname: redis
+ command: redis-server --port 6379
+ ports:
+ - "6379:6379"
+ volumes:
+ - ./redis/data:/data
+ environment:
+ - TZ=Asia/Seoul
+ labels:
+ - "name=redis"
+ - "mode=standalone"
+ restart: always
+ networks:
+ - network
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/alarm/controller/AlarmController.java b/src/main/java/com/haejwo/tripcometrue/domain/alarm/controller/AlarmController.java
new file mode 100644
index 00000000..f1737f19
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/alarm/controller/AlarmController.java
@@ -0,0 +1,30 @@
+package com.haejwo.tripcometrue.domain.alarm.controller;
+
+import com.haejwo.tripcometrue.domain.alarm.dto.response.AlarmResponseDto;
+import com.haejwo.tripcometrue.domain.alarm.service.AlarmService;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+public class AlarmController {
+
+ private final AlarmService alarmService;
+
+
+ @GetMapping("/v1/member/alarms")
+ public ResponseEntity>> getAlarms(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ Pageable pageable
+ ) {
+ Page alarmsResponse = alarmService.getAlarms(principalDetails, pageable);
+ return ResponseEntity.ok(ResponseDTO.okWithData(alarmsResponse));
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/alarm/dto/response/AlarmResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/alarm/dto/response/AlarmResponseDto.java
new file mode 100644
index 00000000..8669ccf0
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/alarm/dto/response/AlarmResponseDto.java
@@ -0,0 +1,30 @@
+package com.haejwo.tripcometrue.domain.alarm.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.haejwo.tripcometrue.domain.alarm.entity.Alarm;
+import com.haejwo.tripcometrue.domain.alarm.entity.AlarmType;
+import java.time.LocalDateTime;
+
+public record AlarmResponseDto(
+ Long alarmId,
+ String alarmArgs,
+ String fromMemberNickname,
+ Long windowId,
+ Long objectId,
+ AlarmType alarmType,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH-mm-ss")
+ LocalDateTime createdAt
+) {
+
+ public static AlarmResponseDto fromEntity(Alarm alarm) {
+ return new AlarmResponseDto(
+ alarm.getId(),
+ alarm.getAlarmArgs(),
+ alarm.getFromMember().getMemberBase().getNickname(),
+ alarm.getWindowId(),
+ alarm.getObjectId(),
+ alarm.getAlarmType(),
+ alarm.getCreatedAt()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/alarm/entity/Alarm.java b/src/main/java/com/haejwo/tripcometrue/domain/alarm/entity/Alarm.java
new file mode 100644
index 00000000..e677bce9
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/alarm/entity/Alarm.java
@@ -0,0 +1,62 @@
+package com.haejwo.tripcometrue.domain.alarm.entity;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.global.entity.BaseTimeEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor
+public class Alarm extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "alarm_id")
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "from_member_id")
+ private Member fromMember;
+
+ private Long windowId;
+ private Long objectId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "to_member_id")
+ private Member toMember;
+
+ @Enumerated(EnumType.STRING)
+ private AlarmType alarmType;
+
+ private String alarmArgs;
+
+
+ @Builder
+ public Alarm(
+ Member fromMember,
+ Member toMember,
+ AlarmType alarmType,
+ Long windowId,
+ Long objectId,
+ String alarmArgs) {
+ this.fromMember = fromMember;
+ this.toMember = toMember;
+ this.alarmType = alarmType;
+ this.windowId = windowId;
+ this.objectId = objectId;
+ this.alarmArgs = alarmArgs;
+ }
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/alarm/entity/AlarmType.java b/src/main/java/com/haejwo/tripcometrue/domain/alarm/entity/AlarmType.java
new file mode 100644
index 00000000..5b4449fd
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/alarm/entity/AlarmType.java
@@ -0,0 +1,16 @@
+package com.haejwo.tripcometrue.domain.alarm.entity;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum AlarmType {
+
+ NEW_TRIP_RECORD_REVIEW("์ ๋ฆฌ๋ทฐ ์๋ฆผ"),
+ NEW_TRIP_RECORD_COMMENT("์ ์ฌํํ๊ธฐ ๋๊ธ ์๋ฆผ"),
+ NEW_PLACE_REVIEW_COMMENT("์ ์ฌํ์ง๋ฆฌ๋ทฐ ๋๊ธ ์๋ฆผ"),
+ REVIEW_WRITE_REQUEST("๋ฆฌ๋ทฐ ์์ฑ ์์ฒญ ์๋ฆผ");
+
+ private final String message;
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/alarm/repository/AlarmRepository.java b/src/main/java/com/haejwo/tripcometrue/domain/alarm/repository/AlarmRepository.java
new file mode 100644
index 00000000..ae73ab49
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/alarm/repository/AlarmRepository.java
@@ -0,0 +1,14 @@
+package com.haejwo.tripcometrue.domain.alarm.repository;
+
+import com.haejwo.tripcometrue.domain.alarm.entity.Alarm;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface AlarmRepository extends JpaRepository {
+
+ Page findAllByToMember(Member member, Pageable pageable);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/alarm/service/AlarmService.java b/src/main/java/com/haejwo/tripcometrue/domain/alarm/service/AlarmService.java
new file mode 100644
index 00000000..7116dfbc
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/alarm/service/AlarmService.java
@@ -0,0 +1,57 @@
+package com.haejwo.tripcometrue.domain.alarm.service;
+
+import com.haejwo.tripcometrue.domain.alarm.dto.response.AlarmResponseDto;
+import com.haejwo.tripcometrue.domain.alarm.entity.Alarm;
+import com.haejwo.tripcometrue.domain.alarm.entity.AlarmType;
+import com.haejwo.tripcometrue.domain.alarm.repository.AlarmRepository;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+
+
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class AlarmService {
+
+ private final AlarmRepository alarmRepository;
+
+
+ public Page getAlarms(PrincipalDetails principalDetails, Pageable pageable) {
+ Page alarms = alarmRepository.findAllByToMember(principalDetails.getMember(), pageable);
+ return alarms.map(AlarmResponseDto::fromEntity);
+ }
+
+ public void addAlarm(Member fromMember, Member toMember, AlarmType alarmType, Long windowId,
+ Long objectId) {
+ String alarmArgs = createAlarmArgs(fromMember.getMemberBase().getNickname(), alarmType);
+ Alarm alarm = Alarm.builder()
+ .fromMember(fromMember)
+ .toMember(toMember)
+ .alarmType(alarmType)
+ .windowId(windowId)
+ .objectId(objectId)
+ .alarmArgs(alarmArgs)
+ .build();
+ alarmRepository.save(alarm);
+ }
+
+ private String createAlarmArgs(String memberNickname, AlarmType alarmType) {
+ switch (alarmType) {
+ case NEW_TRIP_RECORD_REVIEW:
+ return memberNickname + "๋์ด ์ฌํ ํ๊ธฐ์ ๋ฆฌ๋ทฐ๋ฅผ ๋จ๊ธฐ์
จ์ต๋๋ค.";
+ case NEW_TRIP_RECORD_COMMENT:
+ return memberNickname + "๋์ด ์ฌํํ๊ธฐ์ ๋๊ธ์ ๋จ๊ธฐ์
จ์ต๋๋ค.";
+ case NEW_PLACE_REVIEW_COMMENT:
+ return memberNickname + "๋์ด ์ฌํ์ง๋ฆฌ๋ทฐ์ ๋๊ธ์ ๋จ๊ธฐ์
จ์ต๋๋ค.";
+ case REVIEW_WRITE_REQUEST:
+ return "์ฌํ ํ๊ธฐ์ ๋ฆฌ๋ทฐ๋ฅผ ๋จ๊ฒจ์ฃผ์ธ์.";
+ default:
+ return "์๋ฆผ ํ์
์ด ์ง์ ๋์ง ์์์ต๋๋ค.";
+ }
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CityContentReadController.java b/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CityContentReadController.java
new file mode 100644
index 00000000..edf494b2
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CityContentReadController.java
@@ -0,0 +1,135 @@
+package com.haejwo.tripcometrue.domain.city.controller;
+
+import com.haejwo.tripcometrue.domain.city.dto.response.*;
+import com.haejwo.tripcometrue.domain.city.service.CityContentReadService;
+import com.haejwo.tripcometrue.domain.triprecord.dto.response.triprecord_schedule_media.TripRecordScheduleVideoListItemResponseDto;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import com.haejwo.tripcometrue.global.util.SliceResponseDto;
+import com.haejwo.tripcometrue.global.validator.annotation.HomeTopListQueryType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RequiredArgsConstructor
+@RequestMapping("/v1/cities")
+@RestController
+public class CityContentReadController {
+
+ private final CityContentReadService cityContentReadService;
+
+ // ํํผ๋ TOP ์ธ๊ธฐ ๋์ ๋ฆฌ์คํธ ์กฐํ
+ @GetMapping("/top-list")
+ public ResponseEntity>> getTopCityList(
+ @RequestParam @HomeTopListQueryType String type
+ ) {
+ return ResponseEntity
+ .ok()
+ .body(
+ ResponseDTO.okWithData(
+ cityContentReadService.getTopCityList(type)
+ )
+ );
+ }
+
+ // ๋์ ๊ฐค๋ฌ๋ฆฌ ๋๋ณด๊ธฐ ์กฐํ
+ @GetMapping("/{cityId}/images")
+ public ResponseEntity>> getImagesByCityIdPagination(
+ @PathVariable("cityId") Long cityId,
+ @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
+ ) {
+ return ResponseEntity
+ .ok()
+ .body(
+ ResponseDTO.okWithData(
+ cityContentReadService.getImages(cityId, pageable)
+ )
+ );
+ }
+
+ // ๋์ ๊ฐค๋ฌ๋ฆฌ ๋ฆฌ์คํธ ์กฐํ
+ @GetMapping("/{cityId}/images/list")
+ public ResponseEntity>> getImagesByCityId(
+ @PathVariable("cityId") Long cityId
+ ) {
+ return ResponseEntity
+ .ok()
+ .body(
+ ResponseDTO.okWithData(
+ cityContentReadService.getImages(cityId)
+ )
+ );
+ }
+
+ // ๋์ ์ผ์ธ ๋๋ณด๊ธฐ ์กฐํ
+ @GetMapping("/{cityId}/videos")
+ public ResponseEntity>> getVideosByCityIdPagination(
+ @PathVariable("cityId") Long cityId,
+ @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
+ ) {
+ return ResponseEntity
+ .ok()
+ .body(
+ ResponseDTO.okWithData(
+ cityContentReadService.getVideos(cityId, pageable)
+ )
+ );
+ }
+
+ // ๋์ ์ผ์ธ ๋ฆฌ์คํธ ์กฐํ
+ @GetMapping("/{cityId}/videos/list")
+ public ResponseEntity>> getVideosByCityId(
+ @PathVariable("cityId") Long cityId
+ ) {
+ return ResponseEntity
+ .ok()
+ .body(
+ ResponseDTO.okWithData(
+ cityContentReadService.getVideos(cityId)
+ )
+ );
+ }
+
+ // ๋์ ํซํ๋ ์ด์ค ์กฐํ
+ @GetMapping("/{cityId}/hot-places")
+ public ResponseEntity>> getHotPlaces(
+ @PathVariable("cityId") Long cityId
+ ) {
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(
+ ResponseDTO.okWithData(cityContentReadService.getHotPlaces(cityId))
+ );
+ }
+
+ // ๋์ ์ฌํ์ง ๋๋ณด๊ธฐ ์กฐํ ๋ฐ ๋์ ์ฌํ์ง ๊ฒ์ ์กฐํ (ํ์ด์ง)
+ @GetMapping("/{cityId}/places")
+ public ResponseEntity>> getPlacesByCityIdPagination(
+ @PathVariable("cityId") Long cityId,
+ @RequestParam(required = false, name = "placeName") String placeName,
+ @PageableDefault(sort = "storedCount", direction = Sort.Direction.DESC) Pageable pageable
+ ) {
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(
+ ResponseDTO.okWithData(cityContentReadService.getPlaces(cityId, placeName, pageable))
+ );
+ }
+
+ // ์/๊ฒฝ๋ ์ ๋ณด ํฌํจ ๋์ ์ฌํ์ง ๋ฆฌ์คํธ ์กฐํ
+ @GetMapping("/{cityId}/places/list")
+ public ResponseEntity>> getPlacesWithLatLongByCityId(
+ @PathVariable("cityId") Long cityId
+ ) {
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(
+ ResponseDTO.okWithData(cityContentReadService.getPlacesWithLatLong(cityId))
+ );
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CityEditController.java b/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CityEditController.java
new file mode 100644
index 00000000..ae9fd0e4
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CityEditController.java
@@ -0,0 +1,42 @@
+package com.haejwo.tripcometrue.domain.city.controller;
+
+import com.haejwo.tripcometrue.domain.city.dto.request.AddCityRequestDto;
+import com.haejwo.tripcometrue.domain.city.dto.response.CityResponseDto;
+import com.haejwo.tripcometrue.domain.city.service.CityEditService;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RequiredArgsConstructor
+@RequestMapping("/v1/cities")
+@RestController
+public class CityEditController {
+
+ private final CityEditService cityEditService;
+
+ @PostMapping
+ public ResponseEntity> addCity(
+ @RequestBody AddCityRequestDto request
+ ) {
+
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(
+ ResponseDTO.okWithData(cityEditService.addCity(request))
+ );
+ }
+
+ @DeleteMapping("/{cityId}")
+ public ResponseEntity> deleteCity(
+ @PathVariable("cityId") Long cityId
+ ) {
+
+ cityEditService.deleteCity(cityId);
+
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(ResponseDTO.ok());
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CityInfoReadController.java b/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CityInfoReadController.java
new file mode 100644
index 00000000..b28c9c56
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CityInfoReadController.java
@@ -0,0 +1,57 @@
+package com.haejwo.tripcometrue.domain.city.controller;
+
+import com.haejwo.tripcometrue.domain.city.dto.response.CityInfoResponseDto;
+import com.haejwo.tripcometrue.domain.city.dto.response.ExchangeRateResponseDto;
+import com.haejwo.tripcometrue.domain.city.dto.response.WeatherResponseDto;
+import com.haejwo.tripcometrue.domain.city.service.CityInfoReadService;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RequiredArgsConstructor
+@RequestMapping("/v1/cities")
+@RestController
+public class CityInfoReadController {
+
+ private final CityInfoReadService cityInfoReadService;
+
+ @GetMapping("/{cityId}")
+ public ResponseEntity> getCityInfo(
+ @PathVariable("cityId") Long cityId
+ ) {
+
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(
+ ResponseDTO.okWithData(cityInfoReadService.getCityInfo(cityId))
+ );
+ }
+
+ @GetMapping("/{cityId}/exchange-rates")
+ public ResponseEntity> getExchangeRate(
+ @PathVariable("cityId") Long cityId
+ ) {
+
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(
+ ResponseDTO.okWithData(cityInfoReadService.getExchangeRateByCityId(cityId))
+ );
+ }
+
+ @GetMapping("/{cityId}/weathers")
+ public ResponseEntity>> getWeatherInfo(
+ @PathVariable("cityId") Long cityId
+ ) {
+
+ return ResponseEntity
+ .status(HttpStatus.OK)
+ .body(
+ ResponseDTO.okWithData(cityInfoReadService.getWeatherInfo(cityId))
+ );
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CitySearchController.java b/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CitySearchController.java
new file mode 100644
index 00000000..3a8ec146
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/controller/CitySearchController.java
@@ -0,0 +1,34 @@
+package com.haejwo.tripcometrue.domain.city.controller;
+
+import com.haejwo.tripcometrue.domain.city.dto.response.CityResponseDto;
+import com.haejwo.tripcometrue.domain.city.service.CitySearchService;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RequiredArgsConstructor
+@RequestMapping("/v1/cities")
+@RestController
+public class CitySearchController {
+
+ private final CitySearchService citySearchService;
+
+ @GetMapping
+ public ResponseEntity>> search(
+ @RequestParam("name") String name
+ ) {
+ return ResponseEntity
+ .ok()
+ .body(
+ ResponseDTO.okWithData(
+ citySearchService.search(name)
+ )
+ );
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/dto/api/ExchangeRateApiDto.java b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/api/ExchangeRateApiDto.java
new file mode 100644
index 00000000..6c8837ad
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/api/ExchangeRateApiDto.java
@@ -0,0 +1,15 @@
+package com.haejwo.tripcometrue.domain.city.dto.api;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public record ExchangeRateApiDto(
+ @JsonProperty("result")
+ Integer result,
+ @JsonProperty("cur_unit")
+ String curUnit,
+ @JsonProperty("cur_nm")
+ String curName,
+ @JsonProperty("deal_bas_r")
+ String dealBaseRate
+) {
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/dto/request/AddCityRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/request/AddCityRequestDto.java
new file mode 100644
index 00000000..304c5fe8
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/request/AddCityRequestDto.java
@@ -0,0 +1,33 @@
+package com.haejwo.tripcometrue.domain.city.dto.request;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.global.enums.CurrencyUnit;
+import com.haejwo.tripcometrue.global.enums.Country;
+
+public record AddCityRequestDto(
+ String name,
+ String englishName,
+ String language,
+ String timeDifference,
+ String voltage,
+ String visa,
+ String currency,
+ String weatherRecommendation,
+ String weatherDescription,
+ Country country
+) {
+ public City toEntity() {
+ return City.builder()
+ .name(this.name)
+ .englishName(this.englishName)
+ .language(this.language)
+ .timeDifference(this.timeDifference)
+ .voltage(this.voltage)
+ .visa(this.visa)
+ .currency(CurrencyUnit.valueOf(this.currency))
+ .weatherRecommendation(this.weatherRecommendation)
+ .weatherDescription(this.weatherDescription)
+ .country(this.country)
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityImageContentResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityImageContentResponseDto.java
new file mode 100644
index 00000000..ed2e205a
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityImageContentResponseDto.java
@@ -0,0 +1,36 @@
+package com.haejwo.tripcometrue.domain.city.dto.response;
+
+import com.haejwo.tripcometrue.domain.triprecord.entity.TripRecord;
+import com.haejwo.tripcometrue.domain.triprecord.entity.TripRecordSchedule;
+import com.haejwo.tripcometrue.domain.triprecord.entity.TripRecordScheduleImage;
+import lombok.Builder;
+
+public record CityImageContentResponseDto(
+ Long imageId,
+ Long tripRecordId,
+ String imageUrl,
+ Integer tripRecordStoreCount
+) {
+ @Builder
+ public CityImageContentResponseDto {
+ }
+
+ public static CityImageContentResponseDto fromEntity(TripRecordSchedule entity, TripRecordScheduleImage image) {
+ return CityImageContentResponseDto.builder()
+ .imageId(image.getId())
+ .tripRecordId(entity.getTripRecord().getId())
+ .imageUrl(image.getImageUrl())
+ .tripRecordStoreCount(entity.getTripRecord().getStoreCount())
+ .build();
+ }
+
+ public static CityImageContentResponseDto fromEntity(TripRecordScheduleImage entity) {
+ TripRecord tripRecord = entity.getTripRecordSchedule().getTripRecord();
+ return CityImageContentResponseDto.builder()
+ .imageId(entity.getId())
+ .tripRecordId(tripRecord.getId())
+ .imageUrl(entity.getImageUrl())
+ .tripRecordStoreCount(tripRecord.getStoreCount())
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityInfoResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityInfoResponseDto.java
new file mode 100644
index 00000000..facac4e5
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityInfoResponseDto.java
@@ -0,0 +1,39 @@
+package com.haejwo.tripcometrue.domain.city.dto.response;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import lombok.Builder;
+
+import java.util.Objects;
+
+public record CityInfoResponseDto(
+ Long id,
+ String name,
+ String language,
+ String timeDifference,
+ String voltage,
+ String visa,
+ String curUnit,
+ String curName
+) {
+
+ @Builder
+ public CityInfoResponseDto {
+ }
+
+ public static CityInfoResponseDto fromEntity(City entity) {
+ return CityInfoResponseDto.builder()
+ .id(entity.getId())
+ .name(entity.getName())
+ .language(entity.getLanguage())
+ .timeDifference(entity.getTimeDifference())
+ .voltage(entity.getVoltage())
+ .visa(entity.getVisa())
+ .curUnit(
+ Objects.nonNull(entity.getCurrency()) ? entity.getCurrency().name() : null
+ )
+ .curName(
+ Objects.nonNull(entity.getCurrency()) ? entity.getCurrency().getCurrencyName() : null
+ )
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityPlaceResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityPlaceResponseDto.java
new file mode 100644
index 00000000..3224b16f
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityPlaceResponseDto.java
@@ -0,0 +1,27 @@
+package com.haejwo.tripcometrue.domain.city.dto.response;
+
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import lombok.Builder;
+
+public record CityPlaceResponseDto(
+ Long placeId,
+ String placeName,
+ Integer storedCount,
+ Integer commentTotal,
+ String imageUrl
+) {
+
+ @Builder
+ public CityPlaceResponseDto {
+ }
+
+ public static CityPlaceResponseDto fromEntity(Place entity, String imageUrl) {
+ return CityPlaceResponseDto.builder()
+ .placeId(entity.getId())
+ .placeName(entity.getName())
+ .storedCount(entity.getStoredCount())
+ .commentTotal(entity.getCommentCount())
+ .imageUrl(imageUrl)
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityPlaceWithLatLongResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityPlaceWithLatLongResponseDto.java
new file mode 100644
index 00000000..59bfc3e6
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityPlaceWithLatLongResponseDto.java
@@ -0,0 +1,31 @@
+package com.haejwo.tripcometrue.domain.city.dto.response;
+
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import lombok.Builder;
+
+public record CityPlaceWithLatLongResponseDto(
+ Long placeId,
+ String placeName,
+ Integer storedCount,
+ Integer commentTotal,
+ String imageUrl,
+ Double latitude,
+ Double longitude
+) {
+
+ @Builder
+ public CityPlaceWithLatLongResponseDto {
+ }
+
+ public static CityPlaceWithLatLongResponseDto fromEntity(Place entity, String imageUrl) {
+ return CityPlaceWithLatLongResponseDto.builder()
+ .placeId(entity.getId())
+ .placeName(entity.getName())
+ .storedCount(entity.getStoredCount())
+ .commentTotal(entity.getCommentCount())
+ .imageUrl(imageUrl)
+ .latitude(entity.getLatitude())
+ .longitude(entity.getLongitude())
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityResponseDto.java
new file mode 100644
index 00000000..39cf3928
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/CityResponseDto.java
@@ -0,0 +1,67 @@
+package com.haejwo.tripcometrue.domain.city.dto.response;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import lombok.Builder;
+
+import java.util.Objects;
+
+public record CityResponseDto(
+ Long cityId,
+ String name,
+ String englishName,
+ String language,
+ String timeDifference,
+ String voltage,
+ String visa,
+ String curUnit,
+ String exchangeRate,
+ String weatherRecommendation,
+ String weatherDescription,
+ String country,
+ String imageUrl
+) {
+
+ @Builder
+ public CityResponseDto {
+ }
+
+ public static CityResponseDto fromEntity(City entity) {
+ return CityResponseDto.builder()
+ .cityId(entity.getId())
+ .name(entity.getName())
+ .englishName(entity.getEnglishName())
+ .language(entity.getLanguage())
+ .timeDifference(entity.getTimeDifference())
+ .voltage(entity.getVoltage())
+ .visa(entity.getVisa())
+ .curUnit(
+ Objects.nonNull(entity.getCurrency()) ? entity.getCurrency().name() : null
+ )
+ .exchangeRate(null)
+ .weatherRecommendation(entity.getWeatherRecommendation())
+ .weatherDescription(entity.getWeatherDescription())
+ .country(entity.getCountry().getDescription())
+ .imageUrl(entity.getImageUrl())
+ .build();
+ }
+
+ public static CityResponseDto fromEntity(City entity, String exchangeRate) {
+ return CityResponseDto.builder()
+ .cityId(entity.getId())
+ .name(entity.getName())
+ .englishName(entity.getEnglishName())
+ .language(entity.getLanguage())
+ .timeDifference(entity.getTimeDifference())
+ .voltage(entity.getVoltage())
+ .visa(entity.getVisa())
+ .curUnit(
+ Objects.nonNull(entity.getCurrency()) ? entity.getCurrency().name() : null
+ )
+ .exchangeRate(exchangeRate)
+ .weatherRecommendation(entity.getWeatherRecommendation())
+ .weatherDescription(entity.getWeatherDescription())
+ .country(entity.getCountry().getDescription())
+ .imageUrl(entity.getImageUrl())
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/ExchangeRateResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/ExchangeRateResponseDto.java
new file mode 100644
index 00000000..5f620bd3
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/ExchangeRateResponseDto.java
@@ -0,0 +1,15 @@
+package com.haejwo.tripcometrue.domain.city.dto.response;
+
+import lombok.Builder;
+
+public record ExchangeRateResponseDto(
+ String curUnit,
+ String curName,
+ String exchangeRate,
+ String country
+) {
+
+ @Builder
+ public ExchangeRateResponseDto {
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/TopCityResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/TopCityResponseDto.java
new file mode 100644
index 00000000..7419a070
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/TopCityResponseDto.java
@@ -0,0 +1,25 @@
+package com.haejwo.tripcometrue.domain.city.dto.response;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import lombok.Builder;
+
+public record TopCityResponseDto(
+ Long cityId,
+ String cityName,
+ Integer storedCount,
+ String imageUrl
+) {
+
+ @Builder
+ public TopCityResponseDto {
+ }
+
+ public static TopCityResponseDto fromEntity(City entity) {
+ return TopCityResponseDto.builder()
+ .cityId(entity.getId())
+ .cityName(entity.getName())
+ .storedCount(entity.getStoreCount())
+ .imageUrl(entity.getImageUrl())
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/WeatherResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/WeatherResponseDto.java
new file mode 100644
index 00000000..5bb8ef6a
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/dto/response/WeatherResponseDto.java
@@ -0,0 +1,27 @@
+package com.haejwo.tripcometrue.domain.city.dto.response;
+
+import com.haejwo.tripcometrue.domain.city.entity.Weather;
+import lombok.Builder;
+
+public record WeatherResponseDto(
+ Integer month,
+ String maxAvgTempC,
+ String minAvgTempC,
+ String maxAvgTempF,
+ String minAvgTempF
+) {
+
+ @Builder
+ public WeatherResponseDto {
+ }
+
+ public static WeatherResponseDto fromEntity(Weather entity, String maxAvgTempF, String minAvgTempF) {
+ return WeatherResponseDto.builder()
+ .month(entity.getMonth())
+ .maxAvgTempC(entity.getMaxAvgTemp())
+ .minAvgTempC(entity.getMinAvgTemp())
+ .maxAvgTempF(maxAvgTempF)
+ .minAvgTempF(minAvgTempF)
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/entity/City.java b/src/main/java/com/haejwo/tripcometrue/domain/city/entity/City.java
new file mode 100644
index 00000000..bcf5ff4b
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/entity/City.java
@@ -0,0 +1,80 @@
+package com.haejwo.tripcometrue.domain.city.entity;
+
+import com.haejwo.tripcometrue.global.entity.BaseTimeEntity;
+import com.haejwo.tripcometrue.global.enums.Country;
+import com.haejwo.tripcometrue.global.enums.CurrencyUnit;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+public class City extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "city_id")
+ private Long id;
+ private String name;
+ private String englishName;
+ private String language;
+ private String timeDifference;
+ private String voltage;
+ private String visa;
+ @Column(length = 1000)
+ private String weatherRecommendation;
+ @Column(length = 2500)
+ private String weatherDescription;
+ private Integer storeCount;
+ private String imageUrl;
+
+ @Enumerated(EnumType.STRING)
+ private CurrencyUnit currency;
+
+ @Enumerated(EnumType.STRING)
+ private Country country;
+
+ @Builder
+ private City(
+ Long id, String name, String englishName, String language, String timeDifference,
+ String voltage, String visa, CurrencyUnit currency, String weatherRecommendation,
+ String weatherDescription, Integer storeCount, Country country, String imageUrl
+ ) {
+ this.id = id;
+ this.name = name;
+ this.englishName = englishName;
+ this.language = language;
+ this.timeDifference = timeDifference;
+ this.voltage = voltage;
+ this.visa = visa;
+ this.currency = currency;
+ this.weatherRecommendation = weatherRecommendation;
+ this.weatherDescription = weatherDescription;
+ this.storeCount = storeCount;
+ this.country = country;
+ this.imageUrl = imageUrl;
+ }
+
+ @PrePersist
+ public void prePersist() {
+ this.storeCount = 0;
+ }
+
+ public void incrementStoreCount() {
+ if(this.storeCount == null) {
+ this.storeCount = 1;
+ } else {
+ this.storeCount++;
+ }
+ }
+
+ public void decrementStoreCount() {
+ if(this.storeCount > 0) {
+ this.storeCount--;
+ }
+ }
+}
+
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/entity/Weather.java b/src/main/java/com/haejwo/tripcometrue/domain/city/entity/Weather.java
new file mode 100644
index 00000000..8df5e9b7
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/entity/Weather.java
@@ -0,0 +1,38 @@
+package com.haejwo.tripcometrue.domain.city.entity;
+
+import com.haejwo.tripcometrue.global.entity.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Entity
+public class Weather extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ private Integer month;
+
+ private String maxAvgTemp;
+
+ private String minAvgTemp;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "city_id")
+ private City city;
+
+ @Builder
+ private Weather(Long id, City city, Integer month,
+ String maxAvgTemp, String minAvgTemp) {
+ this.id = id;
+ this.city = city;
+ this.month = month;
+ this.maxAvgTemp = maxAvgTemp;
+ this.minAvgTemp = minAvgTemp;
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/exception/CityNotFoundException.java b/src/main/java/com/haejwo/tripcometrue/domain/city/exception/CityNotFoundException.java
new file mode 100644
index 00000000..f6c39d76
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/exception/CityNotFoundException.java
@@ -0,0 +1,13 @@
+package com.haejwo.tripcometrue.domain.city.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class CityNotFoundException extends ApplicationException {
+
+ private static final ErrorCode ERROR_CODE = ErrorCode.CITY_NOT_FOUND;
+
+ public CityNotFoundException() {
+ super(ERROR_CODE);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/exception/ExchangeRateApiCallFailException.java b/src/main/java/com/haejwo/tripcometrue/domain/city/exception/ExchangeRateApiCallFailException.java
new file mode 100644
index 00000000..746d8d7a
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/exception/ExchangeRateApiCallFailException.java
@@ -0,0 +1,16 @@
+package com.haejwo.tripcometrue.domain.city.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class ExchangeRateApiCallFailException extends ApplicationException {
+
+ private static final ErrorCode ERROR_CODE = ErrorCode.EXCHANGE_RATE_API_FAIL;
+ public ExchangeRateApiCallFailException() {
+ super(ERROR_CODE);
+ }
+
+ public ExchangeRateApiCallFailException(String message) {
+ super(ERROR_CODE, message);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/repository/CityRepository.java b/src/main/java/com/haejwo/tripcometrue/domain/city/repository/CityRepository.java
new file mode 100644
index 00000000..61f8c54c
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/repository/CityRepository.java
@@ -0,0 +1,15 @@
+package com.haejwo.tripcometrue.domain.city.repository;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.global.enums.Country;
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface CityRepository
+ extends JpaRepository, CityRepositoryCustom {
+
+ Optional findByNameAndCountry(String name, Country country);
+ List findAllByCountry(Country country);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/repository/CityRepositoryCustom.java b/src/main/java/com/haejwo/tripcometrue/domain/city/repository/CityRepositoryCustom.java
new file mode 100644
index 00000000..cb307312
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/repository/CityRepositoryCustom.java
@@ -0,0 +1,14 @@
+package com.haejwo.tripcometrue.domain.city.repository;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+
+import java.util.List;
+
+public interface CityRepositoryCustom {
+
+ List findTopCityListDomestic(int size);
+
+ List findTopCityListOverseas(int size);
+
+ List findBySearchParams(String name);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/repository/CityRepositoryImpl.java b/src/main/java/com/haejwo/tripcometrue/domain/city/repository/CityRepositoryImpl.java
new file mode 100644
index 00000000..7aff5283
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/repository/CityRepositoryImpl.java
@@ -0,0 +1,63 @@
+package com.haejwo.tripcometrue.domain.city.repository;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.global.enums.Country;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.core.types.dsl.Expressions;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+
+import static com.haejwo.tripcometrue.domain.city.entity.QCity.city;
+
+@RequiredArgsConstructor
+public class CityRepositoryImpl implements CityRepositoryCustom {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public List findTopCityListDomestic(int size) {
+ return queryFactory
+ .selectFrom(city)
+ .where(city.country.eq(Country.KOREA))
+ .orderBy(city.storeCount.desc())
+ .limit(size)
+ .fetch();
+ }
+
+ @Override
+ public List findTopCityListOverseas(int size) {
+ return queryFactory
+ .selectFrom(city)
+ .where(city.country.eq(Country.KOREA).not())
+ .orderBy(city.storeCount.desc())
+ .limit(size)
+ .fetch();
+ }
+
+ @Override
+ public List findBySearchParams(String name) {
+ return queryFactory
+ .selectFrom(city)
+ .where(
+ containsIgnoreCaseName(name)
+ )
+ .orderBy(city.storeCount.desc())
+ .fetch();
+ }
+
+ private BooleanExpression containsIgnoreCaseName(String name) {
+
+ if (!StringUtils.hasText(name)) {
+ return null;
+ }
+
+ String replacedName = name.replaceAll(" ", "");
+
+ return Expressions.stringTemplate(
+ "function('replace',{0},{1},{2})", city.name, " ", ""
+ ).containsIgnoreCase(replacedName);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/repository/WeatherRepository.java b/src/main/java/com/haejwo/tripcometrue/domain/city/repository/WeatherRepository.java
new file mode 100644
index 00000000..1f8d225f
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/repository/WeatherRepository.java
@@ -0,0 +1,12 @@
+package com.haejwo.tripcometrue.domain.city.repository;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.domain.city.entity.Weather;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface WeatherRepository extends JpaRepository {
+
+ List findAllByCityAndMonthInOrderByMonthAsc(City city, List months);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/service/CityContentReadService.java b/src/main/java/com/haejwo/tripcometrue/domain/city/service/CityContentReadService.java
new file mode 100644
index 00000000..8b698139
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/service/CityContentReadService.java
@@ -0,0 +1,178 @@
+package com.haejwo.tripcometrue.domain.city.service;
+
+import com.haejwo.tripcometrue.domain.city.dto.response.*;
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.domain.city.exception.CityNotFoundException;
+import com.haejwo.tripcometrue.domain.city.repository.CityRepository;
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import com.haejwo.tripcometrue.domain.place.repositroy.PlaceRepository;
+import com.haejwo.tripcometrue.domain.triprecord.dto.query.TripRecordScheduleImageWithPlaceIdQueryDto;
+import com.haejwo.tripcometrue.domain.triprecord.dto.response.triprecord_schedule_media.TripRecordScheduleVideoListItemResponseDto;
+import com.haejwo.tripcometrue.domain.triprecord.repository.triprecord_schedule_image.TripRecordScheduleImageRepository;
+import com.haejwo.tripcometrue.domain.triprecord.repository.triprecord_schedule_video.TripRecordScheduleVideoRepository;
+import com.haejwo.tripcometrue.global.util.SliceResponseDto;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+@RequiredArgsConstructor
+@Service
+public class CityContentReadService {
+
+ private final CityRepository cityRepository;
+ private final PlaceRepository placeRepository;
+ private final TripRecordScheduleImageRepository tripRecordScheduleImageRepository;
+ private final TripRecordScheduleVideoRepository tripRecordScheduleVideoRepository;
+
+ private static final int CITY_HOT_PLACES_SIZE = 10;
+ private static final int CITY_MEDIA_CONTENT_SIZE = 10;
+ private static final int HOME_CONTENT_SIZE = 5;
+
+ // ํํผ๋ ์ธ๊ธฐ ๋์ ๋ฆฌ์คํธ ์กฐํ
+ public List getTopCityList(String type) {
+ if (type.equalsIgnoreCase("domestic")) {
+ return cityRepository
+ .findTopCityListDomestic(HOME_CONTENT_SIZE)
+ .stream()
+ .map(TopCityResponseDto::fromEntity)
+ .toList();
+ } else {
+ return cityRepository
+ .findTopCityListOverseas(HOME_CONTENT_SIZE)
+ .stream()
+ .map(TopCityResponseDto::fromEntity)
+ .toList();
+ }
+ }
+
+ // ๋์ ํซํ๋ ์ด์ค ๋ฆฌ์คํธ ์กฐํ
+ @Transactional(readOnly = true)
+ public List getHotPlaces(Long id) {
+ City city = getCityEntity(id);
+
+ List places = placeRepository
+ .findPlacesByCityAndOrderByStoredCountLimitSize(city, CITY_HOT_PLACES_SIZE);
+
+ Map> imageMap = getImageMapFromPlaceIds(places);
+
+ return places
+ .stream()
+ .map(place -> {
+ String imageUrl = Objects.isNull(imageMap.get(place.getId())) ? null :
+ imageMap.get(place.getId())
+ .stream()
+ .filter(Objects::nonNull)
+ .findFirst()
+ .map(TripRecordScheduleImageWithPlaceIdQueryDto::imageUrl)
+ .orElse(null);
+
+ return CityPlaceResponseDto.fromEntity(place, imageUrl);
+ })
+ .toList();
+ }
+
+ // ์/๊ฒฝ๋ ์ ๋ณด ํฌํจ ๋์ ์ฌํ์ง ์ ์ฒด ์กฐํ
+ @Transactional(readOnly = true)
+ public List getPlacesWithLatLong(Long cityId) {
+ List places = placeRepository.findByCityId(cityId);
+
+ Map> imageMap = getImageMapFromPlaceIds(places);
+
+ return places
+ .stream()
+ .map(place -> {
+ String imageUrl = Objects.isNull(imageMap.get(place.getId())) ? null :
+ imageMap.get(place.getId())
+ .stream()
+ .filter(Objects::nonNull)
+ .findFirst()
+ .map(TripRecordScheduleImageWithPlaceIdQueryDto::imageUrl)
+ .orElse(null);
+
+ return CityPlaceWithLatLongResponseDto.fromEntity(place, imageUrl);
+ })
+ .toList();
+ }
+
+ // ๋์ ์ฌํ์ง ์ ์ฒด ์กฐํ ๋ฐ ๊ฒ์ (์คํฌ๋กค ํ์ด์ง)
+ @Transactional(readOnly = true)
+ public SliceResponseDto getPlaces(Long cityId, String placeName, Pageable pageable) {
+ Slice places = placeRepository.findPlacesByCityIdAndPlaceName(cityId, placeName, pageable);
+
+ Map> imageMap = getImageMapFromPlaceIds(places.getContent());
+
+ return SliceResponseDto.of(
+ places
+ .map(place -> {
+ String imageUrl = Objects.isNull(imageMap.get(place.getId())) ? null :
+ imageMap.get(place.getId())
+ .stream()
+ .filter(Objects::nonNull)
+ .findFirst()
+ .map(TripRecordScheduleImageWithPlaceIdQueryDto::imageUrl)
+ .orElse(null);
+
+ return CityPlaceResponseDto.fromEntity(place, imageUrl);
+ })
+ );
+ }
+
+ // ๋์ ๊ฐค๋ฌ๋ฆฌ ๋ฆฌ์คํธ ์กฐํ
+ @Transactional(readOnly = true)
+ public List getImages(Long cityId) {
+ return tripRecordScheduleImageRepository.findByCityIdOrderByCreatedAtDescLimitSize(cityId, CITY_MEDIA_CONTENT_SIZE)
+ .stream()
+ .map(CityImageContentResponseDto::fromEntity)
+ .toList();
+ }
+
+ // ๋์ ๊ฐค๋ฌ๋ฆฌ ๋ฆฌ์คํธ ์กฐํ (ํ์ด์ง, ์ ๋ ฌ)
+ @Transactional(readOnly = true)
+ public SliceResponseDto getImages(Long cityId, Pageable pageable) {
+ return SliceResponseDto.of(
+ tripRecordScheduleImageRepository.findByCityId(cityId, pageable)
+ .map(CityImageContentResponseDto::fromEntity)
+ );
+ }
+
+ // ๋์ ์ผ์ธ ๋ฆฌ์คํธ ์กฐํ
+ @Transactional(readOnly = true)
+ public List getVideos(Long cityId) {
+ return tripRecordScheduleVideoRepository
+ .findByCityIdOrderByCreatedAtDescLimitSize(cityId, CITY_MEDIA_CONTENT_SIZE)
+ .stream()
+ .map(TripRecordScheduleVideoListItemResponseDto::fromQueryDto)
+ .toList();
+ }
+
+ // ๋์ ์ผ์ธ ๋ฆฌ์คํธ ์กฐํ (ํ์ด์ง, ์ ๋ ฌ)
+ @Transactional(readOnly = true)
+ public SliceResponseDto getVideos(Long cityId, Pageable pageable) {
+ return SliceResponseDto.of(
+ tripRecordScheduleVideoRepository
+ .findByCityId(cityId, pageable)
+ .map(TripRecordScheduleVideoListItemResponseDto::fromQueryDto)
+ );
+ }
+
+ private City getCityEntity(Long id) {
+ return cityRepository.findById(id)
+ .orElseThrow(CityNotFoundException::new);
+ }
+
+ private Map> getImageMapFromPlaceIds(List places) {
+ List placeIds = places.stream().map(Place::getId).toList();
+
+ return tripRecordScheduleImageRepository
+ .findInPlaceIdsOrderByCreatedAtDesc(placeIds)
+ .stream()
+ .collect(Collectors.groupingBy(TripRecordScheduleImageWithPlaceIdQueryDto::placeId));
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/service/CityEditService.java b/src/main/java/com/haejwo/tripcometrue/domain/city/service/CityEditService.java
new file mode 100644
index 00000000..b85f3dd9
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/service/CityEditService.java
@@ -0,0 +1,36 @@
+package com.haejwo.tripcometrue.domain.city.service;
+
+import com.haejwo.tripcometrue.domain.city.dto.request.AddCityRequestDto;
+import com.haejwo.tripcometrue.domain.city.dto.response.CityResponseDto;
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.domain.city.exception.CityNotFoundException;
+import com.haejwo.tripcometrue.domain.city.repository.CityRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Service
+public class CityEditService {
+
+ private final CityRepository cityRepository;
+
+ @Transactional
+ public CityResponseDto addCity(AddCityRequestDto request) {
+ return CityResponseDto.fromEntity(
+ cityRepository.save(request.toEntity())
+ );
+ }
+
+ @Transactional
+ public void deleteCity(Long id) {
+ City city = getCityEntity(id);
+
+ cityRepository.delete(city);
+ }
+
+ private City getCityEntity(Long id) {
+ return cityRepository.findById(id)
+ .orElseThrow(CityNotFoundException::new);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/service/CityInfoReadService.java b/src/main/java/com/haejwo/tripcometrue/domain/city/service/CityInfoReadService.java
new file mode 100644
index 00000000..9d3c6556
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/service/CityInfoReadService.java
@@ -0,0 +1,111 @@
+package com.haejwo.tripcometrue.domain.city.service;
+
+import com.haejwo.tripcometrue.domain.city.dto.response.CityInfoResponseDto;
+import com.haejwo.tripcometrue.domain.city.dto.response.ExchangeRateResponseDto;
+import com.haejwo.tripcometrue.domain.city.dto.response.WeatherResponseDto;
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.global.enums.CurrencyUnit;
+import com.haejwo.tripcometrue.domain.city.entity.Weather;
+import com.haejwo.tripcometrue.domain.city.exception.CityNotFoundException;
+import com.haejwo.tripcometrue.domain.city.repository.CityRepository;
+import com.haejwo.tripcometrue.domain.city.repository.WeatherRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+@RequiredArgsConstructor
+@Service
+public class CityInfoReadService {
+
+ private final CityRepository cityRepository;
+ private final WeatherRepository weatherRepository;
+ private final RedisTemplate redisTemplate;
+
+ private static final int WEATHER_MONTH_GAP = 3;
+
+ @Transactional(readOnly = true)
+ public CityInfoResponseDto getCityInfo(Long id) {
+ return CityInfoResponseDto.fromEntity(
+ getCityEntity(id)
+ );
+ }
+
+ @Transactional(readOnly = true)
+ public ExchangeRateResponseDto getExchangeRateByCityId(Long cityId) {
+
+ City city = cityRepository.findById(cityId)
+ .orElseThrow(CityNotFoundException::new);
+
+ CurrencyUnit currencyUnit = city.getCurrency();
+ String exchangeRate = (String) redisTemplate.opsForValue()
+ .get("exchange-rate:" + currencyUnit);
+
+ return ExchangeRateResponseDto.builder()
+ .curUnit(currencyUnit.name())
+ .curName(currencyUnit.getCurrencyName())
+ .exchangeRate(exchangeRate)
+ .country(city.getCountry().getDescription())
+ .build();
+ }
+
+ @Transactional(readOnly = true)
+ public List getWeatherInfo(Long id) {
+ City city = getCityEntity(id);
+
+ // ํ์ฌ ๋ฌ ํฌํจ ํฅํ 3๊ฐ์
+ int curMonth = LocalDate.now().getMonthValue();
+ List months = new ArrayList<>();
+ for (int i = 0; i <= WEATHER_MONTH_GAP; i++) {
+ months.add(((curMonth + i) % 13) + 1);
+ }
+
+ List weathers = weatherRepository.findAllByCityAndMonthInOrderByMonthAsc(city, months);
+
+ return sortWeatherInfos(weathers)
+ .stream()
+ .map(
+ w -> WeatherResponseDto.fromEntity(w, convertToTempF(w.getMaxAvgTemp()), convertToTempF(w.getMinAvgTemp()))
+ ).toList();
+ }
+
+ private City getCityEntity(Long id) {
+ return cityRepository.findById(id)
+ .orElseThrow(CityNotFoundException::new);
+ }
+
+ private List sortWeatherInfos(List weathers) {
+ int lastIdx = weathers.size() - 1;
+
+ if(weathers.get(lastIdx).getMonth() - weathers.get(0).getMonth() > WEATHER_MONTH_GAP) {
+ weathers.add(0, weathers.remove(lastIdx));
+
+ for (int i = lastIdx; i >= 0; i--) {
+ int last = weathers.get(lastIdx).getMonth();
+ int prev = weathers.get(lastIdx - 1).getMonth();
+
+ weathers.add(0, weathers.remove(lastIdx));
+
+ if (last - prev != 1) {
+ break;
+ }
+ }
+ }
+
+ return weathers;
+ }
+
+ private String convertToTempF(String tempC) {
+ return new BigDecimal(tempC)
+ .multiply(new BigDecimal("1.8"))
+ .add(new BigDecimal("32"))
+ .setScale(2, RoundingMode.HALF_UP)
+ .toString();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/service/CitySearchService.java b/src/main/java/com/haejwo/tripcometrue/domain/city/service/CitySearchService.java
new file mode 100644
index 00000000..abea4ce1
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/service/CitySearchService.java
@@ -0,0 +1,45 @@
+package com.haejwo.tripcometrue.domain.city.service;
+
+import com.haejwo.tripcometrue.domain.city.dto.response.CityResponseDto;
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.global.enums.CurrencyUnit;
+import com.haejwo.tripcometrue.domain.city.repository.CityRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Objects;
+
+@RequiredArgsConstructor
+@Service
+public class CitySearchService {
+
+ private final CityRepository cityRepository;
+ private final RedisTemplate redisTemplate;
+ private static final String EXCHANGE_RATE_REDIS_KEY_PREFIX = "exchange-rate:";
+
+ @Transactional(readOnly = true)
+ public List search(String name) {
+ return cityRepository
+ .findBySearchParams(name)
+ .stream()
+ .map(city -> {
+ String exchangeRate = getExchangeRate(city);
+ return CityResponseDto.fromEntity(city, exchangeRate);
+ }
+ )
+ .toList();
+ }
+
+ private String getExchangeRate(City city) {
+ CurrencyUnit currency = city.getCurrency();
+ if (Objects.isNull(currency)) {
+ return null;
+ }
+
+ return (String) redisTemplate.opsForValue()
+ .get(EXCHANGE_RATE_REDIS_KEY_PREFIX + currency.name());
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/service/ExchangeRateApiCaller.java b/src/main/java/com/haejwo/tripcometrue/domain/city/service/ExchangeRateApiCaller.java
new file mode 100644
index 00000000..1fcac8fb
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/service/ExchangeRateApiCaller.java
@@ -0,0 +1,71 @@
+package com.haejwo.tripcometrue.domain.city.service;
+
+import com.haejwo.tripcometrue.domain.city.dto.api.ExchangeRateApiDto;
+import com.haejwo.tripcometrue.domain.city.exception.ExchangeRateApiCallFailException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.net.URI;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Objects;
+
+@RequiredArgsConstructor
+@Service
+public class ExchangeRateApiCaller {
+
+ private final RestTemplate restTemplate;
+
+ @Value("${exchange-rate.api.url}")
+ private String API_URL;
+
+ @Value("${exchange-rate.api.key}")
+ private String API_KEY;
+
+ public List call() {
+
+ URI uri = buildUri(LocalDate.now());
+
+ try {
+ ResponseEntity> responseEntity = restTemplate.exchange(
+ uri, HttpMethod.GET, null, new ParameterizedTypeReference<>() {}
+ );
+
+ if(Objects.nonNull(responseEntity.getBody()) && responseEntity.getBody().get(0).result() != 1) {
+ Integer result = responseEntity.getBody().get(0).result();
+ String errorMessage = switch (result) {
+ case 2 -> "DATA ์ฝ๋ ์ค๋ฅ";
+ case 3 -> "์ธ์ฆ์ฝ๋ ์ค๋ฅ";
+ case 4 -> "์ผ์ผ์ ํํ์ ๋ง๊ฐ";
+ default -> "ํ์จ API ํธ์ถ ์คํจ";
+ };
+
+ throw new ExchangeRateApiCallFailException(errorMessage);
+ }
+
+ return responseEntity.getBody();
+ } catch (Exception e) {
+ throw new ExchangeRateApiCallFailException();
+ }
+ }
+
+ private URI buildUri(LocalDate date) {
+ DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+ return UriComponentsBuilder
+ .fromHttpUrl(API_URL)
+ .queryParam("authkey", API_KEY)
+ .queryParam("searchdate", dateFormatter.format(date))
+ .queryParam("data", "AP01")
+ .build()
+ .encode()
+ .toUri();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/city/service/ExchangeRateUpdateScheduler.java b/src/main/java/com/haejwo/tripcometrue/domain/city/service/ExchangeRateUpdateScheduler.java
new file mode 100644
index 00000000..0e4aacb6
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/city/service/ExchangeRateUpdateScheduler.java
@@ -0,0 +1,52 @@
+package com.haejwo.tripcometrue.domain.city.service;
+
+import com.haejwo.tripcometrue.domain.city.dto.api.ExchangeRateApiDto;
+import com.haejwo.tripcometrue.global.enums.CurrencyUnit;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+@RequiredArgsConstructor
+@Service
+public class ExchangeRateUpdateScheduler {
+
+ private final ExchangeRateApiCaller exchangeRateApiCaller;
+ private final RedisTemplate redisTemplate;
+ private static final String EXCHANGE_RATE_REDIS_KEY_PREFIX = "exchange-rate:";
+
+ // ๋งค์ผ 9:00, 12:00, 15:00 ํ์จ ์ ๋ณด ์
๋ฐ์ดํธ
+ @Scheduled(cron = "0 0 9,12,15 * * *")
+ public void update() {
+ List exchangeRates = exchangeRateApiCaller.call();
+
+ for (ExchangeRateApiDto exchangeRate : exchangeRates) {
+ String curUnitString = exchangeRate.curUnit().trim().substring(0, 3);
+
+ if(curUnitString.equals("KRW")) continue;
+
+ saveExchangeRateToRedis(exchangeRate, curUnitString);
+ }
+ }
+
+ private void saveExchangeRateToRedis(ExchangeRateApiDto exchangeRate, String curUnitString) {
+ ValueOperations operations = redisTemplate.opsForValue();
+ CurrencyUnit currencyUnit = CurrencyUnit.valueOf(curUnitString);
+
+ String key = EXCHANGE_RATE_REDIS_KEY_PREFIX + currencyUnit;
+ String value = currencyUnit.getStandard() + ":" + exchangeRate.dealBaseRate();
+
+ if (currencyUnit.getStandard() != 1) {
+ BigDecimal rate = new BigDecimal(exchangeRate.dealBaseRate())
+ .divide(new BigDecimal(currencyUnit.getStandard()));
+
+ value = "1:" + rate;
+ }
+
+ operations.set(key, value);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/controller/PlaceReviewCommentController.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/controller/PlaceReviewCommentController.java
new file mode 100644
index 00000000..794cb2b4
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/controller/PlaceReviewCommentController.java
@@ -0,0 +1,51 @@
+package com.haejwo.tripcometrue.domain.comment.placereview.controller;
+
+import com.haejwo.tripcometrue.domain.comment.placereview.dto.request.PlaceReviewCommentRequestDto;
+import com.haejwo.tripcometrue.domain.comment.placereview.service.PlaceReviewCommentService;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/places/reviews")
+public class PlaceReviewCommentController {
+
+ private final PlaceReviewCommentService commentService;
+
+ @PostMapping("/{placeReviewId}/comments")
+ public ResponseEntity> registerComment(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long placeReviewId,
+ @RequestBody @Valid com.haejwo.tripcometrue.domain.comment.placereview.dto.request.PlaceReviewCommentRequestDto requestDto
+ ) {
+
+ commentService.saveComment(principalDetails, placeReviewId, requestDto);
+ return ResponseEntity.ok(ResponseDTO.ok());
+ }
+
+ @PostMapping("/comments/{placeReviewCommentId}/reply-comments")
+ public ResponseEntity> registerReplyComment(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long placeReviewCommentId,
+ @RequestBody @Valid PlaceReviewCommentRequestDto requestDto
+ ) {
+
+ commentService.saveReplyComment(principalDetails, placeReviewCommentId, requestDto);
+ return ResponseEntity.ok(ResponseDTO.ok());
+ }
+
+ @DeleteMapping("/comments/{placeReviewCommentId}")
+ public ResponseEntity> deleteComment(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long placeReviewCommentId
+ ) {
+
+ commentService.removeComment(principalDetails, placeReviewCommentId);
+ return ResponseEntity.ok(ResponseDTO.ok());
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/controller/PlaceReviewCommentControllerAdvice.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/controller/PlaceReviewCommentControllerAdvice.java
new file mode 100644
index 00000000..35553e16
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/controller/PlaceReviewCommentControllerAdvice.java
@@ -0,0 +1,21 @@
+package com.haejwo.tripcometrue.domain.comment.placereview.controller;
+
+import com.haejwo.tripcometrue.domain.comment.placereview.exception.PlaceReviewCommentNotFoundException;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class PlaceReviewCommentControllerAdvice {
+
+ @ExceptionHandler(PlaceReviewCommentNotFoundException.class)
+ public ResponseEntity> handlePlaceReviewCommentNotFoundException(PlaceReviewCommentNotFoundException e) {
+ HttpStatus status = e.getErrorCode().getHttpStatus();
+
+ return ResponseEntity
+ .status(status)
+ .body(ResponseDTO.errorWithMessage(status, e.getMessage()));
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/dto/request/PlaceReviewCommentRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/dto/request/PlaceReviewCommentRequestDto.java
new file mode 100644
index 00000000..ee7f37b1
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/dto/request/PlaceReviewCommentRequestDto.java
@@ -0,0 +1,39 @@
+package com.haejwo.tripcometrue.domain.comment.placereview.dto.request;
+
+import com.haejwo.tripcometrue.domain.comment.placereview.entity.PlaceReviewComment;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import jakarta.validation.constraints.NotNull;
+import org.hibernate.validator.constraints.Length;
+
+public record PlaceReviewCommentRequestDto(
+
+ @NotNull(message = "๋ณธ๋ฌธ์ ํ์๋ก ์
๋ ฅํด์ผ ํฉ๋๋ค.")
+ @Length(min = 1, max = 500, message = "์์ฑ ํ์ฉ ๋ฒ์๋ ์ต์ 1์ ๋๋ ์ต๋ 500์ ์
๋๋ค.")
+ String content
+
+) {
+
+ public static PlaceReviewComment toComment(Member member, PlaceReview placeReview, PlaceReviewCommentRequestDto requestDto) {
+ return PlaceReviewComment.builder()
+ .member(member)
+ .placeReview(placeReview)
+ .content(requestDto.content)
+ .build();
+ }
+
+ public static PlaceReviewComment toReplyComment(
+ Member member,
+ PlaceReview placeReview,
+ PlaceReviewComment placeReviewComment,
+ PlaceReviewCommentRequestDto requestDto
+ ) {
+
+ return PlaceReviewComment.builder()
+ .member(member)
+ .placeReview(placeReview)
+ .parentComment(placeReviewComment)
+ .content(requestDto.content)
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/dto/response/PlaceReviewCommentListResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/dto/response/PlaceReviewCommentListResponseDto.java
new file mode 100644
index 00000000..6bb7b81d
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/dto/response/PlaceReviewCommentListResponseDto.java
@@ -0,0 +1,30 @@
+package com.haejwo.tripcometrue.domain.comment.placereview.dto.response;
+
+import com.haejwo.tripcometrue.domain.comment.placereview.entity.PlaceReviewComment;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import org.springframework.data.domain.Slice;
+
+import java.util.List;
+import java.util.Objects;
+
+public record PlaceReviewCommentListResponseDto(
+
+ int totalCount,
+ List comments
+
+) {
+
+ public static PlaceReviewCommentListResponseDto fromData(int totalCount, Slice placeReviewComments, Member loginMember) {
+ return new PlaceReviewCommentListResponseDto(
+ totalCount,
+ placeReviewComments.map(placeReviewComment -> {
+ if (placeReviewComment.getParentComment() == null) { //์ต์์ ๋๊ธ๋ง ํฌํจ
+ return PlaceReviewCommentResponseDto.fromEntity(placeReviewComment, loginMember);
+ }
+ return null;
+ })
+ .filter(Objects::nonNull)
+ .toList()
+ );
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/dto/response/PlaceReviewCommentResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/dto/response/PlaceReviewCommentResponseDto.java
new file mode 100644
index 00000000..bc8e21f9
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/dto/response/PlaceReviewCommentResponseDto.java
@@ -0,0 +1,54 @@
+package com.haejwo.tripcometrue.domain.comment.placereview.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.haejwo.tripcometrue.domain.comment.placereview.entity.PlaceReviewComment;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+
+import java.time.LocalDateTime;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+
+public record PlaceReviewCommentResponseDto(
+
+ Long commentId,
+ Long memberId,
+ String profileUrl,
+ String nickname,
+ boolean isWriter,
+ String content,
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH-mm-ss")
+ LocalDateTime createdAt,
+
+ List replyComments
+
+) {
+
+ public static PlaceReviewCommentResponseDto fromEntity(PlaceReviewComment placeReviewComment, Member loginMember) {
+ return new PlaceReviewCommentResponseDto(
+ placeReviewComment.getId(),
+ placeReviewComment.getMember().getId(),
+ placeReviewComment.getMember().getProfileImage(),
+ placeReviewComment.getMember().getMemberBase().getNickname(),
+ isWriter(placeReviewComment, loginMember),
+ placeReviewComment.getContent(),
+ placeReviewComment.getCreatedAt(),
+ getReplyComments(placeReviewComment, loginMember) //์์ ๋๊ธ ๋ฆฌ์คํธ์ ๋ด๊ธฐ
+ );
+ }
+
+ private static boolean isWriter(PlaceReviewComment placeReviewComment, Member loginMember) {
+ return Objects.equals(placeReviewComment.getMember(), loginMember);
+ }
+
+ private static List getReplyComments(PlaceReviewComment placeReviewComment, Member loginMember) {
+ if (placeReviewComment.getParentComment() == null) {
+ return placeReviewComment.getChildComments().stream()
+ .map(comment -> PlaceReviewCommentResponseDto.fromEntity(comment, loginMember))
+ .sorted(Comparator.comparing(PlaceReviewCommentResponseDto::createdAt).reversed())
+ .toList();
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/entity/PlaceReviewComment.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/entity/PlaceReviewComment.java
new file mode 100644
index 00000000..eb0c08d0
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/entity/PlaceReviewComment.java
@@ -0,0 +1,53 @@
+package com.haejwo.tripcometrue.domain.comment.placereview.entity;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import com.haejwo.tripcometrue.global.entity.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static jakarta.persistence.CascadeType.REMOVE;
+import static jakarta.persistence.FetchType.LAZY;
+
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class PlaceReviewComment extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "place_review_comment_id")
+ private Long id;
+
+ @ManyToOne(fetch = LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @ManyToOne(fetch = LAZY)
+ @JoinColumn(name = "place_review_id")
+ private PlaceReview placeReview;
+
+ @ManyToOne(fetch = LAZY)
+ @JoinColumn(name = "parent_comment_id")
+ private PlaceReviewComment parentComment;
+
+ @OneToMany(mappedBy = "parentComment", cascade = REMOVE, orphanRemoval = true)
+ private List childComments = new ArrayList<>();
+
+ @Column(nullable = false)
+ private String content;
+
+ @Builder
+ public PlaceReviewComment(Member member, PlaceReview placeReview, PlaceReviewComment parentComment, String content) {
+ this.member = member;
+ this.placeReview = placeReview;
+ this.parentComment = parentComment;
+ this.content = content;
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/exception/PlaceReviewCommentNotFoundException.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/exception/PlaceReviewCommentNotFoundException.java
new file mode 100644
index 00000000..8fdb9fa2
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/exception/PlaceReviewCommentNotFoundException.java
@@ -0,0 +1,12 @@
+package com.haejwo.tripcometrue.domain.comment.placereview.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class PlaceReviewCommentNotFoundException extends ApplicationException {
+ private static final ErrorCode ERROR_CODE = ErrorCode.PLACE_REVIEW_COMMENT_NOT_FOUND;
+
+ public PlaceReviewCommentNotFoundException() {
+ super(ERROR_CODE);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/repository/PlaceReviewCommentRepository.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/repository/PlaceReviewCommentRepository.java
new file mode 100644
index 00000000..816dcf03
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/repository/PlaceReviewCommentRepository.java
@@ -0,0 +1,23 @@
+package com.haejwo.tripcometrue.domain.comment.placereview.repository;
+
+import com.haejwo.tripcometrue.domain.comment.placereview.entity.PlaceReviewComment;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import io.lettuce.core.dynamic.annotation.Param;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+
+public interface PlaceReviewCommentRepository extends JpaRepository {
+
+ Slice findByPlaceReviewOrderByCreatedAtDesc(PlaceReview placeReview, Pageable pageable);
+
+ @Modifying
+ @Query("delete from PlaceReviewComment prc where prc.parentComment.id = :placeReviewCommentId")
+ int deleteChildrenByPlaceReviewCommentId(@Param("placeReviewCommentId") Long placeReviewCommentId);
+
+ @Modifying
+ @Query("delete from PlaceReviewComment prc where prc.id = :placeReviewCommentId")
+ int deleteParentByPlaceReviewCommentId(@Param("placeReviewCommentId") Long placeReviewCommentId);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/service/PlaceReviewCommentService.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/service/PlaceReviewCommentService.java
new file mode 100644
index 00000000..e4e89fc7
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/placereview/service/PlaceReviewCommentService.java
@@ -0,0 +1,114 @@
+package com.haejwo.tripcometrue.domain.comment.placereview.service;
+
+import com.haejwo.tripcometrue.domain.alarm.entity.AlarmType;
+import com.haejwo.tripcometrue.domain.alarm.service.AlarmService;
+import com.haejwo.tripcometrue.domain.comment.placereview.dto.request.PlaceReviewCommentRequestDto;
+import com.haejwo.tripcometrue.domain.comment.placereview.entity.PlaceReviewComment;
+import com.haejwo.tripcometrue.domain.comment.placereview.exception.PlaceReviewCommentNotFoundException;
+import com.haejwo.tripcometrue.domain.comment.placereview.repository.PlaceReviewCommentRepository;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.member.exception.UserInvalidAccessException;
+import com.haejwo.tripcometrue.domain.member.exception.UserNotFoundException;
+import com.haejwo.tripcometrue.domain.member.repository.MemberRepository;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import com.haejwo.tripcometrue.domain.review.placereview.exception.PlaceReviewNotFoundException;
+import com.haejwo.tripcometrue.domain.review.placereview.repository.PlaceReviewRepository;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Objects;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class PlaceReviewCommentService {
+
+ private final MemberRepository memberRepository;
+ private final PlaceReviewRepository placeReviewRepository;
+ private final PlaceReviewCommentRepository placeReviewCommentRepository;
+ private final AlarmService alarmService;
+
+ public void saveComment(
+ PrincipalDetails principalDetails,
+ Long placeReviewId,
+ com.haejwo.tripcometrue.domain.comment.placereview.dto.request.PlaceReviewCommentRequestDto requestDto
+ ) {
+
+ Member loginMember = getMember(principalDetails);
+ PlaceReview placeReview = getPlaceReviewById(placeReviewId);
+
+ PlaceReviewComment comment = com.haejwo.tripcometrue.domain.comment.placereview.dto.request.PlaceReviewCommentRequestDto.toComment(loginMember, placeReview, requestDto);
+ placeReviewCommentRepository.save(comment);
+ placeReview.increaseCommentCount();
+
+ alarmService.addAlarm(
+ loginMember,
+ placeReview.getMember(),
+ AlarmType.NEW_PLACE_REVIEW_COMMENT,
+ placeReviewId,
+ comment.getId());
+ }
+
+ private Member getMember(PrincipalDetails principalDetails) {
+ return memberRepository.findById(principalDetails.getMember().getId())
+ .orElseThrow(UserNotFoundException::new);
+ }
+
+ private PlaceReview getPlaceReviewById(Long placeReviewId) {
+ return placeReviewRepository.findById(placeReviewId)
+ .orElseThrow(PlaceReviewNotFoundException::new);
+ }
+
+ public void saveReplyComment(
+ PrincipalDetails principalDetails,
+ Long placeReviewCommentId,
+ PlaceReviewCommentRequestDto requestDto
+ ) {
+
+ Member loginMember = getMember(principalDetails);
+ PlaceReviewComment placeReviewComment = getPlaceReviewCommentById(placeReviewCommentId);
+ PlaceReview placeReview = placeReviewComment.getPlaceReview();
+
+ PlaceReviewComment comment = PlaceReviewCommentRequestDto.toReplyComment(loginMember, placeReview, placeReviewComment, requestDto);
+ placeReviewCommentRepository.save(comment);
+ placeReview.increaseCommentCount();
+ }
+
+ private PlaceReviewComment getPlaceReviewCommentById(Long placeReviewCommentId) {
+ return placeReviewCommentRepository.findById(placeReviewCommentId)
+ .orElseThrow(PlaceReviewCommentNotFoundException::new);
+ }
+
+ public void removeComment(PrincipalDetails principalDetails, Long placeReviewCommentId) {
+
+ Member loginMember = getMember(principalDetails);
+ PlaceReviewComment placeReviewComment = getPlaceReviewComment(placeReviewCommentId);
+ PlaceReview placeReview = placeReviewComment.getPlaceReview();
+
+ validateRightMemberAccess(loginMember, placeReviewComment);
+
+ int removedCount = getRemovedCount(placeReviewCommentId, placeReviewComment);
+ placeReview.decreaseCommentCount(removedCount);
+ }
+
+ private PlaceReviewComment getPlaceReviewComment(Long placeReviewCommentId) {
+ return placeReviewCommentRepository.findById(placeReviewCommentId)
+ .orElseThrow(PlaceReviewCommentNotFoundException::new);
+ }
+
+ private int getRemovedCount(Long placeReviewCommentId, PlaceReviewComment placeReviewComment) {
+ int childrenCount = placeReviewCommentRepository.deleteChildrenByPlaceReviewCommentId(placeReviewComment.getId());
+ int parentCount = placeReviewCommentRepository.deleteParentByPlaceReviewCommentId(placeReviewCommentId);
+ return childrenCount + parentCount;
+ }
+
+ private void validateRightMemberAccess(Member member, PlaceReviewComment placeReviewComment) {
+ if (!Objects.equals(placeReviewComment.getMember().getId(), member.getId())) {
+ throw new UserInvalidAccessException();
+ }
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/controller/TripRecordCommentController.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/controller/TripRecordCommentController.java
new file mode 100644
index 00000000..2f25d36e
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/controller/TripRecordCommentController.java
@@ -0,0 +1,68 @@
+package com.haejwo.tripcometrue.domain.comment.triprecord.controller;
+
+import com.haejwo.tripcometrue.domain.comment.triprecord.dto.request.TripRecordCommentRequestDto;
+import com.haejwo.tripcometrue.domain.comment.triprecord.dto.response.TripRecordCommentListResponseDto;
+import com.haejwo.tripcometrue.domain.comment.triprecord.service.TripRecordCommentService;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import static org.springframework.data.domain.Sort.Direction;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/trip-records")
+public class TripRecordCommentController {
+
+ private final TripRecordCommentService commentService;
+
+ @PostMapping("/{tripRecordId}/comments")
+ public ResponseEntity> registerComment(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long tripRecordId,
+ @RequestBody @Valid TripRecordCommentRequestDto requestDto
+ ) {
+
+ commentService.saveComment(principalDetails, tripRecordId, requestDto);
+ return ResponseEntity.ok(ResponseDTO.ok());
+ }
+
+ @PostMapping("/comments/{tripRecordCommentId}/reply-comments")
+ public ResponseEntity> registerReplyComment(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long tripRecordCommentId,
+ @RequestBody @Valid TripRecordCommentRequestDto requestDto
+ ) {
+
+ commentService.saveReplyComment(principalDetails, tripRecordCommentId, requestDto);
+ return ResponseEntity.ok(ResponseDTO.ok());
+ }
+
+ @GetMapping("/{tripRecordId}/comments")
+ public ResponseEntity> getCommentList(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long tripRecordId,
+ @PageableDefault(sort = "createdAt", direction = Direction.DESC) Pageable pageable
+ ) {
+
+ TripRecordCommentListResponseDto responseDto =
+ commentService.getCommentList(principalDetails, tripRecordId, pageable);
+ return ResponseEntity.ok(ResponseDTO.okWithData(responseDto));
+ }
+
+ @DeleteMapping("/comments/{tripRecordCommentId}")
+ public ResponseEntity> deleteComment(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long tripRecordCommentId
+ ) {
+
+ commentService.removeComment(principalDetails, tripRecordCommentId);
+ return ResponseEntity.ok(ResponseDTO.ok());
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/controller/TripRecordCommentControllerAdvice.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/controller/TripRecordCommentControllerAdvice.java
new file mode 100644
index 00000000..0cb104ab
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/controller/TripRecordCommentControllerAdvice.java
@@ -0,0 +1,21 @@
+package com.haejwo.tripcometrue.domain.comment.triprecord.controller;
+
+import com.haejwo.tripcometrue.domain.comment.triprecord.exception.TripRecordCommentNotFoundException;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class TripRecordCommentControllerAdvice {
+
+ @ExceptionHandler(TripRecordCommentNotFoundException.class)
+ public ResponseEntity> handleTripRecordCommentNotFoundException(TripRecordCommentNotFoundException e) {
+ HttpStatus status = e.getErrorCode().getHttpStatus();
+
+ return ResponseEntity
+ .status(status)
+ .body(ResponseDTO.errorWithMessage(status, e.getMessage()));
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/dto/request/TripRecordCommentRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/dto/request/TripRecordCommentRequestDto.java
new file mode 100644
index 00000000..81a0a40b
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/dto/request/TripRecordCommentRequestDto.java
@@ -0,0 +1,39 @@
+package com.haejwo.tripcometrue.domain.comment.triprecord.dto.request;
+
+import com.haejwo.tripcometrue.domain.comment.triprecord.entity.TripRecordComment;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.triprecord.entity.TripRecord;
+import jakarta.validation.constraints.NotNull;
+import org.hibernate.validator.constraints.Length;
+
+public record TripRecordCommentRequestDto(
+
+ @NotNull(message = "๋ณธ๋ฌธ์ ํ์๋ก ์
๋ ฅํด์ผ ํฉ๋๋ค.")
+ @Length(min = 1, max = 500, message = "์์ฑ ํ์ฉ ๋ฒ์๋ ์ต์ 1์ ๋๋ ์ต๋ 500์ ์
๋๋ค.")
+ String content
+
+) {
+
+ public static TripRecordComment toComment(Member member, TripRecord tripRecord, TripRecordCommentRequestDto requestDto) {
+ return TripRecordComment.builder()
+ .member(member)
+ .tripRecord(tripRecord)
+ .content(requestDto.content)
+ .build();
+ }
+
+ public static TripRecordComment toReplyComment(
+ Member member,
+ TripRecord tripRecord,
+ TripRecordComment tripRecordComment,
+ TripRecordCommentRequestDto requestDto
+ ) {
+
+ return TripRecordComment.builder()
+ .member(member)
+ .tripRecord(tripRecord)
+ .parentComment(tripRecordComment)
+ .content(requestDto.content)
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/dto/response/TripRecordCommentListResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/dto/response/TripRecordCommentListResponseDto.java
new file mode 100644
index 00000000..1b0b2cc8
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/dto/response/TripRecordCommentListResponseDto.java
@@ -0,0 +1,30 @@
+package com.haejwo.tripcometrue.domain.comment.triprecord.dto.response;
+
+import com.haejwo.tripcometrue.domain.comment.triprecord.entity.TripRecordComment;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import org.springframework.data.domain.Slice;
+
+import java.util.List;
+import java.util.Objects;
+
+public record TripRecordCommentListResponseDto(
+
+ int totalCount,
+ List comments
+
+) {
+
+ public static TripRecordCommentListResponseDto fromData(int totalCount, Slice tripRecordComments, Member loginMember) {
+ return new TripRecordCommentListResponseDto(
+ totalCount,
+ tripRecordComments.map(tripRecordComment -> {
+ if (tripRecordComment.getParentComment() == null) { //์ต์์ ๋๊ธ๋ง ํฌํจ
+ return TripRecordCommentResponseDto.fromEntity(tripRecordComment, loginMember);
+ }
+ return null;
+ })
+ .filter(Objects::nonNull)
+ .toList()
+ );
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/dto/response/TripRecordCommentResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/dto/response/TripRecordCommentResponseDto.java
new file mode 100644
index 00000000..01ea4e73
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/dto/response/TripRecordCommentResponseDto.java
@@ -0,0 +1,54 @@
+package com.haejwo.tripcometrue.domain.comment.triprecord.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.haejwo.tripcometrue.domain.comment.triprecord.entity.TripRecordComment;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+
+import java.time.LocalDateTime;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+
+public record TripRecordCommentResponseDto(
+
+ Long commentId,
+ Long memberId,
+ String profileUrl,
+ String nickname,
+ String content,
+ boolean isWriter,
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH-mm-ss")
+ LocalDateTime createdAt,
+
+ List replyComments
+
+) {
+
+ public static TripRecordCommentResponseDto fromEntity(TripRecordComment tripRecordComment, Member loginMember) {
+ return new TripRecordCommentResponseDto(
+ tripRecordComment.getId(),
+ tripRecordComment.getMember().getId(),
+ tripRecordComment.getMember().getProfileImage(),
+ tripRecordComment.getMember().getMemberBase().getNickname(),
+ tripRecordComment.getContent(),
+ isWriter(tripRecordComment, loginMember),
+ tripRecordComment.getCreatedAt(),
+ getReplyComments(tripRecordComment, loginMember) //์์ ๋๊ธ ๋ฆฌ์คํธ์ ๋ด๊ธฐ
+ );
+ }
+
+ private static boolean isWriter(TripRecordComment tripRecordComment, Member loginMember) {
+ return Objects.equals(tripRecordComment.getMember(), loginMember);
+ }
+
+ private static List getReplyComments(TripRecordComment tripRecordComment, Member loginMember) {
+ if (tripRecordComment.getParentComment() == null) {
+ return tripRecordComment.getChildComments().stream()
+ .map(comment -> TripRecordCommentResponseDto.fromEntity(comment, loginMember))
+ .sorted(Comparator.comparing(TripRecordCommentResponseDto::createdAt).reversed())
+ .toList();
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/entity/TripRecordComment.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/entity/TripRecordComment.java
new file mode 100644
index 00000000..ae1afeaf
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/entity/TripRecordComment.java
@@ -0,0 +1,53 @@
+package com.haejwo.tripcometrue.domain.comment.triprecord.entity;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.triprecord.entity.TripRecord;
+import com.haejwo.tripcometrue.global.entity.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static jakarta.persistence.CascadeType.REMOVE;
+import static jakarta.persistence.FetchType.LAZY;
+
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class TripRecordComment extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "trip_record_comment_id")
+ private Long id;
+
+ @ManyToOne(fetch = LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @ManyToOne(fetch = LAZY)
+ @JoinColumn(name = "trip_record_id")
+ private TripRecord tripRecord;
+
+ @ManyToOne(fetch = LAZY)
+ @JoinColumn(name = "parent_comment_id")
+ private TripRecordComment parentComment;
+
+ @OneToMany(mappedBy = "parentComment", cascade = REMOVE, orphanRemoval = true)
+ private List childComments = new ArrayList<>();
+
+ @Column(nullable = false)
+ private String content;
+
+ @Builder
+ public TripRecordComment(Member member, TripRecord tripRecord, TripRecordComment parentComment, String content) {
+ this.member = member;
+ this.tripRecord = tripRecord;
+ this.parentComment = parentComment;
+ this.content = content;
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/exception/TripRecordCommentNotFoundException.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/exception/TripRecordCommentNotFoundException.java
new file mode 100644
index 00000000..25dd855e
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/exception/TripRecordCommentNotFoundException.java
@@ -0,0 +1,12 @@
+package com.haejwo.tripcometrue.domain.comment.triprecord.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class TripRecordCommentNotFoundException extends ApplicationException {
+ private static final ErrorCode ERROR_CODE = ErrorCode.TRIP_RECORD_COMMENT_NOT_FOUND;
+
+ public TripRecordCommentNotFoundException() {
+ super(ERROR_CODE);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/repository/TripRecordCommentRepository.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/repository/TripRecordCommentRepository.java
new file mode 100644
index 00000000..bf9730aa
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/repository/TripRecordCommentRepository.java
@@ -0,0 +1,23 @@
+package com.haejwo.tripcometrue.domain.comment.triprecord.repository;
+
+import com.haejwo.tripcometrue.domain.comment.triprecord.entity.TripRecordComment;
+import com.haejwo.tripcometrue.domain.triprecord.entity.TripRecord;
+import io.lettuce.core.dynamic.annotation.Param;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+
+public interface TripRecordCommentRepository extends JpaRepository {
+
+ Slice findByTripRecord(TripRecord tripRecord, Pageable pageable);
+
+ @Modifying
+ @Query("delete from TripRecordComment trc where trc.parentComment.id = :tripRecordCommentId")
+ int deleteChildrenByTripRecordCommentId(@Param("tripRecordCommentId") Long tripRecordCommentId);
+
+ @Modifying
+ @Query("delete from TripRecordComment trc where trc.id = :tripRecordCommentId")
+ int deleteParentByTripRecordCommentId(@Param("tripRecordCommentId") Long tripRecordCommentId);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/service/TripRecordCommentService.java b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/service/TripRecordCommentService.java
new file mode 100644
index 00000000..d5284a4f
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/comment/triprecord/service/TripRecordCommentService.java
@@ -0,0 +1,134 @@
+package com.haejwo.tripcometrue.domain.comment.triprecord.service;
+
+import com.haejwo.tripcometrue.domain.alarm.entity.AlarmType;
+import com.haejwo.tripcometrue.domain.alarm.service.AlarmService;
+import com.haejwo.tripcometrue.domain.comment.triprecord.dto.request.TripRecordCommentRequestDto;
+import com.haejwo.tripcometrue.domain.comment.triprecord.dto.response.TripRecordCommentListResponseDto;
+import com.haejwo.tripcometrue.domain.comment.triprecord.entity.TripRecordComment;
+import com.haejwo.tripcometrue.domain.comment.triprecord.exception.TripRecordCommentNotFoundException;
+import com.haejwo.tripcometrue.domain.comment.triprecord.repository.TripRecordCommentRepository;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.member.exception.UserInvalidAccessException;
+import com.haejwo.tripcometrue.domain.member.exception.UserNotFoundException;
+import com.haejwo.tripcometrue.domain.member.repository.MemberRepository;
+import com.haejwo.tripcometrue.domain.triprecord.entity.TripRecord;
+import com.haejwo.tripcometrue.domain.triprecord.exception.TripRecordNotFoundException;
+import com.haejwo.tripcometrue.domain.triprecord.repository.triprecord.TripRecordRepository;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Objects;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class TripRecordCommentService {
+
+ private final MemberRepository memberRepository;
+ private final TripRecordRepository tripRecordRepository;
+ private final TripRecordCommentRepository tripRecordCommentRepository;
+ private final AlarmService alarmService;
+
+ public void saveComment(
+ PrincipalDetails principalDetails,
+ Long tripRecordId,
+ TripRecordCommentRequestDto requestDto
+ ) {
+
+ Member loginMember = getMember(principalDetails);
+ TripRecord tripRecord = getTripRecordById(tripRecordId);
+
+ TripRecordComment comment = TripRecordCommentRequestDto.toComment(loginMember, tripRecord, requestDto);
+ tripRecordCommentRepository.save(comment);
+ tripRecord.incrementCommentCount();
+
+ alarmService.addAlarm(
+ loginMember,
+ tripRecord.getMember(),
+ AlarmType.NEW_TRIP_RECORD_COMMENT,
+ tripRecordId,
+ comment.getId());
+ }
+
+ private Member getMember(PrincipalDetails principalDetails) {
+ if (principalDetails != null) {
+ return memberRepository.findById(principalDetails.getMember().getId())
+ .orElseThrow(UserNotFoundException::new);
+ }
+ return null;
+ }
+
+ public void saveReplyComment(
+ PrincipalDetails principalDetails,
+ Long tripRecordCommentId,
+ TripRecordCommentRequestDto requestDto
+ ) {
+
+ Member loginMember = getMember(principalDetails);
+ TripRecordComment tripRecordComment = getTripRecordCommentById(tripRecordCommentId);
+ TripRecord tripRecord = tripRecordComment.getTripRecord();
+
+ TripRecordComment comment = TripRecordCommentRequestDto.toReplyComment(loginMember, tripRecord, tripRecordComment, requestDto);
+ tripRecordCommentRepository.save(comment);
+ tripRecord.incrementCommentCount();
+ }
+
+ private TripRecordComment getTripRecordCommentById(Long tripRecordCommentId) {
+ return tripRecordCommentRepository.findById(tripRecordCommentId)
+ .orElseThrow(TripRecordCommentNotFoundException::new);
+ }
+
+ private TripRecord getTripRecordById(Long tripRecordId) {
+ return tripRecordRepository.findById(tripRecordId)
+ .orElseThrow(TripRecordNotFoundException::new);
+ }
+
+ @Transactional(readOnly = true)
+ public TripRecordCommentListResponseDto getCommentList(
+ PrincipalDetails principalDetails,
+ Long tripRecordId,
+ Pageable pageable
+ ) {
+
+ Member loginMember = getMember(principalDetails);
+ TripRecord tripRecord = getTripRecordById(tripRecordId);
+
+ Slice tripRecordComments = tripRecordCommentRepository.findByTripRecord(tripRecord, pageable);
+ return TripRecordCommentListResponseDto.fromData(tripRecord.getCommentCount(), tripRecordComments, loginMember);
+ }
+
+ public void removeComment(PrincipalDetails principalDetails, Long tripRecordCommentId) {
+
+ Member loginMember = getMember(principalDetails);
+ TripRecordComment tripRecordComment = getTripRecordComment(tripRecordCommentId);
+ TripRecord tripRecord = tripRecordComment.getTripRecord();
+
+ validateRightMemberAccess(loginMember, tripRecordComment);
+
+ int removedCount = getRemovedCount(tripRecordCommentId, tripRecordComment);
+ tripRecord.decreaseCommentCount(removedCount);
+ }
+
+ private int getRemovedCount(Long tripRecordCommentId, TripRecordComment tripRecordComment) {
+ int childrenCount = tripRecordCommentRepository.deleteChildrenByTripRecordCommentId(tripRecordComment.getId());
+ int parentCount = tripRecordCommentRepository.deleteParentByTripRecordCommentId(tripRecordCommentId);
+ return childrenCount + parentCount;
+ }
+
+ private TripRecordComment getTripRecordComment(Long tripRecordCommentId) {
+ return tripRecordCommentRepository.findById(tripRecordCommentId)
+ .orElseThrow(TripRecordCommentNotFoundException::new);
+ }
+
+ private void validateRightMemberAccess(Member member, TripRecordComment tripRecordComment) {
+ if (!Objects.equals(tripRecordComment.getMember().getId(), member.getId())) {
+ throw new UserInvalidAccessException();
+ }
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/likes/controller/LikesController.java b/src/main/java/com/haejwo/tripcometrue/domain/likes/controller/LikesController.java
new file mode 100644
index 00000000..bd24a6dd
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/likes/controller/LikesController.java
@@ -0,0 +1,59 @@
+package com.haejwo.tripcometrue.domain.likes.controller;
+
+import com.haejwo.tripcometrue.domain.likes.dto.response.PlaceReviewLikesResponseDto;
+import com.haejwo.tripcometrue.domain.likes.dto.response.TripRecordReviewLikesResponseDto;
+import com.haejwo.tripcometrue.domain.likes.service.LikesService;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+ @RestController
+ @RequiredArgsConstructor
+ public class LikesController {
+
+ private final LikesService likesService;
+
+ @PostMapping("/v1/places/reviews/{placeReviewId}/likes")
+ public ResponseEntity> likePlaceReview(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long placeReviewId) {
+
+ PlaceReviewLikesResponseDto responseDto = likesService.likePlaceReview(principalDetails, placeReviewId);
+ return ResponseEntity.ok(ResponseDTO.okWithData(responseDto));
+ }
+
+ @PostMapping("/v1/trip-records/reviews/{tripRecordReviewId}/likes")
+ public ResponseEntity> likeTripRecordReview(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long tripRecordReviewId) {
+
+ TripRecordReviewLikesResponseDto responseDto = likesService.likeTripRecordReview(principalDetails, tripRecordReviewId);
+ return ResponseEntity.ok(ResponseDTO.okWithData(responseDto));
+ }
+
+ @DeleteMapping("/v1/places/reviews/{placeReviewId}/likes")
+ public ResponseEntity> unlikePlaceReview(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long placeReviewId) {
+
+ likesService.unlikePlaceReview(principalDetails, placeReviewId);
+ return ResponseEntity.ok(ResponseDTO.ok());
+ }
+
+ @DeleteMapping("/v1/trip-records/reviews/{tripRecordReviewId}/likes")
+ public ResponseEntity> unlikeTripRecordReview(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long tripRecordReviewId) {
+
+ likesService.unlikeTripRecordReview(principalDetails, tripRecordReviewId);
+ return ResponseEntity.ok(ResponseDTO.ok());
+ }
+ }
+
+
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/likes/controller/LikesControllerAdvice.java b/src/main/java/com/haejwo/tripcometrue/domain/likes/controller/LikesControllerAdvice.java
new file mode 100644
index 00000000..c778fe42
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/likes/controller/LikesControllerAdvice.java
@@ -0,0 +1,20 @@
+package com.haejwo.tripcometrue.domain.likes.controller;
+import com.haejwo.tripcometrue.domain.likes.exception.InvalidLikesException;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class LikesControllerAdvice {
+
+ @ExceptionHandler(InvalidLikesException.class)
+ public ResponseEntity> handleInvalidLikesException(InvalidLikesException e) {
+ HttpStatus status = e.getErrorCode().getHttpStatus();
+
+ return ResponseEntity
+ .status(status)
+ .body(ResponseDTO.errorWithMessage(status, e.getMessage()));
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/likes/dto/response/PlaceReviewLikesResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/likes/dto/response/PlaceReviewLikesResponseDto.java
new file mode 100644
index 00000000..a2fca12b
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/likes/dto/response/PlaceReviewLikesResponseDto.java
@@ -0,0 +1,19 @@
+package com.haejwo.tripcometrue.domain.likes.dto.response;
+import com.haejwo.tripcometrue.domain.likes.entity.PlaceReviewLikes;
+import lombok.Builder;
+
+
+@Builder
+public record PlaceReviewLikesResponseDto(
+ Long likeId,
+ Long memberId,
+ Long placeReviewId
+) {
+
+ public static PlaceReviewLikesResponseDto fromEntity(PlaceReviewLikes placeReviewLikes) {
+ return new PlaceReviewLikesResponseDto(
+ placeReviewLikes.getId(),
+ placeReviewLikes.getMember().getId(),
+ placeReviewLikes.getPlaceReview().getId());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/likes/dto/response/TripRecordReviewLikesResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/likes/dto/response/TripRecordReviewLikesResponseDto.java
new file mode 100644
index 00000000..9058eab4
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/likes/dto/response/TripRecordReviewLikesResponseDto.java
@@ -0,0 +1,19 @@
+package com.haejwo.tripcometrue.domain.likes.dto.response;
+import com.haejwo.tripcometrue.domain.likes.entity.TripRecordReviewLikes;
+import lombok.Builder;
+
+@Builder
+public record TripRecordReviewLikesResponseDto(
+ Long likeId,
+ Long memberId,
+ Long tripRecordReviewId
+) {
+
+ public static TripRecordReviewLikesResponseDto fromEntity(
+ TripRecordReviewLikes tripRecordReviewLikes) {
+ return new TripRecordReviewLikesResponseDto(
+ tripRecordReviewLikes.getId(),
+ tripRecordReviewLikes.getMember().getId(),
+ tripRecordReviewLikes.getTripRecordReview().getId());
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/likes/entity/PlaceReviewLikes.java b/src/main/java/com/haejwo/tripcometrue/domain/likes/entity/PlaceReviewLikes.java
new file mode 100644
index 00000000..5c24b41d
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/likes/entity/PlaceReviewLikes.java
@@ -0,0 +1,47 @@
+package com.haejwo.tripcometrue.domain.likes.entity;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.OnDelete;
+import org.hibernate.annotations.OnDeleteAction;
+
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class PlaceReviewLikes {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "place_review_likes_id")
+ private Long Id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @OnDelete(action = OnDeleteAction.CASCADE)
+ @JoinColumn(name = "place_review_id")
+ private PlaceReview placeReview;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @OnDelete(action = OnDeleteAction.CASCADE)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+
+ @Builder
+ public PlaceReviewLikes(Member member, PlaceReview placeReview) {
+ this.member = member;
+ this.placeReview = placeReview;
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/likes/entity/TripRecordReviewLikes.java b/src/main/java/com/haejwo/tripcometrue/domain/likes/entity/TripRecordReviewLikes.java
new file mode 100644
index 00000000..f0d98dc5
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/likes/entity/TripRecordReviewLikes.java
@@ -0,0 +1,49 @@
+package com.haejwo.tripcometrue.domain.likes.entity;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.review.triprecordreview.entity.TripRecordReview;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.OnDelete;
+import org.hibernate.annotations.OnDeleteAction;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class TripRecordReviewLikes {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "trip_record_review_likes_id")
+ private Long Id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @OnDelete(action = OnDeleteAction.CASCADE)
+ @JoinColumn(name = "trip_record_review_id")
+ private TripRecordReview tripRecordReview;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @OnDelete(action = OnDeleteAction.CASCADE)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+
+ @Builder
+ public TripRecordReviewLikes(Member member, TripRecordReview tripRecordReview) {
+ this.member = member;
+ this.tripRecordReview = tripRecordReview;
+ }
+
+
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/likes/exception/InvalidLikesException.java b/src/main/java/com/haejwo/tripcometrue/domain/likes/exception/InvalidLikesException.java
new file mode 100644
index 00000000..2fc52345
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/likes/exception/InvalidLikesException.java
@@ -0,0 +1,11 @@
+package com.haejwo.tripcometrue.domain.likes.exception;
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class InvalidLikesException extends ApplicationException {
+
+ public InvalidLikesException(ErrorCode errorCode){
+ super(errorCode);
+ }
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/likes/repository/PlaceReviewLikesRepository.java b/src/main/java/com/haejwo/tripcometrue/domain/likes/repository/PlaceReviewLikesRepository.java
new file mode 100644
index 00000000..13d3333a
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/likes/repository/PlaceReviewLikesRepository.java
@@ -0,0 +1,14 @@
+package com.haejwo.tripcometrue.domain.likes.repository;
+import com.haejwo.tripcometrue.domain.likes.entity.PlaceReviewLikes;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface PlaceReviewLikesRepository extends JpaRepository {
+
+ Optional findByMemberIdAndPlaceReviewId(Long memberId, Long placeReviewId);
+ boolean existsByMemberAndPlaceReview(Member member, PlaceReview placeReview);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/likes/repository/TripRecordReviewLikesRepository.java b/src/main/java/com/haejwo/tripcometrue/domain/likes/repository/TripRecordReviewLikesRepository.java
new file mode 100644
index 00000000..f7525551
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/likes/repository/TripRecordReviewLikesRepository.java
@@ -0,0 +1,14 @@
+package com.haejwo.tripcometrue.domain.likes.repository;
+import com.haejwo.tripcometrue.domain.likes.entity.TripRecordReviewLikes;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.review.triprecordreview.entity.TripRecordReview;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface TripRecordReviewLikesRepository extends JpaRepository {
+
+ Optional findByMemberIdAndTripRecordReviewId(Long memberId, Long tripRecordReviewId);
+ public boolean existsByMemberAndTripRecordReview(Member member, TripRecordReview tripRecordReview);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/likes/service/LikesService.java b/src/main/java/com/haejwo/tripcometrue/domain/likes/service/LikesService.java
new file mode 100644
index 00000000..6fcadd38
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/likes/service/LikesService.java
@@ -0,0 +1,118 @@
+package com.haejwo.tripcometrue.domain.likes.service;
+
+import com.haejwo.tripcometrue.domain.likes.dto.response.PlaceReviewLikesResponseDto;
+import com.haejwo.tripcometrue.domain.likes.dto.response.TripRecordReviewLikesResponseDto;
+import com.haejwo.tripcometrue.domain.likes.entity.PlaceReviewLikes;
+import com.haejwo.tripcometrue.domain.likes.entity.TripRecordReviewLikes;
+import com.haejwo.tripcometrue.domain.likes.exception.InvalidLikesException;
+import com.haejwo.tripcometrue.domain.likes.repository.PlaceReviewLikesRepository;
+import com.haejwo.tripcometrue.domain.likes.repository.TripRecordReviewLikesRepository;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.member.repository.MemberRepository;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import com.haejwo.tripcometrue.domain.review.placereview.exception.PlaceReviewNotFoundException;
+import com.haejwo.tripcometrue.domain.review.placereview.repository.PlaceReviewRepository;
+import com.haejwo.tripcometrue.domain.review.triprecordreview.entity.TripRecordReview;
+import com.haejwo.tripcometrue.domain.review.triprecordreview.exception.TripRecordReviewNotFoundException;
+import com.haejwo.tripcometrue.domain.review.triprecordreview.repository.TripRecordReviewRepository;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import jakarta.transaction.Transactional;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+ @Service
+ @RequiredArgsConstructor
+ public class LikesService {
+
+ private final MemberRepository memberRepository;
+ private final PlaceReviewRepository placeReviewRepository;
+ private final TripRecordReviewRepository tripRecordReviewRepository;
+ private final PlaceReviewLikesRepository placeReviewLikesRepository;
+ private final TripRecordReviewLikesRepository tripRecordReviewLikesRepository;
+
+ @Transactional
+ public PlaceReviewLikesResponseDto likePlaceReview(PrincipalDetails principalDetails, Long placeReviewId) {
+ Long memberId = principalDetails.getMember().getId();
+
+ Optional existingLikes = placeReviewLikesRepository.findByMemberIdAndPlaceReviewId(memberId, placeReviewId);
+ if (existingLikes.isPresent()) {
+ throw new InvalidLikesException(ErrorCode.LIKES_ALREADY_EXIST);
+ }
+
+ Member member = memberRepository.findById(memberId)
+ .orElseThrow(() -> new InvalidLikesException(ErrorCode.USER_NOT_FOUND));
+ PlaceReview placeReview = placeReviewRepository.findById(placeReviewId)
+ .orElseThrow(() -> new InvalidLikesException(ErrorCode.PLACE_REVIEW_NOT_FOUND));
+
+ PlaceReviewLikes like = PlaceReviewLikes.builder()
+ .member(member)
+ .placeReview(placeReview)
+ .build();
+ placeReviewLikesRepository.save(like);
+ placeReview.increaseLikesCount();
+
+ return PlaceReviewLikesResponseDto.fromEntity(like);
+ }
+
+ @Transactional
+ public TripRecordReviewLikesResponseDto likeTripRecordReview(PrincipalDetails principalDetails, Long tripRecordReviewId) {
+ Long memberId = principalDetails.getMember().getId();
+
+ Optional existingLikes = tripRecordReviewLikesRepository.findByMemberIdAndTripRecordReviewId(memberId, tripRecordReviewId);
+ if (existingLikes.isPresent()) {
+ throw new InvalidLikesException(ErrorCode.LIKES_ALREADY_EXIST);
+ }
+
+ Member member = memberRepository.findById(memberId)
+ .orElseThrow(() -> new InvalidLikesException(ErrorCode.USER_NOT_FOUND));
+ TripRecordReview tripRecordReview = tripRecordReviewRepository.findById(tripRecordReviewId)
+ .orElseThrow(() -> new InvalidLikesException(ErrorCode.TRIP_RECORD_REVIEW_NOT_FOUND));
+
+ TripRecordReviewLikes like = TripRecordReviewLikes.builder()
+ .member(member)
+ .tripRecordReview(tripRecordReview)
+ .build();
+ tripRecordReviewLikesRepository.save(like);
+ tripRecordReview.increaseLikesCount();
+
+ return TripRecordReviewLikesResponseDto.fromEntity(like);
+ }
+
+ @Transactional
+ public void unlikePlaceReview(PrincipalDetails principalDetails, Long placeReviewId) {
+ Long memberId = principalDetails.getMember().getId();
+ PlaceReview placeReview = findByPlaceReviewId(placeReviewId);
+
+ PlaceReviewLikes like = placeReviewLikesRepository.findByMemberIdAndPlaceReviewId(memberId, placeReviewId)
+ .orElseThrow(() -> new InvalidLikesException(ErrorCode.LIKES_NOT_FOUND));
+
+ placeReviewLikesRepository.delete(like);
+ placeReview.decreaseLikesCount();
+ }
+
+ private PlaceReview findByPlaceReviewId(Long placeReviewId) {
+ return placeReviewRepository.findById(placeReviewId)
+ .orElseThrow(PlaceReviewNotFoundException::new);
+ }
+
+ @Transactional
+ public void unlikeTripRecordReview(PrincipalDetails principalDetails, Long tripRecordReviewId) {
+ Long memberId = principalDetails.getMember().getId();
+ TripRecordReview tripRecordReview = findByTripRecordReviewId(tripRecordReviewId);
+
+ TripRecordReviewLikes like = tripRecordReviewLikesRepository.findByMemberIdAndTripRecordReviewId(memberId, tripRecordReviewId)
+ .orElseThrow(() -> new InvalidLikesException(ErrorCode.LIKES_NOT_FOUND));
+
+ tripRecordReviewLikesRepository.delete(like);
+ tripRecordReview.decreaseLikesCount();
+ }
+
+ private TripRecordReview findByTripRecordReviewId(Long tripRecordReviewId) {
+ return tripRecordReviewRepository.findById(tripRecordReviewId)
+ .orElseThrow(TripRecordReviewNotFoundException::new);
+ }
+ }
+
+
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberController.java b/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberController.java
index 42033e55..1ceae609 100644
--- a/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberController.java
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberController.java
@@ -1,15 +1,32 @@
package com.haejwo.tripcometrue.domain.member.controller;
-import com.haejwo.tripcometrue.domain.member.request.SignUpRequest;
-import com.haejwo.tripcometrue.domain.member.response.SignUpResponse;
+import com.haejwo.tripcometrue.domain.member.dto.request.IntroductionRequestDto;
+import com.haejwo.tripcometrue.domain.member.dto.request.NicknameRequestDto;
+import com.haejwo.tripcometrue.domain.member.dto.request.PasswordRequestDto;
+import com.haejwo.tripcometrue.domain.member.dto.request.ProfileImageRequestDto;
+import com.haejwo.tripcometrue.domain.member.dto.request.SignUpRequestDto;
+import com.haejwo.tripcometrue.domain.member.dto.response.IntroductionResponseDto;
+import com.haejwo.tripcometrue.domain.member.dto.response.LoginResponseDto;
+import com.haejwo.tripcometrue.domain.member.dto.response.MemberDetailResponseDto;
+import com.haejwo.tripcometrue.domain.member.dto.response.NicknameResponseDto;
+import com.haejwo.tripcometrue.domain.member.dto.response.ProfileImageResponseDto;
+import com.haejwo.tripcometrue.domain.member.dto.response.SignUpResponseDto;
+import com.haejwo.tripcometrue.domain.member.dto.response.TestUserResponseDto;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
import com.haejwo.tripcometrue.domain.member.service.MemberService;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
import com.haejwo.tripcometrue.global.util.ResponseDTO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@@ -20,9 +37,125 @@ public class MemberController {
private final MemberService memberService;
@PostMapping("/signup")
- public ResponseEntity> signup(
- @Valid @RequestBody SignUpRequest signUpRequest) {
- ResponseDTO response = memberService.signup(signUpRequest);
- return ResponseEntity.status(response.getCode()).body(response);
+ public ResponseEntity> signup(
+ @Valid @RequestBody SignUpRequestDto signUpRequestDto) {
+ SignUpResponseDto signUpResponseDto = memberService.signup(signUpRequestDto);
+ ResponseDTO response = ResponseDTO.okWithData(signUpResponseDto);
+ return ResponseEntity
+ .status(response.getCode())
+ .body(response);
+ }
+
+ // Authenticated user ์ํํ
์คํธ ์ฝ๋์
๋๋ค
+ @GetMapping("/test/jwt")
+ public ResponseEntity> test(
+ @AuthenticationPrincipal PrincipalDetails principalDetails) {
+ Member member = principalDetails.getMember();
+
+ TestUserResponseDto testUserResponseDto = TestUserResponseDto.fromEntity(member);
+ ResponseDTO response = ResponseDTO.okWithData(testUserResponseDto);
+ return ResponseEntity
+ .status(response.getCode())
+ .body(response);
+ }
+
+ @GetMapping("/check-duplicated-email")
+ public ResponseEntity> checkDuplicateEmail(
+ @RequestParam String email) {
+ memberService.checkDuplicateEmail(email);
+ ResponseDTO response = ResponseDTO.ok();
+ return ResponseEntity
+ .status(response.getCode())
+ .body(response);
+ }
+
+ @GetMapping("/oauth2/info")
+ public ResponseEntity> oauth2Test(
+ @RequestParam String token, @RequestParam String email, @RequestParam String name) {
+ LoginResponseDto loginResponseDto = new LoginResponseDto(email, name, token);
+
+ ResponseDTO response = ResponseDTO.okWithData(loginResponseDto);
+ return ResponseEntity
+ .status(response.getCode())
+ .body(response);
+ }
+
+ @PatchMapping("/change-password")
+ public ResponseEntity> changePassword(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @Valid @RequestBody PasswordRequestDto passwordRequestDto) {
+
+ memberService.changePassword(principalDetails, passwordRequestDto);
+ ResponseDTO response = ResponseDTO.ok();
+ return ResponseEntity
+ .status(response.getCode())
+ .body(response);
+ }
+
+ @PostMapping("/check-password")
+ public ResponseEntity> checkPassword(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @Valid @RequestBody PasswordRequestDto passwordRequestDto) {
+
+ memberService.checkPassword(principalDetails, passwordRequestDto);
+ ResponseDTO response = ResponseDTO.ok();
+ return ResponseEntity
+ .status(response.getCode())
+ .body(response);
+ }
+
+ @PatchMapping("/profile-image")
+ public ResponseEntity> updateProfileImage(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @Valid @RequestBody ProfileImageRequestDto requestDto) {
+
+ ProfileImageResponseDto responseDto = memberService.updateProfileImage(principalDetails, requestDto);
+ ResponseDTO response = ResponseDTO.okWithData(responseDto);
+
+ return ResponseEntity
+ .status(response.getCode())
+ .body(response);
+ }
+
+ @PatchMapping("/introduction")
+ public ResponseEntity> updateIntroduction(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @Valid @RequestBody IntroductionRequestDto requestDto) {
+
+ IntroductionResponseDto responseDto = memberService.updateIntroduction(principalDetails, requestDto);
+ ResponseDTO response = ResponseDTO.okWithData(responseDto);
+
+ return ResponseEntity
+ .status(response.getCode())
+ .body(response);
+ }
+
+ @PatchMapping("/nickname")
+ public ResponseEntity> updateNickname(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @Valid @RequestBody NicknameRequestDto requestDto) {
+
+ NicknameResponseDto responseDto = memberService.updateNickname(principalDetails, requestDto);
+ ResponseDTO response = ResponseDTO.okWithData(responseDto);
+
+ return ResponseEntity
+ .status(response.getCode())
+ .body(response);
+ }
+
+ @DeleteMapping
+ public ResponseEntity> deleteAccount(@AuthenticationPrincipal PrincipalDetails principalDetails) {
+ memberService.deleteAccount(principalDetails);
+ return ResponseEntity.ok(ResponseDTO.ok());
+ }
+
+ @GetMapping("/details")
+ public ResponseEntity> getMemberDetails(
+ @AuthenticationPrincipal PrincipalDetails principalDetails) {
+ MemberDetailResponseDto responseDto = memberService.getMemberDetails(principalDetails);
+ ResponseDTO response = ResponseDTO.okWithData(responseDto);
+ return ResponseEntity
+ .status(response.getCode())
+ .body(response);
}
}
\ No newline at end of file
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberControllerAdvice.java b/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberControllerAdvice.java
index 7b213e3d..31420096 100644
--- a/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberControllerAdvice.java
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberControllerAdvice.java
@@ -1,6 +1,14 @@
package com.haejwo.tripcometrue.domain.member.controller;
+import com.haejwo.tripcometrue.domain.member.exception.CurrentPasswordNotMatchException;
import com.haejwo.tripcometrue.domain.member.exception.EmailDuplicateException;
+import com.haejwo.tripcometrue.domain.member.exception.EmailNotMatchException;
+import com.haejwo.tripcometrue.domain.member.exception.IntroductionLengthExceededException;
+import com.haejwo.tripcometrue.domain.member.exception.NewPasswordNotMatchException;
+import com.haejwo.tripcometrue.domain.member.exception.NewPasswordSameAsOldException;
+import com.haejwo.tripcometrue.domain.member.exception.UserInvalidAccessException;
+import com.haejwo.tripcometrue.domain.member.exception.*;
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
import com.haejwo.tripcometrue.global.util.ResponseDTO;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -23,5 +31,44 @@ public ResponseEntity> handleUserControllerExceptions(
return ResponseEntity.status(status).body(
ResponseDTO.errorWithMessage(status, exception.getMessage()));
}
+
+ @ExceptionHandler(UserInvalidAccessException.class)
+ public ResponseEntity> userInvalidAccessException(
+ UserInvalidAccessException e
+ ) {
+ HttpStatus status = e.getErrorCode().getHttpStatus();
+
+ return ResponseEntity
+ .status(status)
+ .body(ResponseDTO.errorWithMessage(status, e.getMessage()));
+ }
+
+ @ExceptionHandler({CurrentPasswordNotMatchException.class, NewPasswordSameAsOldException.class, NewPasswordNotMatchException.class,
+ EmailNotMatchException.class})
+ public ResponseEntity> handleApplicationException(ApplicationException exception) {
+ HttpStatus status = exception.getErrorCode().getHttpStatus();
+
+ return ResponseEntity
+ .status(status)
+ .body(ResponseDTO.errorWithMessage(status, exception.getMessage()));
+ }
+
+ @ExceptionHandler(IntroductionLengthExceededException.class)
+ public ResponseEntity> handleIntroductionLengthExceededException(IntroductionLengthExceededException exception) {
+ HttpStatus status = exception.getErrorCode().getHttpStatus();
+
+ return ResponseEntity
+ .status(status)
+ .body(ResponseDTO.errorWithMessage(status, exception.getMessage()));
+ }
+
+ @ExceptionHandler(UserNotFoundException.class)
+ public ResponseEntity> handleUserNotFoundException(UserNotFoundException exception) {
+ HttpStatus status = exception.getErrorCode().getHttpStatus();
+
+ return ResponseEntity
+ .status(status)
+ .body(ResponseDTO.errorWithMessage(status, exception.getMessage()));
+ }
}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberReadSearchController.java b/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberReadSearchController.java
new file mode 100644
index 00000000..49d77f52
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/controller/MemberReadSearchController.java
@@ -0,0 +1,74 @@
+package com.haejwo.tripcometrue.domain.member.controller;
+
+import com.haejwo.tripcometrue.domain.member.dto.response.MemberCreatorInfoResponseDto;
+import com.haejwo.tripcometrue.domain.member.dto.response.MemberDetailListItemResponseDto;
+import com.haejwo.tripcometrue.domain.member.dto.response.MemberSearchResultWithContentResponseDto;
+import com.haejwo.tripcometrue.domain.member.facade.MemberReadSearchFacade;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import com.haejwo.tripcometrue.global.util.SliceResponseDto;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RequiredArgsConstructor
+@RequestMapping("/v1/members")
+@RestController
+public class MemberReadSearchController {
+
+ private final MemberReadSearchFacade memberReadSearchFacade;
+
+ @GetMapping("/{memberId}")
+ public ResponseEntity> memberCreatorInfo(
+ @PathVariable("memberId") Long memberId
+ ) {
+ return ResponseEntity
+ .ok()
+ .body(
+ ResponseDTO.okWithData(
+ memberReadSearchFacade.getCreatorInfo(memberId)
+ )
+ );
+ }
+
+ @GetMapping("/list")
+ public ResponseEntity> searchByNicknameResultWithContent(
+ @RequestParam("query") String query
+ ) {
+ return ResponseEntity
+ .ok()
+ .body(
+ ResponseDTO.okWithData(
+ memberReadSearchFacade.searchByNicknameResultWithContent(query)
+ )
+ );
+ }
+
+ @GetMapping
+ public ResponseEntity>> searchByNicknamePagination(
+ @RequestParam("query") String query,
+ @PageableDefault(sort = "rating", direction = Sort.Direction.DESC) Pageable pageable
+ ) {
+ return ResponseEntity
+ .ok()
+ .body(
+ ResponseDTO.okWithData(
+ memberReadSearchFacade.searchByNicknamePagination(query, pageable)
+ )
+ );
+ }
+
+ // ํ ํผ๋ HOT ์ธ๊ธฐ ํฌ๋ฆฌ์์ดํฐ ๋ฆฌ์คํธ ์กฐํ
+ @GetMapping("/top-list")
+ public ResponseEntity> listTopCreators() {
+ return ResponseEntity
+ .ok()
+ .body(
+ ResponseDTO.okWithData(
+ memberReadSearchFacade.listTopMemberCreators()
+ )
+ );
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/EmailCheckRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/EmailCheckRequestDto.java
new file mode 100644
index 00000000..97213a87
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/EmailCheckRequestDto.java
@@ -0,0 +1,12 @@
+package com.haejwo.tripcometrue.domain.member.dto.request;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+public record EmailCheckRequestDto(
+
+ @NotBlank(message = "์ด๋ฉ์ผ์ ์
๋ ฅํด์ฃผ์ธ์.")
+ @Email(message = "์ ํจํ ์ด๋ฉ์ผ ์ฃผ์๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.")
+ String email
+
+) {}
\ No newline at end of file
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/IntroductionRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/IntroductionRequestDto.java
new file mode 100644
index 00000000..f2611c39
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/IntroductionRequestDto.java
@@ -0,0 +1,7 @@
+package com.haejwo.tripcometrue.domain.member.dto.request;
+
+import jakarta.validation.constraints.Size;
+
+public record IntroductionRequestDto(
+ String introduction)
+{ }
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/LoginRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/LoginRequestDto.java
new file mode 100644
index 00000000..88098c05
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/LoginRequestDto.java
@@ -0,0 +1,12 @@
+package com.haejwo.tripcometrue.domain.member.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+
+public record LoginRequestDto(
+ @NotNull(message = "email์ ํ์๊ฐ์
๋๋ค")
+ String email,
+ @NotNull(message = "password์ ํ์๊ฐ์
๋๋ค")
+ String password
+) {
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/NicknameRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/NicknameRequestDto.java
new file mode 100644
index 00000000..24974fde
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/NicknameRequestDto.java
@@ -0,0 +1,8 @@
+package com.haejwo.tripcometrue.domain.member.dto.request;
+
+import com.haejwo.tripcometrue.domain.member.dto.response.NicknameResponseDto;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+
+public record NicknameRequestDto(
+ String nickname
+) {}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/PasswordRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/PasswordRequestDto.java
new file mode 100644
index 00000000..c2f23b44
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/PasswordRequestDto.java
@@ -0,0 +1,11 @@
+package com.haejwo.tripcometrue.domain.member.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record PasswordRequestDto(
+ String currentPassword,
+
+ String newPassword,
+
+ String confirmPassword
+) {}
\ No newline at end of file
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/ProfileImageRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/ProfileImageRequestDto.java
new file mode 100644
index 00000000..66c52edf
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/ProfileImageRequestDto.java
@@ -0,0 +1,5 @@
+package com.haejwo.tripcometrue.domain.member.dto.request;
+
+public record ProfileImageRequestDto(
+ String profile_image
+) {}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/request/SignUpRequest.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/SignUpRequestDto.java
similarity index 71%
rename from src/main/java/com/haejwo/tripcometrue/domain/member/request/SignUpRequest.java
rename to src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/SignUpRequestDto.java
index e65d952d..66c32663 100644
--- a/src/main/java/com/haejwo/tripcometrue/domain/member/request/SignUpRequest.java
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/request/SignUpRequestDto.java
@@ -1,25 +1,23 @@
-package com.haejwo.tripcometrue.domain.member.request;
+package com.haejwo.tripcometrue.domain.member.dto.request;
import com.haejwo.tripcometrue.domain.member.entity.Member;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
-public record SignUpRequest(
+
+public record SignUpRequestDto(
@Pattern(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", message = "์ด๋ฉ์ผ ํ์์ด ์ ํจํ์ง ์์ต๋๋ค")
@NotNull(message = "email์ ํ์๊ฐ์
๋๋ค")
String email,
- @NotNull(message = "nickname์ ํ์๊ฐ์
๋๋ค")
- String nickname,
-
@NotNull(message = "password๋ ํ์๊ฐ์
๋๋ค")
String password
) {
- public Member toEntity(String encodedPassword) {
+ public Member toEntity(String encodedPassword, String name) {
return Member.builder()
.email(email)
- .nickname(nickname)
+ .nickname(name)
.password(encodedPassword)
.authority("ROLE_USER")
.build();
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/IntroductionResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/IntroductionResponseDto.java
new file mode 100644
index 00000000..b65ccfcd
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/IntroductionResponseDto.java
@@ -0,0 +1,9 @@
+package com.haejwo.tripcometrue.domain.member.dto.response;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+
+public record IntroductionResponseDto(String introduction) {
+ public static IntroductionResponseDto fromEntity(Member member) {
+ return new IntroductionResponseDto(member.getIntroduction());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/LoginResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/LoginResponseDto.java
new file mode 100644
index 00000000..82a053d3
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/LoginResponseDto.java
@@ -0,0 +1,18 @@
+package com.haejwo.tripcometrue.domain.member.dto.response;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+
+public record LoginResponseDto(
+ String email,
+ String name,
+ String token
+) {
+
+ public static LoginResponseDto fromEntity(Member member, String token) {
+ return new LoginResponseDto(
+ member.getMemberBase().getEmail(),
+ member.getMemberBase().getNickname(),
+ token
+ );
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberCreatorInfoResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberCreatorInfoResponseDto.java
new file mode 100644
index 00000000..71932284
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberCreatorInfoResponseDto.java
@@ -0,0 +1,18 @@
+package com.haejwo.tripcometrue.domain.member.dto.response;
+
+import com.haejwo.tripcometrue.domain.triprecord.dto.response.triprecord.TripRecordListItemResponseDto;
+import com.haejwo.tripcometrue.domain.triprecord.dto.response.triprecord_schedule_media.TripRecordScheduleVideoListItemResponseDto;
+import lombok.Builder;
+
+import java.util.List;
+
+public record MemberCreatorInfoResponseDto(
+ MemberDetailListItemResponseDto memberDetailInfo,
+ List videos,
+ List tripRecords
+) {
+
+ @Builder
+ public MemberCreatorInfoResponseDto {
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberDetailListItemResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberDetailListItemResponseDto.java
new file mode 100644
index 00000000..f60c6e74
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberDetailListItemResponseDto.java
@@ -0,0 +1,30 @@
+package com.haejwo.tripcometrue.domain.member.dto.response;
+
+import lombok.Builder;
+
+public record MemberDetailListItemResponseDto(
+ Long memberId,
+ String nickname,
+ String introduction,
+ String profileImageUrl,
+ Double averageRating,
+ Integer tripRecordTotal,
+ Integer videoTotal
+) {
+
+ @Builder
+ public MemberDetailListItemResponseDto {
+ }
+
+ public static MemberDetailListItemResponseDto of(MemberSimpleResponseDto dto, Integer tripRecordTotal, Integer videoTotal) {
+ return MemberDetailListItemResponseDto.builder()
+ .memberId(dto.memberId())
+ .nickname(dto.nickname())
+ .introduction(dto.introduction())
+ .profileImageUrl(dto.profileImageUrl())
+ .averageRating(dto.averageRating())
+ .tripRecordTotal(tripRecordTotal)
+ .videoTotal(videoTotal)
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberDetailResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberDetailResponseDto.java
new file mode 100644
index 00000000..40dc57d2
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberDetailResponseDto.java
@@ -0,0 +1,23 @@
+package com.haejwo.tripcometrue.domain.member.dto.response;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+
+public record MemberDetailResponseDto(
+ Long id,
+ String nickname,
+ String profileImage,
+ String introduction,
+ Integer totalPoint,
+ String tripLevel
+) {
+ public static MemberDetailResponseDto fromEntity(Member member) {
+ return new MemberDetailResponseDto(
+ member.getId(),
+ member.getMemberBase().getNickname(),
+ member.getProfileImage(),
+ member.getIntroduction(),
+ member.getTotalPoint(),
+ member.getTripLevel().name()
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberInfoWithTripRecordsResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberInfoWithTripRecordsResponseDto.java
new file mode 100644
index 00000000..f7097af7
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberInfoWithTripRecordsResponseDto.java
@@ -0,0 +1,16 @@
+package com.haejwo.tripcometrue.domain.member.dto.response;
+
+import com.haejwo.tripcometrue.domain.triprecord.dto.response.triprecord.TripRecordListItemResponseDto;
+import lombok.Builder;
+
+import java.util.List;
+
+public record MemberInfoWithTripRecordsResponseDto(
+ MemberSimpleResponseDto memberInfo,
+ List tripRecords
+) {
+
+ @Builder
+ public MemberInfoWithTripRecordsResponseDto {
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberSearchResultWithContentResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberSearchResultWithContentResponseDto.java
new file mode 100644
index 00000000..98ffb6bb
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberSearchResultWithContentResponseDto.java
@@ -0,0 +1,18 @@
+package com.haejwo.tripcometrue.domain.member.dto.response;
+
+import com.haejwo.tripcometrue.domain.triprecord.dto.response.triprecord.TripRecordListItemResponseDto;
+import com.haejwo.tripcometrue.domain.triprecord.dto.response.triprecord_schedule_media.TripRecordScheduleVideoListItemResponseDto;
+import lombok.Builder;
+
+import java.util.List;
+
+public record MemberSearchResultWithContentResponseDto(
+ List members,
+ List videos,
+ List tripRecords
+) {
+
+ @Builder
+ public MemberSearchResultWithContentResponseDto {
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberSimpleResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberSimpleResponseDto.java
new file mode 100644
index 00000000..a07015aa
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/MemberSimpleResponseDto.java
@@ -0,0 +1,27 @@
+package com.haejwo.tripcometrue.domain.member.dto.response;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import lombok.Builder;
+
+public record MemberSimpleResponseDto(
+ Long memberId,
+ String nickname,
+ String introduction,
+ String profileImageUrl,
+ Double averageRating
+) {
+
+ @Builder
+ public MemberSimpleResponseDto {
+ }
+
+ public static MemberSimpleResponseDto fromEntity(Member entity) {
+ return MemberSimpleResponseDto.builder()
+ .memberId(entity.getId())
+ .nickname(entity.getMemberBase().getNickname())
+ .introduction(entity.getIntroduction())
+ .profileImageUrl(entity.getProfileImage())
+ .averageRating(entity.getMemberRating())
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/NicknameResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/NicknameResponseDto.java
new file mode 100644
index 00000000..561ded5a
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/NicknameResponseDto.java
@@ -0,0 +1,9 @@
+package com.haejwo.tripcometrue.domain.member.dto.response;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+
+public record NicknameResponseDto(String nickname) {
+ public static NicknameResponseDto fromEntity(Member member) {
+ return new NicknameResponseDto(member.getMemberBase().getNickname());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/ProfileImageResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/ProfileImageResponseDto.java
new file mode 100644
index 00000000..9dfd77c1
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/ProfileImageResponseDto.java
@@ -0,0 +1,9 @@
+package com.haejwo.tripcometrue.domain.member.dto.response;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+
+public record ProfileImageResponseDto(String profileImageUrl) {
+ public static ProfileImageResponseDto fromEntity(Member member) {
+ return new ProfileImageResponseDto(member.getProfileImage());
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/response/SignUpResponse.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/SignUpResponseDto.java
similarity index 51%
rename from src/main/java/com/haejwo/tripcometrue/domain/member/response/SignUpResponse.java
rename to src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/SignUpResponseDto.java
index b0fd5e21..d03ea4dd 100644
--- a/src/main/java/com/haejwo/tripcometrue/domain/member/response/SignUpResponse.java
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/SignUpResponseDto.java
@@ -1,18 +1,19 @@
-package com.haejwo.tripcometrue.domain.member.response;
+package com.haejwo.tripcometrue.domain.member.dto.response;
import com.haejwo.tripcometrue.domain.member.entity.Member;
-public record SignUpResponse(
+public record SignUpResponseDto(
Long memberId,
String email,
String name
) {
- public static SignUpResponse fromEntity(Member member) {
- return new SignUpResponse (
- member.getMemberId(),
+
+ public static SignUpResponseDto fromEntity(Member member) {
+ return new SignUpResponseDto(
+ member.getId(),
member.getMemberBase().getEmail(),
member.getMemberBase().getNickname()
);
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/TestUserResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/TestUserResponseDto.java
new file mode 100644
index 00000000..3ca11d7c
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/dto/response/TestUserResponseDto.java
@@ -0,0 +1,22 @@
+package com.haejwo.tripcometrue.domain.member.dto.response;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+
+public record TestUserResponseDto(
+
+ String email,
+ String nickname,
+ String authority,
+ String provider
+
+) {
+
+ public static TestUserResponseDto fromEntity(Member member) {
+ return new TestUserResponseDto(
+ member.getMemberBase().getEmail(),
+ member.getMemberBase().getNickname(),
+ member.getMemberBase().getAuthority(),
+ member.getProvider()
+ );
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/entity/Member.java b/src/main/java/com/haejwo/tripcometrue/domain/member/entity/Member.java
index 69c9f41c..73c50ea4 100644
--- a/src/main/java/com/haejwo/tripcometrue/domain/member/entity/Member.java
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/entity/Member.java
@@ -1,16 +1,24 @@
package com.haejwo.tripcometrue.domain.member.entity;
+import com.haejwo.tripcometrue.domain.member.entity.tripLevel.TripLevel;
import com.haejwo.tripcometrue.global.entity.BaseTimeEntity;
+import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
+import jakarta.persistence.PrePersist;
+import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
+import java.util.Objects;
+
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@@ -18,21 +26,69 @@ public class Member extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long memberId;
+ @Column(name = "member_id")
+ private Long id;
@Embedded
protected MemberBase memberBase;
private String provider;
- private String profile_image;
+ private String profileImage;
+
+ private Integer totalPoint;
+
+ @Enumerated(EnumType.STRING)
+ private TripLevel tripLevel;
+
+ private String introduction;
+
+ private Integer nickNameChangeCount;
- private Integer total_point;
+ private Double memberRating;
- private Double member_rating;
+ private LocalDateTime nickNameChangeTime;
@Builder
- public Member(String email, String nickname, String password, String authority) {
+ public Member(String email, String nickname, String password, String authority,
+ String provider, Double memberRating) {
this.memberBase = new MemberBase(email, nickname, password, authority);
+ this.provider = provider;
+ this.memberRating = Objects.isNull(memberRating) ? 0.0 : memberRating;
+ }
+
+ public void updateProfileImage(String profileImage){
+ this.profileImage = profileImage;
+ }
+
+ public void updateIntroduction(String introduction){
+ this.introduction = introduction;
+ }
+
+ public void updateNickNameChangeCount(){
+ this.nickNameChangeCount = (this.nickNameChangeCount == null) ? 1 : this.nickNameChangeCount + 1;
+ }
+
+ public void updateNickNameChangeTime(LocalDateTime nicknameChangeTime){
+ this.nickNameChangeTime = nicknameChangeTime;
+ }
+
+ public void updateTripLevel(){
+ this.tripLevel = TripLevel.getLevelByPoint(this.totalPoint);
+ }
+
+ public void earnPoint(int point) {
+ this.totalPoint += point;
+ updateTripLevel();
+ }
+
+ @PrePersist
+ private void init(){
+ totalPoint = (totalPoint == null) ? 0 : totalPoint;
+ nickNameChangeCount = (nickNameChangeCount == null) ? 0 : nickNameChangeCount;
+ tripLevel = (tripLevel == null) ? TripLevel.BEGINNER : tripLevel;
+ profileImage = (profileImage == null) ? "https://i.imgur.com/PWZeQcP.png" : profileImage; //์์ ๋ํดํธํ๋กํ ์ด๋ฏธ์ง ๋ฐ์ดํฐ
+
+ updateTripLevel();
}
}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/entity/MemberBase.java b/src/main/java/com/haejwo/tripcometrue/domain/member/entity/MemberBase.java
index a5156c7d..70298bd2 100644
--- a/src/main/java/com/haejwo/tripcometrue/domain/member/entity/MemberBase.java
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/entity/MemberBase.java
@@ -11,8 +11,15 @@
@AllArgsConstructor
@Embeddable
public class MemberBase {
- private String email;
- private String nickname;
- private String password;
- private String authority;
+
+ private String email;
+ private String nickname;
+ private String password;
+ private String authority;
+
+ public void changePassword(String encodedNewPassword) {
+ this.password = encodedNewPassword;
+ }
+
+ public void changeNickname(String nickname){this.nickname = nickname;}
}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/entity/tripLevel/TripLevel.java b/src/main/java/com/haejwo/tripcometrue/domain/member/entity/tripLevel/TripLevel.java
new file mode 100644
index 00000000..4659cc8e
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/entity/tripLevel/TripLevel.java
@@ -0,0 +1,24 @@
+package com.haejwo.tripcometrue.domain.member.entity.tripLevel;
+
+public enum TripLevel {
+ BEGINNER(0, 49),
+ RUNNER(50, 149),
+ TRAVELER(150, Integer.MAX_VALUE);
+
+ private final int min;
+ private final int max;
+
+ TripLevel(int min, int max) {
+ this.min = min;
+ this.max = max;
+ }
+
+ public static TripLevel getLevelByPoint(int totalPoints) {
+ for (TripLevel level : TripLevel.values()) {
+ if (totalPoints >= level.min && totalPoints <= level.max) {
+ return level;
+ }
+ }
+ return TRAVELER;
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/exception/CurrentPasswordNotMatchException.java b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/CurrentPasswordNotMatchException.java
new file mode 100644
index 00000000..a8814156
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/CurrentPasswordNotMatchException.java
@@ -0,0 +1,10 @@
+package com.haejwo.tripcometrue.domain.member.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class CurrentPasswordNotMatchException extends ApplicationException {
+ private static final ErrorCode ERROR_CODE = ErrorCode.CURRENT_PASSWORD_NOT_MATCH;
+
+ public CurrentPasswordNotMatchException(){super(ERROR_CODE);}
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/exception/EmailNotMatchException.java b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/EmailNotMatchException.java
new file mode 100644
index 00000000..ade336f8
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/EmailNotMatchException.java
@@ -0,0 +1,10 @@
+package com.haejwo.tripcometrue.domain.member.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class EmailNotMatchException extends ApplicationException {
+ private static final ErrorCode ERROR_CODE = ErrorCode.EMAIL_NOT_MATCH;
+
+ public EmailNotMatchException(){super(ERROR_CODE);}
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/exception/IntroductionLengthExceededException.java b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/IntroductionLengthExceededException.java
new file mode 100644
index 00000000..6a115909
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/IntroductionLengthExceededException.java
@@ -0,0 +1,11 @@
+package com.haejwo.tripcometrue.domain.member.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class IntroductionLengthExceededException extends ApplicationException {
+ private static final ErrorCode ERROR_CODE = ErrorCode.INTRODUCTION_TOO_LONG;
+
+ public IntroductionLengthExceededException(){super(ERROR_CODE);}
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NewPasswordNotMatchException.java b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NewPasswordNotMatchException.java
new file mode 100644
index 00000000..5921aaef
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NewPasswordNotMatchException.java
@@ -0,0 +1,11 @@
+package com.haejwo.tripcometrue.domain.member.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class NewPasswordNotMatchException extends ApplicationException {
+ private static final ErrorCode ERROR_CODE = ErrorCode.NEW_PASSWORD_NOT_MATCH;
+
+ public NewPasswordNotMatchException(){super(ERROR_CODE);}
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NewPasswordSameAsOldException.java b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NewPasswordSameAsOldException.java
new file mode 100644
index 00000000..00832eee
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NewPasswordSameAsOldException.java
@@ -0,0 +1,11 @@
+package com.haejwo.tripcometrue.domain.member.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class NewPasswordSameAsOldException extends ApplicationException {
+
+ private static final ErrorCode ERROR_CODE = ErrorCode.NEW_PASSWORD_SAME_AS_OLD;
+
+ public NewPasswordSameAsOldException(){super(ERROR_CODE);}
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NicknameAlreadyExistsException.java b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NicknameAlreadyExistsException.java
new file mode 100644
index 00000000..b851e709
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NicknameAlreadyExistsException.java
@@ -0,0 +1,11 @@
+package com.haejwo.tripcometrue.domain.member.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class NicknameAlreadyExistsException extends ApplicationException {
+
+ private static final ErrorCode ERROR_CODE = ErrorCode.NICKNAME_ALREADY_EXISTS;
+
+ public NicknameAlreadyExistsException(){super(ERROR_CODE);}
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NicknameChangeNotAvailableException.java b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NicknameChangeNotAvailableException.java
new file mode 100644
index 00000000..ea061182
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/NicknameChangeNotAvailableException.java
@@ -0,0 +1,14 @@
+package com.haejwo.tripcometrue.domain.member.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class NicknameChangeNotAvailableException extends ApplicationException {
+
+ private static ErrorCode ERROR_CODE = ErrorCode.NICKNAME_CHANGE_NOT_AVAILABLE;
+
+ public NicknameChangeNotAvailableException(){
+ super(ERROR_CODE);
+ }
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/exception/UserInvalidAccessException.java b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/UserInvalidAccessException.java
new file mode 100644
index 00000000..1c155c68
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/UserInvalidAccessException.java
@@ -0,0 +1,14 @@
+package com.haejwo.tripcometrue.domain.member.exception;
+
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class UserInvalidAccessException extends ApplicationException {
+
+ private static final ErrorCode ERROR_CODE = ErrorCode.USER_INVALID_ACCESS;
+
+ public UserInvalidAccessException() {
+ super(ERROR_CODE);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/exception/UserNotFoundException.java b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/UserNotFoundException.java
new file mode 100644
index 00000000..94a01b8b
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/exception/UserNotFoundException.java
@@ -0,0 +1,14 @@
+package com.haejwo.tripcometrue.domain.member.exception;
+
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class UserNotFoundException extends ApplicationException {
+
+ private static final ErrorCode ERROR_CODE = ErrorCode.USER_NOT_FOUND;
+
+ public UserNotFoundException() {
+ super(ERROR_CODE);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/facade/MemberReadSearchFacade.java b/src/main/java/com/haejwo/tripcometrue/domain/member/facade/MemberReadSearchFacade.java
new file mode 100644
index 00000000..9d156d4a
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/facade/MemberReadSearchFacade.java
@@ -0,0 +1,146 @@
+package com.haejwo.tripcometrue.domain.member.facade;
+
+import com.haejwo.tripcometrue.domain.member.dto.response.*;
+import com.haejwo.tripcometrue.domain.member.service.MemberReadSearchService;
+import com.haejwo.tripcometrue.domain.triprecord.dto.response.triprecord.TripRecordListItemResponseDto;
+import com.haejwo.tripcometrue.domain.triprecord.dto.response.triprecord_schedule_media.TripRecordScheduleVideoListItemResponseDto;
+import com.haejwo.tripcometrue.domain.triprecord.service.TripRecordScheduleVideoService;
+import com.haejwo.tripcometrue.domain.triprecord.service.TripRecordService;
+import com.haejwo.tripcometrue.global.util.SliceResponseDto;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class MemberReadSearchFacade {
+
+ private final MemberReadSearchService memberReadSearchService;
+ private final TripRecordScheduleVideoService tripRecordScheduleVideoService;
+ private final TripRecordService tripRecordService;
+
+ public MemberSearchResultWithContentResponseDto searchByNicknameResultWithContent(String nickname) {
+ List members = memberReadSearchService.searchByNickname(nickname);
+
+ List memberIds = members
+ .stream()
+ .map(MemberSimpleResponseDto::memberId)
+ .toList();
+
+ List videos = tripRecordScheduleVideoService.getVideosInMemberIds(memberIds);
+ List tripRecords = tripRecordService.findTripRecordsWihMemberInMemberIds(memberIds);
+
+ return MemberSearchResultWithContentResponseDto.builder()
+ .members(members)
+ .videos(videos)
+ .tripRecords(tripRecords)
+ .build();
+ }
+
+ public SliceResponseDto searchByNicknamePagination(String query, Pageable pageable) {
+
+ SliceResponseDto sliceResult = memberReadSearchService.searchByNickname(query, pageable);
+
+ List memberIds = sliceResult.content()
+ .stream()
+ .map(MemberSimpleResponseDto::memberId)
+ .toList();
+
+ Map> tripRecordMap = getGroupByMemberIdTripRecordMap(
+ tripRecordService.findTripRecordsWihMemberInMemberIds(memberIds)
+ );
+
+ Map> videoMap = getGroupByMemberIdVideoMap(
+ tripRecordScheduleVideoService.getVideosInMemberIds(memberIds)
+ );
+
+
+ return SliceResponseDto.builder()
+ .content(
+ sliceResult.content()
+ .stream()
+ .map(dto -> {
+ int tripRecordTotal = (Objects.isNull(tripRecordMap.get(dto.memberId()))) ? 0 : tripRecordMap.get(dto.memberId()).size();
+ int videoTotal = (Objects.isNull(videoMap.get(dto.memberId()))) ? 0 : videoMap.get(dto.memberId()).size();
+
+ return MemberDetailListItemResponseDto.of(dto, tripRecordTotal, videoTotal);
+ }
+ )
+ .toList()
+ )
+ .totalCount(sliceResult.totalCount())
+ .currentPageNum(sliceResult.currentPageNum())
+ .pageSize(sliceResult.pageSize())
+ .sort(sliceResult.sort())
+ .first(sliceResult.first())
+ .last(sliceResult.last())
+ .build();
+ }
+
+ public MemberCreatorInfoResponseDto getCreatorInfo(Long memberId) {
+ MemberSimpleResponseDto memberInfo = memberReadSearchService.getMemberSimpleInfo(memberId);
+
+ List videos = tripRecordScheduleVideoService.getVideosInMemberIds(List.of(memberId));
+ List tripRecords = tripRecordService.findTripRecordsWihMemberInMemberIds(List.of(memberId));
+
+ return MemberCreatorInfoResponseDto.builder()
+ .memberDetailInfo(
+ MemberDetailListItemResponseDto.of(
+ memberInfo, tripRecords.size(), videos.size()
+ )
+ )
+ .videos(videos)
+ .tripRecords(tripRecords)
+ .build();
+ }
+
+ public List listTopMemberCreators() {
+ List memberSimpleInfos = memberReadSearchService.listTopMemberSimpleInfos();
+ log.info("memberSimpleInfo");
+
+
+ Map> tripRecordMap = getGroupByMemberIdTripRecordMap(
+ tripRecordService.findTripRecordsWihMemberInMemberIds(
+ memberSimpleInfos
+ .stream()
+ .map(MemberSimpleResponseDto::memberId)
+ .toList()
+ )
+ );
+
+ return memberSimpleInfos.stream()
+ .map(memberInfo -> MemberInfoWithTripRecordsResponseDto.builder()
+ .memberInfo(memberInfo)
+ .tripRecords(
+ Objects.nonNull(tripRecordMap.get(memberInfo.memberId())) ?
+ tripRecordMap.get(memberInfo.memberId()) : new ArrayList<>()
+ )
+ .build()
+ )
+ .toList();
+ }
+
+ private Map> getGroupByMemberIdVideoMap(
+ List tripRecordScheduleVideos
+ ) {
+ return tripRecordScheduleVideos
+ .stream()
+ .collect(Collectors.groupingBy(TripRecordScheduleVideoListItemResponseDto::memberId));
+ }
+
+ private Map> getGroupByMemberIdTripRecordMap(
+ List tripRecords
+ ) {
+ return tripRecords
+ .stream()
+ .collect(Collectors.groupingBy(TripRecordListItemResponseDto::memberId));
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/repository/MemberRepository.java b/src/main/java/com/haejwo/tripcometrue/domain/member/repository/MemberRepository.java
index d762be18..00f91e11 100644
--- a/src/main/java/com/haejwo/tripcometrue/domain/member/repository/MemberRepository.java
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/repository/MemberRepository.java
@@ -4,8 +4,12 @@
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
-public interface MemberRepository extends JpaRepository {
+public interface MemberRepository
+ extends JpaRepository, MemberRepositoryCustom {
Optional findByMemberBaseEmail(String email);
+ Optional findByMemberBaseEmailAndProvider(String email, String provider);
+
+ Optional findByMemberBaseNickname(String nickname);
}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/repository/MemberRepositoryCustom.java b/src/main/java/com/haejwo/tripcometrue/domain/member/repository/MemberRepositoryCustom.java
new file mode 100644
index 00000000..daca45af
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/repository/MemberRepositoryCustom.java
@@ -0,0 +1,14 @@
+package com.haejwo.tripcometrue.domain.member.repository;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+
+import java.util.List;
+
+public interface MemberRepositoryCustom {
+
+ List findByNicknameOrderByMemberRating(String nickname);
+
+ Slice findByNicknameOrderByMemberRating(String nickname, Pageable pageable);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/repository/MemberRepositoryImpl.java b/src/main/java/com/haejwo/tripcometrue/domain/member/repository/MemberRepositoryImpl.java
new file mode 100644
index 00000000..ac2f2d2a
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/repository/MemberRepositoryImpl.java
@@ -0,0 +1,68 @@
+package com.haejwo.tripcometrue.domain.member.repository;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.core.types.dsl.Expressions;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.SliceImpl;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+
+import static com.haejwo.tripcometrue.domain.member.entity.QMember.member;
+
+@RequiredArgsConstructor
+public class MemberRepositoryImpl implements MemberRepositoryCustom {
+
+ private final JPAQueryFactory queryFactory;
+
+ @Override
+ public List findByNicknameOrderByMemberRating(String nickname) {
+ return queryFactory
+ .selectFrom(member)
+ .where(
+ containsIgnoreCaseNickname(nickname)
+ )
+ .orderBy(member.memberRating.desc())
+ .fetch();
+ }
+
+ @Override
+ public Slice findByNicknameOrderByMemberRating(String nickname, Pageable pageable) {
+
+ int pageSize = pageable.getPageSize();
+
+ List content = queryFactory
+ .selectFrom(member)
+ .where(
+ containsIgnoreCaseNickname(nickname)
+ )
+ .orderBy(member.memberRating.desc())
+ .offset(pageable.getOffset())
+ .limit(pageSize + 1)
+ .fetch();
+
+ boolean hasNext = false;
+ if (content.size() > pageSize) {
+ content.remove(pageSize);
+ hasNext = true;
+ }
+
+ return new SliceImpl<>(content, pageable, hasNext);
+ }
+
+ private BooleanExpression containsIgnoreCaseNickname(String nickname) {
+
+ if (!StringUtils.hasText(nickname)) {
+ return null;
+ }
+
+ String replacedNickname = nickname.replaceAll(" ", "");
+
+ return Expressions.stringTemplate("function('replace',{0},{1},{2})", member.memberBase.nickname, " ", "")
+ .containsIgnoreCase(replacedNickname);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/service/MemberReadSearchService.java b/src/main/java/com/haejwo/tripcometrue/domain/member/service/MemberReadSearchService.java
new file mode 100644
index 00000000..ea547d8b
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/service/MemberReadSearchService.java
@@ -0,0 +1,69 @@
+package com.haejwo.tripcometrue.domain.member.service;
+
+import com.haejwo.tripcometrue.domain.member.dto.response.MemberSimpleResponseDto;
+import com.haejwo.tripcometrue.domain.member.exception.UserNotFoundException;
+import com.haejwo.tripcometrue.domain.member.repository.MemberRepository;
+import com.haejwo.tripcometrue.domain.triprecordViewHistory.repository.TripRecordViewHistoryRepository;
+import com.haejwo.tripcometrue.global.util.SliceResponseDto;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+@RequiredArgsConstructor
+@Service
+public class MemberReadSearchService {
+
+ private final MemberRepository memberRepository;
+ private final TripRecordViewHistoryRepository tripRecordViewHistoryRepository;
+ private static final int HOME_TOP_CREATORS_SIZE = 10;
+
+ @Transactional(readOnly = true)
+ public MemberSimpleResponseDto getMemberSimpleInfo(Long memberId) {
+ return MemberSimpleResponseDto.fromEntity(
+ memberRepository.findById(memberId)
+ .orElseThrow(UserNotFoundException::new)
+ );
+ }
+
+ @Transactional(readOnly = true)
+ public List listTopMemberSimpleInfos() {
+ LocalDate current = LocalDate.now();
+ LocalDateTime start = current.minusDays(3).atStartOfDay();
+ LocalDateTime end = current.minusDays(1).atTime(23, 59, 59);
+
+ return tripRecordViewHistoryRepository
+ .findTopListMembers(start, end, HOME_TOP_CREATORS_SIZE)
+ .stream()
+ .map(dto -> MemberSimpleResponseDto.builder()
+ .memberId(dto.memberId())
+ .nickname(dto.nickname())
+ .introduction(dto.introduction())
+ .profileImageUrl(dto.profileImageUrl())
+ .build()
+ ).toList();
+ }
+
+ @Transactional(readOnly = true)
+ public List searchByNickname(String nickname) {
+ return memberRepository
+ .findByNicknameOrderByMemberRating(nickname)
+ .stream()
+ .map(MemberSimpleResponseDto::fromEntity)
+ .toList();
+ }
+
+ @Transactional(readOnly = true)
+ public SliceResponseDto searchByNickname(String nickname, Pageable pageable) {
+
+ return SliceResponseDto.of(
+ memberRepository
+ .findByNicknameOrderByMemberRating(nickname, pageable)
+ .map(MemberSimpleResponseDto::fromEntity)
+ );
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/member/service/MemberService.java b/src/main/java/com/haejwo/tripcometrue/domain/member/service/MemberService.java
index 0d813f05..e0ee0785 100644
--- a/src/main/java/com/haejwo/tripcometrue/domain/member/service/MemberService.java
+++ b/src/main/java/com/haejwo/tripcometrue/domain/member/service/MemberService.java
@@ -1,11 +1,20 @@
package com.haejwo.tripcometrue.domain.member.service;
+import com.haejwo.tripcometrue.domain.member.dto.request.IntroductionRequestDto;
+import com.haejwo.tripcometrue.domain.member.dto.request.NicknameRequestDto;
+import com.haejwo.tripcometrue.domain.member.dto.request.PasswordRequestDto;
+import com.haejwo.tripcometrue.domain.member.dto.request.ProfileImageRequestDto;
+import com.haejwo.tripcometrue.domain.member.dto.request.SignUpRequestDto;
+import com.haejwo.tripcometrue.domain.member.dto.response.*;
import com.haejwo.tripcometrue.domain.member.entity.Member;
-import com.haejwo.tripcometrue.domain.member.exception.EmailDuplicateException;
+import com.haejwo.tripcometrue.domain.member.exception.*;
import com.haejwo.tripcometrue.domain.member.repository.MemberRepository;
-import com.haejwo.tripcometrue.domain.member.request.SignUpRequest;
-import com.haejwo.tripcometrue.domain.member.response.SignUpResponse;
-import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@@ -19,16 +28,151 @@ public class MemberService {
private final MemberRepository memberRepository;
private final BCryptPasswordEncoder passwordEncoder;
- public ResponseDTO signup(SignUpRequest signUpRequest) {
+ public SignUpResponseDto signup(SignUpRequestDto signUpRequestDto) {
- memberRepository.findByMemberBaseEmail(signUpRequest.email()).ifPresent(user -> {
+ memberRepository.findByMemberBaseEmail(signUpRequestDto.email()).ifPresent(user -> {
throw new EmailDuplicateException();
});
- String encodedPassword = passwordEncoder.encode(signUpRequest.password());
+ String encodedPassword = passwordEncoder.encode(signUpRequestDto.password());
- Member newMember = signUpRequest.toEntity(encodedPassword);
+ Member newMember = signUpRequestDto.toEntity(encodedPassword, generateName());
memberRepository.save(newMember);
- return ResponseDTO.okWithData(SignUpResponse.fromEntity(newMember));
+ return SignUpResponseDto.fromEntity(newMember);
}
-}
\ No newline at end of file
+
+ public void checkDuplicateEmail(String email) {
+ memberRepository.findByMemberBaseEmail(email).ifPresent(user -> {
+ throw new EmailDuplicateException();
+ });
+ }
+
+ public String generateName() {
+ List first = Arrays.asList("์์ ๋ก์ด", "์์ดํ",
+ "๋น๋นํ", "๋ฐฐ๋ถ๋ฅธ", "์์ค์", "๋ฉ์๋",
+ "์ฉ๊ธฐ์๋", "์ฌ์ฌํ", "์์๊ธด", "์ด์", "๋์์์น๋", "ํ๋ณตํ", "์ฌ๋์ค๋ฌ์ด", "์์ํ");
+ List name = Arrays.asList("์ฌ์", "์ฝ๋ผ๋ฆฌ", "ํธ๋์ด", "๊ณฐ", "์ฌ์ฐ", "๋๋", "๋๊ตฌ๋ฆฌ",
+ "์ฐธ์", "๊ณ ์ด๋์น", "๊ฐ์์ง", "๊ณ ์์ด", "๊ฑฐ๋ถ์ด", "ํ ๋ผ", "์ต๋ฌด์", "ํ์ด์๋", "ํญ๊ท", "ํ๋ง",
+ "์ผ๋ฃฉ๋ง", "์นํ", "์
์ด", "๊ธฐ๋ฆฐ", "์๋ฌ", "์ผ์", "๋ค๋์ฅ", "ํ๋ค", "์ฝ์๋ผ", "์ต๋ฌด์", "๋
์๋ฆฌ", "์ํ์นด");
+ Collections.shuffle(first);
+ Collections.shuffle(name);
+ return first.get(0) + name.get(0);
+ }
+
+ public void changePassword(
+ PrincipalDetails principalDetails, PasswordRequestDto passwordRequestDto) {
+ Member member = getLoginMember(principalDetails);
+
+ String currentPassword = member.getMemberBase().getPassword();
+ String newPassword = passwordRequestDto.newPassword();
+
+ //์ ๋น๋ฐ๋ฒํธ, ํ์ฌ ๋น๋ฐ๋ฒํธ์ ๋์ผ์ฌ๋ถ ์ต์ข
๊ฒ์ฆ
+ if (passwordEncoder.matches(newPassword, currentPassword)) {
+ throw new NewPasswordSameAsOldException();
+ }
+
+ //์๋น๋ฐ๋ฒํธ ์ฌ์
๋ ฅ๊ฐ ๋์ผ์ฌ๋ถ ์ต์ข
๊ฒ์ฆ
+ if (!passwordRequestDto.newPassword().equals(passwordRequestDto.confirmPassword())) {
+ throw new NewPasswordNotMatchException();
+ }
+
+ String encodedNewPassword = passwordEncoder.encode(newPassword);
+ member.getMemberBase().changePassword(encodedNewPassword);
+ memberRepository.save(member);
+ }
+
+ public void checkPassword(
+ PrincipalDetails principalDetails, PasswordRequestDto passwordRequestDto) {
+ Member member = principalDetails.getMember();
+ String currentPassword = member.getMemberBase().getPassword();
+ String inPuttedCurrentPassword = passwordRequestDto.currentPassword();
+ String newPassword = passwordRequestDto.newPassword();
+
+ // ํ์ฌ ๋น๋ฐ๋ฒํธ๋ง ์
๋ ฅ๋ ๊ฒฝ์ฐ
+ if (passwordRequestDto.currentPassword() != null
+ && passwordRequestDto.newPassword() == null) {
+ if (!passwordEncoder.matches(passwordRequestDto.currentPassword(), currentPassword)) {
+ throw new CurrentPasswordNotMatchException();
+ }
+ }
+
+ // ์ ๋น๋ฐ๋ฒํธ๋ง ์
๋ ฅ๋ ๊ฒฝ์ฐ
+ if (passwordRequestDto.newPassword() != null
+ && passwordRequestDto.currentPassword() == null) {
+ if (passwordEncoder.matches(passwordRequestDto.newPassword(), currentPassword)) {
+ throw new NewPasswordSameAsOldException();
+ }
+ }
+
+ // ์ ๋น๋ฐ๋ฒํธ ํ์ธ์ด ์
๋ ฅ๋ ๊ฒฝ์ฐ
+ if (passwordRequestDto.confirmPassword() != null) {
+ if (!passwordRequestDto.newPassword().equals(passwordRequestDto.confirmPassword())) {
+ throw new NewPasswordNotMatchException();
+ }
+ }
+ }
+
+ public ProfileImageResponseDto updateProfileImage(
+ PrincipalDetails principalDetails, ProfileImageRequestDto requestDto) {
+ Member member = getLoginMember(principalDetails);
+
+ member.updateProfileImage(requestDto.profile_image());
+ memberRepository.save(member);
+
+ return ProfileImageResponseDto.fromEntity(member);
+ }
+
+ public IntroductionResponseDto updateIntroduction(
+ PrincipalDetails principalDetails, IntroductionRequestDto requestDto) {
+ if (requestDto.introduction().length() > 20) {
+ throw new IntroductionLengthExceededException();
+ }
+
+ Member member = getLoginMember(principalDetails);
+ member.updateIntroduction(requestDto.introduction());
+
+ return IntroductionResponseDto.fromEntity(member);
+ }
+
+ public NicknameResponseDto updateNickname(
+ PrincipalDetails principalDetails, NicknameRequestDto requestDto) {
+ Member member = memberRepository.findById(principalDetails.getMember().getId())
+ .orElseThrow();
+
+ memberRepository.findByMemberBaseNickname(requestDto.nickname())
+ .ifPresent(existingMember -> {
+ throw new NicknameAlreadyExistsException();
+ });
+
+ if(member.getNickNameChangeTime() != null &&
+ ChronoUnit.MONTHS.between(member.getNickNameChangeTime(), LocalDateTime.now()) < 6){
+ throw new NicknameChangeNotAvailableException();
+ }
+
+ member.getMemberBase().changeNickname(requestDto.nickname());
+ member.updateNickNameChangeCount();
+ member.updateNickNameChangeTime(LocalDateTime.now());
+
+ return NicknameResponseDto.fromEntity(member);
+ }
+
+ public void deleteAccount(PrincipalDetails principalDetails) {
+
+ Member member = memberRepository.findById(principalDetails.getMember().getId())
+ .orElseThrow();
+
+ memberRepository.delete(member);
+ }
+
+ public Member getLoginMember(PrincipalDetails principalDetails) {
+ Member member = principalDetails.getMember();
+ return member;
+ }
+
+ public MemberDetailResponseDto getMemberDetails(PrincipalDetails principalDetails) {
+ Member member = getLoginMember(principalDetails);
+ return MemberDetailResponseDto.fromEntity(member);
+ }
+
+}
+
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/controller/PlaceController.java b/src/main/java/com/haejwo/tripcometrue/domain/place/controller/PlaceController.java
new file mode 100644
index 00000000..96994cab
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/controller/PlaceController.java
@@ -0,0 +1,154 @@
+package com.haejwo.tripcometrue.domain.place.controller;
+
+import com.haejwo.tripcometrue.domain.place.dto.request.PlaceRequestDto;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceListItemResponseDto;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceMapInfoResponseDto;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceNearbyResponseDto;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceResponseDto;
+import com.haejwo.tripcometrue.domain.place.service.PlaceService;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import java.util.List;
+
+import com.haejwo.tripcometrue.global.util.SliceResponseDto;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.data.web.SortDefault;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/v1/places")
+@RequiredArgsConstructor
+public class PlaceController {
+
+ private final PlaceService placeService;
+
+ @PostMapping
+ public ResponseEntity> placeAdd(
+ @RequestBody PlaceRequestDto requestDto
+ ) {
+
+ PlaceResponseDto responseDto = placeService.addPlace(requestDto);
+ ResponseDTO responseBody = ResponseDTO.okWithData(responseDto);
+
+ return ResponseEntity
+ .status(responseBody.getCode())
+ .body(responseBody);
+ }
+
+ @GetMapping("/{placeId}")
+ public ResponseEntity> placeDetails(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long placeId
+ ) {
+
+ PlaceResponseDto responseDto = placeService.findPlace(principalDetails, placeId);
+ ResponseDTO responseBody = ResponseDTO.okWithData(responseDto);
+
+ return ResponseEntity
+ .status(responseBody.getCode())
+ .body(responseBody);
+ }
+
+ @GetMapping("/{placeId}/maplist")
+ public ResponseEntity>> placeMapInfoList(
+ @PathVariable Long placeId
+ ) {
+
+ List responseDtos = placeService.findPlaceMapInfoList(placeId);
+
+ ResponseDTO> responseBody = ResponseDTO.okWithData(responseDtos);
+
+ return ResponseEntity
+ .status(responseBody.getCode())
+ .body(responseBody);
+ }
+
+ @GetMapping("/{placeId}/nearby")
+ public ResponseEntity>> placeNearbyList(
+ @PathVariable Long placeId
+ ) {
+
+ List responseDtos = placeService.findNearbyPlaceList(placeId);
+
+ ResponseDTO> responseBody = ResponseDTO.okWithData(responseDtos);
+
+ return ResponseEntity.status(responseBody
+ .getCode())
+ .body(responseBody);
+ }
+
+ @GetMapping
+ public ResponseEntity>> placeList(
+ Pageable pageable,
+ @RequestParam Integer storedCount
+ ) {
+
+ Page placePage = placeService.findPlaces(pageable, storedCount);
+
+ ResponseDTO responseBody = ResponseDTO.okWithData(placePage);
+
+ return ResponseEntity
+ .status(responseBody.getCode())
+ .body(responseBody);
+ }
+
+ @GetMapping("/search")
+ public ResponseEntity>> searchPlacesByName(
+ @RequestParam("placeName") String placeName,
+ @PageableDefault
+ @SortDefault.SortDefaults({
+ @SortDefault(sort = "storedCount", direction = Sort.Direction.DESC),
+ @SortDefault(sort = "commentCount", direction = Sort.Direction.DESC)})
+ Pageable pageable
+ ) {
+ return ResponseEntity
+ .ok()
+ .body(
+ ResponseDTO.okWithData(
+ placeService.listPlacesByName(placeName, pageable)
+ )
+ );
+ }
+
+ @PutMapping("/{placeId}")
+ public ResponseEntity> placeModify(
+ @PathVariable Long placeId,
+ @RequestBody PlaceRequestDto requestDto
+ ) {
+
+ PlaceResponseDto responseDto = placeService.modifyPlace(placeId, requestDto);
+ ResponseDTO responseBody = ResponseDTO.okWithData(responseDto);
+
+ return ResponseEntity
+ .status(responseBody.getCode())
+ .body(responseBody);
+ }
+
+ @DeleteMapping("/{placeId}")
+ public ResponseEntity placeRemove(
+ @PathVariable Long placeId
+ ) {
+
+ placeService.removePlace(placeId);
+ ResponseDTO responseBody = ResponseDTO.ok();
+
+ return ResponseEntity
+ .status(responseBody.getCode())
+ .body(responseBody);
+ }
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/controller/PlaceControllerAdvice.java b/src/main/java/com/haejwo/tripcometrue/domain/place/controller/PlaceControllerAdvice.java
new file mode 100644
index 00000000..9f51a461
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/controller/PlaceControllerAdvice.java
@@ -0,0 +1,29 @@
+package com.haejwo.tripcometrue.domain.place.controller;
+
+import com.haejwo.tripcometrue.domain.place.exception.PlaceNotFoundException;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class PlaceControllerAdvice {
+
+ @ExceptionHandler(PlaceNotFoundException.class)
+ public ResponseEntity> placeNotFoundExceptionHandler(
+ PlaceNotFoundException e
+ ) {
+ HttpStatus status = e.getErrorCode().getHttpStatus();
+
+ return ResponseEntity
+ .status(status)
+ .body(ResponseDTO.errorWithMessage(status, e.getMessage()));
+
+
+ }
+
+
+
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/dto/PlaceDto.java b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/PlaceDto.java
new file mode 100644
index 00000000..237e2f49
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/PlaceDto.java
@@ -0,0 +1,38 @@
+package com.haejwo.tripcometrue.domain.place.dto;
+
+import java.time.LocalTime;
+import lombok.Builder;
+
+public record PlaceDto(
+ Long id,
+ String name,
+ String address,
+ String description,
+ LocalTime weekdayOpenTime,
+ LocalTime weekdayCloseTime,
+ LocalTime weekendOpenTime,
+ LocalTime weekendCloseTime,
+ Integer storedCount) {
+
+ @Builder
+ public PlaceDto(
+ Long id,
+ String name,
+ String address,
+ String description,
+ LocalTime weekdayOpenTime,
+ LocalTime weekdayCloseTime,
+ LocalTime weekendOpenTime,
+ LocalTime weekendCloseTime,
+ Integer storedCount) {
+ this.id = id;
+ this.name = name;
+ this.address = address;
+ this.description = description;
+ this.weekdayOpenTime = weekdayOpenTime;
+ this.weekdayCloseTime = weekdayCloseTime;
+ this.weekendOpenTime = weekendOpenTime;
+ this.weekendCloseTime = weekendCloseTime;
+ this.storedCount = storedCount;
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/dto/request/PlaceFilterRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/request/PlaceFilterRequestDto.java
new file mode 100644
index 00000000..adbafccf
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/request/PlaceFilterRequestDto.java
@@ -0,0 +1,13 @@
+package com.haejwo.tripcometrue.domain.place.dto.request;
+
+public record PlaceFilterRequestDto(
+ Integer stored_count,
+ Integer storedCount
+) {
+
+ // record๋ Compact Constructor๋ผ๋ ๊ธฐ๋ฅ์์ด, ์์ฑ์ ๋ด๋ถ์ ๋ณ์์ ๋ํ ๋ก์ง์ด ๋ง์ง๋ง์ผ๋ก ๋์ํ์ฌ ๋ณ์ ์ด๊ธฐํ๋ฅผ ํ๋ค.
+ public PlaceFilterRequestDto {
+ storedCount = stored_count;
+ }
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/dto/request/PlaceRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/request/PlaceRequestDto.java
new file mode 100644
index 00000000..bb444f89
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/request/PlaceRequestDto.java
@@ -0,0 +1,58 @@
+package com.haejwo.tripcometrue.domain.place.dto.request;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import java.time.LocalTime;
+import lombok.Builder;
+
+public record PlaceRequestDto(
+ String name,
+ String address,
+ String description,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime weekdayOpenTime,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime weekdayCloseTime,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime weekendOpenTime,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime weekendCloseTime,
+ Integer storedCount,
+ Long cityId
+) {
+
+ @Builder
+ public PlaceRequestDto(
+ String name,
+ String address,
+ String description,
+ LocalTime weekdayOpenTime,
+ LocalTime weekdayCloseTime,
+ LocalTime weekendOpenTime,
+ LocalTime weekendCloseTime,
+ Integer storedCount,
+ Long cityId
+ ) {
+ this.name = name;
+ this.address = address;
+ this.description = description;
+ this.weekdayOpenTime = weekdayOpenTime;
+ this.weekdayCloseTime = weekdayCloseTime;
+ this.weekendOpenTime = weekendOpenTime;
+ this.weekendCloseTime = weekendCloseTime;
+ this.storedCount = storedCount;
+ this.cityId = cityId;
+ }
+
+ public Place toEntity(City city) {
+ return Place.builder()
+ .name(this.name)
+ .address(this.address)
+ .description(this.description)
+ .weekdayOpenTime(this.weekdayOpenTime)
+ .weekdayCloseTime(this.weekdayCloseTime)
+ .weekendOpenTime(this.weekendOpenTime)
+ .weekendCloseTime(this.weekendCloseTime)
+ .storedCount(this.storedCount)
+ .city(city)
+ .build();
+ }
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceListItemResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceListItemResponseDto.java
new file mode 100644
index 00000000..23931849
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceListItemResponseDto.java
@@ -0,0 +1,44 @@
+package com.haejwo.tripcometrue.domain.place.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import lombok.Builder;
+
+import java.time.LocalTime;
+
+public record PlaceListItemResponseDto(
+ Long placeId,
+ String placeName,
+ String address,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
+ LocalTime weekdayOpenTime,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
+ LocalTime weekdayCloseTime,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
+ LocalTime weekendOpenTime,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
+ LocalTime weekendCloseTime,
+ String telNumber,
+ String cityName,
+ String imageUrl
+) {
+
+ @Builder
+ public PlaceListItemResponseDto {
+ }
+
+ public static PlaceListItemResponseDto fromEntity(Place entity, String imageUrl) {
+ return PlaceListItemResponseDto.builder()
+ .placeId(entity.getId())
+ .placeName(entity.getName())
+ .address(entity.getAddress())
+ .weekdayOpenTime(entity.getWeekdayOpenTime())
+ .weekdayCloseTime(entity.getWeekdayCloseTime())
+ .weekendOpenTime(entity.getWeekendOpenTime())
+ .weekendCloseTime(entity.getWeekendCloseTime())
+ .telNumber(entity.getPhoneNumber())
+ .cityName(entity.getCity().getName())
+ .imageUrl(imageUrl)
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceMapInfoResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceMapInfoResponseDto.java
new file mode 100644
index 00000000..aebba7fc
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceMapInfoResponseDto.java
@@ -0,0 +1,46 @@
+package com.haejwo.tripcometrue.domain.place.dto.response;
+
+import lombok.Builder;
+
+public record PlaceMapInfoResponseDto(
+ Long placeId,
+ String placeName,
+ Double latitude,
+ Double longitude,
+ Integer storeCount,
+ Integer commentCount,
+ String imageUrl
+
+
+) {
+
+ @Builder
+ public PlaceMapInfoResponseDto(Long placeId, String placeName, Double latitude,
+ Double longitude,
+ Integer storeCount, Integer commentCount, String imageUrl) {
+ this.placeId = placeId;
+ this.placeName = placeName;
+ this.latitude = latitude;
+ this.longitude = longitude;
+ this.storeCount = storeCount;
+ this.commentCount = commentCount;
+ this.imageUrl = imageUrl;
+ }
+
+
+// public static PlaceMapInfoResponseDto fromEntity(Place entity) {
+// return PlaceMapInfoResponseDto.builder()
+// .placeId(entity.getId())
+// .placeName(entity.getName())
+// .latitude(entity.getLatitude())
+// .longitude(entity.getLongitude())
+// .storeCount()
+// .commentCount()
+// .build();
+// }
+
+
+
+
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceNearbyResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceNearbyResponseDto.java
new file mode 100644
index 00000000..3282585b
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceNearbyResponseDto.java
@@ -0,0 +1,28 @@
+package com.haejwo.tripcometrue.domain.place.dto.response;
+
+import lombok.Builder;
+
+public record PlaceNearbyResponseDto(
+ Long placeId,
+ String placeName,
+ String imageUrl,
+ Double latitude,
+ Double longitude,
+ Integer storedCount,
+ Integer reviewCount,
+ Integer commentCount
+) {
+
+ @Builder
+ public PlaceNearbyResponseDto(Long placeId, String placeName, String imageUrl, Double latitude,
+ Double longitude, Integer storedCount, Integer reviewCount, Integer commentCount) {
+ this.placeId = placeId;
+ this.placeName = placeName;
+ this.imageUrl = imageUrl;
+ this.latitude = latitude;
+ this.longitude = longitude;
+ this.storedCount = storedCount;
+ this.reviewCount = reviewCount;
+ this.commentCount = commentCount;
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceResponseDto.java
new file mode 100644
index 00000000..9e70c2a0
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/dto/response/PlaceResponseDto.java
@@ -0,0 +1,86 @@
+package com.haejwo.tripcometrue.domain.place.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonFormat.Shape;
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import java.time.LocalTime;
+import lombok.Builder;
+public record PlaceResponseDto(
+ Long id,
+ String name,
+ String address,
+ String description,
+ String phoneNumber,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime weekdayOpenTime,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime weekdayCloseTime,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime weekendOpenTime,
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") LocalTime weekendCloseTime,
+ Double latitude,
+ Double longitude,
+ Integer storedCount,
+ Boolean isStored,
+ Long cityId
+) {
+
+ @Builder
+ public PlaceResponseDto(Long id, String name, String address, String description,
+ String phoneNumber,
+ @JsonFormat(shape = Shape.STRING, pattern = "HH:mm") LocalTime weekdayOpenTime,
+ @JsonFormat(shape = Shape.STRING, pattern = "HH:mm") LocalTime weekdayCloseTime,
+ @JsonFormat(shape = Shape.STRING, pattern = "HH:mm") LocalTime weekendOpenTime,
+ @JsonFormat(shape = Shape.STRING, pattern = "HH:mm") LocalTime weekendCloseTime,
+ Double latitude, Double longitude, Integer storedCount, Boolean isStored, Long cityId) {
+ this.id = id;
+ this.name = name;
+ this.address = address;
+ this.description = description;
+ this.phoneNumber = phoneNumber;
+ this.weekdayOpenTime = weekdayOpenTime;
+ this.weekdayCloseTime = weekdayCloseTime;
+ this.weekendOpenTime = weekendOpenTime;
+ this.weekendCloseTime = weekendCloseTime;
+ this.latitude = latitude;
+ this.longitude = longitude;
+ this.storedCount = storedCount;
+ this.isStored = isStored;
+ this.cityId = cityId;
+ }
+
+ public static PlaceResponseDto fromEntity(Place entity) {
+ return PlaceResponseDto.builder()
+ .id(entity.getId())
+ .name(entity.getName())
+ .address(entity.getAddress())
+ .description(entity.getDescription())
+ .phoneNumber(entity.getPhoneNumber())
+ .weekdayOpenTime(entity.getWeekdayOpenTime())
+ .weekdayCloseTime(entity.getWeekdayCloseTime())
+ .weekendOpenTime(entity.getWeekendOpenTime())
+ .weekendCloseTime(entity.getWeekendCloseTime())
+ .latitude(entity.getLatitude())
+ .longitude(entity.getLongitude())
+ .storedCount(entity.getStoredCount())
+ .cityId(entity.getCity().getId())
+ .build();
+ }
+
+ public static PlaceResponseDto fromEntity(Place entity, Boolean isStored) {
+ return PlaceResponseDto.builder()
+ .id(entity.getId())
+ .name(entity.getName())
+ .address(entity.getAddress())
+ .description(entity.getDescription())
+ .phoneNumber(entity.getPhoneNumber())
+ .weekdayOpenTime(entity.getWeekdayOpenTime())
+ .weekdayCloseTime(entity.getWeekdayCloseTime())
+ .weekendOpenTime(entity.getWeekendOpenTime())
+ .weekendCloseTime(entity.getWeekendCloseTime())
+ .latitude(entity.getLatitude())
+ .longitude(entity.getLongitude())
+ .storedCount(entity.getStoredCount())
+ .isStored(isStored)
+ .cityId(entity.getCity().getId())
+ .build();
+ }
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/entity/Place.java b/src/main/java/com/haejwo/tripcometrue/domain/place/entity/Place.java
new file mode 100644
index 00000000..3322855c
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/entity/Place.java
@@ -0,0 +1,115 @@
+package com.haejwo.tripcometrue.domain.place.entity;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.domain.place.dto.request.PlaceRequestDto;
+import com.haejwo.tripcometrue.global.entity.BaseTimeEntity;
+import jakarta.persistence.*;
+
+import java.time.LocalTime;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Place extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "place_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private String name;
+ @Column(nullable = false)
+ private String address;
+ private String description;
+ private String phoneNumber;
+ private LocalTime weekdayOpenTime;
+ private LocalTime weekdayCloseTime;
+ private LocalTime weekendOpenTime;
+ private LocalTime weekendCloseTime;
+ private Double latitude;
+ private Double longitude;
+
+ private Integer storedCount;
+ private Integer reviewCount;
+ private Integer commentCount;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "city_id")
+ private City city;
+
+ @PrePersist
+ public void prePersist() {
+ this.storedCount = this.storedCount == null ? 0 : storedCount;
+ this.reviewCount = 0;
+ this.commentCount = 0;
+ }
+
+ @Builder
+ public Place(Long id, String name, String address, String description, String phoneNumber,
+ LocalTime weekdayOpenTime, LocalTime weekdayCloseTime, LocalTime weekendOpenTime,
+ LocalTime weekendCloseTime, Double latitude, Double longitude, Integer storedCount,
+ Integer reviewCount, Integer commentCount, City city) {
+ this.id = id;
+ this.name = name;
+ this.address = address;
+ this.description = description;
+ this.phoneNumber = phoneNumber;
+ this.weekdayOpenTime = weekdayOpenTime;
+ this.weekdayCloseTime = weekdayCloseTime;
+ this.weekendOpenTime = weekendOpenTime;
+ this.weekendCloseTime = weekendCloseTime;
+ this.latitude = latitude;
+ this.longitude = longitude;
+ this.storedCount = storedCount;
+ this.reviewCount = reviewCount;
+ this.commentCount = commentCount;
+ this.city = city;
+ }
+
+ public void update(PlaceRequestDto requestDto) {
+ this.name = requestDto.name();
+ this.address = requestDto.address();
+ this.description = requestDto.description();
+ this.weekdayOpenTime = requestDto.weekdayOpenTime();
+ this.weekdayCloseTime = requestDto.weekdayCloseTime();
+ this.weekendOpenTime = requestDto.weekendOpenTime();
+ this.weekendCloseTime = requestDto.weekendCloseTime();
+ this.storedCount = requestDto.storedCount();
+ }
+
+ public void incrementStoreCount() {
+ if(this.storedCount == null) {
+ this.storedCount = 1;
+ } else {
+ this.storedCount++;
+ }
+ }
+
+ public void decrementStoreCount() {
+ if(this.storedCount > 0) {
+ this.storedCount--;
+ }
+ }
+
+ public void incrementReviewCount() {
+ if(this.reviewCount == null) {
+ this.reviewCount = 1;
+ } else {
+ this.reviewCount++;
+ }
+ }
+
+ public void incrementCommentCount() {
+ if(this.commentCount == null) {
+ this.commentCount = 1;
+ } else {
+ this.commentCount++;
+ }
+ }
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/exception/PlaceNotFoundException.java b/src/main/java/com/haejwo/tripcometrue/domain/place/exception/PlaceNotFoundException.java
new file mode 100644
index 00000000..70ed7998
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/exception/PlaceNotFoundException.java
@@ -0,0 +1,13 @@
+package com.haejwo.tripcometrue.domain.place.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class PlaceNotFoundException extends ApplicationException {
+ private static final ErrorCode ERROR_CODE = ErrorCode.PLACE_NOT_FOUND;
+
+ public PlaceNotFoundException() {
+ super(ERROR_CODE);
+ }
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/repositroy/PlaceRepository.java b/src/main/java/com/haejwo/tripcometrue/domain/place/repositroy/PlaceRepository.java
new file mode 100644
index 00000000..11b25a21
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/repositroy/PlaceRepository.java
@@ -0,0 +1,11 @@
+package com.haejwo.tripcometrue.domain.place.repositroy;
+
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PlaceRepository extends
+ JpaRepository, PlaceRepositoryCustom
+{
+ List findByCityId(Long cityId);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/repositroy/PlaceRepositoryCustom.java b/src/main/java/com/haejwo/tripcometrue/domain/place/repositroy/PlaceRepositoryCustom.java
new file mode 100644
index 00000000..de1eb6d9
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/repositroy/PlaceRepositoryCustom.java
@@ -0,0 +1,27 @@
+package com.haejwo.tripcometrue.domain.place.repositroy;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceMapInfoResponseDto;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceNearbyResponseDto;
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+
+import java.util.List;
+
+public interface PlaceRepositoryCustom {
+
+ Page findPlaceWithFilter(Pageable pageable,
+ Integer storedCount);
+
+ Slice findPlacesByCityIdAndPlaceName(Long cityId, String placeName, Pageable pageable);
+
+ Slice findPlacesWithCityByPlaceName(String placeName, Pageable pageable);
+
+ List findPlacesByCityAndOrderByStoredCountLimitSize(City city, int size);
+
+ List findPlaceMapInfoListByPlaceId(Long placeId);
+
+ List findNearbyPlaces(Long placeId);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/repositroy/PlaceRepositoryImpl.java b/src/main/java/com/haejwo/tripcometrue/domain/place/repositroy/PlaceRepositoryImpl.java
new file mode 100644
index 00000000..0872294f
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/repositroy/PlaceRepositoryImpl.java
@@ -0,0 +1,281 @@
+package com.haejwo.tripcometrue.domain.place.repositroy;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.domain.city.entity.QCity;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceMapInfoResponseDto;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceNearbyResponseDto;
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import com.haejwo.tripcometrue.domain.place.entity.QPlace;
+import com.haejwo.tripcometrue.domain.triprecord.entity.QTripRecordSchedule;
+import com.haejwo.tripcometrue.domain.triprecord.entity.QTripRecordScheduleImage;
+import com.querydsl.core.BooleanBuilder;
+import com.querydsl.core.Tuple;
+import com.querydsl.core.types.NullExpression;
+import com.querydsl.core.types.Order;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.OrderSpecifier.NullHandling;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.core.types.dsl.Expressions;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.SliceImpl;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
+import org.springframework.util.StringUtils;
+
+import static com.haejwo.tripcometrue.domain.city.entity.QCity.city;
+import static com.haejwo.tripcometrue.domain.place.entity.QPlace.place;
+
+public class PlaceRepositoryImpl extends QuerydslRepositorySupport implements PlaceRepositoryCustom {
+
+ private final JPAQueryFactory queryFactory;
+
+ public PlaceRepositoryImpl(JPAQueryFactory queryFactory) {
+ super(Place.class);
+ this.queryFactory = queryFactory;
+ }
+
+ @Override
+ public Page findPlaceWithFilter(Pageable pageable, Integer storedCount) {
+
+ QPlace place = QPlace.place;
+ BooleanBuilder booleanBuilder = new BooleanBuilder();
+
+ if (storedCount != null && storedCount >= 0) {
+ booleanBuilder.and(place.storedCount.goe(storedCount));
+ }
+
+ List result = from(place)
+ .where(booleanBuilder)
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize())
+ .fetch();
+
+ // ํ๋ก ํธ์ Page ์ ๋ณด ํ์ ์ ๋ฌด์ ๋ฐ๋ผ ์๋ต ๊ฐ์ฒด List, Page ๋๋จ
+ long total = from(place).where(booleanBuilder).fetchCount();
+
+ return new PageImpl<>(result, pageable, total);
+
+ }
+
+ @Override
+ public Slice findPlacesByCityIdAndPlaceName(Long cityId, String placeName, Pageable pageable) {
+
+ int pageSize = pageable.getPageSize();
+ List content = queryFactory
+ .selectFrom(place)
+ .join(place.city, city)
+ .where(
+ city.id.eq(cityId),
+ containsIgnoreCasePlaceName(placeName)
+ )
+ .offset(pageable.getOffset())
+ .limit(pageSize + 1)
+ .orderBy(getSort(pageable))
+ .fetch();
+
+ boolean hasNext = false;
+ if (content.size() > pageSize) {
+ content.remove(pageSize);
+ hasNext = true;
+ }
+
+ return new SliceImpl<>(content, pageable, hasNext);
+ }
+
+ @Override
+ public Slice findPlacesWithCityByPlaceName(String placeName, Pageable pageable) {
+
+ int pageSize = pageable.getPageSize();
+ List content = queryFactory
+ .selectFrom(place)
+ .join(place.city, city).fetchJoin()
+ .where(
+ containsIgnoreCasePlaceName(placeName)
+ )
+ .orderBy(getSort(pageable))
+ .offset(pageable.getOffset())
+ .limit(pageSize + 1)
+ .fetch();
+
+ boolean hasNext = false;
+ if (content.size() > pageSize) {
+ content.remove(pageSize);
+ hasNext = true;
+ }
+
+ return new SliceImpl<>(content, pageable, hasNext);
+ }
+
+ @Override
+ public List findPlacesByCityAndOrderByStoredCountLimitSize(City city, int size) {
+
+ return queryFactory
+ .selectFrom(place)
+ .join(place.city)
+ .where(place.city.eq(city))
+ .orderBy(place.storedCount.desc(), place.createdAt.desc())
+ .limit(size)
+ .fetch();
+ }
+
+ @Override
+ public List findPlaceMapInfoListByPlaceId(Long placeId) {
+
+ QPlace qPlace = place;
+ QCity qCity = city;
+ QTripRecordSchedule qTripRecordSchedule = QTripRecordSchedule.tripRecordSchedule;
+ QTripRecordScheduleImage qTripRecordScheduleImage = QTripRecordScheduleImage.tripRecordScheduleImage;
+
+ List places = queryFactory
+ .selectFrom(qPlace)
+ .join(qPlace.city, qCity)
+ .where(qCity.id.eq(
+ queryFactory
+ .select(qCity.id)
+ .from(qPlace)
+ .where(qPlace.id.eq(placeId))
+ .fetchOne()
+ ))
+ .fetch();
+
+ List placeIds = places.stream().map(Place::getId).collect(Collectors.toList());
+
+ List images = queryFactory
+ .select(qTripRecordSchedule.place.id, qTripRecordScheduleImage.imageUrl.min())
+ .from(qTripRecordSchedule)
+ .join(qTripRecordSchedule.tripRecordScheduleImages, qTripRecordScheduleImage)
+ .where(qTripRecordSchedule.place.id.in(placeIds))
+ .groupBy(qTripRecordSchedule.place.id)
+ .fetch();
+
+ List result = placeIds.stream()
+ .map(id -> { // placeId๋ฅผ id๋ก ๋ณ๊ฒฝ
+ String imageUrl = images.stream()
+ .filter(tuple -> tuple.get(0, Long.class).equals(id))
+ .map(tuple -> tuple.get(1, String.class))
+ .findFirst()
+ .orElse(null);
+ Place place = places.stream()
+ .filter(p -> p.getId().equals(id))
+ .findFirst()
+ .orElseThrow();
+ return PlaceMapInfoResponseDto.builder()
+ .placeId(place.getId())
+ .placeName(place.getName())
+ .latitude(place.getLatitude())
+ .longitude(place.getLongitude())
+ .storeCount(place.getStoredCount())
+ .commentCount(place.getCommentCount())
+ .imageUrl(imageUrl)
+ .build();
+ })
+ .collect(Collectors.toList());
+
+ return result;
+
+ }
+
+ @Override
+ public List findNearbyPlaces(Long placeId) {
+
+ QPlace qPlace = QPlace.place;
+ QTripRecordSchedule qTripRecordSchedule = QTripRecordSchedule.tripRecordSchedule;
+ QTripRecordScheduleImage qTripRecordScheduleImage = QTripRecordScheduleImage.tripRecordScheduleImage;
+
+ // ์ฃผ์ด์ง placeId์ ํด๋นํ๋ ์ฅ์์ ์๋, ๊ฒฝ๋๋ฅผ ์ฐพ๋๋ค.
+ Place centerPlace = queryFactory
+ .selectFrom(qPlace)
+ .where(qPlace.id.eq(placeId))
+ .fetchOne();
+
+ // ์ฃผ๋ณ ์ฅ์๋ฅผ ๋ฝ์๋
๋๋ค. (1๋ ๋ฒ์ ๋ด๋ฉด ๋ณดํต 11km ์์ชฝ์)
+ List nearbyPlaces = queryFactory
+ .selectFrom(qPlace)
+ .where(qPlace.latitude.between(centerPlace.getLatitude() - 1, centerPlace.getLatitude() + 1)
+ .and(qPlace.longitude.between(centerPlace.getLongitude() - 1, centerPlace.getLongitude() + 1)))
+ .orderBy(qPlace.storedCount.desc())
+ .where(qPlace.id.ne(placeId))
+ .limit(5)
+ .fetch();
+
+ // ๊ฐ ์ฅ์์ ํด๋นํ๋ ์ค์ผ์ค ์ด๋ฏธ์ง๋ฅผ ์ฐพ์ PlaceNearbyResponseDto ๊ฐ์ฒด๋ฅผ ์์ฑํ๋ค. (์ด์ฐจํผ ์ปจํ
์ธ ๊ฐ 5๊ฐ ๊ณ ์ ์ด์ฌ์ ์ด๊ฒ ๋ ๊ฐ๋จํจ)
+ List result = nearbyPlaces.stream()
+ .map(place -> {
+ String imageUrl = queryFactory
+ .select(qTripRecordScheduleImage.imageUrl)
+ .from(qTripRecordScheduleImage)
+ .join(qTripRecordScheduleImage.tripRecordSchedule, qTripRecordSchedule)
+ .where(qTripRecordSchedule.place.id.eq(place.getId()))
+ .orderBy(qTripRecordScheduleImage.id.asc())
+ .fetchFirst();
+
+ return new PlaceNearbyResponseDto(
+ place.getId(),
+ place.getName(),
+ imageUrl,
+ place.getLatitude(),
+ place.getLongitude(),
+ place.getStoredCount(),
+ place.getReviewCount(),
+ place.getCommentCount()
+ );
+ })
+ .collect(Collectors.toList());
+
+ return result;
+ }
+
+ private BooleanExpression containsIgnoreCasePlaceName(String placeName) {
+ if (!StringUtils.hasText(placeName)) {
+ return null;
+ }
+
+ String replacedWhitespace = placeName.replaceAll(" ", "");
+
+ return Expressions.stringTemplate(
+ "function('replace',{0},{1},{2})", place.name, " ", ""
+ ).containsIgnoreCase(replacedWhitespace);
+ }
+
+ private OrderSpecifier>[] getSort(Pageable pageable) {
+
+ List> orderSpecifiers = new LinkedList<>();
+ if (!pageable.getSort().isEmpty()) {
+ for (Sort.Order sortOrder : pageable.getSort()) {
+ Order direction = sortOrder.getDirection().isAscending() ? Order.ASC : Order.DESC;
+
+ String property = sortOrder.getProperty();
+ switch (property) {
+ case "id":
+ orderSpecifiers.add(new OrderSpecifier<>(direction, place.id));
+ break;
+ case "createdAt":
+ orderSpecifiers.add(new OrderSpecifier<>(direction, place.createdAt));
+ break;
+ case "storedCount":
+ orderSpecifiers.add(new OrderSpecifier<>(direction, place.storedCount));
+ break;
+ case "commentCount":
+ orderSpecifiers.add(new OrderSpecifier<>(direction, place.commentCount));
+ break;
+ }
+ }
+ }
+
+ // ์๋ฌด ์กฐ๊ฑด ์กด์ฌํ์ง ์๋ ๊ฒฝ์ฐ, order by null
+ if(orderSpecifiers.isEmpty()) {
+ orderSpecifiers.add(new OrderSpecifier(Order.ASC, NullExpression.DEFAULT, NullHandling.Default));
+ }
+
+ return orderSpecifiers.toArray(OrderSpecifier[]::new);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/place/service/PlaceService.java b/src/main/java/com/haejwo/tripcometrue/domain/place/service/PlaceService.java
new file mode 100644
index 00000000..f4723919
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/place/service/PlaceService.java
@@ -0,0 +1,147 @@
+package com.haejwo.tripcometrue.domain.place.service;
+
+import com.haejwo.tripcometrue.domain.city.entity.City;
+import com.haejwo.tripcometrue.domain.city.exception.CityNotFoundException;
+import com.haejwo.tripcometrue.domain.city.repository.CityRepository;
+import com.haejwo.tripcometrue.domain.place.dto.request.PlaceRequestDto;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceListItemResponseDto;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceMapInfoResponseDto;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceNearbyResponseDto;
+import com.haejwo.tripcometrue.domain.place.dto.response.PlaceResponseDto;
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import com.haejwo.tripcometrue.domain.place.exception.PlaceNotFoundException;
+import com.haejwo.tripcometrue.domain.place.repositroy.PlaceRepository;
+import com.haejwo.tripcometrue.domain.store.repository.PlaceStoreRepository;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import java.util.List;
+import java.util.Objects;
+
+import com.haejwo.tripcometrue.domain.triprecord.dto.query.TripRecordScheduleImageWithPlaceIdQueryDto;
+import com.haejwo.tripcometrue.domain.triprecord.repository.triprecord_schedule_image.TripRecordScheduleImageRepository;
+import com.haejwo.tripcometrue.global.util.SliceResponseDto;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class PlaceService {
+
+ private final PlaceRepository placeRepository;
+ private final CityRepository cityRepository;
+ private final TripRecordScheduleImageRepository tripRecordScheduleImageRepository;
+ private final PlaceStoreRepository placeStoreRepository;
+
+ @Transactional
+ public PlaceResponseDto addPlace(PlaceRequestDto requestDto) {
+ City city = cityRepository.findById(requestDto.cityId())
+ .orElseThrow(CityNotFoundException::new);
+ Place requestPlace = requestDto.toEntity(city);
+ Place savedPlace = placeRepository.save(requestPlace);
+ PlaceResponseDto responseDto = PlaceResponseDto.fromEntity(savedPlace);
+
+ return responseDto;
+
+ }
+
+ @Transactional(readOnly = true)
+ public PlaceResponseDto findPlace(PrincipalDetails principalDetails, Long placeId) {
+
+ Place findPlace = findPlaceById(placeId);
+
+ Boolean isStored = false;
+ if(principalDetails != null) {
+ isStored = placeStoreRepository.existsByMemberAndPlace(principalDetails.getMember(), findPlace);
+ }
+
+ PlaceResponseDto responseDto = PlaceResponseDto.fromEntity(findPlace, isStored);
+
+ return responseDto;
+ }
+
+ @Transactional(readOnly = true)
+ public Page findPlaces(Pageable pageable, Integer storedCount) {
+
+ Page findPlaces = placeRepository.findPlaceWithFilter(pageable, storedCount);
+
+ Page result = findPlaces.map(PlaceResponseDto::fromEntity);
+
+ return result;
+
+ }
+
+ @Transactional(readOnly = true)
+ public SliceResponseDto listPlacesByName(
+ String placeName, Pageable pageable
+ ) {
+ Slice places = placeRepository.findPlacesWithCityByPlaceName(placeName, pageable);
+
+ return SliceResponseDto.of(
+ places.map(
+ place ->
+ PlaceListItemResponseDto
+ .fromEntity(
+ place,
+ // ์ฌํ์ง ๋ํ์ด๋ฏธ์ง ์ถ์ถ
+ tripRecordScheduleImageRepository
+ .findInPlaceIdsOrderByCreatedAtDesc(
+ places.getContent()
+ .stream()
+ .map(Place::getId)
+ .toList()
+ )
+ .stream()
+ .filter(Objects::nonNull)
+ .findFirst()
+ .map(TripRecordScheduleImageWithPlaceIdQueryDto::imageUrl)
+ .orElse(null)
+ )
+ )
+ );
+ }
+
+ public List findPlaceMapInfoList(Long placeId) {
+
+ List responseDtos = placeRepository.findPlaceMapInfoListByPlaceId(placeId);
+
+ return responseDtos;
+ }
+
+ public List findNearbyPlaceList(Long placeId) {
+
+ List responseDtos = placeRepository.findNearbyPlaces(placeId);
+
+ return responseDtos;
+
+ }
+
+ @Transactional
+ public PlaceResponseDto modifyPlace(Long placeId, PlaceRequestDto requestDto) {
+
+ Place place = findPlaceById(placeId);
+ place.update(requestDto);
+ PlaceResponseDto responseDto = PlaceResponseDto.fromEntity(place);
+
+ return responseDto;
+ }
+
+ @Transactional
+ public void removePlace(Long placeId) {
+ Place findPlace = findPlaceById(placeId);
+ placeRepository.delete(findPlace);
+ }
+
+ private Place findPlaceById(Long placeId) {
+
+ Place findPlace = placeRepository.findById(placeId)
+ .orElseThrow(PlaceNotFoundException::new);
+
+ return findPlace;
+ }
+
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/global/PointType.java b/src/main/java/com/haejwo/tripcometrue/domain/review/global/PointType.java
new file mode 100644
index 00000000..574f4daf
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/global/PointType.java
@@ -0,0 +1,14 @@
+package com.haejwo.tripcometrue.domain.review.global;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum PointType {
+
+ ONLY_ONE_POINT(1),
+ TWO_POINTS(2);
+
+ private final int point;
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/controller/PlaceReviewController.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/controller/PlaceReviewController.java
new file mode 100644
index 00000000..6cabddfa
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/controller/PlaceReviewController.java
@@ -0,0 +1,100 @@
+package com.haejwo.tripcometrue.domain.review.placereview.controller;
+
+import com.haejwo.tripcometrue.domain.review.placereview.dto.request.DeletePlaceReviewRequestDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.request.PlaceReviewRequestDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.response.PlaceReviewListResponseDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.response.PlaceReviewResponseDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.response.RegisterPlaceReviewResponseDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.response.delete.DeletePlaceReviewResponseDto;
+import com.haejwo.tripcometrue.domain.review.placereview.service.PlaceReviewService;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import static org.springframework.http.HttpStatus.CREATED;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/v1/places")
+public class PlaceReviewController {
+
+ private final PlaceReviewService placeReviewService;
+
+ @PostMapping("/{placeId}/reviews")
+ public ResponseEntity> registerPlaceReview(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long placeId,
+ @RequestBody @Validated PlaceReviewRequestDto requestDto
+ ) {
+
+ RegisterPlaceReviewResponseDto responseDto = placeReviewService
+ .savePlaceReview(principalDetails, placeId, requestDto);
+ return ResponseEntity
+ .status(CREATED)
+ .body(ResponseDTO.successWithData(CREATED, responseDto));
+ }
+
+ @GetMapping("/reviews/{placeReviewId}")
+ public ResponseEntity> getOnePlaceReview(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long placeReviewId
+ ) {
+
+ PlaceReviewResponseDto responseDto = placeReviewService
+ .getPlaceReview(principalDetails, placeReviewId);
+ return ResponseEntity.ok(ResponseDTO.okWithData(responseDto));
+ }
+
+ @PutMapping("/reviews/{placeReviewId}")
+ public ResponseEntity> modifyPlaceReview(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long placeReviewId,
+ @RequestBody @Validated PlaceReviewRequestDto requestDto
+ ) {
+
+ PlaceReviewResponseDto responseDto = placeReviewService
+ .modifyPlaceReview(principalDetails, placeReviewId, requestDto);
+ return ResponseEntity.ok(ResponseDTO.okWithData(responseDto));
+ }
+
+ @DeleteMapping("/reviews")
+ public ResponseEntity> removePlaceReviews(
+ @RequestBody DeletePlaceReviewRequestDto requestDto
+ ) {
+
+ DeletePlaceReviewResponseDto responseDto =
+ placeReviewService.deletePlaceReviews(requestDto);
+ return ResponseEntity.ok(ResponseDTO.okWithData(responseDto));
+ }
+
+ @GetMapping("/{placeId}/reviews")
+ public ResponseEntity> getPlaceReviewList(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ @PathVariable Long placeId,
+ @RequestParam(defaultValue = "false") boolean onlyImage,
+ Pageable pageable
+ ) {
+
+ PlaceReviewListResponseDto responseDtos =
+ placeReviewService.getPlaceReviewList(principalDetails, placeId, onlyImage, pageable);
+ return ResponseEntity.ok(ResponseDTO.okWithData(responseDtos));
+ }
+
+ @GetMapping("/reviews/my")
+ public ResponseEntity> getMyPlaceReviews(
+ @AuthenticationPrincipal PrincipalDetails principalDetails,
+ Pageable pageable
+ ) {
+
+ PlaceReviewListResponseDto responseDtos
+ = placeReviewService.getMyPlaceReviewList(principalDetails, pageable);
+ return ResponseEntity.ok((ResponseDTO.okWithData(responseDtos)));
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/controller/PlaceReviewControllerAdvice.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/controller/PlaceReviewControllerAdvice.java
new file mode 100644
index 00000000..99c36870
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/controller/PlaceReviewControllerAdvice.java
@@ -0,0 +1,41 @@
+package com.haejwo.tripcometrue.domain.review.placereview.controller;
+
+import com.haejwo.tripcometrue.domain.review.placereview.exception.PlaceReviewAlreadyExistsException;
+import com.haejwo.tripcometrue.domain.review.placereview.exception.PlaceReviewDeleteAllFailureException;
+import com.haejwo.tripcometrue.domain.review.placereview.exception.PlaceReviewNotFoundException;
+import com.haejwo.tripcometrue.global.util.ResponseDTO;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class PlaceReviewControllerAdvice {
+
+ @ExceptionHandler(PlaceReviewNotFoundException.class)
+ public ResponseEntity> handlePlaceReviewNotFoundException(PlaceReviewNotFoundException e) {
+ HttpStatus status = e.getErrorCode().getHttpStatus();
+
+ return ResponseEntity
+ .status(status)
+ .body(ResponseDTO.errorWithMessage(status, e.getMessage()));
+ }
+
+ @ExceptionHandler(PlaceReviewAlreadyExistsException.class)
+ public ResponseEntity> handlePlaceReviewAlreadyExistsException(PlaceReviewAlreadyExistsException e) {
+ HttpStatus status = e.getErrorCode().getHttpStatus();
+
+ return ResponseEntity
+ .status(status)
+ .body(ResponseDTO.errorWithMessage(status, e.getMessage()));
+ }
+
+ @ExceptionHandler(PlaceReviewDeleteAllFailureException.class)
+ public ResponseEntity> handlePlaceReviewDeleteAllFailureException(PlaceReviewDeleteAllFailureException e) {
+ HttpStatus status = e.getErrorCode().getHttpStatus();
+
+ return ResponseEntity
+ .status(status)
+ .body(ResponseDTO.errorWithMessage(status, e.getMessage()));
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/request/DeletePlaceReviewRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/request/DeletePlaceReviewRequestDto.java
new file mode 100644
index 00000000..fee695d2
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/request/DeletePlaceReviewRequestDto.java
@@ -0,0 +1,10 @@
+package com.haejwo.tripcometrue.domain.review.placereview.dto.request;
+
+import java.util.List;
+
+public record DeletePlaceReviewRequestDto(
+
+ List placeReviewIds
+
+) {
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/request/PlaceReviewRequestDto.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/request/PlaceReviewRequestDto.java
new file mode 100644
index 00000000..a4580564
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/request/PlaceReviewRequestDto.java
@@ -0,0 +1,26 @@
+package com.haejwo.tripcometrue.domain.review.placereview.dto.request;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import jakarta.validation.constraints.NotBlank;
+import org.hibernate.validator.constraints.Length;
+
+public record PlaceReviewRequestDto(
+
+ String imageUrl,
+
+ @NotBlank
+ @Length(min = 10, max = 2000, message = "์์ฑ ํ์ฉ ๋ฒ์๋ ์ต์ 10์ ๋๋ ์ต๋ 2,000์ ์
๋๋ค.")
+ String content
+
+) {
+ public static PlaceReview toEntity(Member member, Place place, PlaceReviewRequestDto requestDto) {
+ return PlaceReview.builder()
+ .member(member)
+ .place(place)
+ .imageUrl(requestDto.imageUrl)
+ .content(requestDto.content)
+ .build();
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/PlaceReviewListResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/PlaceReviewListResponseDto.java
new file mode 100644
index 00000000..df42fdaa
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/PlaceReviewListResponseDto.java
@@ -0,0 +1,29 @@
+package com.haejwo.tripcometrue.domain.review.placereview.dto.response;
+
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import org.springframework.data.domain.Page;
+
+import java.util.List;
+
+public record PlaceReviewListResponseDto(
+
+ Long totalCount,
+ int nowPageNumber,
+ boolean isFirst,
+ boolean isLast,
+ List placeReviews
+
+) {
+ public static PlaceReviewListResponseDto fromResponseDtos(
+ Page reviews,
+ List placeReviews
+ ) {
+ return new PlaceReviewListResponseDto(
+ reviews.getTotalElements(),
+ reviews.getNumber(),
+ reviews.isFirst(),
+ reviews.isLast(),
+ placeReviews
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/PlaceReviewResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/PlaceReviewResponseDto.java
new file mode 100644
index 00000000..8150377c
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/PlaceReviewResponseDto.java
@@ -0,0 +1,81 @@
+package com.haejwo.tripcometrue.domain.review.placereview.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.haejwo.tripcometrue.domain.comment.placereview.dto.response.PlaceReviewCommentResponseDto;
+import com.haejwo.tripcometrue.domain.comment.placereview.entity.PlaceReviewComment;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import org.springframework.data.domain.Slice;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Objects;
+
+public record PlaceReviewResponseDto(
+
+ Long placeReviewId,
+ Long memberId,
+ String nickname,
+ String profileUrl,
+ String imageUrl,
+ String content,
+ Integer likeCount,
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH-mm-ss")
+ LocalDateTime createdAt,
+
+ boolean amILike,
+ Integer commentCount,
+ List comments
+
+) {
+
+ public static PlaceReviewResponseDto fromEntityWithComment(
+ PlaceReview placeReview,
+ boolean amILike,
+ Slice placeReviewComments,
+ Member member
+ ) {
+
+ return new PlaceReviewResponseDto(
+ placeReview.getId(),
+ placeReview.getMember().getId(),
+ placeReview.getMember().getMemberBase().getNickname(),
+ placeReview.getMember().getProfileImage(),
+ placeReview.getImageUrl(),
+ placeReview.getContent(),
+ placeReview.getLikeCount(),
+ placeReview.getCreatedAt(),
+ amILike,
+ placeReview.getCommentCount(),
+ placeReviewComments.map(placeReviewComment -> {
+ if (placeReviewComment.getParentComment() == null) {
+ return PlaceReviewCommentResponseDto.fromEntity(placeReviewComment, member);
+ }
+ return null;
+ })
+ .filter(Objects::nonNull)
+ .toList()
+ );
+ }
+
+ public static PlaceReviewResponseDto fromEntity(
+ PlaceReview placeReview,
+ boolean amILike
+ ) {
+
+ return new PlaceReviewResponseDto(
+ placeReview.getId(),
+ placeReview.getMember().getId(),
+ placeReview.getMember().getMemberBase().getNickname(),
+ placeReview.getMember().getProfileImage(),
+ placeReview.getImageUrl(),
+ placeReview.getContent(),
+ placeReview.getLikeCount(),
+ placeReview.getCreatedAt(),
+ amILike,
+ placeReview.getCommentCount(),
+ null
+ );
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/RegisterPlaceReviewResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/RegisterPlaceReviewResponseDto.java
new file mode 100644
index 00000000..cf457c3e
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/RegisterPlaceReviewResponseDto.java
@@ -0,0 +1,32 @@
+package com.haejwo.tripcometrue.domain.review.placereview.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+
+import java.time.LocalDateTime;
+
+public record RegisterPlaceReviewResponseDto(
+
+ Long placeReviewId,
+ Long memberId,
+ String nickname,
+ String profileUrl,
+ String imageUrl,
+ String content,
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH-mm-ss")
+ LocalDateTime createdAt
+
+) {
+ public static RegisterPlaceReviewResponseDto fromEntity(PlaceReview placeReview) {
+ return new RegisterPlaceReviewResponseDto(
+ placeReview.getId(),
+ placeReview.getMember().getId(),
+ placeReview.getMember().getMemberBase().getNickname(),
+ placeReview.getMember().getProfileImage(),
+ placeReview.getImageUrl(),
+ placeReview.getContent(),
+ placeReview.getCreatedAt()
+ );
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/delete/DeleteAllSuccessPlaceReviewResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/delete/DeleteAllSuccessPlaceReviewResponseDto.java
new file mode 100644
index 00000000..3ac225c3
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/delete/DeleteAllSuccessPlaceReviewResponseDto.java
@@ -0,0 +1,9 @@
+package com.haejwo.tripcometrue.domain.review.placereview.dto.response.delete;
+
+import lombok.Getter;
+
+@Getter
+public class DeleteAllSuccessPlaceReviewResponseDto implements DeletePlaceReviewResponseDto {
+
+ private final String successMessage = "์ฑ๊ณต์ ์ผ๋ก ๋ชจ๋ ์ฌํ์ง ๋ฆฌ๋ทฐ(๋ค)์ ์ญ์ ํ์ต๋๋ค.";
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/delete/DeletePlaceReviewResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/delete/DeletePlaceReviewResponseDto.java
new file mode 100644
index 00000000..cb66231b
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/delete/DeletePlaceReviewResponseDto.java
@@ -0,0 +1,4 @@
+package com.haejwo.tripcometrue.domain.review.placereview.dto.response.delete;
+
+public interface DeletePlaceReviewResponseDto {
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/delete/DeleteSomeFailurePlaceReviewResponseDto.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/delete/DeleteSomeFailurePlaceReviewResponseDto.java
new file mode 100644
index 00000000..97d0b8aa
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/dto/response/delete/DeleteSomeFailurePlaceReviewResponseDto.java
@@ -0,0 +1,16 @@
+package com.haejwo.tripcometrue.domain.review.placereview.dto.response.delete;
+
+import lombok.Getter;
+
+import java.util.List;
+
+@Getter
+public class DeleteSomeFailurePlaceReviewResponseDto implements DeletePlaceReviewResponseDto {
+
+ private final String errorMessage = "์ญ์ ์ ์คํจํ ์ฌํ์ง ๋ฆฌ๋ทฐ(๋ค)์ด ์กด์ฌํฉ๋๋ค.";
+ private final List failedPlaceReviewIds;
+
+ public DeleteSomeFailurePlaceReviewResponseDto(List failedIds) {
+ this.failedPlaceReviewIds = failedIds;
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/entity/PlaceReview.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/entity/PlaceReview.java
new file mode 100644
index 00000000..b710fae5
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/entity/PlaceReview.java
@@ -0,0 +1,100 @@
+package com.haejwo.tripcometrue.domain.review.placereview.entity;
+
+import com.haejwo.tripcometrue.domain.likes.entity.PlaceReviewLikes;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.request.PlaceReviewRequestDto;
+import com.haejwo.tripcometrue.global.entity.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static com.haejwo.tripcometrue.domain.review.global.PointType.ONLY_ONE_POINT;
+import static com.haejwo.tripcometrue.domain.review.global.PointType.TWO_POINTS;
+import static jakarta.persistence.CascadeType.REMOVE;
+import static jakarta.persistence.FetchType.LAZY;
+
+@Getter
+@Entity
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class PlaceReview extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "place_review_id")
+ private Long id;
+
+ @ManyToOne(fetch = LAZY)
+ @JoinColumn(name = "member_id")
+ private Member member;
+
+ @ManyToOne(fetch = LAZY)
+ @JoinColumn(name = "place_id")
+ private Place place;
+
+ @OneToMany(mappedBy = "placeReview", cascade = REMOVE, orphanRemoval = true)
+ private List placeReviewLikeses = new ArrayList<>();
+
+ @Column(nullable = false)
+ private String content;
+
+ private String imageUrl;
+ private Integer likeCount;
+ private Integer commentCount;
+ private boolean hasAnyRegisteredPhotoUrl;
+
+ @Builder
+ public PlaceReview(Member member, Place place, String content, String imageUrl) {
+ this.member = member;
+ this.place = place;
+ this.content = content;
+ this.imageUrl = imageUrl;
+ }
+
+ public void save(PlaceReviewRequestDto requestDto, Member member) {
+ if (requestDto.imageUrl() != null) {
+ member.earnPoint(TWO_POINTS.getPoint());
+ hasAnyRegisteredPhotoUrl = true;
+ return;
+ }
+
+ member.earnPoint(ONLY_ONE_POINT.getPoint());
+ }
+
+ public void update(PlaceReviewRequestDto requestDto, Member member) {
+ this.content = requestDto.content();
+
+ if (!hasAnyRegisteredPhotoUrl && requestDto.imageUrl() != null) {
+ member.earnPoint(ONLY_ONE_POINT.getPoint());
+ hasAnyRegisteredPhotoUrl = true;
+ }
+ this.imageUrl = requestDto.imageUrl();
+ }
+
+ public void increaseLikesCount() {
+ likeCount += 1;
+ }
+
+ public void decreaseLikesCount() {
+ likeCount -= 1;
+ }
+
+ public void increaseCommentCount() {
+ commentCount += 1;
+ }
+
+ public void decreaseCommentCount(int count) {
+ this.commentCount -= count;
+ }
+
+ @PrePersist
+ private void init() {
+ likeCount = 0;
+ commentCount = 0;
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/exception/PlaceReviewAlreadyExistsException.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/exception/PlaceReviewAlreadyExistsException.java
new file mode 100644
index 00000000..ac20b391
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/exception/PlaceReviewAlreadyExistsException.java
@@ -0,0 +1,13 @@
+package com.haejwo.tripcometrue.domain.review.placereview.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class PlaceReviewAlreadyExistsException extends ApplicationException {
+
+ private static final ErrorCode ERROR_CODE = ErrorCode.PLACE_REVIEW_ALREADY_EXISTS;
+
+ public PlaceReviewAlreadyExistsException() {
+ super(ERROR_CODE);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/exception/PlaceReviewDeleteAllFailureException.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/exception/PlaceReviewDeleteAllFailureException.java
new file mode 100644
index 00000000..b6792b05
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/exception/PlaceReviewDeleteAllFailureException.java
@@ -0,0 +1,13 @@
+package com.haejwo.tripcometrue.domain.review.placereview.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class PlaceReviewDeleteAllFailureException extends ApplicationException {
+
+ private static final ErrorCode ERROR_CODE = ErrorCode.PLACE_REVIEW_DELETE_ALL_FAILURE;
+
+ public PlaceReviewDeleteAllFailureException() {
+ super(ERROR_CODE);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/exception/PlaceReviewNotFoundException.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/exception/PlaceReviewNotFoundException.java
new file mode 100644
index 00000000..886b700b
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/exception/PlaceReviewNotFoundException.java
@@ -0,0 +1,13 @@
+package com.haejwo.tripcometrue.domain.review.placereview.exception;
+
+import com.haejwo.tripcometrue.global.exception.ApplicationException;
+import com.haejwo.tripcometrue.global.exception.ErrorCode;
+
+public class PlaceReviewNotFoundException extends ApplicationException {
+
+ private static ErrorCode ERROR_CODE = ErrorCode.PLACE_REVIEW_NOT_FOUND;
+
+ public PlaceReviewNotFoundException(){
+ super(ERROR_CODE);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/repository/PlaceReviewRepository.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/repository/PlaceReviewRepository.java
new file mode 100644
index 00000000..739d8776
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/repository/PlaceReviewRepository.java
@@ -0,0 +1,20 @@
+package com.haejwo.tripcometrue.domain.review.placereview.repository;
+
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface PlaceReviewRepository extends JpaRepository, PlaceReviewRepositoryCustom {
+
+ boolean existsByMemberAndPlace(Member member, Place place);
+
+ @Query("select pr from PlaceReview pr join fetch pr.member m where pr.member = :member order by pr.createdAt desc")
+ Page findByMember(@Param("member") Member member, Pageable pageable);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/repository/PlaceReviewRepositoryCustom.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/repository/PlaceReviewRepositoryCustom.java
new file mode 100644
index 00000000..738b0fa4
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/repository/PlaceReviewRepositoryCustom.java
@@ -0,0 +1,11 @@
+package com.haejwo.tripcometrue.domain.review.placereview.repository;
+
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+public interface PlaceReviewRepositoryCustom {
+
+ Page findByPlace(Place place, boolean onlyImage, Pageable pageable);
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/repository/PlaceReviewRepositoryCustomImpl.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/repository/PlaceReviewRepositoryCustomImpl.java
new file mode 100644
index 00000000..fd3e0675
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/repository/PlaceReviewRepositoryCustomImpl.java
@@ -0,0 +1,67 @@
+package com.haejwo.tripcometrue.domain.review.placereview.repository;
+
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.core.types.dsl.BooleanExpression;
+import com.querydsl.core.types.dsl.PathBuilder;
+import com.querydsl.jpa.impl.JPAQuery;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.support.PageableExecutionUtils;
+
+import java.util.List;
+
+import static com.haejwo.tripcometrue.domain.review.placereview.entity.QPlaceReview.placeReview;
+
+public class PlaceReviewRepositoryCustomImpl implements PlaceReviewRepositoryCustom {
+
+ private final JPAQueryFactory queryFactory;
+
+ public PlaceReviewRepositoryCustomImpl(EntityManager em) {
+ this.queryFactory = new JPAQueryFactory(em);
+ }
+
+ @Override
+ public Page findByPlace(Place place, boolean onlyImage, Pageable pageable) {
+
+ List content = queryFactory
+ .selectFrom(placeReview)
+ .join(placeReview.place).on(placeReview.place.id.eq(place.getId()))
+ .where(imageIsNotNull(onlyImage))
+ .offset(pageable.getOffset())
+ .limit(pageable.getPageSize())
+ .orderBy(getOrderSpecifier(pageable.getSort()))
+ .fetch();
+
+ JPAQuery count = queryFactory
+ .select(placeReview.count())
+ .from(placeReview)
+ .where(placeReview.place.id.eq(place.getId()), imageIsNotNull(onlyImage));
+
+ return PageableExecutionUtils.getPage(content, pageable, count::fetchOne);
+ }
+
+ private BooleanExpression imageIsNotNull(boolean onlyImage) {
+ if (onlyImage) {
+ return placeReview.imageUrl.isNotNull();
+ }
+ return null;
+ }
+
+ private OrderSpecifier>[] getOrderSpecifier(Sort sort) {
+ return sort.stream()
+ .map(order -> {
+ PathBuilder entityPath = new PathBuilder<>(PlaceReview.class, "placeReview");
+ String property = order.getProperty();
+ OrderSpecifier> orderSpecifier = order.isAscending() ?
+ entityPath.getString(property).asc() :
+ entityPath.getString(property).desc();
+ return orderSpecifier;
+ })
+ .toArray(OrderSpecifier[]::new);
+ }
+}
diff --git a/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/service/PlaceReviewService.java b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/service/PlaceReviewService.java
new file mode 100644
index 00000000..14988644
--- /dev/null
+++ b/src/main/java/com/haejwo/tripcometrue/domain/review/placereview/service/PlaceReviewService.java
@@ -0,0 +1,217 @@
+package com.haejwo.tripcometrue.domain.review.placereview.service;
+
+import com.haejwo.tripcometrue.domain.comment.placereview.entity.PlaceReviewComment;
+import com.haejwo.tripcometrue.domain.comment.placereview.repository.PlaceReviewCommentRepository;
+import com.haejwo.tripcometrue.domain.likes.entity.PlaceReviewLikes;
+import com.haejwo.tripcometrue.domain.member.entity.Member;
+import com.haejwo.tripcometrue.domain.member.exception.UserInvalidAccessException;
+import com.haejwo.tripcometrue.domain.member.exception.UserNotFoundException;
+import com.haejwo.tripcometrue.domain.member.repository.MemberRepository;
+import com.haejwo.tripcometrue.domain.place.entity.Place;
+import com.haejwo.tripcometrue.domain.place.exception.PlaceNotFoundException;
+import com.haejwo.tripcometrue.domain.place.repositroy.PlaceRepository;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.request.DeletePlaceReviewRequestDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.request.PlaceReviewRequestDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.response.PlaceReviewListResponseDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.response.PlaceReviewResponseDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.response.RegisterPlaceReviewResponseDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.response.delete.DeleteAllSuccessPlaceReviewResponseDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.response.delete.DeletePlaceReviewResponseDto;
+import com.haejwo.tripcometrue.domain.review.placereview.dto.response.delete.DeleteSomeFailurePlaceReviewResponseDto;
+import com.haejwo.tripcometrue.domain.review.placereview.entity.PlaceReview;
+import com.haejwo.tripcometrue.domain.review.placereview.exception.PlaceReviewAlreadyExistsException;
+import com.haejwo.tripcometrue.domain.review.placereview.exception.PlaceReviewDeleteAllFailureException;
+import com.haejwo.tripcometrue.domain.review.placereview.exception.PlaceReviewNotFoundException;
+import com.haejwo.tripcometrue.domain.review.placereview.repository.PlaceReviewRepository;
+import com.haejwo.tripcometrue.global.springsecurity.PrincipalDetails;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class PlaceReviewService {
+
+ private final PlaceReviewRepository placeReviewRepository;
+ private final PlaceRepository placeRepository;
+ private final MemberRepository memberRepository;
+ private final PlaceReviewCommentRepository placeReviewCommentRepository;
+
+ @Transactional
+ public RegisterPlaceReviewResponseDto savePlaceReview(
+ PrincipalDetails principalDetails,
+ Long placeId,
+ PlaceReviewRequestDto requestDto
+ ) {
+
+ Member loginMember = getMember(principalDetails);
+ Place place = getPlaceById(placeId);
+
+ isAlreadyPlaceReviewExists(loginMember, place);
+
+ PlaceReview placeReview = PlaceReviewRequestDto.toEntity(loginMember, place, requestDto);
+ placeReview.save(requestDto, loginMember);
+
+ return RegisterPlaceReviewResponseDto.fromEntity(placeReviewRepository.save(placeReview));
+ }
+
+ private Member getMember(PrincipalDetails principalDetails) {
+ if (principalDetails == null) {
+ return null;
+ }
+ return memberRepository.findById(principalDetails.getMember().getId())
+ .orElseThrow(UserNotFoundException::new);
+ }
+
+ private Place getPlaceById(Long placeId) {
+ return placeRepository.findById(placeId)
+ .orElseThrow(PlaceNotFoundException::new);
+ }
+
+ private void isAlreadyPlaceReviewExists(Member member, Place place) {
+ if (placeReviewRepository.existsByMemberAndPlace(member, place)) {
+ throw new PlaceReviewAlreadyExistsException();
+ }
+ }
+
+ public PlaceReviewResponseDto getPlaceReview(PrincipalDetails principalDetails, Long placeReviewId) {
+
+ PlaceReview placeReview = getPlaceReviewById(placeReviewId);
+ boolean hasLiked = false;
+
+ if (isLoggedIn(principalDetails)) {
+ hasLiked = hasLikedPlaceReview(principalDetails, placeReview);
+ }
+
+ Member loginMember = getMember(principalDetails);
+ Slice comments = getPlaceReviewSlice(placeReview);
+
+ return PlaceReviewResponseDto.fromEntityWithComment(placeReview, hasLiked, comments, loginMember);
+ }
+
+ private PlaceReview getPlaceReviewById(Long placeReviewId) {
+ return placeReviewRepository.findById(placeReviewId)
+ .orElseThrow(PlaceReviewNotFoundException::new);
+ }
+
+ private boolean isLoggedIn(PrincipalDetails principalDetails) {
+ return principalDetails != null;
+ }
+
+ private boolean hasLikedPlaceReview(PrincipalDetails principalDetails, PlaceReview placeReview) {
+ List memberIds = placeReview.getPlaceReviewLikeses().stream()
+ .map(PlaceReviewLikes::getMember)
+ .map(Member::getId)
+ .toList();
+ return memberIds.contains(principalDetails.getMember().getId());
+ }
+
+ private Slice getPlaceReviewSlice(PlaceReview placeReview) {
+ return placeReviewCommentRepository.findByPlaceReviewOrderByCreatedAtDesc(placeReview, null);
+ }
+
+ @Transactional
+ public PlaceReviewResponseDto modifyPlaceReview(
+ PrincipalDetails principalDetails,
+ Long placeReviewId,
+ PlaceReviewRequestDto requestDto
+ ) {
+
+ Member loginMember = getMember(principalDetails);
+ PlaceReview placeReview = getPlaceReviewById(placeReviewId);
+
+ validateRightMemberAccess(loginMember, placeReview);
+ placeReview.update(requestDto, loginMember);
+ Slice comments = getPlaceReviewSlice(placeReview);
+
+ return PlaceReviewResponseDto
+ .fromEntityWithComment(placeReview, hasLikedPlaceReview(principalDetails, placeReview), comments, loginMember);
+ }
+
+ private void validateRightMemberAccess(Member member, PlaceReview placeReview) {
+ if (!Objects.equals(placeReview.getMember().getId(), member.getId())) {
+ throw new UserInvalidAccessException();
+ }
+ }
+
+ @Transactional
+ public DeletePlaceReviewResponseDto deletePlaceReviews(
+ DeletePlaceReviewRequestDto requestDto
+ ) {
+
+ List placeReviewIds = requestDto.placeReviewIds();
+ List failedIds = new ArrayList<>();
+
+ placeReviewIds.forEach(placeReviewId -> {
+ if (placeReviewRepository.existsById(placeReviewId)) {
+ placeReviewRepository.deleteById(placeReviewId);
+ } else {
+ failedIds.add(placeReviewId);
+ }
+ });
+
+ if (isDeleteAllFail(placeReviewIds, failedIds)) {
+ throw new PlaceReviewDeleteAllFailureException();
+ }
+ if (!failedIds.isEmpty()) {
+ return new DeleteSomeFailurePlaceReviewResponseDto(failedIds);
+ }
+
+ return new DeleteAllSuccessPlaceReviewResponseDto();
+ }
+
+ private boolean isDeleteAllFail(List placeReviewIds, List failedIds) {
+ return placeReviewIds.size() == failedIds.size();
+ }
+
+ public PlaceReviewListResponseDto getPlaceReviewList(
+ PrincipalDetails principalDetails,
+ Long placeId,
+ boolean onlyImage,
+ Pageable pageable
+ ) {
+
+ Place place = getPlaceById(placeId);
+ Page reviews = placeReviewRepository.findByPlace(place, onlyImage, pageable);
+
+ return PlaceReviewListResponseDto.fromResponseDtos(
+ reviews,
+ reviews.map(placeReview -> {
+ boolean hasLiked = false;
+ if (principalDetails != null) {
+ hasLiked = hasLikedPlaceReview(principalDetails, placeReview);
+ }
+ return PlaceReviewResponseDto.fromEntity(
+ placeReview,
+ hasLiked
+ );
+ }
+ ).toList());
+ }
+
+ public PlaceReviewListResponseDto getMyPlaceReviewList(
+ PrincipalDetails principalDetails,
+ Pageable pageable
+ ) {
+
+ Member loginMember = getMember(principalDetails);
+ Page