diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 00000000..11f0d9d0 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,81 @@ +name: CD (자동배포) + +on: + push: + branches: + - develop +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Java 21 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '21' + + - name: Set up Docker + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + run: echo "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin + + - name: Set Image Tag + id: image_tag + run: echo "IMAGE_TAG=$(date +'%Y-%m-%d_%H-%M-%S')-$(echo ${{ github.sha }} | cut -c1-8)" >> $GITHUB_ENV + + - name: Decode env.properties from GitHub Secrets + run: | + echo "${{ secrets.ENV_FILE }}" | base64 --decode > ./env.properties + + - name: Transfer env.properties to EC2 + uses: appleboy/scp-action@v0.1.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + source: "./env.properties" + target: "/home/ubuntu/" + + - name: Build and Push Docker image + run: docker buildx build --push --platform linux/amd64 -t kimsongmok/splanet:${{ env.IMAGE_TAG }} . + + - name: Deploy to EC2 + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + script: | + sudo docker pull kimsongmok/splanet:${{ env.IMAGE_TAG }} + sudo docker stop splanet || true + sudo docker rm splanet || true + sudo docker network inspect splanet >/dev/null 2>&1 || sudo docker network create splanet + sudo docker run -d --name splanet \ + --network splanet \ + --env-file /home/ubuntu/env.properties \ + -p 80:8080 --restart unless-stopped kimsongmok/splanet:${{ env.IMAGE_TAG }} + + - name: Check Docker container status + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + script: | + sudo docker ps -a + sudo docker logs splanet + + - name: Clean up old Docker images + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + script: | + docker image ls --format "{{.ID}} {{.Repository}}:{{.Tag}}" | grep 'kimsongmok/splanet' | tail -n +4 | awk '{print $1}' | xargs docker rmi -f + sudo docker system prune -f diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..4948fb66 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI (빌드 및 테스트) + +on: + pull_request: + branches: + - master + - develop + - 'weekly/**' + +jobs: + build-and-test: + runs-on: ubuntu-22.04 + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE }} + MYSQL_USER: ${{ secrets.MYSQL_USER }} + MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }} + MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }} + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h localhost" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + redis: + image: redis + ports: + - 6379:6379 + + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + + - name: Cache Gradle dependencies + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Decode env.properties from GitHub Secrets + run: | + echo "${{ secrets.ENV_FILE }}" | base64 --decode > ./src/main/resources/env.properties + + - name: Set environment variables from env.properties + run: | + set -o allexport + source ./src/main/resources/env.properties + set +o allexport + + - name: Set up JDK 21 + uses: actions/setup-java@v2 + with: + java-version: '21' + distribution: 'temurin' + + - name: Wait for MySQL to be ready + run: | + for i in {30..0}; do + if docker exec $(docker ps -q --filter name=mysql) mysqladmin ping -h localhost; then + echo "MySQL is ready" + break + fi + echo "Waiting for MySQL..." + sleep 1 + done + if [ $i -eq 0 ]; then + echo "MySQL did not become ready in time" + docker logs $(docker ps -q --filter name=mysql) + exit 1 + fi + + - name: Build with Gradle + run: ./gradlew build + + - name: Run Tests + run: ./gradlew test \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b7fe629f..84d6f0b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,10 +6,17 @@ COPY build.gradle . COPY settings.gradle . COPY src src RUN chmod +x ./gradlew -RUN ./gradlew bootJar +# Gradle 빌드에서 프로필을 지정하여 실행 +RUN ./gradlew bootJar -Dspring.profiles.active=${SPRING_PROFILES_ACTIVE} + +# 런타임 단계 FROM eclipse-temurin:21 COPY --from=builder build/libs/*.jar app.jar +# 런타임에서도 동일하게 환경 변수 사용 +ENV SPRING_PROFILES_ACTIVE=prod + + ENTRYPOINT ["java", "-jar", "/app.jar"] -VOLUME /tmp +VOLUME /tmp \ No newline at end of file diff --git a/build.gradle b/build.gradle index d9924785..bde34fb4 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,8 @@ plugins { id 'java' id 'org.springframework.boot' version '3.3.3' id 'io.spring.dependency-management' version '1.1.6' + id 'com.google.protobuf' version '0.9.4' + } group = 'com.splanet' @@ -21,6 +23,12 @@ configurations { repositories { mavenCentral() + maven { + url 'https://repo.spring.io/milestone' + } + maven { + url 'https://repo.spring.io/snapshot' + } } dependencies { @@ -32,12 +40,25 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'javax.xml.bind:jaxb-api:2.3.1' + implementation 'org.springframework.boot:spring-boot-starter-actuator' // implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' implementation 'io.jsonwebtoken:jjwt:0.9.1' implementation 'org.hibernate.validator:hibernate-validator:8.0.0.Final' implementation 'jakarta.validation:jakarta.validation-api:3.0.2' - implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.apache.httpcomponents.client5:httpclient5:5.2.1' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + // gRPC 및 Protocol Buffers 의존성 + implementation 'io.grpc:grpc-netty-shaded:1.56.1' + implementation 'io.grpc:grpc-protobuf:1.56.1' + implementation 'io.grpc:grpc-stub:1.56.1' + implementation 'com.google.protobuf:protobuf-java:3.23.4' + + // gRPC 관련 필요한 의존성 + implementation 'javax.annotation:javax.annotation-api:1.3.2' + implementation 'com.google.code.gson:gson:2.8.9' + + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' @@ -46,8 +67,42 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + //gpt + // implementation 'org.springframework.boot:spring-boot-starter-web' + // implementation 'com.fasterxml.jackson.core:jackson-databind' + // implementation 'org.springframework.boot:spring-boot-starter' + implementation platform("org.springframework.ai:spring-ai-bom:0.8.0") + implementation 'org.springframework.ai:spring-ai-openai' + + } tasks.named('test') { useJUnitPlatform() } + +protobuf { + protoc { + artifact = 'com.google.protobuf:protoc:3.23.4' + } + plugins { + grpc { + artifact = 'io.grpc:protoc-gen-grpc-java:1.66.0' + } + } + generateProtoTasks { + all().forEach { task -> + task.plugins { + grpc {} + } + } + } +} + +sourceSets { + main { + java { + srcDirs 'build/generated/source/proto/main/java', 'build/generated/source/proto/main/grpc' + } + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 769880e8..c940c54b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,50 +1,50 @@ version: '3' services: - mysql: - container_name: mysql - image: mysql:8.0 - restart: always - environment: - MYSQL_USER: ${MYSQL_USER} - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} - MYSQL_PASSWORD: ${MYSQL_PASSWORD} - MYSQL_DATABASE: ${MYSQL_DATABASE} - volumes: - - ./splanet-db/mysql:/var/lib/mysql - ports: - - 3306:3306 - networks: - - splanet - - redis: - container_name: redis - image: redis - ports: - - 6379:6379 - networks: - - splanet - - -# 개발할때는 주석처리하여 로컬로 개발합니다. -# springboot: -# container_name: springboot_splanet -# build: -# context: . -# dockerfile: Dockerfile +# mysql: +# container_name: mysql +# image: mysql:8.0 # restart: always -# depends_on: -# - mysql -# ports: -# - 8080:8080 # environment: -# SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} -# SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} -# SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} -# SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE} +# MYSQL_USER: ${MYSQL_USER} +# MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} +# MYSQL_PASSWORD: ${MYSQL_PASSWORD} +# MYSQL_DATABASE: ${MYSQL_DATABASE} +# volumes: +# - ./splanet-db/mysql:/var/lib/mysql +# ports: +# - 3306:3306 +# networks: +# - splanet +# +# redis: +# container_name: redis +# image: redis +# ports: +# - 6379:6379 # networks: # - splanet + +# 개발할때는 주석처리하여 로컬로 개발합니다. + springboot: + container_name: springboot_splanet + build: + context: . + dockerfile: Dockerfile + restart: always + depends_on: + - mysql + ports: + - 8080:8080 + environment: + SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} + SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} + SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE} + networks: + - splanet + networks: splanet: driver: bridge diff --git a/src/main/java/com/splanet/splanet/config/WebConfig.java b/src/main/java/com/splanet/splanet/config/WebConfig.java new file mode 100644 index 00000000..ee84bcee --- /dev/null +++ b/src/main/java/com/splanet/splanet/config/WebConfig.java @@ -0,0 +1,16 @@ +package com.splanet.splanet.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { //인터페이스 WebMvcConfigurer 상속 + registry.addMapping("/**") //모든 경로를 허용해줄것이므로 + .allowedOrigins("*") //리소스 공유 허락할 origin 지정 + .allowedMethods("*"); //모든 메소드를 허용 + } +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/config/WebSocketConfig.java b/src/main/java/com/splanet/splanet/config/WebSocketConfig.java new file mode 100644 index 00000000..db192738 --- /dev/null +++ b/src/main/java/com/splanet/splanet/config/WebSocketConfig.java @@ -0,0 +1,24 @@ +package com.splanet.splanet.config; + +import com.splanet.splanet.core.handler.SpeechWebSocketHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + + private final SpeechWebSocketHandler speechWebSocketHandler; + + public WebSocketConfig(SpeechWebSocketHandler speechWebSocketHandler) { + this.speechWebSocketHandler = speechWebSocketHandler; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(speechWebSocketHandler, "/ws/stt") + .setAllowedOrigins("*"); + } +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/core/exception/ErrorCode.java b/src/main/java/com/splanet/splanet/core/exception/ErrorCode.java index e2a5dda6..26a0cbaa 100644 --- a/src/main/java/com/splanet/splanet/core/exception/ErrorCode.java +++ b/src/main/java/com/splanet/splanet/core/exception/ErrorCode.java @@ -36,6 +36,17 @@ public enum ErrorCode { INVITATION_ALREADY_PROCESSED("초대가 이미 처리되었습니다.", HttpStatus.BAD_REQUEST), USER_ALREADY_IN_TEAM("해당 유저는 이미 팀에 속해 있습니다.", HttpStatus.BAD_REQUEST), + + // friend + FRIEND_NOT_FOUND("친구가 아닙니다.",HttpStatus.NOT_FOUND), + FRIEND_REQUEST_NOT_FOUND("해당 친구 요청을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + FRIEND_ALREADY_EXISTS("이미 친구 목록에 있습니다.", HttpStatus.BAD_REQUEST), + FRIEND_REQUEST_ALREADY_SENT("이미 요청을 보냈습니다.",HttpStatus.BAD_REQUEST), + FRIEND_REQUEST_NOT_FOUND_IN_RECEIVED_LIST("내가 받은 요청이 아닙니다.", HttpStatus.NOT_FOUND), + FRIEND_REQUEST_NOT_RECEIVER("본인이 보낸 요청은 수락하거나 거절할 수 없습니다.", HttpStatus.BAD_REQUEST), + FRIEND_REQUEST_ALREADY_ACCEPTED_OR_REJECTED("이미 수락하거나 거절한 사용자 입니다.", HttpStatus.BAD_REQUEST), + SELF_FRIEND_REQUEST_NOT_ALLOWED("본인에게 친구요청을 보낼 수 없습니다.", HttpStatus.BAD_REQUEST), + // redis REDIS_SCAN_FAILED("Redis 키 스캔 중 오류가 발생했습니다.", HttpStatus.SERVICE_UNAVAILABLE); diff --git a/src/main/java/com/splanet/splanet/core/handler/SpeechWebSocketHandler.java b/src/main/java/com/splanet/splanet/core/handler/SpeechWebSocketHandler.java new file mode 100644 index 00000000..f1710902 --- /dev/null +++ b/src/main/java/com/splanet/splanet/core/handler/SpeechWebSocketHandler.java @@ -0,0 +1,100 @@ +package com.splanet.splanet.core.handler; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.protobuf.ByteString; +import com.nbp.cdncp.nest.grpc.proto.v1.NestResponse; +import com.splanet.splanet.stt.service.ClovaSpeechGrpcService; +import io.grpc.stub.StreamObserver; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.BinaryWebSocketHandler; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; + +@Component +public class SpeechWebSocketHandler extends BinaryWebSocketHandler { + + private final ClovaSpeechGrpcService clovaSpeechGrpcService; + private final Map> clientObservers = new ConcurrentHashMap<>(); + + public SpeechWebSocketHandler(ClovaSpeechGrpcService clovaSpeechGrpcService) { + this.clovaSpeechGrpcService = clovaSpeechGrpcService; + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + // 세션이 열릴 때마다 새로운 gRPC 스트림을 생성 + StreamObserver responseObserver = new StreamObserver() { + @Override + public void onNext(NestResponse value) { + // 서버로부터 받은 응답 처리 + try { + String contents = value.getContents(); // JSON 문자열 + + // JSON 파싱 + JsonParser parser = new JsonParser(); + JsonObject jsonObject = parser.parse(contents).getAsJsonObject(); + + if (jsonObject.has("transcription")) { + JsonObject transcription = jsonObject.getAsJsonObject("transcription"); + String text = transcription.get("text").getAsString(); + // 클라이언트로 text 필드만 전송 + session.sendMessage(new TextMessage(text)); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void onError(Throwable t) { + t.printStackTrace(); + try { + session.sendMessage(new TextMessage("오류 발생: " + t.getMessage())); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Override + public void onCompleted() { + // 스트림 완료 처리 + try { + session.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + }; + + // 오디오 데이터를 전송할 StreamObserver 생성 + StreamObserver requestObserver = clovaSpeechGrpcService.recognize(responseObserver); + clientObservers.put(session.getId(), requestObserver); + } + + @Override + protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception { + // 클라이언트로부터 받은 오디오 데이터를 gRPC 서비스로 전달 + StreamObserver requestObserver = clientObservers.get(session.getId()); + if (requestObserver != null) { + byte[] audioData = message.getPayload().array(); + ByteString audioChunk = ByteString.copyFrom(audioData); + requestObserver.onNext(audioChunk); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { + // 세션이 종료되면 gRPC 스트림도 종료 + StreamObserver requestObserver = clientObservers.remove(session.getId()); + if (requestObserver != null) { + requestObserver.onCompleted(); + } + } +} diff --git a/src/main/java/com/splanet/splanet/core/properties/ClovaProperties.java b/src/main/java/com/splanet/splanet/core/properties/ClovaProperties.java new file mode 100644 index 00000000..d223958d --- /dev/null +++ b/src/main/java/com/splanet/splanet/core/properties/ClovaProperties.java @@ -0,0 +1,15 @@ +package com.splanet.splanet.core.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "clova.speech") +public class ClovaProperties { + private String clientSecret; + private String language; +} diff --git a/src/main/java/com/splanet/splanet/core/properties/JwtProperties.java b/src/main/java/com/splanet/splanet/core/properties/JwtProperties.java new file mode 100644 index 00000000..3c787400 --- /dev/null +++ b/src/main/java/com/splanet/splanet/core/properties/JwtProperties.java @@ -0,0 +1,14 @@ +package com.splanet.splanet.core.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + private String secret; +} diff --git a/src/main/java/com/splanet/splanet/friend/controller/FriendApi.java b/src/main/java/com/splanet/splanet/friend/controller/FriendApi.java new file mode 100644 index 00000000..d3a2114e --- /dev/null +++ b/src/main/java/com/splanet/splanet/friend/controller/FriendApi.java @@ -0,0 +1,50 @@ +package com.splanet.splanet.friend.controller; + +import com.splanet.splanet.friend.dto.FriendResponse; +import com.splanet.splanet.plan.dto.PlanResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +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.RequestMapping; + +import java.util.List; +import java.util.Map; + +@RequestMapping("/api/friends") +@Tag(name = "Friend", description = "친구 관련 API") +public interface FriendApi { + + @GetMapping + @Operation(summary = "친구 목록 조회", description = "사용자의 친구 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "친구 목록이 성공적으로 조회되었습니다."), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.") + }) + ResponseEntity> getFriends( + @Parameter(description = "JWT 인증으로 전달된 사용자 ID", required = true) @AuthenticationPrincipal Long userId); + + @GetMapping("/{friendId}/plans") + @Operation(summary = "친구 플랜 조회", description = "친구의 공개된 플랜 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "친구의 공개된 계획이 성공적으로 조회되었습니다."), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다 (유효하지 않은 친구 ID)."), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다."), + @ApiResponse(responseCode = "404", description = "친구를 찾을 수 없습니다.") + }) + ResponseEntity> getFriendPlan( + @Parameter(description = "조회할 친구 ID", required = true) @PathVariable("friendId") Long friendId, + @Parameter(description = "JWT 인증으로 전달된 사용자 ID", required = true) @AuthenticationPrincipal Long userId); + + @DeleteMapping("/{friendId}") + @Operation(summary = "친구 삭제하기", description = "친구 목록에서 삭제합니다.") + ResponseEntity> unfriend( + @Parameter(description = "삭제할 친구 ID", required = true) @PathVariable("friendId") Long friendId, + @Parameter(description = "JWT 인증으로 전달된 사용자 ID", required = true) @AuthenticationPrincipal Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friend/controller/FriendController.java b/src/main/java/com/splanet/splanet/friend/controller/FriendController.java new file mode 100644 index 00000000..ebbbccc7 --- /dev/null +++ b/src/main/java/com/splanet/splanet/friend/controller/FriendController.java @@ -0,0 +1,45 @@ +package com.splanet.splanet.friend.controller; + +import com.splanet.splanet.friend.dto.FriendResponse; +import com.splanet.splanet.friend.service.FriendService; +import com.splanet.splanet.plan.dto.PlanResponseDto; +import com.splanet.splanet.plan.repository.PlanRepository; +import com.splanet.splanet.user.repository.UserRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +public class FriendController implements FriendApi { + + private final FriendService friendService; + + public FriendController(FriendService friendService) { + this.friendService = friendService;; + } + + @Override + public ResponseEntity> getFriends(Long userId) { + List friends = friendService.getFriends(userId); + return ResponseEntity.ok(friends); + } + + @Override + public ResponseEntity> getFriendPlan( + @PathVariable Long friendId, + @AuthenticationPrincipal Long userId) { + return friendService.getFriendPlan(friendId, userId); + } + + @Override + public ResponseEntity> unfriend( + @PathVariable Long friendId, + @AuthenticationPrincipal Long userId) { + ResponseEntity> responseEntity = friendService.unfriend(friendId, userId); + return responseEntity; + } +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friend/dto/FriendResponse.java b/src/main/java/com/splanet/splanet/friend/dto/FriendResponse.java new file mode 100644 index 00000000..1ba32453 --- /dev/null +++ b/src/main/java/com/splanet/splanet/friend/dto/FriendResponse.java @@ -0,0 +1,4 @@ +package com.splanet.splanet.friend.dto; + +public record FriendResponse(Long userId, String nickname, String profileImage) { +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friend/entity/Friend.java b/src/main/java/com/splanet/splanet/friend/entity/Friend.java index 0036adbf..8be6e070 100644 --- a/src/main/java/com/splanet/splanet/friend/entity/Friend.java +++ b/src/main/java/com/splanet/splanet/friend/entity/Friend.java @@ -1,6 +1,7 @@ package com.splanet.splanet.friend.entity; import com.splanet.splanet.core.BaseEntity; +import com.splanet.splanet.user.entity.User; import jakarta.persistence.*; import lombok.*; import lombok.experimental.SuperBuilder; @@ -13,9 +14,11 @@ @Entity public class Friend extends BaseEntity { - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; - @Column(name = "friend_id", nullable = false) - private Long friendId; + @ManyToOne + @JoinColumn(name = "friend_id", nullable = false) + private User friend; } \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friend/repository/FriendRepository.java b/src/main/java/com/splanet/splanet/friend/repository/FriendRepository.java index 48b5fc00..03b87d14 100644 --- a/src/main/java/com/splanet/splanet/friend/repository/FriendRepository.java +++ b/src/main/java/com/splanet/splanet/friend/repository/FriendRepository.java @@ -2,8 +2,20 @@ import com.splanet.splanet.friend.entity.Friend; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; + + @Repository public interface FriendRepository extends JpaRepository { + List findByUserId(Long userId); + boolean existsByUserIdAndFriendId(Long userId, Long friendId); + + @Modifying + @Query("DELETE FROM Friend f WHERE f.user.id = :requesterId AND f.friend.id = :receiverId") + void deleteByRequesterIdAndReceiverId(@Param("requesterId") Long requesterId, @Param("receiverId") Long receiverId); } \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friend/service/FriendService.java b/src/main/java/com/splanet/splanet/friend/service/FriendService.java new file mode 100644 index 00000000..93d6891d --- /dev/null +++ b/src/main/java/com/splanet/splanet/friend/service/FriendService.java @@ -0,0 +1,103 @@ +package com.splanet.splanet.friend.service; + +import com.splanet.splanet.core.exception.BusinessException; +import com.splanet.splanet.core.exception.ErrorCode; +import com.splanet.splanet.friend.dto.FriendResponse; +import com.splanet.splanet.friend.entity.Friend; +import com.splanet.splanet.friend.repository.FriendRepository; +import com.splanet.splanet.friendRequest.entity.FriendRequest; +import com.splanet.splanet.friendRequest.repository.FriendRequestRepository; +import com.splanet.splanet.plan.dto.PlanResponseDto; +import com.splanet.splanet.plan.entity.Plan; +import com.splanet.splanet.plan.repository.PlanRepository; +import com.splanet.splanet.user.entity.User; +import com.splanet.splanet.user.repository.UserRepository; +import jakarta.transaction.Transactional; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class FriendService { + + private final FriendRepository friendRepository; + private final PlanRepository planRepository; + private final FriendRequestRepository friendRequestRepository; + + public FriendService(FriendRepository friendRepository, PlanRepository planRepository, FriendRequestRepository friendRequestRepository) { + this.friendRepository = friendRepository; + this.planRepository = planRepository; + this.friendRequestRepository = friendRequestRepository; + } + + // 친구 목록 조회 + public List getFriends(Long userId) { + List friends = friendRepository.findByUserId(userId); + + // Friend 엔티티를 FriendResponse DTO로 변환 + return friends.stream() + .map(friend -> { + User friendUser = friend.getFriend(); + return new FriendResponse( + friendUser.getId(), + friendUser.getNickname(), + friendUser.getProfileImage() + ); + }) + .collect(Collectors.toList()); + } + + // 친구의 공개 플랜 조회 + public ResponseEntity> getFriendPlan(Long friendId, Long userId) { + // 친구 목록에 friendId가 있는지 확인 + boolean isFriend = friendRepository.existsByUserIdAndFriendId(userId, friendId); + + if (!isFriend) { + throw new BusinessException(ErrorCode.FRIEND_NOT_FOUND); + } + + List publicPlans = planRepository.findAllByUserIdAndAccessibility(friendId, true); + + // 공개된 플랜이 없을 경우, 빈 목록 반환 + if (publicPlans.isEmpty()) { + return ResponseEntity.ok(Collections.emptyList()); + } + + List planResponseDtos = publicPlans.stream() + .map(plan -> PlanResponseDto.builder() + .id(plan.getId()) + .title(plan.getTitle()) + .description(plan.getDescription()) + .startDate(plan.getStartDate()) + .endDate(plan.getEndDate()) + .accessibility(plan.getAccessibility()) + .isCompleted(plan.getIsCompleted()) + .createdAt(plan.getCreatedAt()) + .updatedAt(plan.getUpdatedAt()) + .build()) + .collect(Collectors.toList()); + + return ResponseEntity.ok(planResponseDtos); + } + + // 친구 삭제(취소)하기 + @Transactional + public ResponseEntity> unfriend(Long friendId, Long userId) { + if (!friendRepository.existsByUserIdAndFriendId(userId, friendId)) { + throw new BusinessException(ErrorCode.FRIEND_NOT_FOUND); + } + + friendRepository.deleteByRequesterIdAndReceiverId(userId, friendId); + + List pendingRequests = friendRequestRepository.findPendingRequestsByReceiverId(userId, friendId, FriendRequest.Status.PENDING); + for (FriendRequest request : pendingRequests) { + friendRequestRepository.delete(request); + } + + return ResponseEntity.ok(Map.of("message", "친구 맺기 취소되었습니다!")); + } +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friendRequest/controller/FriendRequestApi.java b/src/main/java/com/splanet/splanet/friendRequest/controller/FriendRequestApi.java new file mode 100644 index 00000000..b31c72d5 --- /dev/null +++ b/src/main/java/com/splanet/splanet/friendRequest/controller/FriendRequestApi.java @@ -0,0 +1,72 @@ +package com.splanet.splanet.friendRequest.controller; + +import com.splanet.splanet.friendRequest.dto.FriendRequestCreateRequest; +import com.splanet.splanet.friendRequest.dto.ReceivedFriendRequestResponse; +import com.splanet.splanet.friendRequest.dto.SentFriendRequestResponse; +import com.splanet.splanet.friendRequest.dto.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequestMapping("/api/friends/requests") +@Tag(name = "FriendRequest", description = "친구 요청 관련 API") +public interface FriendRequestApi { + + @PostMapping + @Operation(summary = "친구 요청", description = "특정 사용자에게 친구 요청을 보냅니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "친구 요청이 성공적으로 전송되었습니다."), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다 (유효하지 않은 유저 ID)."), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.") + }) + ResponseEntity sendFriendRequest( + @Parameter(description = "친구 요청을 보낼 사용자 ID", required = true) @AuthenticationPrincipal Long userId, + @RequestBody FriendRequestCreateRequest request); + + @PostMapping("/{requestId}/accept") + @Operation(summary = "친구 요청 수락", description = "친구 요청 수락") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "친구 요청 성공적으로 수락되었습니다."), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다 (유효하지 않은 유저 ID)."), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다."), + @ApiResponse(responseCode = "404", description = "친구 요청을 찾을 수 없습니다.") + }) + ResponseEntity acceptFriendRequest(@AuthenticationPrincipal Long userId, + @Parameter(description = "친구 요청 ID", required = true) @PathVariable Long requestId); + + @PostMapping("/{requestId}/reject") + @Operation(summary = "친구 요청 거절", description = "친구 요청 거절") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "친구 요청이 성공적으로 거절되었습니다."), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다."), + @ApiResponse(responseCode = "404", description = "친구 요청을 찾을 수 없습니다."), + @ApiResponse(responseCode = "404", description = "친구 요청을 찾을 수 없습니다.") + }) + ResponseEntity rejectFriendRequest( + @Parameter(description = "친구 요청 ID", required = true) @PathVariable Long requestId, @AuthenticationPrincipal Long userId); + + @GetMapping("/received") + @Operation(summary = "친구 요청 목록 조회 (받은 요청)", description = "사용자가 받은 친구 요청 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "받은 친구 요청 목록이 성공적으로 조회되었습니다."), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.") + }) + ResponseEntity> getReceivedRequests( + @Parameter(description = "JWT 인증으로 전달된 사용자 ID", required = true) @AuthenticationPrincipal Long userId); + + @GetMapping("/sent") + @Operation(summary = "친구 요청 목록 조회 (보낸 요청)", description = "사용자가 보낸 친구 요청 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "보낸 친구 요청 목록이 성공적으로 조회되었습니다."), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.") + }) + ResponseEntity> getSentRequests( + @Parameter(description = "JWT 인증으로 전달된 사용자 ID", required = true) @AuthenticationPrincipal Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friendRequest/controller/FriendRequestController.java b/src/main/java/com/splanet/splanet/friendRequest/controller/FriendRequestController.java new file mode 100644 index 00000000..a952084b --- /dev/null +++ b/src/main/java/com/splanet/splanet/friendRequest/controller/FriendRequestController.java @@ -0,0 +1,61 @@ +package com.splanet.splanet.friendRequest.controller; + +import com.splanet.splanet.friendRequest.dto.FriendRequestCreateRequest; +import com.splanet.splanet.friendRequest.dto.ReceivedFriendRequestResponse; +import com.splanet.splanet.friendRequest.dto.SentFriendRequestResponse; +import com.splanet.splanet.friendRequest.dto.SuccessResponse; +import com.splanet.splanet.friendRequest.service.FriendRequestService; +import com.splanet.splanet.user.repository.UserRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +public class FriendRequestController implements FriendRequestApi{ + + private final FriendRequestService friendRequestService; + private final UserRepository userRepository; + + public FriendRequestController(FriendRequestService friendRequestService, UserRepository userRepository) { + this.friendRequestService = friendRequestService; + this.userRepository = userRepository; + } + + // 친구 요청 전송 + @Override + public ResponseEntity sendFriendRequest(@AuthenticationPrincipal Long userId, + @RequestBody FriendRequestCreateRequest request) { + Long receiverId = request.receiverId(); + friendRequestService.sendFriendRequest(userId, receiverId); + SuccessResponse response = new SuccessResponse("친구 요청이 성공적으로 전송되었습니다."); + + return ResponseEntity.ok(response); + } + + // 친구 요청 수락 + @Override + public ResponseEntity acceptFriendRequest(@AuthenticationPrincipal Long userId, + @PathVariable Long requestId) { + ReceivedFriendRequestResponse response = friendRequestService.acceptFriendRequest(requestId, userId); + return ResponseEntity.ok(response); + } + + // 친구 요청 거절 + @Override + public ResponseEntity rejectFriendRequest(@PathVariable Long requestId, @AuthenticationPrincipal Long userId) { + return ResponseEntity.ok(friendRequestService.rejectFriendRequest(requestId, userId)); + } + + // 친구 요청 목록 조회(받은 요청) + public ResponseEntity> getReceivedRequests(@AuthenticationPrincipal Long userId) { + return ResponseEntity.ok(friendRequestService.getReceivedFriendRequests(userId)); + } + + // 친구 요청 목록 조회(보낸 요청) + @Override + public ResponseEntity> getSentRequests(@AuthenticationPrincipal Long userId) { + return ResponseEntity.ok(friendRequestService.getSentFriendRequests(userId)); + } +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friendRequest/dto/FriendRequestCreateRequest.java b/src/main/java/com/splanet/splanet/friendRequest/dto/FriendRequestCreateRequest.java new file mode 100644 index 00000000..3b1c5260 --- /dev/null +++ b/src/main/java/com/splanet/splanet/friendRequest/dto/FriendRequestCreateRequest.java @@ -0,0 +1,3 @@ +package com.splanet.splanet.friendRequest.dto; + +public record FriendRequestCreateRequest(Long receiverId) {} diff --git a/src/main/java/com/splanet/splanet/friendRequest/dto/ReceivedFriendRequestResponse.java b/src/main/java/com/splanet/splanet/friendRequest/dto/ReceivedFriendRequestResponse.java new file mode 100644 index 00000000..913507f0 --- /dev/null +++ b/src/main/java/com/splanet/splanet/friendRequest/dto/ReceivedFriendRequestResponse.java @@ -0,0 +1,10 @@ +package com.splanet.splanet.friendRequest.dto; + +// 받은 요청 정보담는 dto +public record ReceivedFriendRequestResponse( + Long id, + Long requesterId, + String requesterName, + String status, + String profileImage +) {} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friendRequest/dto/SentFriendRequestResponse.java b/src/main/java/com/splanet/splanet/friendRequest/dto/SentFriendRequestResponse.java new file mode 100644 index 00000000..9f8b0aa8 --- /dev/null +++ b/src/main/java/com/splanet/splanet/friendRequest/dto/SentFriendRequestResponse.java @@ -0,0 +1,10 @@ +package com.splanet.splanet.friendRequest.dto; + +// 보낸 요청 정보담는 dto +public record SentFriendRequestResponse( + Long id, + Long receiverId, + String receiverName, + String status, + String profileImage +) {} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friendRequest/dto/SuccessResponse.java b/src/main/java/com/splanet/splanet/friendRequest/dto/SuccessResponse.java new file mode 100644 index 00000000..36e959e9 --- /dev/null +++ b/src/main/java/com/splanet/splanet/friendRequest/dto/SuccessResponse.java @@ -0,0 +1,3 @@ +package com.splanet.splanet.friendRequest.dto; + +public record SuccessResponse(String message) {} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friendRequest/entity/FriendRequest.java b/src/main/java/com/splanet/splanet/friendRequest/entity/FriendRequest.java index c89544ca..f4bb62a5 100644 --- a/src/main/java/com/splanet/splanet/friendRequest/entity/FriendRequest.java +++ b/src/main/java/com/splanet/splanet/friendRequest/entity/FriendRequest.java @@ -1,6 +1,8 @@ package com.splanet.splanet.friendRequest.entity; import com.splanet.splanet.core.BaseEntity; +import com.splanet.splanet.friend.entity.Friend; +import com.splanet.splanet.user.entity.User; import jakarta.persistence.*; import lombok.*; import lombok.experimental.SuperBuilder; @@ -13,11 +15,13 @@ @Entity public class FriendRequest extends BaseEntity { - @Column(name = "requester_id", nullable = false) - private Long requesterId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "requester_id", nullable = false) + private User requester; - @Column(name = "receiver_id", nullable = false) - private Long receiverId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id", nullable = false) + private User receiver; @Enumerated(EnumType.STRING) @Column(name = "status") @@ -34,4 +38,11 @@ public enum Status { ACCEPTED, REJECTED } + + public Friend accept() { + return Friend.builder() + .user(requester) + .friend(receiver) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friendRequest/repository/FriendRequestRepository.java b/src/main/java/com/splanet/splanet/friendRequest/repository/FriendRequestRepository.java index 26560222..96ff90e3 100644 --- a/src/main/java/com/splanet/splanet/friendRequest/repository/FriendRequestRepository.java +++ b/src/main/java/com/splanet/splanet/friendRequest/repository/FriendRequestRepository.java @@ -2,8 +2,27 @@ import com.splanet.splanet.friendRequest.entity.FriendRequest; 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; +import java.util.List; + @Repository public interface FriendRequestRepository extends JpaRepository { + + @Query("SELECT fr FROM FriendRequest fr JOIN FETCH fr.requester WHERE fr.receiver.id = :userId") + List findByReceiverIdWithRequester(@Param("userId") Long userId); + + @Query("SELECT fr FROM FriendRequest fr JOIN FETCH fr.receiver WHERE fr.requester.id = :userId") + List findByRequesterIdWithReceiver(@Param("userId") Long userId); + + @Query("SELECT fr FROM FriendRequest fr WHERE fr.receiver.id = :receiverId AND fr.requester.id = :requesterId AND fr.status = :status") + List findPendingRequestsByReceiverId(@Param("receiverId") Long receiverId, @Param("requesterId") Long requesterId, @Param("status") FriendRequest.Status status); + + @Query("SELECT fr FROM FriendRequest fr WHERE fr.receiver.id = :userId") + List findByReceiverId(@Param("userId") Long userId); + + @Query("SELECT fr FROM FriendRequest fr WHERE fr.requester.id = :userId") + List findByRequesterId(@Param("userId") Long userId); } \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/friendRequest/service/FriendRequestService.java b/src/main/java/com/splanet/splanet/friendRequest/service/FriendRequestService.java new file mode 100644 index 00000000..19f27554 --- /dev/null +++ b/src/main/java/com/splanet/splanet/friendRequest/service/FriendRequestService.java @@ -0,0 +1,213 @@ +package com.splanet.splanet.friendRequest.service; + +import com.splanet.splanet.core.exception.BusinessException; +import com.splanet.splanet.core.exception.ErrorCode; +import com.splanet.splanet.friend.entity.Friend; +import com.splanet.splanet.friend.repository.FriendRepository; +import com.splanet.splanet.friendRequest.dto.ReceivedFriendRequestResponse; +import com.splanet.splanet.friendRequest.dto.SentFriendRequestResponse; +import com.splanet.splanet.friendRequest.entity.FriendRequest; +import com.splanet.splanet.friendRequest.repository.FriendRequestRepository; +import com.splanet.splanet.user.entity.User; +import com.splanet.splanet.user.repository.UserRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class FriendRequestService { + + private final FriendRequestRepository friendRequestRepository; + private final FriendRepository friendRepository; + private final UserRepository userRepository; + + public FriendRequestService(FriendRequestRepository friendRequestRepository, UserRepository userRepository, FriendRepository friendRepository) { + this.friendRequestRepository = friendRequestRepository; + this.userRepository = userRepository; + this.friendRepository = friendRepository; + } + + // 친구 요청 전송 + public void sendFriendRequest(Long userId, Long receiverId) { + // 본인에게 요청 보낼 수 없음 + if (userId.equals(receiverId)) { + throw new BusinessException(ErrorCode.SELF_FRIEND_REQUEST_NOT_ALLOWED); + } + + // 요청자가 이미 친구 목록에 있는지 확인 + if (friendRepository.existsByUserIdAndFriendId(userId, receiverId)) { + throw new BusinessException(ErrorCode.FRIEND_ALREADY_EXISTS); + } + + // 이미 보낸 요청이 있는지 확인 + List existingRequests = friendRequestRepository.findPendingRequestsByReceiverId(receiverId, userId, FriendRequest.Status.PENDING); + if (!existingRequests.isEmpty()) { + throw new BusinessException(ErrorCode.FRIEND_REQUEST_ALREADY_SENT); + } + + User receiver = userRepository.findById(receiverId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + User requester = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + FriendRequest friendRequest = FriendRequest.builder() + .requester(requester) + .receiver(receiver) + .status(FriendRequest.Status.PENDING) + .build(); + + friendRequestRepository.save(friendRequest); + } + + // 친구 요청 수락 + public ReceivedFriendRequestResponse acceptFriendRequest(Long requestId, Long userId) { + FriendRequest friendRequest = friendRequestRepository.findById(requestId) + .orElseThrow(() -> new BusinessException(ErrorCode.FRIEND_REQUEST_NOT_FOUND)); + + if (friendRequest.getStatus() != FriendRequest.Status.PENDING) { + throw new BusinessException(ErrorCode.FRIEND_REQUEST_ALREADY_ACCEPTED_OR_REJECTED); + } + + // 요청자 본인이 수락을 시도할 경우, 내가 보낸 요청 처리 할 수 없음 + if (friendRequest.getRequester().getId().equals(userId)) { + throw new BusinessException(ErrorCode.FRIEND_REQUEST_NOT_RECEIVER); + } + + // 내가 받은 요청이 아닐 경우, 예외처리 + if (!friendRequest.getReceiver().getId().equals(userId)) { + throw new BusinessException(ErrorCode.FRIEND_REQUEST_NOT_FOUND_IN_RECEIVED_LIST); + } + + List receivedRequests = getReceivedFriendRequests(userId); + + boolean isRequestPresent = receivedRequests.stream() + .anyMatch(request -> request.requesterId().equals(friendRequest.getRequester().getId())); + + // 받은 요청 목록에 없어도 예외처리 + if (!isRequestPresent) { + throw new BusinessException(ErrorCode.FRIEND_REQUEST_NOT_FOUND_IN_RECEIVED_LIST); + } + + FriendRequest updatedFriendRequest = FriendRequest.builder() + .id(friendRequest.getId()) + .requester(friendRequest.getRequester()) + .receiver(friendRequest.getReceiver()) + .status(FriendRequest.Status.ACCEPTED) + .createdAt(friendRequest.getCreatedAt()) + .updatedAt(LocalDateTime.now()) + .build(); + + friendRequestRepository.save(updatedFriendRequest); + + User requester = friendRequest.getRequester(); + User receiver = friendRequest.getReceiver(); + + Friend friend1 = Friend.builder() + .user(requester) + .friend(receiver) + .build(); + + Friend friend2 = Friend.builder() + .user(receiver) + .friend(requester) + .build(); + + friendRepository.save(friend1); + friendRepository.save(friend2); + + return new ReceivedFriendRequestResponse( + friendRequest.getId(), + requester.getId(), + requester.getNickname(), + friendRequest.getStatus().name(), + requester.getProfileImage() + ); + } + + // 친구 요청 거절 + public ReceivedFriendRequestResponse rejectFriendRequest(Long requestId, Long userId) { + FriendRequest friendRequest = friendRequestRepository.findById(requestId) + .orElseThrow(() -> new BusinessException(ErrorCode.FRIEND_REQUEST_NOT_FOUND)); + + if (friendRequest.getStatus() != FriendRequest.Status.PENDING) { + throw new BusinessException(ErrorCode.FRIEND_REQUEST_ALREADY_ACCEPTED_OR_REJECTED); + } + + // 요청자 본인이 거절을 시도할 경우, 내가 보낸 요청 처리 할 수 없음 + if (friendRequest.getRequester().getId().equals(userId)) { + throw new BusinessException(ErrorCode.FRIEND_REQUEST_NOT_RECEIVER); + } + + // 내가 받은 요청이 아닐 경우, 예외처리 + if (!friendRequest.getReceiver().getId().equals(userId)) { + throw new BusinessException(ErrorCode.FRIEND_REQUEST_NOT_FOUND_IN_RECEIVED_LIST); + } + + List receivedRequests = getReceivedFriendRequests(userId); + + boolean isRequestPresent = receivedRequests.stream() + .anyMatch(request -> request.requesterId().equals(friendRequest.getRequester().getId())); + + // 받은 요청 목록에 없어도 예외처리 + if (!isRequestPresent) { + throw new BusinessException(ErrorCode.FRIEND_REQUEST_NOT_FOUND_IN_RECEIVED_LIST); + } + + FriendRequest updatedFriendRequest = FriendRequest.builder() + .id(friendRequest.getId()) + .requester(friendRequest.getRequester()) + .receiver(friendRequest.getReceiver()) + .status(FriendRequest.Status.REJECTED) + .createdAt(friendRequest.getCreatedAt()) + .updatedAt(LocalDateTime.now()) + .build(); + + friendRequestRepository.save(updatedFriendRequest); + + User requester = friendRequest.getRequester(); + + return new ReceivedFriendRequestResponse( + friendRequest.getId(), + requester.getId(), + requester.getNickname(), + updatedFriendRequest.getStatus().name(), + requester.getProfileImage() + ); + } + + // 친구 요청 목록 조회(받은 요청) + public List getReceivedFriendRequests(Long userId) { + List requests = friendRequestRepository.findByReceiverIdWithRequester(userId); + + // PENDING인 요청만 + return requests.stream() + .filter(request -> request.getStatus() == FriendRequest.Status.PENDING) + .map(request -> new ReceivedFriendRequestResponse( + request.getId(), + request.getRequester().getId(), + request.getRequester().getNickname(), + request.getStatus().name(), + request.getRequester().getProfileImage() + )) + .collect(Collectors.toList()); + } + + // 친구 요청 목록 조회(보낸 요청) + public List getSentFriendRequests(Long userId) { + List requests = friendRequestRepository.findByRequesterIdWithReceiver(userId); + + // PENDING인 요청만 + return requests.stream() + .filter(request -> request.getStatus() == FriendRequest.Status.PENDING) + .map(request -> new SentFriendRequestResponse( + request.getId(), + request.getReceiver().getId(), + request.getReceiver().getNickname(), + request.getStatus().name(), + request.getRequester().getProfileImage() + )) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/gpt/Message.java b/src/main/java/com/splanet/splanet/gpt/Message.java new file mode 100644 index 00000000..c64ff08c --- /dev/null +++ b/src/main/java/com/splanet/splanet/gpt/Message.java @@ -0,0 +1,11 @@ +package com.splanet.splanet.gpt; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +class Message { + private String role; // 메시지의 역할 (user, assistant 등) + private String content; // 메시지 내용 +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/gpt/OpenAiChatClient.java b/src/main/java/com/splanet/splanet/gpt/OpenAiChatClient.java new file mode 100644 index 00000000..e1249a10 --- /dev/null +++ b/src/main/java/com/splanet/splanet/gpt/OpenAiChatClient.java @@ -0,0 +1,57 @@ +package com.splanet.splanet.gpt; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Flux; + +import java.util.List; + +@Component +public class OpenAiChatClient { + + private final WebClient webClient; + private final OpenAiProperties openAiProperties; + private final SchedulePromptGenerator promptGenerator; + + public OpenAiChatClient(WebClient.Builder webClientBuilder, OpenAiProperties openAiProperties, SchedulePromptGenerator promptGenerator) { + this.webClient = webClientBuilder.baseUrl("https://api.openai.com/v1").build(); + this.openAiProperties = openAiProperties; + this.promptGenerator = new SchedulePromptGenerator(); + } + + // 스케줄 생성 요청 처리 메소드 + public ScheduleResponse createSchedule(ScheduleRequest scheduleRequest) throws JsonProcessingException { + String jsonResponse = call(scheduleRequest); + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.readValue(jsonResponse, ScheduleResponse.class); + } + + private String call(ScheduleRequest scheduleRequest) throws JsonProcessingException { + String jsonRequest = new ObjectMapper().writeValueAsString(new RequestBody("gpt-4o-mini", List.of( + new Message("user", promptGenerator.generateSchedulePrompt(scheduleRequest)) + ))); + + // OpenAI API 호출 + String responseJson = webClient.post() + .uri("/chat/completions") + .header("Authorization", "Bearer " + openAiProperties.getApiKey()) + .bodyValue(jsonRequest) + .retrieve() + .bodyToMono(String.class) + .block(); + + return responseJson; + } + + // 스트리밍 메소드 + public Flux stream(Prompt prompt) { + return webClient.post() + .uri("/chat/completions") + .header("Authorization", "Bearer " + openAiProperties.getApiKey()) + .bodyValue(prompt) + .retrieve() + .bodyToFlux(ScheduleResponse.class); + } +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/gpt/OpenAiProperties.java b/src/main/java/com/splanet/splanet/gpt/OpenAiProperties.java new file mode 100644 index 00000000..282fa863 --- /dev/null +++ b/src/main/java/com/splanet/splanet/gpt/OpenAiProperties.java @@ -0,0 +1,14 @@ +package com.splanet.splanet.gpt; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@ConfigurationProperties(prefix = "gpt-api-key") +@Configuration +public class OpenAiProperties { + private String apiKey; +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/gpt/Prompt.java b/src/main/java/com/splanet/splanet/gpt/Prompt.java new file mode 100644 index 00000000..27da96c1 --- /dev/null +++ b/src/main/java/com/splanet/splanet/gpt/Prompt.java @@ -0,0 +1,12 @@ +package com.splanet.splanet.gpt; + +import lombok.Getter; +import lombok.AllArgsConstructor; + +import java.util.List; + +@Getter +@AllArgsConstructor // 모든 필드를 사용하는 생성자를 자동 생성 +public class Prompt { + private List messages; // 메시지 목록 +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/gpt/RequestBody.java b/src/main/java/com/splanet/splanet/gpt/RequestBody.java new file mode 100644 index 00000000..35983b26 --- /dev/null +++ b/src/main/java/com/splanet/splanet/gpt/RequestBody.java @@ -0,0 +1,13 @@ +package com.splanet.splanet.gpt; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class RequestBody { + private String model; // 모델 이름 + private List messages; // 메시지 리스트 +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/gpt/SchedulePromptGenerator.java b/src/main/java/com/splanet/splanet/gpt/SchedulePromptGenerator.java new file mode 100644 index 00000000..f3266881 --- /dev/null +++ b/src/main/java/com/splanet/splanet/gpt/SchedulePromptGenerator.java @@ -0,0 +1,37 @@ +package com.splanet.splanet.gpt; + +import org.springframework.stereotype.Component; + +@Component +public class SchedulePromptGenerator { + + public String generateSchedulePrompt(ScheduleRequest request) { + StringBuilder prompt = new StringBuilder(); + + prompt.append("splanet은 사용자가 입력한 스케줄 정보를 바탕으로 맞춤형 플래너를 제공하는 서비스입니다. 사용자가 음성으로 입력한 정보를 분석하여 최적의 스케줄을 제시해야 합니다.\n\n") + .append("사용자가 다음과 같은 정보를 입력했습니다. 이 정보를 바탕으로 요청된 컨셉에 따라 스케줄을 추천해 주세요. 각 스케줄은 하루 24시간을 30분 단위로 쪼개고, 각 업무의 시작 시간과 종료 시간을 포함해야 합니다.\n\n") + .append("요청 정보:\n") + .append("- 스케줄 기간: \"10월 1일부터 10월 2일까지\"\n") + .append("- 업무 목록: ").append(request.getTaskList()).append("\n") + .append("- 업무 소요 시간: ").append(request.getTaskDurations()).append("\n") + .append("- 우선순위: ").append(request.getPriority()).append("\n") + .append("- 스케줄 컨셉: \"널널한 스케줄\", \"빡빡한 스케줄\"\n") + .append("- 하루를 30분 단위로 쪼갠 시간 목록: ").append(request.getTimeSlots()).append("\n") + .append("위 정보를 바탕으로 다음과 같은 조건을 준수하여 스케줄을 추천해주세요:\n") + .append("1. 요청된 모든 일정을 사용해야 합니다.\n") + .append("2. 각 스케줄은 요청된 두 개의 서로 다른 컨셉(사용자가 입력한다. 예시로는 널널한 스케줄, 빡빡한 스케줄이 있다.)을 따릅니다.\n") + .append("3. 추천 예시: 사용자가 선택할 수 있도록 두 개의 추천 스케줄을 제공합니다.\n") + .append("4. 응답 형식: 답변은 JSON 형식으로만 제공해야 하며, 다음과 같은 구조를 따라야 합니다:\n") + .append(" { \"schedules\": [{ \"concept\": \"스케줄 컨셉\", \"schedule\": [{ \"date\": \"MM-DD\", \"tasks\": [{ \"task\": \"업무명\", \"duration\": \"소요시간\", \"priority\": \"우선순위\", \"startTime\": \"시작시간\", \"endTime\": \"종료시간\" }] }] }] }] }\n") + .append("5. 형식 규칙:\n") + .append(" - 날짜는 MM-DD 형식으로, 시간은 24시간(30분 단위) 형식이어야 합니다.\n") + .append(" - 입력받은 업무를 지정된 일정 안에 모두 포함시켜야 합니다.\n") + .append(" - 우선순위는 고유한 정수 값이어야 합니다.\n") + .append(" - 업무 시간은 주어진 스케줄 기간 안에만 분할하여 채울 수 있습니다.\n") + .append(" - 입력받은 날짜 각각의 일정을 구현해야 합니다.\n") + .append("6. 예시: 결과값으로 각 날짜마다 널널한 스케줄과 빡빡한 스케줄 컨셉을 입력받으면, 스케줄1(빡빡한 스케줄): 10월1일+10월2일, 스케줄2(널널한 스케줄): 10월1일+10월2일 총 4개의 스케줄을 제시해야 합니다. 오직 스케줄 데이터 정보만 json으로 출력한다.\n") + .append("7. 제외할 내용: 일정 JSON 외에는 다른 내용이 포함되지 않아야 하며, 스케줄링과 관련 없는 질문에는 \"이와 관련된 질문에는 답변할 수 없습니다.\"라고 응답해야 합니다.\n"); + + return prompt.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/gpt/ScheduleRequest.java b/src/main/java/com/splanet/splanet/gpt/ScheduleRequest.java new file mode 100644 index 00000000..e6f254e0 --- /dev/null +++ b/src/main/java/com/splanet/splanet/gpt/ScheduleRequest.java @@ -0,0 +1,19 @@ +package com.splanet.splanet.gpt; + +import lombok.*; + +import java.util.List; +import java.util.Map; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleRequest { + private String schedulePeriod; // 스케줄 기간 + private List taskList; // 업무 목록 + private Map taskDurations; // 업무 소요 시간 (업무명: 소요시간) + private Map priority; // 업무 우선순위 (업무명: 우선순위) + private List scheduleConcepts; // 스케줄 컨셉 목록 + private List timeSlots; // 30분 단위 시간 목록 +} diff --git a/src/main/java/com/splanet/splanet/gpt/ScheduleResponse.java b/src/main/java/com/splanet/splanet/gpt/ScheduleResponse.java new file mode 100644 index 00000000..86b4dada --- /dev/null +++ b/src/main/java/com/splanet/splanet/gpt/ScheduleResponse.java @@ -0,0 +1,33 @@ +package com.splanet.splanet.gpt; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class ScheduleResponse { + private List schedules; // 스케줄 리스트 + + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class Schedule { + private String concept; // 스케줄 컨셉 + private List tasks; // 업무 리스트 + + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class Task { + private String task; // 업무명 + private String duration; // 소요 시간 + private int priority; // 우선순위 + private String startTime; // 시작 시간 + private String endTime; // 종료 시간 + } + } +} \ No newline at end of file diff --git a/src/main/java/com/splanet/splanet/jwt/JwtAuthenticationFilter.java b/src/main/java/com/splanet/splanet/jwt/JwtAuthenticationFilter.java index 68f0a513..26142f8b 100644 --- a/src/main/java/com/splanet/splanet/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/splanet/splanet/jwt/JwtAuthenticationFilter.java @@ -76,7 +76,7 @@ private boolean isApiPath(String requestURI) { } private boolean isExemptedPath(String requestURI) { - return requestURI.equals("/api/users/create") || requestURI.startsWith("/api/token"); + return requestURI.equals("/api/users/create") || requestURI.startsWith("/api/token") || requestURI.startsWith("/api/stt"); } private void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException { diff --git a/src/main/java/com/splanet/splanet/jwt/JwtTokenProvider.java b/src/main/java/com/splanet/splanet/jwt/JwtTokenProvider.java index 405b5445..afa88a23 100644 --- a/src/main/java/com/splanet/splanet/jwt/JwtTokenProvider.java +++ b/src/main/java/com/splanet/splanet/jwt/JwtTokenProvider.java @@ -2,6 +2,7 @@ import com.splanet.splanet.core.exception.BusinessException; import com.splanet.splanet.core.exception.ErrorCode; +import com.splanet.splanet.core.properties.JwtProperties; import io.jsonwebtoken.*; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.HttpServletRequest; @@ -18,16 +19,17 @@ public class JwtTokenProvider { private Key secretKey; - @Value("${jwt.secret}") - private String secret; - private static final long TOKEN_VALIDITY_IN_MILLISECONDS = 3600000; // 1시간 private static final long REFRESH_TOKEN_VALIDITY_IN_MILLISECONDS = 604800000; // 7일 + private final JwtProperties jwtProperties; + public JwtTokenProvider(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + } @PostConstruct protected void init() { - this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName()); + this.secretKey = new SecretKeySpec(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName()); } public String createAccessToken(Long userId) { diff --git a/src/main/java/com/splanet/splanet/plan/repository/PlanRepository.java b/src/main/java/com/splanet/splanet/plan/repository/PlanRepository.java index a3f67b6b..41e4604f 100644 --- a/src/main/java/com/splanet/splanet/plan/repository/PlanRepository.java +++ b/src/main/java/com/splanet/splanet/plan/repository/PlanRepository.java @@ -9,4 +9,5 @@ @Repository public interface PlanRepository extends JpaRepository { List findAllByUserId(Long userId); + List findAllByUserIdAndAccessibility(Long userId, Boolean accessibility); } diff --git a/src/main/java/com/splanet/splanet/previewplan/entity/PlanCard.java b/src/main/java/com/splanet/splanet/previewplan/entity/PlanCard.java index 43f3f28d..54121a5f 100644 --- a/src/main/java/com/splanet/splanet/previewplan/entity/PlanCard.java +++ b/src/main/java/com/splanet/splanet/previewplan/entity/PlanCard.java @@ -29,8 +29,8 @@ public class PlanCard { private String startDate; private String endDate; - @TimeToLive(unit = TimeUnit.HOURS) - private Long expiration = 1L; + @TimeToLive + private Long expiration; public static String generateId() { return UUID.randomUUID().toString().split("-")[0]; diff --git a/src/main/java/com/splanet/splanet/previewplan/entity/PlanGroup.java b/src/main/java/com/splanet/splanet/previewplan/entity/PlanGroup.java index abf99d59..7708647b 100644 --- a/src/main/java/com/splanet/splanet/previewplan/entity/PlanGroup.java +++ b/src/main/java/com/splanet/splanet/previewplan/entity/PlanGroup.java @@ -25,6 +25,6 @@ public class PlanGroup { private String groupId; private Set planCardIds; - @TimeToLive(unit = TimeUnit.HOURS) - private Long expiration = 1L; + @TimeToLive + private Long expiration; } diff --git a/src/main/java/com/splanet/splanet/previewplan/entity/PreviewPlan.java b/src/main/java/com/splanet/splanet/previewplan/entity/PreviewPlan.java index 5665bbb4..f88525cd 100644 --- a/src/main/java/com/splanet/splanet/previewplan/entity/PreviewPlan.java +++ b/src/main/java/com/splanet/splanet/previewplan/entity/PreviewPlan.java @@ -1,6 +1,7 @@ package com.splanet.splanet.previewplan.entity; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.data.annotation.Id; @@ -13,6 +14,7 @@ @Getter @NoArgsConstructor @AllArgsConstructor +@Builder @RedisHash("previewPlan") public class PreviewPlan { @@ -21,6 +23,6 @@ public class PreviewPlan { private List groupIds; - @TimeToLive(unit = TimeUnit.HOURS) - private Long expiration = 1L; + @TimeToLive + private Long expiration; } diff --git a/src/main/java/com/splanet/splanet/previewplan/service/PreviewPlanService.java b/src/main/java/com/splanet/splanet/previewplan/service/PreviewPlanService.java index e8daaa8e..485767d4 100644 --- a/src/main/java/com/splanet/splanet/previewplan/service/PreviewPlanService.java +++ b/src/main/java/com/splanet/splanet/previewplan/service/PreviewPlanService.java @@ -24,6 +24,7 @@ @RequiredArgsConstructor public class PreviewPlanService { + private static final long EXPIRATION_TIME = 3600L; private static final String PLAN_GROUP_PREFIX = "planGroup:"; private static final String PLAN_CARD_PREFIX = "planCard:"; private static final int PLAN_CARD_PREFIX_LENGTH = PLAN_CARD_PREFIX.length(); @@ -94,6 +95,7 @@ private PlanCard buildPlanCard(String customKey, String deviceId, String groupId .description(planCardRequestDto.description()) .startDate(planCardRequestDto.startDate()) .endDate(planCardRequestDto.endDate()) + .expiration(EXPIRATION_TIME) .build(); } @@ -101,9 +103,11 @@ private void updatePlanGroup(String deviceId, String groupId, String cardId, boo String groupKey = generateGroupKey(deviceId, groupId); PlanGroup planGroup = planGroupRepository.findById(groupKey) .orElse(PlanGroup.builder() + .customKey(groupKey) .deviceId(deviceId) .groupId(groupId) .planCardIds(new HashSet<>()) + .expiration(EXPIRATION_TIME) .build()); if (add) { diff --git a/src/main/java/com/splanet/splanet/stt/service/ClovaSpeechGrpcService.java b/src/main/java/com/splanet/splanet/stt/service/ClovaSpeechGrpcService.java new file mode 100644 index 00000000..47cd0257 --- /dev/null +++ b/src/main/java/com/splanet/splanet/stt/service/ClovaSpeechGrpcService.java @@ -0,0 +1,89 @@ +package com.splanet.splanet.stt.service; + +import com.google.protobuf.ByteString; +import com.nbp.cdncp.nest.grpc.proto.v1.*; +import com.splanet.splanet.core.properties.ClovaProperties; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.stub.MetadataUtils; +import io.grpc.stub.StreamObserver; +import org.springframework.stereotype.Service; + +@Service +public class ClovaSpeechGrpcService implements ClovaSpeechService { + + private final NestServiceGrpc.NestServiceStub nestServiceStub; + private final ClovaProperties clovaProperties; + + public ClovaSpeechGrpcService(ClovaProperties clovaProperties) { + this.clovaProperties = clovaProperties; + + // gRPC 채널 생성 + ManagedChannel channel = NettyChannelBuilder + .forAddress("clovaspeech-gw.ncloud.com", 50051) + .useTransportSecurity() + .build(); + + // Stub 생성 및 인증 정보 설정 + NestServiceGrpc.NestServiceStub stub = NestServiceGrpc.newStub(channel); + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), "Bearer " + clovaProperties.getClientSecret()); + this.nestServiceStub = MetadataUtils.attachHeaders(stub, metadata); + } + + @Override + public StreamObserver recognize(StreamObserver responseObserver) { + StreamObserver requestObserver = nestServiceStub.recognize(responseObserver); + + // Config 메시지 전송 + requestObserver.onNext(createConfigRequest(clovaProperties.getLanguage())); + + return new StreamObserver() { + private int sequenceId = 0; + + @Override + public void onNext(ByteString audioChunk) { + NestRequest dataRequest = createDataRequest(audioChunk, sequenceId, false); + requestObserver.onNext(dataRequest); + sequenceId++; + } + + @Override + public void onError(Throwable t) { + t.printStackTrace(); + requestObserver.onError(t); + } + + @Override + public void onCompleted() { + requestObserver.onCompleted(); + } + }; + } + + // Config 설정 + private NestRequest createConfigRequest(String language) { + NestConfig config = NestConfig.newBuilder() + .setConfig("{\"transcription\":{\"language\":\"" + language + "\"}}") + .build(); + + return NestRequest.newBuilder() + .setType(RequestType.CONFIG) + .setConfig(config) + .build(); + } + + // 데이터 구성 + private NestRequest createDataRequest(ByteString audioChunk, int sequenceId, boolean epFlag) { + NestData data = NestData.newBuilder() + .setChunk(audioChunk) + .setExtraContents("{\"seqId\":" + sequenceId + ",\"epFlag\":" + epFlag + "}") + .build(); + + return NestRequest.newBuilder() + .setType(RequestType.DATA) + .setData(data) + .build(); + } +} diff --git a/src/main/java/com/splanet/splanet/stt/service/ClovaSpeechService.java b/src/main/java/com/splanet/splanet/stt/service/ClovaSpeechService.java new file mode 100644 index 00000000..ddfb9f04 --- /dev/null +++ b/src/main/java/com/splanet/splanet/stt/service/ClovaSpeechService.java @@ -0,0 +1,9 @@ +package com.splanet.splanet.stt.service; + +import com.google.protobuf.ByteString; +import com.nbp.cdncp.nest.grpc.proto.v1.NestResponse; +import io.grpc.stub.StreamObserver; + +public interface ClovaSpeechService { + StreamObserver recognize(StreamObserver responseObserver); +} diff --git a/src/main/proto/nest.proto b/src/main/proto/nest.proto new file mode 100644 index 00000000..77e3efad --- /dev/null +++ b/src/main/proto/nest.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; +option java_multiple_files = true; + +package com.nbp.cdncp.nest.grpc.proto.v1; + +enum RequestType { + CONFIG = 0; + DATA = 1; +} + +message NestConfig { + string config = 1; +} + +message NestData { + bytes chunk = 1; + string extra_contents = 2; +} + +message NestRequest { + RequestType type = 1; + oneof part { + NestConfig config = 2; + NestData data = 3; + } +} + +message NestResponse { + string contents = 1; +} + +service NestService { + rpc recognize(stream NestRequest) returns (stream NestResponse) {} +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 00000000..8eb1e6ac --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,17 @@ +spring: + datasource: + url: jdbc:mysql://localhost:3306/${MYSQL_DATABASE} + username: ${MYSQL_USER} + password: ${MYSQL_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: localhost + port: 6379 + security: + oauth2: + redirect-url: http://localhost:5173/oauth2/redirect + client: + registration: + kakao: + redirect-uri: http://localhost:8080/login/oauth2/code/kakao diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..4ff4c8ac --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,17 @@ +spring: + datasource: + url: jdbc:mysql://${MYSQL_PROD_URL}:3306/${MYSQL_DATABASE} + username: ${MYSQL_PROD_USER} + password: ${MYSQL_PROD_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: redis + port: 6379 + security: + oauth2: + redirect-url: https://splanet.co.kr/oauth2/redirect + client: + registration: + kakao: + redirect-uri: https://splanet.co.kr/login/oauth2/code/kakao diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index feb38a7c..f79fd2b8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,9 +1,4 @@ spring: - datasource: - url: jdbc:mysql://localhost:3306/${MYSQL_DATABASE} - username: ${MYSQL_USER} - password: ${MYSQL_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update @@ -12,15 +7,10 @@ spring: hibernate: dialect: org.hibernate.dialect.MySQLDialect format_sql: true - data: - redis: - host: localhost # 로컬에서 실행하기 때문 - port: 6379 config: - import: env.properties + import: optional:env.properties security: oauth2: - redirect-url: ${REDIRECT_URL} client: provider: kakao: @@ -28,7 +18,6 @@ spring: token-uri: https://kauth.kakao.com/oauth/token user-info-uri: https://kapi.kakao.com/v2/user/me user-name-attribute: id - registration: kakao: client-id: ${CLIENT_ID} @@ -47,3 +36,10 @@ springdoc: swagger-ui: path: /swagger +jwt: + secret: ${JWT_SECRET} + +clova: + speech: + client-secret: ${CLOVA_CLIENT_SECRET} + language: ko diff --git a/src/test/java/com/splanet/splanet/SplanetApplicationTests.java b/src/test/java/com/splanet/splanet/SplanetApplicationTests.java index 6b6fb866..b40e2597 100644 --- a/src/test/java/com/splanet/splanet/SplanetApplicationTests.java +++ b/src/test/java/com/splanet/splanet/SplanetApplicationTests.java @@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +// @SpringBootTest class SplanetApplicationTests { @Test diff --git a/src/test/java/com/splanet/splanet/friend/service/FriendServiceTest.java b/src/test/java/com/splanet/splanet/friend/service/FriendServiceTest.java new file mode 100644 index 00000000..e5b0f11f --- /dev/null +++ b/src/test/java/com/splanet/splanet/friend/service/FriendServiceTest.java @@ -0,0 +1,172 @@ +package com.splanet.splanet.friend.service; + +import com.splanet.splanet.core.exception.BusinessException; +import com.splanet.splanet.core.exception.ErrorCode; +import com.splanet.splanet.friend.dto.FriendResponse; +import com.splanet.splanet.friend.entity.Friend; +import com.splanet.splanet.friend.repository.FriendRepository; +import com.splanet.splanet.plan.dto.PlanResponseDto; +import com.splanet.splanet.plan.entity.Plan; +import com.splanet.splanet.plan.repository.PlanRepository; +import com.splanet.splanet.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FriendServiceTest { + + @InjectMocks + private FriendService friendService; + + @Mock + private FriendRepository friendRepository; + + @Mock + private PlanRepository planRepository; + + @Mock + private User mockUser; + + @Mock + private Friend mockFriend; + + @Mock + private Plan mockPlan; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void 친구목록조회_성공() { + // Arrange + when(mockFriend.getFriend()).thenReturn(mockUser); + when(mockUser.getNickname()).thenReturn("testUser"); + when(mockUser.getProfileImage()).thenReturn("testProfileImageUrl"); + when(friendRepository.findByUserId(1L)).thenReturn(Collections.singletonList(mockFriend)); + + // Act + List friends = friendService.getFriends(1L); + + // Assert + assertNotNull(friends); + assertEquals(1, friends.size()); + assertEquals("testUser", friends.get(0).nickname()); + assertEquals("testProfileImageUrl", friends.get(0).profileImage()); + } + + @Test + void 친구플랜조회_성공() { + // Arrange + LocalDateTime startDate = LocalDateTime.of(2024, 1, 1, 0, 0); + LocalDateTime endDate = LocalDateTime.of(2024, 12, 31, 0, 0); + + // 친구 확인 모킹 + when(friendRepository.existsByUserIdAndFriendId(1L, 1L)).thenReturn(true); + + when(mockPlan.getId()).thenReturn(1L); + when(mockPlan.getTitle()).thenReturn("Test Plan"); + when(mockPlan.getDescription()).thenReturn("This is a test plan."); + when(mockPlan.getStartDate()).thenReturn(startDate); + when(mockPlan.getEndDate()).thenReturn(endDate); + when(mockPlan.getAccessibility()).thenReturn(true); + when(mockPlan.getIsCompleted()).thenReturn(false); + when(mockPlan.getCreatedAt()).thenReturn(null); + when(mockPlan.getUpdatedAt()).thenReturn(null); + when(planRepository.findAllByUserIdAndAccessibility(1L, true)).thenReturn(Collections.singletonList(mockPlan)); + + // Act + ResponseEntity> response = friendService.getFriendPlan(1L, 1L); + + // Assert + assertNotNull(response); + assertEquals(200, response.getStatusCodeValue()); + assertEquals(1, response.getBody().size()); + assertEquals("Test Plan", response.getBody().get(0).getTitle()); + } + + @Test + void 친구플랜조회_성공_플랜없음() { + // Arrange + when(friendRepository.existsByUserIdAndFriendId(1L, 1L)).thenReturn(true); // 친구 확인 성공 + when(planRepository.findAllByUserIdAndAccessibility(1L, true)).thenReturn(Collections.emptyList()); // 플랜 없음 + + // Act + ResponseEntity> response = friendService.getFriendPlan(1L, 1L); + + // Assert + assertNotNull(response); + assertEquals(200, response.getStatusCodeValue()); // 성공적으로 빈 목록을 반환해야 함 + assertTrue(response.getBody().isEmpty()); // 빈 목록인지 확인 + } + + @Test + void 친구플랜조회_성공_여러플랜() { + // Arrange + Plan mockPlan1 = mock(Plan.class); + Plan mockPlan2 = mock(Plan.class); + + when(friendRepository.existsByUserIdAndFriendId(1L, 1L)).thenReturn(true); + when(mockPlan1.getTitle()).thenReturn("Plan 1"); + when(mockPlan2.getTitle()).thenReturn("Plan 2"); + when(planRepository.findAllByUserIdAndAccessibility(1L, true)).thenReturn(List.of(mockPlan1, mockPlan2)); + + // Act + ResponseEntity> response = friendService.getFriendPlan(1L, 1L); + + // Assert + assertNotNull(response); + assertEquals(200, response.getStatusCodeValue()); + assertEquals(2, response.getBody().size()); + assertEquals("Plan 1", response.getBody().get(0).getTitle()); + assertEquals("Plan 2", response.getBody().get(1).getTitle()); + } + + @Test + void 친구플랜조회_실패_친구아님() { + // Arrange + when(friendRepository.existsByUserIdAndFriendId(1L, 2L)).thenReturn(false); // 친구가 아닌 경우 + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> { + friendService.getFriendPlan(2L, 1L); // 다른 userId로 조회 + }); + assertEquals(ErrorCode.FRIEND_NOT_FOUND, exception.getErrorCode()); + } + + @Test + void 친구목록조회_실패_사용자없음() { + // Arrange + when(friendRepository.findByUserId(99L)).thenReturn(Collections.emptyList()); // 존재하지 않는 사용자 + + // Act + List friends = friendService.getFriends(99L); + + // Assert + assertNotNull(friends); + assertTrue(friends.isEmpty()); // 빈 목록이 반환되어야 함 + } + + @Test + void 친구플랜조회_실패_친구관계없음() { + // Arrange + when(friendRepository.existsByUserIdAndFriendId(1L, 2L)).thenReturn(false); // 친구 관계가 없음 + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> { + friendService.getFriendPlan(1L, 2L); + }); + assertEquals(ErrorCode.FRIEND_NOT_FOUND, exception.getErrorCode()); + } +} \ No newline at end of file diff --git a/src/test/java/com/splanet/splanet/friendRequest/service/FriendRequestServiceTest.java b/src/test/java/com/splanet/splanet/friendRequest/service/FriendRequestServiceTest.java new file mode 100644 index 00000000..e07e84f8 --- /dev/null +++ b/src/test/java/com/splanet/splanet/friendRequest/service/FriendRequestServiceTest.java @@ -0,0 +1,234 @@ +package com.splanet.splanet.friendRequest.service; + +import com.splanet.splanet.core.exception.BusinessException; +import com.splanet.splanet.core.exception.ErrorCode; +import com.splanet.splanet.friend.repository.FriendRepository; +import com.splanet.splanet.friendRequest.dto.ReceivedFriendRequestResponse; +import com.splanet.splanet.friendRequest.dto.SentFriendRequestResponse; +import com.splanet.splanet.friendRequest.entity.FriendRequest; +import com.splanet.splanet.friendRequest.repository.FriendRequestRepository; +import com.splanet.splanet.user.entity.User; +import com.splanet.splanet.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class FriendRequestServiceTest { + + @Mock + private FriendRequestRepository friendRequestRepository; + + @Mock + private FriendRepository friendRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private FriendRequestService friendRequestService; + + private User requester; + private User receiver; + private FriendRequest friendRequest; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + requester = User.builder() + .id(1L) + .nickname("요청자") + .profileImage("requester.png") + .build(); + + receiver = User.builder() + .id(2L) + .nickname("수락자") + .profileImage("receiver.png") + .build(); + + friendRequest = FriendRequest.builder() + .id(1L) + .requester(requester) + .receiver(receiver) + .status(FriendRequest.Status.PENDING) + .build(); + } + + @Test + void 친구요청전송_성공() { + Long userId = requester.getId(); + Long receiverId = receiver.getId(); + + when(userRepository.findById(receiverId)).thenReturn(Optional.of(receiver)); + when(userRepository.findById(userId)).thenReturn(Optional.of(requester)); + when(friendRepository.existsByUserIdAndFriendId(userId, receiverId)).thenReturn(false); + + friendRequestService.sendFriendRequest(userId, receiverId); + + verify(friendRequestRepository, times(1)).save(any(FriendRequest.class)); + } + + @Test + void 친구요청전송_본인에게요청() { + Long userId = requester.getId(); + + BusinessException exception = assertThrows(BusinessException.class, () -> + friendRequestService.sendFriendRequest(userId, userId) + ); + + assertEquals(ErrorCode.SELF_FRIEND_REQUEST_NOT_ALLOWED, exception.getErrorCode()); + } + + @Test + void 친구요청수락_요청없음() { + Long requestId = 1L; + + when(friendRequestRepository.findById(requestId)).thenReturn(Optional.empty()); + + BusinessException exception = assertThrows(BusinessException.class, () -> + friendRequestService.acceptFriendRequest(requestId, receiver.getId()) + ); + + assertEquals(ErrorCode.FRIEND_REQUEST_NOT_FOUND, exception.getErrorCode()); + } + + @Test + void 친구요청수락_요청자와수락자가동일() { + Long requestId = friendRequest.getId(); + when(friendRequestRepository.findById(requestId)).thenReturn(Optional.of(friendRequest)); + + BusinessException exception = assertThrows(BusinessException.class, () -> + friendRequestService.acceptFriendRequest(requestId, requester.getId()) + ); + + assertEquals(ErrorCode.FRIEND_REQUEST_NOT_RECEIVER, exception.getErrorCode()); + } + + @Test + void 친구요청거절_요청자와수락자가동일() { + Long requestId = friendRequest.getId(); + when(friendRequestRepository.findById(requestId)).thenReturn(Optional.of(friendRequest)); + + BusinessException exception = assertThrows(BusinessException.class, () -> + friendRequestService.rejectFriendRequest(requestId, requester.getId()) + ); + + assertEquals(ErrorCode.FRIEND_REQUEST_NOT_RECEIVER, exception.getErrorCode()); + } + + @Test + void 친구요청목록조회_받은요청() { + Long userId = receiver.getId(); + + when(friendRequestRepository.findByReceiverIdWithRequester(userId)).thenReturn(Arrays.asList(friendRequest)); + + List responses = friendRequestService.getReceivedFriendRequests(userId); + + assertEquals(1, responses.size()); + assertEquals(requester.getId(), responses.get(0).requesterId()); + } + + @Test + void 친구요청목록조회_보낸요청() { + Long userId = requester.getId(); + + when(friendRequestRepository.findByRequesterIdWithReceiver(userId)).thenReturn(Arrays.asList(friendRequest)); + + List responses = friendRequestService.getSentFriendRequests(userId); + + assertEquals(1, responses.size()); + assertEquals(receiver.getId(), responses.get(0).receiverId()); + } + + @Test + void 친구요청목록조회_받은요청없음() { + Long userId = receiver.getId(); + + when(friendRequestRepository.findByReceiverIdWithRequester(userId)).thenReturn(Collections.emptyList()); + + List responses = friendRequestService.getReceivedFriendRequests(userId); + + assertEquals(0, responses.size()); // 빈 목록인지 확인 + } + + @Test + void 친구요청목록조회_보낸요청없음() { + Long userId = requester.getId(); + + when(friendRequestRepository.findByRequesterId(userId)).thenReturn(Collections.emptyList()); + + List responses = friendRequestService.getSentFriendRequests(userId); + + assertEquals(0, responses.size()); // 빈 목록인지 확인 + } + + @Test + void 친구요청전송_수신자존재하지않음() { + Long userId = requester.getId(); + Long receiverId = 999L; + + when(userRepository.findById(receiverId)).thenReturn(Optional.empty()); + + BusinessException exception = assertThrows(BusinessException.class, () -> + friendRequestService.sendFriendRequest(userId, receiverId) + ); + + assertEquals(ErrorCode.USER_NOT_FOUND, exception.getErrorCode()); + } + + @Test + void 친구요청전송_이미친구인경우() { + Long userId = requester.getId(); + Long receiverId = receiver.getId(); + + when(userRepository.findById(receiverId)).thenReturn(Optional.of(receiver)); + when(userRepository.findById(userId)).thenReturn(Optional.of(requester)); + when(friendRepository.existsByUserIdAndFriendId(userId, receiverId)).thenReturn(true); // 이미 친구인 경우 + + BusinessException exception = assertThrows(BusinessException.class, () -> + friendRequestService.sendFriendRequest(userId, receiverId) + ); + + assertEquals(ErrorCode.FRIEND_ALREADY_EXISTS, exception.getErrorCode()); + } + + @Test + void 친구요청수락_이미수락된요청() { + Long requestId = friendRequest.getId(); + friendRequest.setStatus(FriendRequest.Status.ACCEPTED); + + when(friendRequestRepository.findById(requestId)).thenReturn(Optional.of(friendRequest)); + + BusinessException exception = assertThrows(BusinessException.class, () -> + friendRequestService.acceptFriendRequest(requestId, receiver.getId()) + ); + + assertEquals(ErrorCode.FRIEND_REQUEST_ALREADY_ACCEPTED_OR_REJECTED, exception.getErrorCode()); + } + + @Test + void 친구요청수락_이미거절된요청() { + Long requestId = friendRequest.getId(); + friendRequest.setStatus(FriendRequest.Status.REJECTED); + + when(friendRequestRepository.findById(requestId)).thenReturn(Optional.of(friendRequest)); + + BusinessException exception = assertThrows(BusinessException.class, () -> + friendRequestService.acceptFriendRequest(requestId, receiver.getId()) + ); + + assertEquals(ErrorCode.FRIEND_REQUEST_ALREADY_ACCEPTED_OR_REJECTED, exception.getErrorCode()); + } +} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..e69de29b