diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index b81ca452..72acabc9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,57 +1,108 @@ -name: CD Pipeline - -on: - push: - branches: [ main ] - -jobs: - cd: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Create application-secret.yml - run: | - mkdir -p ./temp_secret - echo "${{ secrets.APPLICATION_SECRET }}" > ./temp_secret/application-secret.yml - shell: bash - - - name: Copy application-secret.yml to EC2 - uses: appleboy/scp-action@v0.1.3 - with: - username: ubuntu - host: ${{ secrets.EC2_HOST }} - key: ${{ secrets.EC2_SSH_KEY }} - source: ./temp_secret/application-secret.yml - target: /home/ubuntu/secret/ - - - name: Copy docker-compose.yml - uses: appleboy/scp-action@v0.1.3 - with: - username: ubuntu - host: ${{ secrets.EC2_HOST }} - key: ${{ secrets.EC2_SSH_KEY }} - source: ./docker-compose.yml - target: /home/ubuntu/cicd/ - - - name: Upload deploy.sh to EC2 - uses: appleboy/scp-action@v0.1.3 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - source: ./deploy.sh - target: /home/ubuntu/cicd/ - - - name: SSH and Deploy - uses: appleboy/ssh-action@v1.0.0 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - script: | - echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin - docker pull ${{ secrets.DOCKERHUB_USERNAME }}/assu-app:latest - sudo chmod +x /home/ubuntu/cicd/deploy.sh +name: CD Pipeline + +on: + push: + branches: [ main ] + +jobs: + cd: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + install: true + + - name: Create buildx builder + run: | + docker buildx create --use --name mybuilder + docker buildx inspect --bootstrap + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build & Push Dependency Cache + run: | + docker buildx build \ + --builder mybuilder \ + --platform linux/amd64 \ + --push \ + --file Dockerfile \ + --tag ${{ secrets.DOCKERHUB_USERNAME }}/assu-app:dependency-cache \ + --target dependencies \ + --cache-to type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/assu-app:dependency-cache,mode=max \ + . + + - name: Build & Push Final App Image + run: | + docker buildx build \ + --builder mybuilder \ + --platform linux/amd64 \ + --push \ + --file Dockerfile \ + --tag ${{ secrets.DOCKERHUB_USERNAME }}/assu-app:latest \ + --build-arg DEPENDENCY_IMAGE=${{ secrets.DOCKERHUB_USERNAME }}/assu-app:dependency-cache \ + --cache-from type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/assu-app:dependency-cache \ + . + + - name: Create application-secret.yml + run: echo "${{ secrets.APPLICATION_SECRET }}" > ./application-secret.yml + shell: bash + + - name: Create service-account.json + run: echo "${{ secrets.SERVICE_ACCOUNT_B64 }}" | base64 -d > ./service-account.json + shell: bash + + - name: Copy application-secret.yml to EC2 + uses: appleboy/scp-action@v0.1.3 + with: + username: ubuntu + host: ${{ secrets.EC2_HOST }} + key: ${{ secrets.EC2_SSH_KEY }} + source: ./application-secret.yml + target: /home/ubuntu/secret/ + + - name: Copy service-account.json to EC2 + uses: appleboy/scp-action@v0.1.3 + with: + username: ubuntu + host: ${{ secrets.EC2_HOST }} + key: ${{ secrets.EC2_SSH_KEY }} + source: ./service-account.json + target: /home/ubuntu/secret/ + + - name: Copy docker-compose.yml + uses: appleboy/scp-action@v0.1.3 + with: + username: ubuntu + host: ${{ secrets.EC2_HOST }} + key: ${{ secrets.EC2_SSH_KEY }} + source: ./docker-compose.yml + target: /home/ubuntu/cicd/ + + - name: Upload deploy.sh to EC2 + uses: appleboy/scp-action@v0.1.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + source: ./deploy.sh + target: /home/ubuntu/cicd/ + + - name: SSH and Deploy + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + docker pull ${{ secrets.DOCKERHUB_USERNAME }}/assu-app:latest + sudo chmod +x /home/ubuntu/cicd/deploy.sh sudo /home/ubuntu/cicd/deploy.sh \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c6e1a0d..bb76cd0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,8 @@ name: CI Pipeline on: push: branches: [ develop ] + pull_request: + branches: [ develop ] jobs: ci: @@ -32,44 +34,4 @@ jobs: run: chmod +x ./gradlew - name: Build and Test - run: ./gradlew clean build test - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - install: true - - - name: Create buildx builder - run: | - docker buildx create --use --name mybuilder - docker buildx inspect --bootstrap - - - name: Login to DockerHub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build & Push Dependency Cache - run: | - docker buildx build \ - --builder mybuilder \ - --platform linux/amd64 \ - --push \ - --file Dockerfile \ - --tag ${{ secrets.DOCKERHUB_USERNAME }}/assu-app:dependency-cache \ - --target dependencies \ - --cache-to type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/assu-app:dependency-cache,mode=max \ - . - - - name: Build & Push Final App Image - run: | - docker buildx build \ - --builder mybuilder \ - --platform linux/amd64 \ - --push \ - --file Dockerfile \ - --tag ${{ secrets.DOCKERHUB_USERNAME }}/assu-app:latest \ - --build-arg DEPENDENCY_IMAGE=${{ secrets.DOCKERHUB_USERNAME }}/assu-app:dependency-cache \ - --cache-from type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/assu-app:dependency-cache \ - . + run: ./gradlew clean build test --no-build-cache diff --git a/.gitignore b/.gitignore index 9503f1da..4c53f8a3 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,6 @@ out/ ### Secret ### src/main/resources/application-secret.yml -src/test/resources/application-test.yml -src/test/resources/application-secret.yml \ No newline at end of file + +### Firebase ### +src/main/resources/firebase/ diff --git a/build.gradle b/build.gradle index d88fee3d..62ac33e0 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.3' id 'io.spring.dependency-management' version '1.1.7' + id 'org.jetbrains.kotlin.jvm' } group = 'com.assu' @@ -20,28 +21,89 @@ configurations { } repositories { + google() mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-amqp' - implementation 'org.springframework.boot:spring-boot-starter-batch' + // Spring boot implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-docker-compose' - runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + // spring security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // rabbit mq + implementation 'org.springframework.boot:spring-boot-starter-amqp' testImplementation 'org.springframework.amqp:spring-rabbit-test' + + // webflux; webclient + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // batch + implementation 'org.springframework.boot:spring-boot-starter-batch' testImplementation 'org.springframework.batch:spring-batch-test' - testImplementation 'org.springframework.security:spring-security-test' + + //Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // GSON + implementation 'com.google.code.gson:gson:2.11.0' + + // S3 + implementation platform('software.amazon.awssdk:bom:2.25.33') + implementation 'software.amazon.awssdk:s3' + + // jsoup (crawl) + implementation 'org.jsoup:jsoup:1.21.1' + + // lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Swagger 3 (SpringDoc OpenAPI) + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4' + + // chatting + implementation 'org.springframework.boot:spring-boot-starter-websocket' + + // maria db + implementation 'org.mariadb.jdbc:mariadb-java-client:3.4.1' + + // spring test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // junit testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // h2 db (test) runtimeOnly 'com.h2database:h2' + + // JSON 처리 + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation group: 'org.javassist', name: 'javassist', version: '3.15.0-GA' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + + // 공간 데이터 + implementation("org.hibernate.orm:hibernate-spatial") + implementation("org.locationtech.jts:jts-core:1.19.0") + + // fcm + implementation 'com.squareup.okhttp3:okhttp:4.10.0' + //implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.google.auth:google-auth-library-oauth2-http:1.18.0' + implementation 'com.google.firebase:firebase-admin:9.2.0' + + // amqp + implementation("org.springframework.boot:spring-boot-starter-amqp") + implementation("com.fasterxml.jackson.core:jackson-databind") } tasks.named('test') { diff --git a/deploy.sh b/deploy.sh index 90e61e38..55d6ada1 100644 --- a/deploy.sh +++ b/deploy.sh @@ -2,7 +2,10 @@ cd /home/ubuntu/cicd +# 환경변수 설정 APP_NAME="assu" +TARGET="" +OLD="" # NGINX 설정 관련 NGINX_CONF_PATH="/etc/nginx" @@ -10,6 +13,7 @@ BLUE_CONF="blue.conf" GREEN_CONF="green.conf" DEFAULT_CONF="nginx.conf" MAX_RETRIES=3 +RETRY_SLEEP_SEC=5 # 활성화된 서비스 확인 및 스위칭 대상 결정 determine_target() { @@ -27,42 +31,40 @@ determine_target() { echo "TARGET: $TARGET" echo "OLD: $OLD" } -# 헬스체크 실패 시 롤백 처리 + +# docker ps 기반 헬스체크: 컨테이너 상태가 Up이면 성공으로 판단 health_check() { - local URL=$1 local RETRIES=0 - local ORIGINAL_TARGET=$TARGET # 원래 TARGET 값을 저장 - - while [ $RETRIES -lt $MAX_RETRIES ]; do - echo "Checking service at $URL... (attempt: $((RETRIES + 1)))" - sleep 3 + local CONTAINER_NAME="app-$TARGET" - # 현재 실행 중인 컨테이너 확인 - CONTAINER_RUNNING=$(docker ps --filter "name=app-$TARGET" --format '{{.Names}}') + echo "Starting docker-ps based health check for '$CONTAINER_NAME'..." - if [ "$CONTAINER_RUNNING" = "app-$TARGET" ]; then - echo "$TARGET container is running." - return 0 # 컨테이너가 실행 중이라면 헬스체크 성공 - else - echo "$TARGET container is not running." - fi + # 초기 대기 (컨테이너 기동 시간 확보) + sleep 30 - RETRIES=$((RETRIES + 1)) - done + while [ $RETRIES -lt $MAX_RETRIES ]; do + echo "Attempt $((RETRIES + 1)) of $MAX_RETRIES: checking docker ps status..." - # 헬스체크 실패 시 롤백 처리 - echo "Health check failed after $MAX_RETRIES attempts." - echo "Rolling back to the original target: $ORIGINAL_TARGET" + # docker ps에서 해당 컨테이너의 상태 문자열을 가져옴 (예: "Up 10 seconds") + local STATUS + STATUS=$(docker ps --filter "name=^${CONTAINER_NAME}$" --format '{{.Status}}' || true) - # TARGET을 원래 값으로 롤백 - TARGET=$ORIGINAL_TARGET - echo "Rolled back TARGET: $TARGET" + if [ -n "$STATUS" ] && [[ "$STATUS" == Up* ]]; then + echo "Health check succeeded! '$CONTAINER_NAME' is Up. (status: $STATUS)" + return 0 + else + # 상태 디버깅용 출력 + docker compose -f docker-compose.yml ps || true + docker logs --tail=50 "$CONTAINER_NAME" 2>/dev/null || true + echo "Current status: '${STATUS:-N/A}'. Retrying in ${RETRY_SLEEP_SEC}s..." + sleep "$RETRY_SLEEP_SEC" + fi - # 로그 파일에 실패 기록 - echo "Failed health check for $TARGET container" > /home/ubuntu/cicd/health_check_failure.log + RETRIES=$((RETRIES + 1)) + done - # docker-compose down 대신 실패 기록 후 종료 - exit 1 + echo "Health check failed after $MAX_RETRIES attempts." + return 1 } # NGINX 설정 스위칭 함수 @@ -74,32 +76,46 @@ switch_nginx_conf() { fi echo "Reloading NGINX configuration..." - nginx -s reload + sudo nginx -s reload } # 이전 컨테이너 종료 함수 down_old_container() { if [ "$OLD" != "none" ]; then echo "Stopping old container: $OLD" - sudo docker stop "app-$OLD" - + docker compose -f docker-compose.yml stop "app-$OLD" || true + docker compose -f docker-compose.yml rm -f "app-$OLD" || true fi } # 메인 실행 로직 main() { + # 대상 컨테이너 결정 determine_target + local TARGET_SERVICE="app-$TARGET" - # 대상 컨테이너 실행 - echo "Starting $TARGET container..." - docker compose -f docker-compose.yml up -d "app-$TARGET" + # 컨테이너 충돌 방지: compose/비compose 둘 다 제거 시도 + echo "Removing any existing container with the name '$TARGET_SERVICE'..." + docker compose -f docker-compose.yml rm -f "$TARGET_SERVICE" 2>/dev/null || true + docker rm -f "$TARGET_SERVICE" 2>/dev/null || true - # 헬스체크 - if [ "$TARGET" = "blue" ]; then - health_check "http://127.0.0.1:8080/actuator/health" - else - health_check "http://127.0.0.1:8081/actuator/health" + # 새 컨테이너 실행 (강제 재생성) + echo "Starting new container: $TARGET_SERVICE..." + docker compose -f docker-compose.yml up -d --force-recreate "$TARGET_SERVICE" + + # docker-ps 기반 헬스 체크 및 롤백 + if ! health_check; then + echo "Health check failed. Initiating rollback..." + + # 실패한 컨테이너를 중지하고 제거 + echo "Removing failed container: '$TARGET_SERVICE'..." + docker compose -f docker-compose.yml stop "$TARGET_SERVICE" || true + docker compose -f docker-compose.yml rm -f "$TARGET_SERVICE" || true + docker rm -f "$TARGET_SERVICE" 2>/dev/null || true + + echo "Rollback complete. The previous version remains active." + exit 1 fi # NGINX 설정 스위칭 diff --git a/docker-compose.yml b/docker-compose.yml index 1ece20cb..9b1f2ac7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,8 @@ services: - SPRING_PROFILES_ACTIVE=blue - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/app/config/ volumes: - - /home/ubuntu/app/config/application-secret.yml:/app/config/application-secret.yml:ro + - /home/ubuntu/secret/application-secret.yml:/app/config/application-secret.yml:ro + - /home/ubuntu/secret/service-account.json:/app/config/service-account.json:ro networks: - assu-network @@ -27,10 +28,11 @@ services: - SPRING_PROFILES_ACTIVE=green - SPRING_CONFIG_ADDITIONAL_LOCATION=file:/app/config/ volumes: - - /home/ubuntu/app/config/application-secret.yml:/app/config/application-secret.yml:ro + - /home/ubuntu/secret/application-secret.yml:/app/config/application-secret.yml:ro + - /home/ubuntu/secret/service-account.json:/app/config/service-account.json:ro networks: - assu-network networks: assu-network: - driver: bridge + external: true diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/settings.gradle b/settings.gradle index 096502d2..12485962 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,6 @@ -rootProject.name = 'server' +pluginManagement { + plugins { + id 'org.jetbrains.kotlin.jvm' version '2.2.0' + } +} +rootProject.name = 'Assu' diff --git a/src/main/java/com/assu/server/ServerApplication.java b/src/main/java/com/assu/server/ServerApplication.java index 3864e11c..9cb6b66e 100644 --- a/src/main/java/com/assu/server/ServerApplication.java +++ b/src/main/java/com/assu/server/ServerApplication.java @@ -1,11 +1,15 @@ package com.assu.server; +import org.springframework.amqp.rabbit.annotation.EnableRabbit; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling +@EnableRabbit public class ServerApplication { public static void main(String[] args) { diff --git a/src/main/java/com/assu/server/domain/admin/controller/AdminController.java b/src/main/java/com/assu/server/domain/admin/controller/AdminController.java index e39e2b34..0ea470cd 100644 --- a/src/main/java/com/assu/server/domain/admin/controller/AdminController.java +++ b/src/main/java/com/assu/server/domain/admin/controller/AdminController.java @@ -1,4 +1,32 @@ package com.assu.server.domain.admin.controller; +import com.assu.server.domain.admin.dto.AdminResponseDTO; +import com.assu.server.domain.admin.service.AdminService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin") +@RequiredArgsConstructor public class AdminController { + + private final AdminService adminService; + + @Operation( + summary = "파트너 추천 API", + description = "제휴하지 않은 파트너 중 한 곳을 랜덤으로 조회합니다." + ) + @GetMapping("/partner-recommend") + public BaseResponse randomPartnerRecommend( + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, adminService.suggestRandomPartner(pd.getId())); + } } diff --git a/src/main/java/com/assu/server/domain/admin/dto/AdminResponseDTO.java b/src/main/java/com/assu/server/domain/admin/dto/AdminResponseDTO.java index 871d3db5..6aa0df16 100644 --- a/src/main/java/com/assu/server/domain/admin/dto/AdminResponseDTO.java +++ b/src/main/java/com/assu/server/domain/admin/dto/AdminResponseDTO.java @@ -1,4 +1,22 @@ package com.assu.server.domain.admin.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + public class AdminResponseDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class RandomPartnerResponseDTO { + private Long partnerId; + private String partnerAddress; + private String partnerDetailAddress; + private String partnerName; + private String partnerUrl; + private String partnerPhone; + } } diff --git a/src/main/java/com/assu/server/domain/admin/entity/Admin.java b/src/main/java/com/assu/server/domain/admin/entity/Admin.java new file mode 100644 index 00000000..875e5265 --- /dev/null +++ b/src/main/java/com/assu/server/domain/admin/entity/Admin.java @@ -0,0 +1,72 @@ +package com.assu.server.domain.admin.entity; + + +import com.assu.server.domain.user.entity.enums.Department; +import com.assu.server.domain.user.entity.enums.Major; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.user.entity.enums.University; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Id; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.locationtech.jts.geom.Point; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Setter +public class Admin { + + @Id + private Long id; // member_id와 동일 + + @OneToOne + @MapsId + @JoinColumn(name = "id") + private Member member; + + private String name; + + private String officeAddress; + + private String detailAddress; + + private String signUrl; + + private Boolean isSignVerified; + + private LocalDateTime signVerifiedAt; + + @Enumerated(EnumType.STRING) + private Major major; + + @Enumerated(EnumType.STRING) + private Department department; + + @Enumerated(EnumType.STRING) + @NotNull + private University university; + + @JdbcTypeCode(SqlTypes.GEOMETRY) + private Point point; + + private double latitude; + private double longitude; + + public void setMember(Member member) { + this.member = member; + } +} diff --git a/src/main/java/com/assu/server/domain/admin/repository/AdminRepository.java b/src/main/java/com/assu/server/domain/admin/repository/AdminRepository.java index 4e6b1faa..8a1eaeeb 100644 --- a/src/main/java/com/assu/server/domain/admin/repository/AdminRepository.java +++ b/src/main/java/com/assu/server/domain/admin/repository/AdminRepository.java @@ -1,4 +1,78 @@ package com.assu.server.domain.admin.repository; -public class AdminRepository { +import java.util.List; +import java.util.Optional; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.user.entity.enums.Department; +import com.assu.server.domain.user.entity.enums.Major; +import com.assu.server.domain.user.entity.enums.University; + +public interface AdminRepository extends JpaRepository { + + // 여기 예원이 머지하고 수정 + @Query("SELECT a FROM Admin a WHERE " + + "(a.university = :university AND a.department IS NULL AND a.major IS NULL) OR " + + "(a.university = :university AND a.department = :department AND a.major IS NULL) OR " + + "(a.university = :university AND a.department = :department AND a.major = :major)") + List findMatchingAdmins(@Param("university") University university, + @Param("department") Department department, + @Param("major") Major major); + + Optional findByName(String name); + + // 후보 수 카운트: 해당 partner와 ACTIVE 제휴가 없는 admin 수 + @Query(value = """ + SELECT COUNT(*) + FROM admin a + LEFT JOIN paper pa + ON pa.admin_id = a.id + AND pa.partner_id = :partnerId + AND pa.is_activated = 'ACTIVE' + WHERE pa.id IS NULL + """, nativeQuery = true) + long countPartner(@Param("partnerId") Long partnerId); + + // 랜덤 오프셋으로 1~N건 가져오기 (LIMIT :offset, :limit) + @Query(value = """ + SELECT a.* + FROM admin a + LEFT JOIN paper pa + ON pa.admin_id = a.id + AND pa.partner_id = :partnerId + AND pa.is_activated = 'ACTIVE' + WHERE pa.id IS NULL + LIMIT :offset, :limit + """, nativeQuery = true) + List findPartnerWithOffset(@Param("partnerId") Long partnerId, + @Param("offset") int offset, + @Param("limit") int limit); + + @Query(value = """ + SELECT a.* + FROM admin a + WHERE a.point IS NOT NULL + AND ST_Contains(ST_GeomFromText(:wkt, 4326), a.point) + """, nativeQuery = true) + List findAllWithinViewport(@Param("wkt") String wkt); + + @Query(""" + select distinct a + from Admin a + where lower(a.name) like lower(concat('%', :keyword, '%')) + """) + List searchAdminByKeyword( + @Param("keyword") String keyword + ); + + Long member(Member member); + + Optional findById(Long id); } diff --git a/src/main/java/com/assu/server/domain/admin/service/AdminService.java b/src/main/java/com/assu/server/domain/admin/service/AdminService.java index c01f5981..32da6ff1 100644 --- a/src/main/java/com/assu/server/domain/admin/service/AdminService.java +++ b/src/main/java/com/assu/server/domain/admin/service/AdminService.java @@ -1,4 +1,17 @@ package com.assu.server.domain.admin.service; +import com.assu.server.domain.admin.dto.AdminResponseDTO; +import java.util.List; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.user.entity.enums.Department; +import com.assu.server.domain.user.entity.enums.Major; +import com.assu.server.domain.user.entity.enums.University; + +// PaperQueryServiceImpl 이 AdminService 참조 중 -> 순환참조 문제 발생하지 않도록 주의 public interface AdminService { + List findMatchingAdmins(University university, Department department, Major major); + + AdminResponseDTO.RandomPartnerResponseDTO suggestRandomPartner(Long adminId); + } diff --git a/src/main/java/com/assu/server/domain/admin/service/AdminServiceImpl.java b/src/main/java/com/assu/server/domain/admin/service/AdminServiceImpl.java index 3c3eb41d..cfcdb411 100644 --- a/src/main/java/com/assu/server/domain/admin/service/AdminServiceImpl.java +++ b/src/main/java/com/assu/server/domain/admin/service/AdminServiceImpl.java @@ -1,4 +1,65 @@ package com.assu.server.domain.admin.service; -public class AdminServiceImpl { + +import java.util.List; +import org.springframework.stereotype.Service; +import com.assu.server.domain.admin.dto.AdminResponseDTO; +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.admin.repository.AdminRepository; +import com.assu.server.domain.user.entity.enums.Department; +import com.assu.server.domain.user.entity.enums.Major; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partner.repository.PartnerRepository; +import com.assu.server.domain.user.entity.enums.University; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import java.util.concurrent.ThreadLocalRandom; + + +@Service +@RequiredArgsConstructor +public class AdminServiceImpl implements AdminService { + + private final AdminRepository adminRepository; + private final PartnerRepository partnerRepository; + @Override + @Transactional + public List findMatchingAdmins(University university, Department department, Major major){ + + + List adminList = adminRepository.findMatchingAdmins(university, department,major); + + return adminList; + } + @Override + @Transactional + public AdminResponseDTO.RandomPartnerResponseDTO suggestRandomPartner(Long adminId) { + + Admin admin = adminRepository.findById(adminId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); + + long total = partnerRepository.countUnpartneredActiveByAdmin(admin.getId()); + if (total <= 0) { + throw new DatabaseException(ErrorStatus.NO_AVAILABLE_PARTNER); + } + + int offset = ThreadLocalRandom.current().nextInt((int)total); + + Partner picked = partnerRepository.findUnpartneredActiveByAdminWithOffset(admin.getId(), offset); + if(picked == null) { + throw new DatabaseException(ErrorStatus.NO_AVAILABLE_PARTNER); + } + + return AdminResponseDTO.RandomPartnerResponseDTO.builder() + .partnerId(picked.getId()) + .partnerName(picked.getName()) + .partnerAddress(picked.getAddress()) + .partnerDetailAddress(picked.getDetailAddress()) + .partnerUrl(picked.getMember().getProfileUrl()) + .partnerPhone(picked.getMember().getPhoneNum()) + .build(); + } + } diff --git a/src/main/java/com/assu/server/domain/auth/controller/AuthController.java b/src/main/java/com/assu/server/domain/auth/controller/AuthController.java index 7bfc995a..35a2744b 100644 --- a/src/main/java/com/assu/server/domain/auth/controller/AuthController.java +++ b/src/main/java/com/assu/server/domain/auth/controller/AuthController.java @@ -1,4 +1,386 @@ package com.assu.server.domain.auth.controller; +import com.assu.server.domain.auth.dto.login.CommonLoginRequest; +import com.assu.server.domain.auth.dto.login.LoginResponse; +import com.assu.server.domain.auth.dto.login.RefreshResponse; +import com.assu.server.domain.auth.dto.phone.PhoneAuthRequestDTO; +import com.assu.server.domain.auth.dto.signup.AdminSignUpRequest; +import com.assu.server.domain.auth.dto.signup.PartnerSignUpRequest; +import com.assu.server.domain.auth.dto.signup.SignUpResponse; +import com.assu.server.domain.auth.dto.signup.StudentTokenSignUpRequest; +import com.assu.server.domain.auth.dto.signup.student.StudentTokenAuthPayload; +import com.assu.server.domain.auth.dto.ssu.USaintAuthRequest; +import com.assu.server.domain.auth.dto.ssu.USaintAuthResponse; +import com.assu.server.domain.auth.service.*; +import com.assu.server.domain.user.entity.enums.University; +import com.assu.server.domain.auth.dto.verification.VerificationRequestDTO; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestBody; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Auth", description = "인증/인가 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") public class AuthController { -} + + private final PhoneAuthService phoneAuthService; + private final EmailAuthService emailAuthService; + private final SignUpService signUpService; + private final LoginService loginService; + private final LogoutService logoutService; + private final SSUAuthService ssuAuthService; + private final WithdrawalService withdrawalService; + + @Operation( + summary = "휴대폰 번호 중복가입 확인 및 인증번호 발송 API", + description = "# [v1.1 (2025-09-25)](https://clumsy-seeder-416.notion.site/2241197c19ed801bbcd9f61c3e5f5457?source=copy_link)\n" + + "- 입력한 휴대폰 번호로 1회용 인증번호(OTP)를 발송합니다.\n" + + "- 중복된 전화번호가 있으면 에러를 반환합니다.\n" + + "- 유효시간/재요청 제한 정책은 서버 설정에 따릅니다.\n" + + "\n**Request Body:**\n" + + " - `phoneNumber` (String, required): 인증번호를 받을 휴대폰 번호\n" + + "\n**Response:**\n" + + " - 성공 시 200(OK)과 성공 메시지 반환" + ) + @PostMapping("/phone-verification/check-and-send") + public BaseResponse checkPhoneAvailabilityAndSendAuthNumber( + @RequestBody @Valid PhoneAuthRequestDTO.PhoneAuthSendRequest request + ) { + phoneAuthService.checkAndSendAuthNumber(request.getPhoneNumber()); + return BaseResponse.onSuccess(SuccessStatus.SEND_AUTH_NUMBER_SUCCESS, null); + } + + @Operation( + summary = "휴대폰 인증번호 검증 API", + description = "# [v1.0 (2025-09-03)](https://clumsy-seeder-416.notion.site/2241197c19ed81bb8c05d9061c0306c0?source=copy_link)\n" + + "- 발송된 인증번호(OTP)를 검증합니다.\n" + + "- 성공 시 서버에 휴대폰 인증 상태가 기록됩니다.\n" + + "\n**Request Body:**\n" + + " - `phoneNumber` (String, required): 인증받을 휴대폰 번호\n" + + " - `authNumber` (String, required): 발송받은 인증번호(OTP)\n" + + "\n**Response:**\n" + + " - 성공 시 200(OK)과 성공 메시지 반환" + ) + @PostMapping("/phone-verification/verify") + public BaseResponse checkAuthNumber( + @RequestBody @Valid PhoneAuthRequestDTO.PhoneAuthVerifyRequest request + ) { + phoneAuthService.verifyAuthNumber( + request.getPhoneNumber(), + request.getAuthNumber() + ); + return BaseResponse.onSuccess(SuccessStatus.VERIFY_AUTH_NUMBER_SUCCESS, null); + } + + @Operation(summary = "이메일 형식 및 중복가입 확인 API", + description = "# [v1.0 (2025-09-18)](https://clumsy-seeder-416.notion.site/2551197c19ed802d8f6dd373dd045f3a?source=copy_link)\n" + + "- 입력한 이메일이 이미 가입된 사용자가 있는지 확인합니다.\n" + + "- 중복된 이메일이 있으면 에러를 반환합니다.\n" + + "\n**Request Body:**\n" + + " - `email` (String, required): 확인할 이메일 주소\n" + + "\n**Response:**\n" + + " - 성공 시 200(OK)과 사용 가능 메시지 반환\n" + + " - 중복 시 404(NOT_FOUND)와 에러 메시지 반환") + @PostMapping("/email-verification/check") + public BaseResponse checkEmailAvailability( + @RequestBody @Valid VerificationRequestDTO.EmailVerificationCheckRequest request) { + emailAuthService.checkEmailAvailability(request); + return BaseResponse.onSuccess(SuccessStatus._OK, null); + } + + @Operation( + summary = "학생 회원가입 API", + description = "# [v1.2 (2025-09-13)](https://clumsy-seeder-416.notion.site/2241197c19ed81129c85cf5bbe1f7971)\n" + + "- `application/json` 요청 바디를 사용합니다.\n" + + "- 처리: 유세인트 인증 → 학생 정보 추출 → 회원가입 완료\n" + + "- 성공 시 201(Created)과 생성된 memberId, JWT 토큰, 기본 정보 반환.\n" + + "\n**Request Body:**\n" + + " - `StudentTokenSignUpRequest` 객체 (JSON, required): 숭실대 학생 토큰 가입 정보\n" + + " - `phoneNumber` (String, required): 휴대폰 번호\n" + + " - `marketingAgree` (Boolean, required): 마케팅 수신 동의\n" + + " - `locationAgree` (Boolean, required): 위치 정보 수집 동의\n" + + " - `StudentTokenAuthPayload` (Object, required): 유세인트 토큰 정보\n" + + " - `sToken` (String, required): 유세인트 sToken\n" + + " - `sIdno` (Integer, required): 유세인트 sIdno\n" + + " - `university` (University enum, required): 대학 이름 (SSU)\n" + + "\n**Response:**\n" + + " - 성공 시 201(Created)과 `SignUpResponse` 객체 반환\n" + + " - `memberId` (Long): 회원 ID\n" + + " - `role` (UserRole): 회원 역할 (STUDENT)\n" + + " - `status` (ActivationStatus): 회원 상태 (ACTIVE)\n" + + " - `tokens` (Object): JWT 토큰 정보 (accessToken, refreshToken, expiresAt)\n" + + " - `basicInfo` (UserBasicInfo): 사용자 기본 정보 (프론트 캐싱용)\n" + + " - `name` (String): 학생 이름\n" + + " - `university` (String): 대학교 (한글명)\n" + + " - `department` (String): 단과대 (한글명)\n" + + " - `major` (String): 전공/학과 (한글명)") + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, content = @Content(schema = @Schema(implementation = StudentTokenSignUpRequest.class))) + @PostMapping(value = "/students/signup", consumes = MediaType.APPLICATION_JSON_VALUE) + public BaseResponse signupStudent( + @Valid @RequestBody StudentTokenSignUpRequest request + ) { + SignUpResponse response; + if(request.getStudentTokenAuth().getUniversity().equals(University.SSU)){ + response = signUpService.signupSsuStudent(request); + } else { + response = null; + } + return BaseResponse.onSuccess(SuccessStatus._OK, response); + } + + @Operation(summary = "제휴업체 회원가입 API", + description = "# [v1.2 (2025-09-13)](https://clumsy-seeder-416.notion.site/2501197c19ed80d7a8f2c3a6fcd8b537)\n" + + "- `multipart/form-data`로 호출합니다.\n" + + "- 파트: `payload`(JSON, PartnerSignUpRequest) + `licenseImage`(파일, 사업자등록증).\n" + + "- 처리: users + common_auth 생성, 이메일 중복/비밀번호 규칙 검증.\n" + + "- 성공 시 201(Created)과 생성된 memberId, JWT 토큰, 기본 정보 반환.\n" + + "\n**Request Parts:**\n" + + " - `request` (JSON, required): `PartnerSignUpRequest` 객체\n" + + " - `email` (String, required): 이메일 주소\n" + + " - `password` (String, required): 비밀번호\n" + + " - `phoneNumber` (String, required): 휴대폰 번호\n" + + " - `companyName` (String, required): 회사명\n" + + " - `businessNumber` (String, required): 사업자등록번호\n" + + " - `representativeName` (String, required): 대표자명\n" + + " - `address` (String, required): 회사 주소\n" + + " - `licenseImage` (MultipartFile, required): 사업자등록증 이미지 파일\n" + + "\n**Response:**\n" + + " - 성공 시 201(Created)과 `SignUpResponse` 객체 반환\n" + + " - `memberId` (Long): 회원 ID\n" + + " - `role` (UserRole): 회원 역할 (PARTNER)\n" + + " - `status` (ActivationStatus): 회원 상태 (ACTIVE)\n" + + " - `tokens` (Object): JWT 토큰 정보 (accessToken, refreshToken, expiresAt)\n" + + " - `basicInfo` (UserBasicInfo): 사용자 기본 정보 (프론트 캐싱용)\n" + + " - `name` (String): 업체명\n" + + " - `university`, `department`, `major`: null (Partner는 해당 없음)") + @PostMapping(value = "/partners/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public BaseResponse signupPartner( + @Valid @RequestPart("request") @Parameter(description = "JSON 형식의 제휴업체 가입 정보", + // 'request' 파트의 content type을 명시적으로 지정 + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PartnerSignUpRequest.class))) PartnerSignUpRequest request, + + @RequestPart("licenseImage") @Parameter(description = "사업자등록증 이미지 파일 (Multipart Part)", required = true, content = @Content(mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE, schema = @Schema(type = "string", format = "binary"))) MultipartFile licenseImage) { + return BaseResponse.onSuccess(SuccessStatus._OK, signUpService.signupPartner(request, licenseImage)); + } + + @Operation(summary = "관리자 회원가입 API", + description = "# [v1.2 (2025-09-13)](https://clumsy-seeder-416.notion.site/2501197c19ed80cdb98bc2b4d5042b48)\n" + + "- `multipart/form-data`로 호출합니다.\n" + + "- 파트: `payload`(JSON, AdminSignUpRequest) + `signImage`(파일, 신분증).\n" + + "- 처리: users + common_auth 생성, 이메일 중복/비밀번호 규칙 검증.\n" + + "- 성공 시 201(Created)과 생성된 memberId, JWT 토큰, 기본 정보 반환.\n" + + "\n**Request Parts:**\n" + + " - `request` (JSON, required): `AdminSignUpRequest` 객체\n" + + " - `email` (String, required): 이메일 주소\n" + + " - `password` (String, required): 비밀번호\n" + + " - `university` (String, required): 대학교 Enum\n" + + " - `department` (String, required): 단과대 Enum\n" + + " - `major` (String, required): 전공 Enum\n" + + " - `phoneNumber` (String, required): 휴대폰 번호\n" + + " - `name` (String, required): 관리자 이름\n" + + " - `department` (String, required): 소속 부서\n" + + " - `position` (String, required): 직책\n" + + " - `signImage` (MultipartFile, required): 인감 이미지 파일\n" + + "\n**Response:**\n" + + " - 성공 시 201(Created)과 `SignUpResponse` 객체 반환\n" + + " - `memberId` (Long): 회원 ID\n" + + " - `role` (UserRole): 회원 역할 (ADMIN)\n" + + " - `status` (ActivationStatus): 회원 상태 (ACTIVE)\n" + + " - `tokens` (Object): JWT 토큰 정보 (accessToken, refreshToken, expiresAt)\n" + + " - `basicInfo` (UserBasicInfo): 사용자 기본 정보 (프론트 캐싱용)\n" + + " - `name` (String): 단체명/관리자 이름\n" + + " - `university` (String): 대학교 (한글명)\n" + + " - `department` (String): 단과대 (한글명)\n" + + " - `major` (String): 전공/학과 (한글명)") + @PostMapping(value = "/admins/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public BaseResponse signupAdmin( + @Valid @RequestPart("request") + @Parameter( + description = "JSON 형식의 관리자 가입 정보", + // 'request' 파트의 content type을 명시적으로 지정 + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = AdminSignUpRequest.class)) + ) + AdminSignUpRequest request, + @RequestPart("signImage") + @Parameter( + description = "인감 이미지 파일 (Multipart Part)", + required = true, + content = @Content(mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE, + schema = @Schema(type = "string", format = "binary")) + ) + MultipartFile signImage) { + return BaseResponse.onSuccess(SuccessStatus._OK, signUpService.signupAdmin(request, signImage)); + } + + @Operation(summary = "공통 로그인 API" + , description = "# [v1.1 (2025-09-13)](https://clumsy-seeder-416.notion.site/2241197c19ed811c961be6a474de0e50)\n" + + "- `application/json`로 호출합니다.\n" + + "- 바디: `LoginRequest(email, password)`.\n" + + "- 처리: 자격 증명 검증 후 Access/Refresh 토큰 발급 및 저장.\n" + + "- 성공 시 200(OK)과 토큰, 만료시각, 기본 정보 반환.\n" + + "\n**Request Body:**\n" + + " - `CommonLoginRequest` 객체 (JSON, required): 로그인 정보\n" + + " - `email` (String, required): 이메일 주소\n" + + " - `password` (String, required): 비밀번호\n" + + "\n**Response:**\n" + + " - 성공 시 200(OK)과 `LoginResponse` 객체 반환\n" + + " - `memberId` (Long): 회원 ID\n" + + " - `role` (UserRole): 회원 역할 (PARTNER/ADMIN)\n" + + " - `status` (ActivationStatus): 회원 상태\n" + + " - `tokens` (Object): JWT 토큰 정보 (accessToken, refreshToken, expiresAt)\n" + + " - `basicInfo` (UserBasicInfo): 사용자 기본 정보 (프론트 캐싱용)\n" + + " - `name` (String): 업체명/단체명/관리자 이름\n" + + " - `university`, `department`, `major`: Admin의 경우 한글명, Partner의 경우 null") + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, content = @Content(schema = @Schema(implementation = CommonLoginRequest.class))) + @PostMapping(value = "/commons/login", consumes = MediaType.APPLICATION_JSON_VALUE) + public BaseResponse loginCommon( + @RequestBody @Valid CommonLoginRequest request + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, loginService.loginCommon(request)); + } + + @Operation(summary = "학생 로그인 API" + , description = "# [v1.2 (2025-09-13)](https://clumsy-seeder-416.notion.site/2501197c19ed80f6b495fa37f8c084a8)\n" + + "- `application/json`로 호출합니다.\n" + + "- 바디: `StudentTokenLoginRequest(sToken, sIdno, university)`.\n" + + "- 처리: 유세인트 인증 → 기존 회원 확인 → JWT 토큰 발급.\n" + + "- 성공 시 200(OK)과 토큰, 만료시각, 기본 정보 반환.\n" + + "\n**Request Body:**\n" + + " - `StudentTokenAuthPayload` 객체 (JSON, required): 숭실대 학생 토큰 로그인 정보\n" + + " - `sToken` (String, required): 유세인트 sToken\n" + + " - `sIdno` (Integer, required): 유세인트 sIdno\n" + + " - `university` (University enum, required): 대학 이름 (SSU)\n" + + "\n**Response:**\n" + + " - 성공 시 200(OK)과 `LoginResponse` 객체 반환\n" + + " - `memberId` (Long): 회원 ID\n" + + " - `role` (UserRole): 회원 역할 (STUDENT)\n" + + " - `status` (ActivationStatus): 회원 상태\n" + + " - `tokens` (Object): JWT 토큰 정보 (accessToken, refreshToken, expiresAt)\n" + + " - `basicInfo` (UserBasicInfo): 사용자 기본 정보 (프론트 캐싱용)\n" + + " - `name` (String): 학생 이름\n" + + " - `university` (String): 대학교 (한글명)\n" + + " - `department` (String): 단과대 (한글명)\n" + + " - `major` (String): 전공/학과 (한글명)") + @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true, content = @Content(schema = @Schema(implementation = StudentTokenAuthPayload.class))) + @PostMapping(value = "/students/login", consumes = MediaType.APPLICATION_JSON_VALUE) + public BaseResponse loginStudent( + @RequestBody @Valid StudentTokenAuthPayload request + ) { + LoginResponse response; + if(request.getUniversity().equals(University.SSU)){ + response = loginService.loginSsuStudent(request); + } else { + response = null; + } + return BaseResponse.onSuccess(SuccessStatus._OK, response); + } + + @Operation( + summary = "Access Token 갱신 API", + description = "# [v1.0 (2025-09-03)](https://clumsy-seeder-416.notion.site/2501197c19ed806ea8cff29f9cd8695a?source=copy_link)\n" + + "- 헤더로 호출합니다.\n" + + "- 헤더: `Authorization: Bearer `(만료 허용), `RefreshToken: `.\n" + + "- 처리: Refresh 검증/회전 후 신규 Access/Refresh 발급 및 저장.\n" + + "- 성공 시 200(OK)과 새 토큰/만료시각 반환.\n" + + "\n**Headers:**\n" + + " - `Authorization` (String, required): Bearer 토큰 형식의 액세스 토큰 (만료 허용)\n" + + " - `RefreshToken` (String, required): 리프레시 토큰\n" + + "\n**Response:**\n" + + " - 성공 시 200(OK)과 `RefreshResponse` 객체 반환\n" + + " - `accessToken` (String): 새로운 액세스 토큰\n" + + " - `refreshToken` (String): 새로운 리프레시 토큰\n" + + " - `expiresAt` (LocalDateTime): 새 토큰 만료 시각\n" + + " - 성공 시 200(OK)과 새 토큰/만료시각 반환." + ) + @Parameters({ + @Parameter(name = "Authorization", description = "Access Token (만료 허용). 형식: `Bearer `", required = true, in = ParameterIn.HEADER, schema = @Schema(type = "string")), + @Parameter(name = "RefreshToken", description = "Refresh Token", required = true, in = ParameterIn.HEADER, schema = @Schema(type = "string")) + }) + @PostMapping("/tokens/refresh") + public BaseResponse refreshToken( + @RequestHeader("RefreshToken") String refreshToken) { + return BaseResponse.onSuccess(SuccessStatus._OK, loginService.refresh(refreshToken)); + } + + @Operation( + summary = "로그아웃 API", + description = "# [v1.0 (2025-09-03)](https://clumsy-seeder-416.notion.site/23a1197c19ed809e9a09fcd741f554c8?source=copy_link)\n" + + "- 헤더로 호출합니다.\n" + + "- 헤더: `Authorization: Bearer `.\n" + + "- 처리: Refresh 무효화(선택), Access 블랙리스트 등록.\n" + + "- 성공 시 200(OK)." + ) + @PostMapping("/logout") + public BaseResponse logout( + @RequestHeader("Authorization") + @Parameter(name = "Authorization", description = "Access Token. 형식: `Bearer `", required = true, + in = ParameterIn.HEADER, schema = @Schema(type = "string")) + String authorization + ) { + logoutService.logout(authorization); + return BaseResponse.onSuccess(SuccessStatus._OK, null); + } + + // 숭실대 인증 및 개인정보 조회 + @Operation( + summary = "숭실대 유세인트 인증 API", + description = "# [v1.0 (2025-09-03)](https://clumsy-seeder-416.notion.site/23a1197c19ed808d9266e641e5c4ea14?source=copy_link)\n" + + "- `application/json`으로 호출합니다.\n" + + "- 요청 바디: `USaintAuthRequest(sToken, sIdno)`.\n" + + "- 처리 순서:\n" + + " 1) 유세인트 SSO 로그인 시도 (sToken, sIdno 검증)\n" + + " 2) 응답 Body 검증 후 세션 쿠키 추출\n" + + " 3) 유세인트 포털 페이지 접근 및 HTML 파싱\n" + + " 4) 이름, 학번, 소속, 학적 상태, 학년/학기 정보 추출\n" + + " 5) 소속 문자열을 전공 Enum(`Major`)으로 매핑\n" + + " 6) 인증 결과를 `USaintAuthResponse` DTO로 반환\n" + ) + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(schema = @Schema(implementation = USaintAuthRequest.class)) + ) + @PostMapping(value = "/students/ssu-verify", consumes = MediaType.APPLICATION_JSON_VALUE) + public BaseResponse ssuAuth( + @RequestBody @Valid USaintAuthRequest request + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, ssuAuthService.uSaintAuth(request)); + } + + @Operation( + summary = "회원 탈퇴 API", + description = "# [v1.0 (2025-09-13)](https://clumsy-seeder-416.notion.site/2501197c19ed800a844bdafa2e2e8d2e?source=copy_link)\n" + + "- 현재 로그인한 사용자의 회원 탈퇴를 처리합니다.\n" + + "- 소프트 삭제 방식으로, 한 달 후 완전히 삭제됩니다.\n" + + "- 탈퇴 즉시 모든 토큰이 무효화됩니다.\n" + + "\n**Headers:**\n" + + " - `Authorization` (String, required): Bearer 토큰 형식의 액세스 토큰\n" + + "\n**Response:**\n" + + " - 성공 시 200(OK)과 성공 메시지 반환\n" + + " - 탈퇴 후 재로그인 가능" + ) + @PatchMapping("/withdraw") + public BaseResponse withdrawMember( + @RequestHeader("Authorization") + @Parameter(name = "Authorization", description = "Access Token. 형식: `Bearer `", required = true, + in = ParameterIn.HEADER, schema = @Schema(type = "string")) + String authorization + ) { + withdrawalService.withdrawCurrentUser(authorization); + return BaseResponse.onSuccess(SuccessStatus._OK, null); + } + +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/auth/dto/AuthReqeustDTO.java b/src/main/java/com/assu/server/domain/auth/dto/AuthReqeustDTO.java deleted file mode 100644 index 51038d96..00000000 --- a/src/main/java/com/assu/server/domain/auth/dto/AuthReqeustDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.auth.dto; - -public class AuthReqeustDTO { -} diff --git a/src/main/java/com/assu/server/domain/auth/dto/AuthResponseDTO.java b/src/main/java/com/assu/server/domain/auth/dto/AuthResponseDTO.java deleted file mode 100644 index 458a3b1f..00000000 --- a/src/main/java/com/assu/server/domain/auth/dto/AuthResponseDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.auth.dto; - -public class AuthResponseDTO { -} diff --git a/src/main/java/com/assu/server/domain/auth/dto/common/UserBasicInfo.java b/src/main/java/com/assu/server/domain/auth/dto/common/UserBasicInfo.java new file mode 100644 index 00000000..6bbe804d --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/common/UserBasicInfo.java @@ -0,0 +1,29 @@ +package com.assu.server.domain.auth.dto.common; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "사용자 기본 정보") +public class UserBasicInfo { + + @Schema(description = "이름/업체명/단체명", example = "홍길동") + private String name; + + @Schema(description = "대학교", example = "숭실대학교") + private String university; + + @Schema(description = "단과대", example = "IT공과대학") + private String department; + + @Schema(description = "전공/학과", example = "소프트웨어학부") + private String major; +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/login/CommonLoginRequest.java b/src/main/java/com/assu/server/domain/auth/dto/login/CommonLoginRequest.java new file mode 100644 index 00000000..20ef18b3 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/login/CommonLoginRequest.java @@ -0,0 +1,28 @@ +package com.assu.server.domain.auth.dto.login; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.*; + +/** 파트너/관리자 공통 로그인 요청 */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "파트너/관리자 공통 로그인 요청") +public class CommonLoginRequest { + + @Schema(description = "로그인 이메일", example = "user@example.com") + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + @Size(max = 255, message = "이메일은 255자를 넘을 수 없습니다.") + private String email; + + @Schema(description = "로그인 비밀번호(평문)", example = "P@ssw0rd!") + @NotBlank(message = "비밀번호는 필수입니다.") + @Size(min = 8, max = 64, message = "비밀번호는 8~64자여야 합니다.") + private String password; +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/login/LoginResponse.java b/src/main/java/com/assu/server/domain/auth/dto/login/LoginResponse.java new file mode 100644 index 00000000..0f24d204 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/login/LoginResponse.java @@ -0,0 +1,34 @@ +package com.assu.server.domain.auth.dto.login; + +import com.assu.server.domain.auth.dto.common.UserBasicInfo; +import com.assu.server.domain.auth.dto.signup.Tokens; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.common.enums.UserRole; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "로그인 성공 응답") +public class LoginResponse { + + @Schema(description = "회원 ID", example = "123") + private Long memberId; + + @Schema(description = "회원 역할", example = "STUDENT") + private UserRole role; + + @Schema(description = "회원 상태", example = "SUSPEND") + private ActivationStatus status; + + @Schema(description = "액세스 토큰/리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private Tokens tokens; + + @Schema(description = "사용자 기본 정보 (캐싱용)") + private UserBasicInfo basicInfo; +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/login/RefreshResponse.java b/src/main/java/com/assu/server/domain/auth/dto/login/RefreshResponse.java new file mode 100644 index 00000000..4a983b5b --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/login/RefreshResponse.java @@ -0,0 +1,4 @@ +package com.assu.server.domain.auth.dto.login; + +public record RefreshResponse(Long memberId, String newAccess, String newRefresh) { +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/phone/PhoneAuthRequestDTO.java b/src/main/java/com/assu/server/domain/auth/dto/phone/PhoneAuthRequestDTO.java new file mode 100644 index 00000000..d4179f0e --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/phone/PhoneAuthRequestDTO.java @@ -0,0 +1,34 @@ +package com.assu.server.domain.auth.dto.phone; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class PhoneAuthRequestDTO { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class PhoneAuthVerifyRequest { + @NotBlank + @Pattern(regexp = "^010\\d{8}$", message = "올바른 전화번호 형식이 아닙니다.") + private String phoneNumber; + + @NotBlank + private String authNumber; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class PhoneAuthSendRequest { + @NotBlank + @Pattern(regexp = "^010\\d{8}$", message = "올바른 전화번호 형식이 아닙니다.") + private String phoneNumber; + } +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/phone/PhoneAuthResponseDTO.java b/src/main/java/com/assu/server/domain/auth/dto/phone/PhoneAuthResponseDTO.java new file mode 100644 index 00000000..5c26bc8c --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/phone/PhoneAuthResponseDTO.java @@ -0,0 +1,4 @@ +package com.assu.server.domain.auth.dto.phone; + +public class PhoneAuthResponseDTO { +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/signup/AdminSignUpRequest.java b/src/main/java/com/assu/server/domain/auth/dto/signup/AdminSignUpRequest.java new file mode 100644 index 00000000..65458dba --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/signup/AdminSignUpRequest.java @@ -0,0 +1,26 @@ +package com.assu.server.domain.auth.dto.signup; + +import com.assu.server.domain.auth.dto.signup.common.CommonAuthPayload; +import com.assu.server.domain.auth.dto.signup.common.CommonInfoPayload; +import com.assu.server.domain.auth.dto.signup.common.CommonSignUpRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.*; +import lombok.experimental.SuperBuilder; + +/** 관리자 가입: multipart payload(JSON) */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public class AdminSignUpRequest extends CommonSignUpRequest { + + @Valid + @NotNull + private CommonAuthPayload commonAuth; + + @Valid + @NotNull + private CommonInfoPayload commonInfo; + // signImage는 @RequestPart MultipartFile 로 별도 수신 +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/signup/PartnerSignUpRequest.java b/src/main/java/com/assu/server/domain/auth/dto/signup/PartnerSignUpRequest.java new file mode 100644 index 00000000..0c30e7a0 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/signup/PartnerSignUpRequest.java @@ -0,0 +1,28 @@ +package com.assu.server.domain.auth.dto.signup; + +import com.assu.server.domain.auth.dto.signup.common.CommonAuthPayload; +import com.assu.server.domain.auth.dto.signup.common.CommonInfoPayload; +import com.assu.server.domain.auth.dto.signup.common.CommonSignUpRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +/** 제휴업체 가입: multipart payload(JSON) */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public class PartnerSignUpRequest extends CommonSignUpRequest { + + @Valid + @NotNull + private CommonAuthPayload commonAuth; + + @Valid + @NotNull + private CommonInfoPayload commonInfo; + // licenseImage는 @RequestPart MultipartFile 로 별도 수신 +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/signup/SignUpResponse.java b/src/main/java/com/assu/server/domain/auth/dto/signup/SignUpResponse.java new file mode 100644 index 00000000..02bdd2ab --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/signup/SignUpResponse.java @@ -0,0 +1,36 @@ +package com.assu.server.domain.auth.dto.signup; + +import com.assu.server.domain.auth.dto.common.UserBasicInfo; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.common.enums.UserRole; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "회원가입 성공 응답") +public class SignUpResponse { + + @Schema(description = "회원 ID", example = "123") + private Long memberId; + + @Schema(description = "회원 역할", example = "STUDENT") + private UserRole role; + + @Schema(description = "회원 상태", example = "ACTIVE") + private ActivationStatus status; + + @Schema(description = "액세스 토큰/리프레시 토큰") + private Tokens tokens; + + @Schema(description = "사용자 기본 정보 (캐싱용)") + private UserBasicInfo basicInfo; +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/signup/StudentTokenSignUpRequest.java b/src/main/java/com/assu/server/domain/auth/dto/signup/StudentTokenSignUpRequest.java new file mode 100644 index 00000000..c5051e65 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/signup/StudentTokenSignUpRequest.java @@ -0,0 +1,22 @@ +package com.assu.server.domain.auth.dto.signup; + +import com.assu.server.domain.auth.dto.signup.common.CommonSignUpRequest; +import com.assu.server.domain.auth.dto.signup.student.StudentTokenAuthPayload; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import lombok.experimental.SuperBuilder; + +/** 학생 가입: sToken, sIdno 기반 */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public class StudentTokenSignUpRequest extends CommonSignUpRequest { + + @Valid + @NotNull + private StudentTokenAuthPayload studentTokenAuth; +} + diff --git a/src/main/java/com/assu/server/domain/auth/dto/signup/Tokens.java b/src/main/java/com/assu/server/domain/auth/dto/signup/Tokens.java new file mode 100644 index 00000000..e414d77e --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/signup/Tokens.java @@ -0,0 +1,11 @@ +package com.assu.server.domain.auth.dto.signup; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class Tokens { + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/signup/common/CommonAuthPayload.java b/src/main/java/com/assu/server/domain/auth/dto/signup/common/CommonAuthPayload.java new file mode 100644 index 00000000..bb80d8b0 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/signup/common/CommonAuthPayload.java @@ -0,0 +1,32 @@ +package com.assu.server.domain.auth.dto.signup.common; + +import com.assu.server.domain.auth.exception.annotation.PasswordMatches; +import com.assu.server.domain.user.entity.enums.Department; +import com.assu.server.domain.user.entity.enums.Major; +import com.assu.server.domain.user.entity.enums.University; + +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Email; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CommonAuthPayload { + @Email @NotBlank + private String email; + + @Size(min = 8, max = 72) @NotBlank + private String password; + + private Department department; + + private Major major; + + private University university; +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/signup/common/CommonInfoPayload.java b/src/main/java/com/assu/server/domain/auth/dto/signup/common/CommonInfoPayload.java new file mode 100644 index 00000000..116d6c19 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/signup/common/CommonInfoPayload.java @@ -0,0 +1,25 @@ +package com.assu.server.domain.auth.dto.signup.common; + +import com.assu.server.domain.map.dto.SelectedPlacePayload; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CommonInfoPayload { + @Size(min = 1, max = 50) @NotBlank + private String name; + + @Size(max = 255) + private String detailAddress; + + @NotNull + private SelectedPlacePayload selectedPlace; +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/signup/common/CommonSignUpRequest.java b/src/main/java/com/assu/server/domain/auth/dto/signup/common/CommonSignUpRequest.java new file mode 100644 index 00000000..80b7570f --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/signup/common/CommonSignUpRequest.java @@ -0,0 +1,27 @@ +package com.assu.server.domain.auth.dto.signup.common; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** 공통 필드 */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public class CommonSignUpRequest { + + @Pattern(regexp = "^(01[016789])\\d{3,4}\\d{4}$", message = "휴대폰 번호 형식이 올바르지 않습니다.") + @NotBlank + private String phoneNumber; + + @NotNull + private Boolean marketingAgree; + + @NotNull + private Boolean locationAgree; +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/signup/student/StudentTokenAuthPayload.java b/src/main/java/com/assu/server/domain/auth/dto/signup/student/StudentTokenAuthPayload.java new file mode 100644 index 00000000..42907454 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/signup/student/StudentTokenAuthPayload.java @@ -0,0 +1,28 @@ +package com.assu.server.domain.auth.dto.signup.student; + +import com.assu.server.domain.user.entity.enums.University; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class StudentTokenAuthPayload { + @Schema(description = "유세인트 sToken", example = "Vy3zFySFx5FASz175Kx7AzKyuSFQEgQ...") + @NotNull(message = "sToken은 필수입니다.") + @JsonProperty(value = "sToken") + private String sToken; + + @Schema(description = "유세인트 sIdno", example = "20211438") + @NotNull(message = "sIdno는 필수입니다.") + @JsonProperty(value = "sIdno") + private String sIdno; + + private University university; +} + + diff --git a/src/main/java/com/assu/server/domain/auth/dto/ssu/USaintAuthRequest.java b/src/main/java/com/assu/server/domain/auth/dto/ssu/USaintAuthRequest.java new file mode 100644 index 00000000..7e3cac94 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/ssu/USaintAuthRequest.java @@ -0,0 +1,19 @@ +package com.assu.server.domain.auth.dto.ssu; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; +import org.jetbrains.annotations.NotNull; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class USaintAuthRequest { + @NotNull + @JsonProperty(value = "sToken") + private String sToken; + @NotNull + @JsonProperty(value = "sIdno") + private String sIdno; +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/ssu/USaintAuthResponse.java b/src/main/java/com/assu/server/domain/auth/dto/ssu/USaintAuthResponse.java new file mode 100644 index 00000000..d4fd4458 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/ssu/USaintAuthResponse.java @@ -0,0 +1,17 @@ +package com.assu.server.domain.auth.dto.ssu; + +import com.assu.server.domain.user.entity.enums.Major; +import lombok.*; + +@Setter +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class USaintAuthResponse { + private String studentNumber; + private String name; + private String enrollmentStatus; + private String yearSemester; + private Major major; +} diff --git a/src/main/java/com/assu/server/domain/auth/dto/verification/VerificationRequestDTO.java b/src/main/java/com/assu/server/domain/auth/dto/verification/VerificationRequestDTO.java new file mode 100644 index 00000000..5ef713ed --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/verification/VerificationRequestDTO.java @@ -0,0 +1,32 @@ +package com.assu.server.domain.auth.dto.verification; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class VerificationRequestDTO { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class PhoneVerificationCheckRequest { + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^010\\d{8}$", message = "올바른 전화번호 형식이 아닙니다.") + private String phoneNumber; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class EmailVerificationCheckRequest { + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + private String email; + } +} diff --git a/src/main/java/com/assu/server/domain/auth/entity/AuthRealm.java b/src/main/java/com/assu/server/domain/auth/entity/AuthRealm.java new file mode 100644 index 00000000..eacd0e1c --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/entity/AuthRealm.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.auth.entity; + +public enum AuthRealm { + COMMON, SSU +} diff --git a/src/main/java/com/assu/server/domain/auth/entity/CommonAuth.java b/src/main/java/com/assu/server/domain/auth/entity/CommonAuth.java new file mode 100644 index 00000000..561f7d2f --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/entity/CommonAuth.java @@ -0,0 +1,43 @@ +package com.assu.server.domain.auth.entity; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "common_auth", + uniqueConstraints = { + @UniqueConstraint(name = "ux_common_auth_email", columnNames = {"email"}) + } +) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CommonAuth extends BaseEntity { + + @Id + @Column(name = "member_id") + private Long id; + + @OneToOne @MapsId + @JoinColumn(name = "member_id", referencedColumnName = "id") + private Member member; + + @Column(name = "email", length = 255, nullable = false) + private String email; + + @Column(name = "password", length = 255, nullable = false) + private String password; // 해시 저장 + + @Column(name = "is_email_verified", nullable = false) + private Boolean isEmailVerified = Boolean.FALSE; + + @Column(name = "last_login_at") + private LocalDateTime lastLoginAt; +} diff --git a/src/main/java/com/assu/server/domain/auth/entity/SSUAuth.java b/src/main/java/com/assu/server/domain/auth/entity/SSUAuth.java new file mode 100644 index 00000000..a60aea68 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/entity/SSUAuth.java @@ -0,0 +1,40 @@ +package com.assu.server.domain.auth.entity; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "ssu_auth", + indexes = { + @Index(name = "ux_ssu_auth_student_id", columnList = "student_number", unique = true) + } +) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SSUAuth extends BaseEntity { + + @Id + @Column(name = "member_id") + private Long id; + + @OneToOne @MapsId + @JoinColumn(name = "member_id", referencedColumnName = "id") + private Member member; + + @Column(name = "student_number", length = 20, nullable = false) + private String studentNumber; + + @Column(name = "is_authenticated", nullable = false) + private Boolean isAuthenticated = Boolean.FALSE; + + @Column(name = "authenticated_at") + private LocalDateTime authenticatedAt; +} diff --git a/src/main/java/com/assu/server/domain/auth/exception/CustomAuthException.java b/src/main/java/com/assu/server/domain/auth/exception/CustomAuthException.java new file mode 100644 index 00000000..138384d2 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/exception/CustomAuthException.java @@ -0,0 +1,11 @@ +package com.assu.server.domain.auth.exception; + +import com.assu.server.global.apiPayload.code.BaseErrorCode; +import com.assu.server.global.exception.GeneralException; + +public class CustomAuthException extends GeneralException { + + public CustomAuthException(BaseErrorCode errorCode) { + super(errorCode); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/auth/exception/annotation/PasswordMatches.java b/src/main/java/com/assu/server/domain/auth/exception/annotation/PasswordMatches.java new file mode 100644 index 00000000..5b8edb5d --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/exception/annotation/PasswordMatches.java @@ -0,0 +1,20 @@ +package com.assu.server.domain.auth.exception.annotation; + +import com.assu.server.domain.auth.exception.validator.PasswordMatchesValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = PasswordMatchesValidator.class) +public @interface PasswordMatches { + String message() default "Password fields do not match"; + Class[] groups() default {}; + Class[] payload() default {}; + + String password(); + String confirm(); +} diff --git a/src/main/java/com/assu/server/domain/auth/exception/validator/PasswordMatchesValidator.java b/src/main/java/com/assu/server/domain/auth/exception/validator/PasswordMatchesValidator.java new file mode 100644 index 00000000..fbb5faf6 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/exception/validator/PasswordMatchesValidator.java @@ -0,0 +1,36 @@ +package com.assu.server.domain.auth.exception.validator; + +import com.assu.server.domain.auth.exception.annotation.PasswordMatches; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.lang.reflect.Field; + +public class PasswordMatchesValidator implements ConstraintValidator { + + private String passwordField; + private String confirmField; + + @Override + public void initialize(PasswordMatches constraintAnnotation) { + this.passwordField = constraintAnnotation.password(); + this.confirmField = constraintAnnotation.confirm(); + } + + @Override + public boolean isValid(Object value, ConstraintValidatorContext context) { + try { + Field pw = value.getClass().getDeclaredField(passwordField); + Field cf = value.getClass().getDeclaredField(confirmField); + pw.setAccessible(true); + cf.setAccessible(true); + Object p = pw.get(value); + Object c = cf.get(value); + if (p == null || c == null) return false; + return p.equals(c); + } catch (Exception e) { + return false; + } + } +} + diff --git a/src/main/java/com/assu/server/domain/auth/repository/CommonAuthRepository.java b/src/main/java/com/assu/server/domain/auth/repository/CommonAuthRepository.java new file mode 100644 index 00000000..26ed3f1a --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/repository/CommonAuthRepository.java @@ -0,0 +1,14 @@ +package com.assu.server.domain.auth.repository; + +import com.assu.server.domain.auth.entity.CommonAuth; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CommonAuthRepository extends JpaRepository { + boolean existsByEmail(String email); + + Optional findByEmail(String email); +} diff --git a/src/main/java/com/assu/server/domain/auth/repository/SSUAuthRepository.java b/src/main/java/com/assu/server/domain/auth/repository/SSUAuthRepository.java new file mode 100644 index 00000000..09079d34 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/repository/SSUAuthRepository.java @@ -0,0 +1,14 @@ +package com.assu.server.domain.auth.repository; + +import com.assu.server.domain.auth.entity.SSUAuth; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SSUAuthRepository extends JpaRepository { + boolean existsByStudentNumber(String studentNumber); + + Optional findByStudentNumber(String studentNumber); +} diff --git a/src/main/java/com/assu/server/domain/auth/schduler/MemberCleanupScheduler.java b/src/main/java/com/assu/server/domain/auth/schduler/MemberCleanupScheduler.java new file mode 100644 index 00000000..46389951 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/schduler/MemberCleanupScheduler.java @@ -0,0 +1,44 @@ +package com.assu.server.domain.auth.schduler; + +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MemberCleanupScheduler { + + private final MemberRepository memberRepository; + + @Scheduled(cron = "0 0 2 * * ?") // 매일 오전 2시 + @Transactional + public void cleanupDeletedMembers() { + log.info("탈퇴 회원 완전 삭제 작업 시작"); + + // 한 달 전 시점 계산 + LocalDateTime oneMonthAgo = LocalDateTime.now().minusMonths(1); + + // 한 달 이상 전에 탈퇴한 회원들 조회 + List membersToDelete = memberRepository.findByDeletedAtBefore(oneMonthAgo); + + if (membersToDelete.isEmpty()) { + log.info("완전 삭제할 탈퇴 회원이 없습니다."); + return; + } + + log.info("완전 삭제할 탈퇴 회원 수: {}", membersToDelete.size()); + + // 실제 데이터베이스에서 삭제 + memberRepository.deleteAll(membersToDelete); + + log.info("탈퇴 회원 완전 삭제 작업 완료: {}명 삭제됨", membersToDelete.size()); + } +} diff --git a/src/main/java/com/assu/server/domain/auth/security/adapter/CommonAuthAdapter.java b/src/main/java/com/assu/server/domain/auth/security/adapter/CommonAuthAdapter.java new file mode 100644 index 00000000..266d7ec4 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/security/adapter/CommonAuthAdapter.java @@ -0,0 +1,82 @@ +package com.assu.server.domain.auth.security.adapter; + +import com.assu.server.domain.auth.entity.AuthRealm; +import com.assu.server.domain.auth.entity.CommonAuth; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.auth.repository.CommonAuthRepository; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CommonAuthAdapter implements RealmAuthAdapter { + private final CommonAuthRepository commonAuthRepository; + private final PasswordEncoder passwordEncoder; // BCrypt + + @Override + public boolean supports(AuthRealm realm) { + return realm == AuthRealm.COMMON; + } + + @Override + public UserDetails loadUserDetails(String email) { + CommonAuth ca = commonAuthRepository.findByEmail(email) + .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER)); + var m = ca.getMember(); + boolean enabled = m.getIsActivated() == ActivationStatus.ACTIVE; + String authority = "ROLE_" + m.getRole().name(); + + return org.springframework.security.core.userdetails.User + .withUsername(ca.getEmail()) + .password(ca.getPassword()) // BCrypt 해시 + .authorities(authority) + .accountExpired(false).accountLocked(false).credentialsExpired(false) + .disabled(!enabled) + .build(); + } + + @Override + public Member loadMember(String email) { + Member member = commonAuthRepository.findByEmail(email) + .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER)) + .getMember(); + + // 탈퇴된 회원이 다시 로그인하면 복구 + if (member.getDeletedAt() != null) { + member.setDeletedAt(null); + commonAuthRepository.save(member.getCommonAuth()); + } + + return member; + } + + @Override + public void registerCredentials(Member member, String email, String rawPassword) { + if (commonAuthRepository.existsByEmail(email)) { + throw new CustomAuthException(ErrorStatus.EXISTED_EMAIL); + } + String hash = passwordEncoder.encode(rawPassword); + commonAuthRepository.save( + CommonAuth.builder() + .member(member) + .email(email) + .password(hash) + .isEmailVerified(false) + .build()); + } + + @Override + public PasswordEncoder passwordEncoder() { + return passwordEncoder; + } + + @Override + public String authRealmValue() { + return "COMMON"; + } +} diff --git a/src/main/java/com/assu/server/domain/auth/security/adapter/RealmAuthAdapter.java b/src/main/java/com/assu/server/domain/auth/security/adapter/RealmAuthAdapter.java new file mode 100644 index 00000000..1f6748d7 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/security/adapter/RealmAuthAdapter.java @@ -0,0 +1,15 @@ +package com.assu.server.domain.auth.security.adapter; + +import com.assu.server.domain.auth.entity.AuthRealm; +import com.assu.server.domain.member.entity.Member; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; + +public interface RealmAuthAdapter { + boolean supports(AuthRealm realm); + UserDetails loadUserDetails(String identifier); + Member loadMember(String identifier); + void registerCredentials(Member member, String username, String rawPassword); + PasswordEncoder passwordEncoder(); + String authRealmValue(); // "COMMON" or "SSU" +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/auth/security/adapter/SSUAuthAdapter.java b/src/main/java/com/assu/server/domain/auth/security/adapter/SSUAuthAdapter.java new file mode 100644 index 00000000..8dcc7b78 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/security/adapter/SSUAuthAdapter.java @@ -0,0 +1,94 @@ +package com.assu.server.domain.auth.security.adapter; + +import com.assu.server.domain.auth.entity.AuthRealm; +import com.assu.server.domain.auth.entity.SSUAuth; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.auth.repository.SSUAuthRepository; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class SSUAuthAdapter implements RealmAuthAdapter { + private final SSUAuthRepository ssuAuthRepository; + + @Override + public boolean supports(AuthRealm realm) { + return realm == AuthRealm.SSU; + } + + @Override + public UserDetails loadUserDetails(String studentNumber) { + SSUAuth sa = ssuAuthRepository.findByStudentNumber(studentNumber) + .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER)); + var m = sa.getMember(); + boolean enabled = m.getIsActivated() == ActivationStatus.ACTIVE; + String authority = "ROLE_" + m.getRole().name(); + + // sToken/sIdno 기반 인증이므로 더미 패스워드 사용 + return org.springframework.security.core.userdetails.User + .withUsername(sa.getStudentNumber()) + .password("") // 더미 패스워드 (실제 인증은 sToken/sIdno로 수행) + .authorities(authority) + .accountExpired(false).accountLocked(false).credentialsExpired(false) + .disabled(!enabled) + .build(); + } + + @Override + public Member loadMember(String studentNumber) { + Member member = ssuAuthRepository.findByStudentNumber(studentNumber) + .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER)) + .getMember(); + + // 탈퇴된 회원이 다시 로그인하면 복구 + if (member.getDeletedAt() != null) { + member.setDeletedAt(null); + ssuAuthRepository.save(member.getSsuAuth()); + } + + return member; + } + + @Override + public void registerCredentials(Member member, String studentNumber, String rawPassword) { + if (ssuAuthRepository.existsByStudentNumber(studentNumber)) { + throw new CustomAuthException(ErrorStatus.EXISTED_STUDENT); + } + ssuAuthRepository.save( + SSUAuth.builder() + .member(member) + .studentNumber(studentNumber) + .isAuthenticated(true) + .authenticatedAt(LocalDateTime.now()) + .build()); + } + + @Override + public PasswordEncoder passwordEncoder() { + // 더미 패스워드 인코더 (실제 인증은 sToken/sIdno로 수행) + return new PasswordEncoder() { + @Override + public String encode(CharSequence rawPassword) { + return ""; + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + return true; // 항상 true 반환 (실제 인증은 sToken/sIdno로 수행) + } + }; + } + + @Override + public String authRealmValue() { + return "SSU"; + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/auth/security/jwt/JwtAuthFilter.java b/src/main/java/com/assu/server/domain/auth/security/jwt/JwtAuthFilter.java new file mode 100644 index 00000000..8f90a3f8 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/security/jwt/JwtAuthFilter.java @@ -0,0 +1,174 @@ +package com.assu.server.domain.auth.security.jwt; + +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * JWT 인증 필터. + * + * 책임: + * - 보호 자원에 대해 Authorization 헤더의 Bearer 토큰을 검증하고 SecurityContext에 + * Authentication을 설정한다. + * - 토큰 재발급 엔드포인트(/auth/token/reissue)에서는 + * 1) Access 토큰(만료 허용)의 서명을 검증하고 블랙리스트 여부를 확인, + * 2) Refresh 토큰의 서명/만료를 검증하고 Redis 저장 여부를 확인한 뒤, + * 3) 만료된 Access 토큰에서 Authentication을 복원해 컨텍스트에 주입한다. + * + * 주의: + * - 화이트리스트는 Swagger 등 공개 리소스에 한정한다. /auth/** 전체를 우회시키지 않는다. + */ +@RequiredArgsConstructor +@Component +@Slf4j +public class JwtAuthFilter extends OncePerRequestFilter { + + @Value("${jwt.header}") + private String jwtHeader; + private final JwtUtil jwtUtil; + private final RedisTemplate redisTemplate; + + private static final AntPathMatcher PATH = new AntPathMatcher(); + // 공개 경로(필터 우회) + private static final String[] WHITELIST = { + "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", + "/swagger-resources/**", "/webjars/**", + // Auth (로그아웃/탈퇴/리프레시 제외) + "/auth/phone-verification/check-and-send", + "/auth/phone-verification/verify", + "/auth/email-verification/check", + "/auth/students/signup", + "/auth/partners/signup", + "/auth/admins/signup", + "/auth/commons/login", + "/auth/students/login", + "/auth/tokens/refresh", + "/auth/students/ssu-verify" + }; + + /** + * 이 요청에 대해 필터를 적용하지 않을지 여부를 판단하는 함수 + * 화이트리스트 패턴은 우회 + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) + return true; // CORS preflight 우회 + if (PATH.match("/auth/tokens/refresh", uri)) + return false; // 토큰 재발급은 필터 적용 + for (String p : WHITELIST) + if (PATH.match(p, uri)) + return true; // 나머지 공개 경로 우회 + return false; // 보호 자원은 필터 적용 + } + + /** + * Authorization 헤더가 존재하고 Bearer 포맷인지 확인한다. + * + * @throws CustomAuthException 헤더 누락/형식 오류 + */ + private static void requireBearerAuthorizationHeader(String authorizationHeader) { + if (authorizationHeader == null) { + throw new CustomAuthException(ErrorStatus.JWT_TOKEN_NOT_RECEIVED); + } + if (!authorizationHeader.startsWith("Bearer ")) { + throw new CustomAuthException(ErrorStatus.JWT_TOKEN_OUT_OF_FORM); + } + } + + /** + * 실제 필터링 로직. + * - 재발급 경로: Access(서명만), Refresh 검증 + 블랙리스트/Redis 확인 후 Authentication 세팅 + * - 일반 보호 경로: Access 검증 + 블랙리스트 확인 후 Authentication 세팅 + */ + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) + throws ServletException, IOException { + + String authorizationHeader = request.getHeader(jwtHeader); + String requestUri = request.getRequestURI(); + + // ───────── 재발급 경로 처리 (/auth/token/reissue) ───────── + if (PATH.match("/auth/tokens/refresh", requestUri)) { + String refreshToken = request.getHeader("refreshToken"); + try { + // Bearer 헤더 검증 + requireBearerAuthorizationHeader(authorizationHeader); + if (refreshToken == null) { + throw new CustomAuthException(ErrorStatus.JWT_TOKEN_NOT_RECEIVED); + } + + // Access 토큰: 서명만 검증(만료 허용) 및 블랙리스트 확인(JTI) + String accessToken = jwtUtil.getTokenFromHeader(authorizationHeader); + Claims accessClaims = jwtUtil.validateTokenOnlySignature(accessToken); + String accessJti = accessClaims.getId(); + Boolean accessBlacklisted = redisTemplate.hasKey("blacklist:" + accessJti); + if (Boolean.TRUE.equals(accessBlacklisted)) { + throw new CustomAuthException(ErrorStatus.LOGOUT_USER); + } + + // Refresh 토큰: 서명/만료 검증 + Redis 저장 여부 확인 + jwtUtil.validateRefreshToken(refreshToken); + Claims refreshClaims = jwtUtil.validateTokenOnlySignature(refreshToken); // 만료 전이어야 함 + Long memberIdFromRefresh = ((Number) refreshClaims.get("userId")).longValue(); + String refreshJti = refreshClaims.getId(); + String refreshKey = String.format("refresh:%d:%s", memberIdFromRefresh, refreshJti); + Boolean refreshExists = redisTemplate.hasKey(refreshKey); + if (Boolean.FALSE.equals(refreshExists)) { + // 저장된 RT가 없으면 유효하지 않은 재발급 시도 + throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION); + } + + // 컨텍스트에 만료된 Access 토큰으로부터 Authentication 복원 + Authentication authentication = jwtUtil.getAuthenticationFromExpiredAccessToken(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + + chain.doFilter(request, response); + return; + } catch (Exception exception) { + log.error("인증 과정 중, 예상치 못한 예외 발생: {}", exception.getMessage(), exception); + throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION); + } + } + + // ───────── 일반 보호 자원 처리 ───────── + // Authorization 헤더가 없거나 Bearer 형식이 아니면 그대로 통과(익명으로 처리됨) + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + chain.doFilter(request, response); + return; + } + + try { + String accessToken = jwtUtil.getTokenFromHeader(authorizationHeader); + + // 블랙리스트 확인(만료 허용 X) + Authentication 복원 + jwtUtil.assertNotBlacklisted(accessToken); + Authentication authentication = jwtUtil.getAuthentication(accessToken); + + SecurityContextHolder.getContext().setAuthentication(authentication); + chain.doFilter(request, response); + } catch (Exception exception) { + log.error("인증 과정 중, 예상치 못한 예외 발생: {}", exception.getMessage(), exception); + throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION); + } + } +} diff --git a/src/main/java/com/assu/server/domain/auth/security/jwt/JwtUtil.java b/src/main/java/com/assu/server/domain/auth/security/jwt/JwtUtil.java new file mode 100644 index 00000000..34a5b373 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/security/jwt/JwtUtil.java @@ -0,0 +1,328 @@ +package com.assu.server.domain.auth.security.jwt; + +import com.assu.server.domain.auth.dto.signup.Tokens; +import com.assu.server.domain.auth.entity.AuthRealm; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * JWT 발급/검증 및 Authentication 복원 유틸리티. + */ +@Component +@RequiredArgsConstructor +@Profile("!test") +public class JwtUtil { + + @Value("${jwt.secret}") + public String secretKey; + + @Value("${jwt.access-valid-seconds:3600}") // 1시간 기본 + private int accessValidSeconds; + + @Value("${jwt.refresh-valid-seconds:1209600}") // 14일 기본 + private int refreshValidSeconds; + + private final MemberRepository memberRepository; + private final RedisTemplate redisTemplate; + + @PostConstruct + public void clearRedisOnStartup() { + if (redisTemplate != null && redisTemplate.getConnectionFactory() != null) { + redisTemplate.getConnectionFactory().getConnection().flushAll(); + } + } + + // ───────── 토큰 생성 공통 유틸 ───────── + /** + * 서명용 SecretKey 생성. + */ + private SecretKey key() { + return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + } + + /** + * "Bearer xxx" 헤더에서 실제 토큰 문자열만 추출. + * @param authorizationHeader Authorization 헤더 값 + * @return 토큰 문자열 + */ + public String getTokenFromHeader(String authorizationHeader) { + return authorizationHeader.split(" ")[1]; + } + + /** + * JWT 문자열 생성. + * @param claims 토큰 클레임 + * @param validSeconds 유효 기간(초) + * @param jti JWT ID(고유 식별자) + * @return 서명된 토큰 문자열 + */ + private String generateToken(Map claims, int validSeconds, String jti) { + return Jwts.builder() + .setHeader(Map.of("typ", "JWT")) + .setClaims(claims) + .setId(jti) + .setIssuedAt(new Date()) + .setExpiration(Date.from(ZonedDateTime.now().plusSeconds(validSeconds).toInstant())) + .signWith(key()) + .compact(); + } + + // ───────── 발급 & 저장 ───────── + /** + * Access/Refresh 토큰 발급. + * - Access: 반환만, 저장하지 않음. + * - Refresh: Redis에 "refresh:{memberId}:{jti}" 키로 저장(TTL=만료). + * @param memberId 사용자 ID + * @param username 이메일 혹은 학번 + * @param role 사용자 역할 + * @param authRealm COMMON / SSU + * @return 발급된 토큰 세트 + */ + public Tokens issueTokens(Long memberId, String username, UserRole role, String authRealm) { + Map baseClaims = new HashMap<>(); + baseClaims.put("userId", memberId); + baseClaims.put("username", username); + baseClaims.put("role", role.name()); + baseClaims.put("authRealm", authRealm); + + String accessJti = UUID.randomUUID().toString(); + String refreshJti = UUID.randomUUID().toString(); + + String accessToken = generateToken(baseClaims, accessValidSeconds, accessJti); + String refreshToken = generateToken(baseClaims, refreshValidSeconds, refreshJti); + + String refreshKey = String.format("refresh:%d:%s", memberId, refreshJti); + redisTemplate.opsForValue().set(refreshKey, refreshToken, refreshValidSeconds, TimeUnit.SECONDS); + + return Tokens.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + // ───────── 검증 ───────── + /** + * Access 토큰 서명/만료 검증. + * @param token Access 토큰 + * @return 유효한 Claims + * @throws CustomAuthException 만료/서명 오류 + */ + public Claims validateToken(String token) { + try { + return Jwts.parserBuilder().setSigningKey(key()).build().parseClaimsJws(token).getBody(); + } catch (ExpiredJwtException exception) { + throw new CustomAuthException(ErrorStatus.JWT_ACCESS_TOKEN_EXPIRED); + } catch (Exception exception) { + throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION); + } + } + + /** + * Access 토큰의 서명만 검증(만료 허용)하여 Claims 추출. + * - 재발급 시 사용. + * @param token Access 토큰 + * @return Claims(만료된 토큰도 반환) + * @throws CustomAuthException 서명 오류 + */ + public Claims validateTokenOnlySignature(String token) { + try { + return Jwts.parserBuilder().setSigningKey(key()).build().parseClaimsJws(token).getBody(); + } catch (ExpiredJwtException exception) { + return exception.getClaims(); // 만료되어도 Claims는 사용 + } catch (Exception exception) { + throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION); + } + } + + /** + * Refresh 토큰 서명/만료 검증. + * - Redis 저장값과의 매칭은 호출부 정책에 따라 별도로 수행 가능. + * @param refreshToken Refresh 토큰 + * @throws CustomAuthException 만료/서명 오류 + */ + public void validateRefreshToken(String refreshToken) { + try { + Jwts.parserBuilder().setSigningKey(key()).build().parseClaimsJws(refreshToken).getBody(); + } catch (ExpiredJwtException exception) { + throw new CustomAuthException(ErrorStatus.JWT_REFRESH_TOKEN_EXPIRED); + } catch (Exception exception) { + throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION); + } + } + + // ───────── Authentication 복원 ───────── + /** + * 유효한 Access 토큰을 Authentication(CustomPrincipal + 권한)으로 복원. + * @param accessToken Access 토큰 + * @return 인증 객체 + */ + public Authentication getAuthentication(String accessToken) { + Claims claims = validateToken(accessToken); // 만료/서명 체크 + Long memberId = ((Number) claims.get("userId")).longValue(); + String roleName = (String) claims.get("role"); + String authRealmName = (String) claims.get("authRealm"); + + // DB 조회 + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER)); + + // PrincipalDetails 빌드 + PrincipalDetails principal = PrincipalDetails.builder() + .memberId(member.getId()) + .username(member.getId().toString()) + .role(UserRole.valueOf(roleName)) + .authRealm(AuthRealm.valueOf(authRealmName)) + .member(member) + .enabled(member.getIsActivated().equals(ActivationStatus.ACTIVE)) + .authorities(List.of(new SimpleGrantedAuthority("ROLE_" + roleName))) + .build(); + + // UsernamePasswordAuthenticationToken 에 PrincipalDetails 세팅 + return new UsernamePasswordAuthenticationToken( + principal, null, principal.getAuthorities() + ); + } + + /** + * 만료된 Access 토큰(서명 유효)을 Authentication으로 복원. + * - 재발급 시 SecurityContext 세팅용. + * @param expiredAccessToken 만료된 Access 토큰 + * @return 인증 객체 + */ + public Authentication getAuthenticationFromExpiredAccessToken(String expiredAccessToken) { + Claims claims = validateTokenOnlySignature(expiredAccessToken); + + Long userId = ((Number) claims.get("userId")).longValue(); + Member member = memberRepository.findById(userId) + .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_MEMBER)); + + UserRole role = UserRole.valueOf((String) claims.get("role")); + String authRealmString = (String) claims.get("authRealm"); + + List authorities = + List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); + + String username=""; + String password=""; + + AuthRealm realm = AuthRealm.valueOf(authRealmString); + if (realm == AuthRealm.COMMON) { + username = member.getCommonAuth().getEmail(); + password = member.getCommonAuth().getPassword(); + } else if (realm == AuthRealm.SSU){ + username = member.getSsuAuth().getStudentNumber(); + password = ""; // 더미 처리 + } + + // DB에서 조회한 member를 직접 넣어줌 + PrincipalDetails principal = PrincipalDetails.builder() + .member(member) + .memberId(member.getId()) + .username(username) + .password(password) + .enabled(member.getIsActivated().equals(ActivationStatus.ACTIVE)) + .role(role) + .authRealm(realm) + .authorities(authorities) + .build(); + + return new UsernamePasswordAuthenticationToken(principal, null, authorities); + } + + // ───────────────────────── 블랙리스트(JTI) ───────────────────────── + + /** + * Access 토큰이 블랙리스트에 포함되어 있지 않은지 확인. + * @param accessToken Access 토큰 + * @throws CustomAuthException 블랙리스트에 포함된 경우 + */ + public void assertNotBlacklisted(String accessToken) { + Claims claims = validateTokenOnlySignature(accessToken); + String jti = claims.getId(); + Boolean exists = redisTemplate.hasKey("blacklist:" + jti); + if (Boolean.TRUE.equals(exists)) { + throw new CustomAuthException(ErrorStatus.LOGOUT_USER); + } + } + + /** + * Access 토큰을 블랙리스트에 추가(남은 만료 시간만큼 TTL 부여). + * @param accessToken Access 토큰 + */ + public void blacklistAccess(String accessToken) { + Claims claims = validateTokenOnlySignature(accessToken); + String jti = claims.getId(); + long ttlSeconds = Math.max(1, (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000); + redisTemplate.opsForValue().set("blacklist:" + jti, "1", ttlSeconds, TimeUnit.SECONDS); + } + + // ───────────────────────── Refresh Token ───────────────────────── + + /** + * 특정 회원의 모든 Refresh 토큰을 Redis에서 제거 (전역 로그아웃용). + * @param memberId 사용자 ID + */ + public void removeAllRefreshTokens(Long memberId) { + String pattern = String.format("refresh:%d:*", memberId); + Set refreshKeys = redisTemplate.keys(pattern); + if (refreshKeys != null && !refreshKeys.isEmpty()) { + redisTemplate.delete(refreshKeys); + } + } + + /** + * Refresh 토큰 유효성 확인 및 회전. + * - 저장된 RT와 일치 여부 확인 + * - 기존 RT 삭제 후 새 토큰 세트 발급 + */ + public Tokens rotateRefreshToken(String refreshToken) { + // 1) Refresh 토큰 서명/만료 검증 + validateRefreshToken(refreshToken); + + // 2) Claims 추출 + Claims refreshClaims = validateTokenOnlySignature(refreshToken); + Long memberId = ((Number) refreshClaims.get("userId")).longValue(); + String username = (String) refreshClaims.get("username"); + String roleString = (String) refreshClaims.get("role"); + String authRealm = (String) refreshClaims.get("authRealm"); + String refreshJti = refreshClaims.getId(); + + UserRole role = UserRole.valueOf(roleString); + + // 3) Redis에 저장된 RT와 일치 확인 + String refreshKey = String.format("refresh:%d:%s", memberId, refreshJti); + String savedRefreshToken = redisTemplate.opsForValue().get(refreshKey); + if (savedRefreshToken == null || !savedRefreshToken.equals(refreshToken)) { + throw new CustomAuthException(ErrorStatus.REFRESH_TOKEN_NOT_EQUAL); + } + + // 4) 기존 RT 삭제 후 새 토큰 발급 + redisTemplate.delete(refreshKey); + return issueTokens(memberId, username, role, authRealm); + } + +} diff --git a/src/main/java/com/assu/server/domain/auth/security/provider/RoutingAuthenticationProvider.java b/src/main/java/com/assu/server/domain/auth/security/provider/RoutingAuthenticationProvider.java new file mode 100644 index 00000000..01d5dc91 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/security/provider/RoutingAuthenticationProvider.java @@ -0,0 +1,69 @@ +package com.assu.server.domain.auth.security.provider; + +import com.assu.server.domain.auth.entity.AuthRealm; +import com.assu.server.domain.auth.security.adapter.RealmAuthAdapter; +import com.assu.server.domain.auth.security.token.LoginUsernamePasswordAuthenticationToken; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class RoutingAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { + + private final List adapters; + + @Override + public boolean supports(Class authentication) { + return LoginUsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); + } + + private AuthRealm resolveRealm(Authentication auth) { + if (auth instanceof LoginUsernamePasswordAuthenticationToken) return ((LoginUsernamePasswordAuthenticationToken) auth).getRealm(); + throw new IllegalArgumentException("Unsupported authentication token: " + auth.getClass()); + } + + private RealmAuthAdapter pickAdapter(AuthRealm realm) { + return adapters.stream() + .filter(a -> a.supports(realm)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No adapter for realm: " + realm)); + } + + @Override + protected void additionalAuthenticationChecks( + UserDetails userDetails, + UsernamePasswordAuthenticationToken authentication) + throws AuthenticationException { + + AuthRealm realm = resolveRealm(authentication); + RealmAuthAdapter adapter = pickAdapter(realm); + + String presented = (authentication.getCredentials() != null) + ? authentication.getCredentials().toString() + : null; + + if (presented == null || !adapter.passwordEncoder().matches(presented, userDetails.getPassword())) { + throw new BadCredentialsException("Bad credentials"); + } + } + + @Override + protected UserDetails retrieveUser( + String username, + UsernamePasswordAuthenticationToken authentication) + throws AuthenticationException { + + AuthRealm realm = resolveRealm(authentication); + RealmAuthAdapter adapter = pickAdapter(realm); + return adapter.loadUserDetails(username); + } +} + diff --git a/src/main/java/com/assu/server/domain/auth/security/token/LoginUsernamePasswordAuthenticationToken.java b/src/main/java/com/assu/server/domain/auth/security/token/LoginUsernamePasswordAuthenticationToken.java new file mode 100644 index 00000000..b90043cc --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/security/token/LoginUsernamePasswordAuthenticationToken.java @@ -0,0 +1,18 @@ +package com.assu.server.domain.auth.security.token; + +import com.assu.server.domain.auth.entity.AuthRealm; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +// 단일 인증 토큰 +public class LoginUsernamePasswordAuthenticationToken + extends UsernamePasswordAuthenticationToken { + + private final AuthRealm realm; + + public LoginUsernamePasswordAuthenticationToken(AuthRealm realm, String principal, String credentials) { + super(principal, credentials); + this.realm = realm; + } + + public AuthRealm getRealm() { return realm; } +} diff --git a/src/main/java/com/assu/server/domain/auth/service/AuthService.java b/src/main/java/com/assu/server/domain/auth/service/AuthService.java deleted file mode 100644 index cc98034f..00000000 --- a/src/main/java/com/assu/server/domain/auth/service/AuthService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.auth.service; - -public interface AuthService { -} diff --git a/src/main/java/com/assu/server/domain/auth/service/AuthServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/AuthServiceImpl.java deleted file mode 100644 index edde3945..00000000 --- a/src/main/java/com/assu/server/domain/auth/service/AuthServiceImpl.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.auth.service; - -public class AuthServiceImpl { -} diff --git a/src/main/java/com/assu/server/domain/auth/service/EmailAuthService.java b/src/main/java/com/assu/server/domain/auth/service/EmailAuthService.java new file mode 100644 index 00000000..c3f9bb0e --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/EmailAuthService.java @@ -0,0 +1,8 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.auth.dto.verification.VerificationRequestDTO; + +public interface EmailAuthService { + + void checkEmailAvailability(VerificationRequestDTO.EmailVerificationCheckRequest request); +} diff --git a/src/main/java/com/assu/server/domain/auth/service/EmailAuthServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/EmailAuthServiceImpl.java new file mode 100644 index 00000000..c852420b --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/EmailAuthServiceImpl.java @@ -0,0 +1,25 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.auth.dto.verification.VerificationRequestDTO; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.auth.repository.CommonAuthRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailAuthServiceImpl implements EmailAuthService { + + private final CommonAuthRepository commonAuthRepository; + + @Override + public void checkEmailAvailability(VerificationRequestDTO.EmailVerificationCheckRequest request) { + + boolean exists = commonAuthRepository.existsByEmail(request.getEmail()); + + if (exists) { + throw new CustomAuthException(ErrorStatus.EXISTED_EMAIL); + } + } +} diff --git a/src/main/java/com/assu/server/domain/auth/service/LoginService.java b/src/main/java/com/assu/server/domain/auth/service/LoginService.java new file mode 100644 index 00000000..ff731366 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/LoginService.java @@ -0,0 +1,12 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.auth.dto.login.CommonLoginRequest; +import com.assu.server.domain.auth.dto.login.LoginResponse; +import com.assu.server.domain.auth.dto.login.RefreshResponse; +import com.assu.server.domain.auth.dto.signup.student.StudentTokenAuthPayload; + +public interface LoginService { + LoginResponse loginCommon(CommonLoginRequest request); + LoginResponse loginSsuStudent(StudentTokenAuthPayload request); + RefreshResponse refresh(String refreshToken); +} diff --git a/src/main/java/com/assu/server/domain/auth/service/LoginServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/LoginServiceImpl.java new file mode 100644 index 00000000..1c85152b --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/LoginServiceImpl.java @@ -0,0 +1,216 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.auth.dto.common.UserBasicInfo; +import com.assu.server.domain.auth.dto.login.CommonLoginRequest; +import com.assu.server.domain.auth.dto.login.LoginResponse; +import com.assu.server.domain.auth.dto.login.RefreshResponse; +import com.assu.server.domain.auth.dto.signup.student.StudentTokenAuthPayload; +import com.assu.server.domain.auth.dto.ssu.USaintAuthRequest; +import com.assu.server.domain.auth.dto.ssu.USaintAuthResponse; +import com.assu.server.domain.auth.dto.signup.Tokens; +import com.assu.server.domain.auth.entity.AuthRealm; +import com.assu.server.domain.auth.security.adapter.RealmAuthAdapter; +import com.assu.server.domain.auth.security.token.LoginUsernamePasswordAuthenticationToken; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.auth.security.jwt.JwtUtil; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.domain.user.entity.enums.Department; +import com.assu.server.domain.user.entity.enums.EnrollmentStatus; +import com.assu.server.domain.user.entity.enums.Major; +import com.assu.server.domain.user.entity.enums.University; +import com.assu.server.domain.user.repository.StudentRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class LoginServiceImpl implements LoginService { + + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; + private final SSUAuthService ssuAuthService; + private final StudentRepository studentRepository; + + // 공통/학생/기타 학교까지 모두 여기로 주입 + private final List realmAuthAdapters; + + private RealmAuthAdapter pickAdapter(AuthRealm realm) { + return realmAuthAdapters.stream() + .filter(a -> a.supports(realm)) + .findFirst() + .orElseThrow(() -> new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION)); + } + + /** + * 공통(파트너/관리자) 로그인: 이메일/비밀번호 기반. + * 1) 인증 성공 시 CommonAuth 조회 + * 2) JWT 발급: username=email, authRealm=COMMON + */ + @Override + public LoginResponse loginCommon(CommonLoginRequest request) { + // 공통(파트너/관리자) 로그인: 이메일/비번 + Authentication authentication = authenticationManager.authenticate( + new LoginUsernamePasswordAuthenticationToken( + AuthRealm.COMMON, + request.getEmail(), + request.getPassword())); + + RealmAuthAdapter adapter = pickAdapter(AuthRealm.COMMON); + + // identifier = email + Member member = adapter.loadMember(authentication.getName()); + + // 토큰 발급 (Access 미저장, Refresh는 Redis 저장) + Tokens tokens = jwtUtil.issueTokens( + member.getId(), + authentication.getName(), // email + member.getRole(), + adapter.authRealmValue() // "COMMON" + ); + + return LoginResponse.builder() + .memberId(member.getId()) + .role(member.getRole()) + .status(member.getIsActivated()) + .tokens(tokens) + .basicInfo(buildUserBasicInfo(member)) + .build(); + } + + /** + * 숭실대 학생 로그인: sToken, sIdno 기반. + * 1) 유세인트 인증으로 학생 정보 확인 + * 2) 기존 회원 확인 + * 3) Student 정보 업데이트 (유세인트에서 크롤링한 최신 정보로) + * 4) JWT 발급: username=studentNumber, authRealm=SSU + */ + @Override + @Transactional + public LoginResponse loginSsuStudent(StudentTokenAuthPayload request) { + // 1) 유세인트 인증 + USaintAuthRequest authRequest = USaintAuthRequest.builder() + .sToken(request.getSToken()) + .sIdno(request.getSIdno()) + .build(); + + USaintAuthResponse authResponse = ssuAuthService.uSaintAuth(authRequest); + + // 2) 기존 회원 확인 + String realmStr = request.getUniversity().toString(); + AuthRealm authRealm = AuthRealm.valueOf(realmStr); + RealmAuthAdapter adapter = pickAdapter(authRealm); + + Member member = adapter.loadMember(authResponse.getStudentNumber().toString()); + + // 3) Student 정보 업데이트 (유세인트에서 크롤링한 최신 정보로) + Student student = member.getStudentProfile(); + if (student == null) { + throw new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER); + } + + // 유세인트에서 크롤링한 최신 정보로 업데이트 + student.updateStudentInfo( + authResponse.getName(), + authResponse.getMajor(), + parseEnrollmentStatus(authResponse.getEnrollmentStatus()), + authResponse.getYearSemester()); + + studentRepository.save(student); + + // 4) 토큰 발급 + Tokens tokens = jwtUtil.issueTokens( + member.getId(), + authResponse.getStudentNumber().toString(), // studentNumber + member.getRole(), + adapter.authRealmValue() // 예: "SSU" + ); + + return LoginResponse.builder() + .memberId(member.getId()) + .role(member.getRole()) + .status(member.getIsActivated()) + .tokens(tokens) + .basicInfo(buildUserBasicInfo(member)) + .build(); + } + + /** + * Refresh 토큰 재발급(회전). + * 전제: JwtAuthFilter가 /auth/refresh 에서 Access(만료 허용) 서명 검증 및 컨텍스트 세팅을 이미 수행. + * + * 절차: + * 1) RT 서명/만료 검증(jwtUtil.validateRefreshToken) + * 2) RT의 Claims 추출(만료 X), memberId/jti/username/role/authRealm 획득 + * 3) Redis 키 "refresh:{memberId}:{jti}" 존재 및 값 일치 확인(도난/중복 재사용 차단) + * 4) 기존 RT 키 삭제(회전), 새 토큰 발급(issueTokens) + */ + @Override + public RefreshResponse refresh(String refreshToken) { + Tokens rotated = jwtUtil.rotateRefreshToken(refreshToken); + return new RefreshResponse( + ((Number) jwtUtil.validateTokenOnlySignature(rotated.getAccessToken()).get("userId")).longValue(), + rotated.getAccessToken(), + rotated.getRefreshToken()); + } + + private EnrollmentStatus parseEnrollmentStatus(String status) { + if (status == null || status.isBlank()) { + return EnrollmentStatus.ENROLLED; + } + if (status.contains("재학")) { + return EnrollmentStatus.ENROLLED; + } else if (status.contains("휴학")) { + return EnrollmentStatus.LEAVE; + } else if (status.contains("졸업")) { + return EnrollmentStatus.GRADUATED; + } else { + // 기본값은 재학으로 설정 + return EnrollmentStatus.ENROLLED; + } + } + + /** + * 사용자 기본 정보를 빌드하는 헬퍼 메서드 + */ + private UserBasicInfo buildUserBasicInfo(Member member) { + UserBasicInfo.UserBasicInfoBuilder builder = UserBasicInfo.builder(); + + switch (member.getRole()) { + case STUDENT -> { + Student student = member.getStudentProfile(); + if (student != null) { + builder.name(student.getName()) + .university(student.getUniversity().getDisplayName()) + .department(student.getDepartment().getDisplayName()) + .major(student.getMajor().getDisplayName()); + } + } + case ADMIN -> { + // Admin 엔티티에서 정보 추출 + var admin = member.getAdminProfile(); + if (admin != null) { + builder.name(admin.getName()) + .university(admin.getUniversity() != null ? admin.getUniversity().getDisplayName() : null) + .department(admin.getDepartment() != null ? admin.getDepartment().getDisplayName() : null) + .major(admin.getMajor() != null ? admin.getMajor().getDisplayName() : null); + } + } + case PARTNER -> { + // Partner 엔티티에서 정보 추출 (Partner는 name만 필요) + var partner = member.getPartnerProfile(); + if (partner != null) { + builder.name(partner.getName()); + } + } + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/auth/service/LogoutService.java b/src/main/java/com/assu/server/domain/auth/service/LogoutService.java new file mode 100644 index 00000000..ff6019db --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/LogoutService.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.auth.service; + +public interface LogoutService { + void logout(String rawAccessToken); +} diff --git a/src/main/java/com/assu/server/domain/auth/service/LogoutServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/LogoutServiceImpl.java new file mode 100644 index 00000000..af98c3e2 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/LogoutServiceImpl.java @@ -0,0 +1,30 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.auth.security.jwt.JwtUtil; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LogoutServiceImpl implements LogoutService { + + private final JwtUtil jwtUtil; + private final RedisTemplate redisTemplate; + + @Override + public void logout(String authorization) { + String rawAccessToken = jwtUtil.getTokenFromHeader(authorization); + + // 1) Access 토큰 Claims 추출 (만료 허용, 서명 검증) + Claims accessClaims = jwtUtil.validateTokenOnlySignature(rawAccessToken); + + // 2) Access 블랙리스트 등록 + jwtUtil.blacklistAccess(rawAccessToken); + + // 3) 해당 사용자(memberId)의 모든 Refresh 토큰 키 제거 (전역 로그아웃) + Long memberId = ((Number) accessClaims.get("userId")).longValue(); + jwtUtil.removeAllRefreshTokens(memberId); + } +} diff --git a/src/main/java/com/assu/server/domain/auth/service/PhoneAuthService.java b/src/main/java/com/assu/server/domain/auth/service/PhoneAuthService.java new file mode 100644 index 00000000..1b345c37 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/PhoneAuthService.java @@ -0,0 +1,6 @@ +package com.assu.server.domain.auth.service; + +public interface PhoneAuthService { + void checkAndSendAuthNumber(String phoneNumber); + void verifyAuthNumber(String phoneNumber, String authNumber); +} diff --git a/src/main/java/com/assu/server/domain/auth/service/PhoneAuthServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/PhoneAuthServiceImpl.java new file mode 100644 index 00000000..239650c5 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/PhoneAuthServiceImpl.java @@ -0,0 +1,59 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.util.RandomNumberUtil; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.infra.aligo.client.AligoSmsClient; +import com.assu.server.infra.aligo.dto.AligoSendResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class PhoneAuthServiceImpl implements PhoneAuthService { + + private final StringRedisTemplate redisTemplate; + private final AligoSmsClient aligoSmsClient; + private final MemberRepository memberRepository; + + private static final Duration AUTH_CODE_TTL = Duration.ofMinutes(5); // 인증번호 5분 유효 + + @Override + public void checkAndSendAuthNumber(String phoneNumber) { + boolean exists = memberRepository.existsByPhoneNum(phoneNumber); + + if (exists) { + throw new CustomAuthException(ErrorStatus.EXISTED_PHONE); + } + + String authNumber = RandomNumberUtil.generateSixDigit(); + redisTemplate.opsForValue().set(phoneNumber, authNumber, AUTH_CODE_TTL); + + String message = "[ASSU] 인증번호: " + authNumber; + + AligoSendResponse response = aligoSmsClient.sendSms(phoneNumber, message, "사용자"); + + // 실패 처리 + if (!response.getResult_code().equals("1")) { + throw new CustomAuthException(ErrorStatus.FAILED_TO_SEND_SMS); + } + } + + @Override + public void verifyAuthNumber(String phoneNumber, String authNumber) { + ValueOperations valueOps = redisTemplate.opsForValue(); + String stored = valueOps.get(phoneNumber); + + if (stored == null || !stored.equals(authNumber)) { + throw new CustomAuthException(ErrorStatus.NOT_VERIFIED_PHONE_NUMBER); + } + + // 인증 성공 시 Redis에서 삭제(Optional) + redisTemplate.delete(phoneNumber); + } +} diff --git a/src/main/java/com/assu/server/domain/auth/service/SSUAuthService.java b/src/main/java/com/assu/server/domain/auth/service/SSUAuthService.java new file mode 100644 index 00000000..99d1098d --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/SSUAuthService.java @@ -0,0 +1,9 @@ +package com.assu.server.domain.auth.service; + + +import com.assu.server.domain.auth.dto.ssu.USaintAuthRequest; +import com.assu.server.domain.auth.dto.ssu.USaintAuthResponse; + +public interface SSUAuthService { + USaintAuthResponse uSaintAuth(USaintAuthRequest uSaintAuthRequest); +} diff --git a/src/main/java/com/assu/server/domain/auth/service/SSUAuthServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/SSUAuthServiceImpl.java new file mode 100644 index 00000000..515d7cfd --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/SSUAuthServiceImpl.java @@ -0,0 +1,240 @@ +package com.assu.server.domain.auth.service; + + +import com.assu.server.domain.auth.dto.ssu.USaintAuthRequest; +import com.assu.server.domain.auth.dto.ssu.USaintAuthResponse; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.user.entity.enums.Major; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SSUAuthServiceImpl implements SSUAuthService { + + private final WebClient webClient; + + private static final String USaintSSOUrl = "https://saint.ssu.ac.kr/webSSO/sso.jsp"; + private static final String USaintPortalUrl = "https://saint.ssu.ac.kr/webSSUMain/main_student.jsp"; + + @Override + public USaintAuthResponse uSaintAuth(USaintAuthRequest uSaintAuthRequest) { + + String sToken = uSaintAuthRequest.getSToken(); + String sIdno = uSaintAuthRequest.getSIdno(); + + // 1) SSO 로그인 요청 + ResponseEntity uSaintSSOResponseEntity; + try { + uSaintSSOResponseEntity = requestUSaintSSO(sToken, sIdno); + } catch (Exception e) { + log.error("API request to uSaint SSO failed.", e); + throw new CustomAuthException(ErrorStatus.SSU_SAINT_SSO_FAILED); + } + + if (uSaintSSOResponseEntity == null || uSaintSSOResponseEntity.getBody() == null) { + log.error("Empty response from USaint SSO. sToken={}, sIdno={}", sToken, sIdno); + throw new CustomAuthException(ErrorStatus.SSU_SAINT_SSO_FAILED); + } + + String body = uSaintSSOResponseEntity.getBody(); + if (!body.contains("location.href = \"/irj/portal\";")) { + log.error("Invalid SSO response. sToken={}, sIdno={}", sToken, sIdno); + throw new CustomAuthException(ErrorStatus.SSU_SAINT_SSO_FAILED); + } + + // 쿠키 추출 + HttpHeaders headers = uSaintSSOResponseEntity.getHeaders(); + List setCookieList = headers.get(HttpHeaders.SET_COOKIE); + + StringBuilder uSaintPortalCookie = new StringBuilder(); + if (setCookieList != null) { + for (String setCookie : setCookieList) { + setCookie = setCookie.split(";")[0]; + uSaintPortalCookie.append(setCookie).append("; "); + } + } + + // 2) 포털 접근 + ResponseEntity portalResponse; + try { + portalResponse = requestUSaintPortal(uSaintPortalCookie); + } catch (Exception e) { + log.error("API request to uSaint Portal failed.", e); + throw new CustomAuthException(ErrorStatus.SSU_SAINT_PORTAL_FAILED); + } + + if (portalResponse == null || portalResponse.getBody() == null) { + log.error("Empty response from uSaint Portal. cookie={}", uSaintPortalCookie); + throw new CustomAuthException(ErrorStatus.SSU_SAINT_PORTAL_FAILED); + } + + String uSaintPortalResponseBody = portalResponse.getBody(); + USaintAuthResponse usaintAuthResponse = USaintAuthResponse.builder().build(); + + // 3) HTML 파싱 + Document doc; + try { + doc = Jsoup.parse(uSaintPortalResponseBody); + } catch (Exception e) { + log.error("Jsoup parsing failed.", e); + throw new CustomAuthException(ErrorStatus.SSU_SAINT_PARSE_FAILED); + } + + Element nameBox = doc.getElementsByClass("main_box09").first(); + Element infoBox = doc.getElementsByClass("main_box09_con").first(); + + if (nameBox == null || infoBox == null) { + log.error("Portal HTML structure parsing failed."); + log.debug(uSaintPortalResponseBody); + throw new CustomAuthException(ErrorStatus.SSU_SAINT_PARSE_FAILED); + } + + // 이름 추출 + Element span = nameBox.getElementsByTag("span").first(); + if (span == null || span.text().isEmpty()) { + log.error("Student name span not found or empty."); + throw new CustomAuthException(ErrorStatus.SSU_SAINT_PARSE_FAILED); + } + usaintAuthResponse.setName(span.text().split("님")[0]); + + // 학번, 소속, 학적 상태, 학년학기 추출 + Elements infoLis = infoBox.getElementsByTag("li"); + for (Element li : infoLis) { + Element dt = li.getElementsByTag("dt").first(); + Element strong = li.getElementsByTag("strong").first(); + + if (dt == null || strong == null || strong.text().isEmpty()) { + log.error("Missing dt/strong in infoBox. li={}", li); + throw new CustomAuthException(ErrorStatus.SSU_SAINT_PARSE_FAILED); + } + + switch (dt.text()) { + case "학번" -> { + try { + usaintAuthResponse.setStudentNumber(strong.text()); + } catch (NumberFormatException e) { + log.error("Invalid studentId format: {}", strong.text()); + throw new CustomAuthException(ErrorStatus.SSU_SAINT_PARSE_FAILED); + } + } + case "소속" -> { + // 원본 문자열 저장 + String majorStr = strong.text(); + + // 매핑된 Enum 값 저장 + switch (majorStr) { + // 인문대학 + case "기독교학과" -> usaintAuthResponse.setMajor(Major.CHRISTIAN_STUDIES); + case "국어국문학과" -> usaintAuthResponse.setMajor(Major.KOREAN_LITERATURE); + case "영어영문학과" -> usaintAuthResponse.setMajor(Major.ENGLISH_LITERATURE); + case "독어독문학과" -> usaintAuthResponse.setMajor(Major.GERMAN_LITERATURE); + case "불어불문학과" -> usaintAuthResponse.setMajor(Major.FRENCH_LITERATURE); + case "중어중문학과" -> usaintAuthResponse.setMajor(Major.CHINESE_LITERATURE); + case "일어일문학과" -> usaintAuthResponse.setMajor(Major.JAPANESE_LITERATURE); + case "철학과" -> usaintAuthResponse.setMajor(Major.PHILOSOPHY); + case "사학과" -> usaintAuthResponse.setMajor(Major.HISTORY); + case "예술창작학부" -> usaintAuthResponse.setMajor(Major.CREATIVE_ARTS); + case "스포츠학부" -> usaintAuthResponse.setMajor(Major.SPORTS); + + // 자연과학대학 + case "수학과" -> usaintAuthResponse.setMajor(Major.MATHEMATICS); + case "화학과" -> usaintAuthResponse.setMajor(Major.CHEMISTRY); + case "의생명시스템학부" -> usaintAuthResponse.setMajor(Major.BIOMEDICAL_SYSTEMS); + case "물리학과" -> usaintAuthResponse.setMajor(Major.PHYSICS); + case "정보통계ㆍ보험수리학과" -> usaintAuthResponse.setMajor(Major.STATISTICS_ACTUARIAL); + + // 법과대학 + case "법학과" -> usaintAuthResponse.setMajor(Major.LAW); + case "국제법무학과" -> usaintAuthResponse.setMajor(Major.INTERNATIONAL_LAW); + + // 사회과학대학 + case "사회복지학부" -> usaintAuthResponse.setMajor(Major.SOCIAL_WELFARE); + case "정치외교학과" -> usaintAuthResponse.setMajor(Major.POLITICAL_SCIENCE); + case "언론홍보학과" -> usaintAuthResponse.setMajor(Major.MEDIA_COMMUNICATION); + case "행정학부" -> usaintAuthResponse.setMajor(Major.PUBLIC_ADMINISTRATION); + case "정보사회학과" -> usaintAuthResponse.setMajor(Major.INFORMATION_SOCIETY); + case "평생교육학과" -> usaintAuthResponse.setMajor(Major.LIFELONG_EDUCATION); + + // 경제통상대학 + case "경제학과" -> usaintAuthResponse.setMajor(Major.ECONOMICS); + case "금융경제학과" -> usaintAuthResponse.setMajor(Major.FINANCIAL_ECONOMICS); + case "글로벌통상학과" -> usaintAuthResponse.setMajor(Major.GLOBAL_TRADE); + case "국제무역학과" -> usaintAuthResponse.setMajor(Major.INTERNATIONAL_TRADE); + + // 경영대학 + case "경영학부" -> usaintAuthResponse.setMajor(Major.BUSINESS_ADMINISTRATION); + case "회계학과" -> usaintAuthResponse.setMajor(Major.ACCOUNTING); + case "벤처경영학과" -> usaintAuthResponse.setMajor(Major.VENTURE_MANAGEMENT); + case "복지경영학과" -> usaintAuthResponse.setMajor(Major.WELFARE_MANAGEMENT); + case "벤처중소기업학과" -> usaintAuthResponse.setMajor(Major.VENTURE_SME); + case "금융학부" -> usaintAuthResponse.setMajor(Major.FINANCE); + case "혁신경영학과" -> usaintAuthResponse.setMajor(Major.INNOVATION_MANAGEMENT); + case "회계세무학과" -> usaintAuthResponse.setMajor(Major.ACCOUNTING_TAX); + + // 공과대학 + case "화학공학과" -> usaintAuthResponse.setMajor(Major.CHEMICAL_ENGINEERING); + case "전기공학부" -> usaintAuthResponse.setMajor(Major.ELECTRICAL_ENGINEERING); + case "건축학부" -> usaintAuthResponse.setMajor(Major.ARCHITECTURE); + case "산업ㆍ정보시스템공학과" -> usaintAuthResponse.setMajor(Major.INDUSTRIAL_INFO_SYSTEMS); + case "기계공학부" -> usaintAuthResponse.setMajor(Major.MECHANICAL_ENGINEERING); + case "신소재공학과" -> usaintAuthResponse.setMajor(Major.MATERIALS_SCIENCE); + + // IT대학 + case "컴퓨터학부" -> usaintAuthResponse.setMajor(Major.COM); + case "소프트웨어학부" -> usaintAuthResponse.setMajor(Major.SW); + case "글로벌미디어학부" -> usaintAuthResponse.setMajor(Major.GM); + case "미디어경영학과" -> usaintAuthResponse.setMajor(Major.MB); + case "AI융합학부" -> usaintAuthResponse.setMajor(Major.AI); + case "전자정보공학부" -> usaintAuthResponse.setMajor(Major.EE); + case "정보보호학과" -> usaintAuthResponse.setMajor(Major.IP); + + // 자유전공학부 + case "자유전공학부" -> usaintAuthResponse.setMajor(Major.LIBERAL_ARTS); + + default -> { + log.debug("{} is not a supported major.", majorStr); + throw new CustomAuthException(ErrorStatus.SSU_SAINT_UNSUPPORTED_MAJOR); + } + } + } + case "과정/학기" -> usaintAuthResponse.setEnrollmentStatus(strong.text()); + case "학년/학기" -> usaintAuthResponse.setYearSemester(strong.text()); + } + } + + return usaintAuthResponse; + } + + private ResponseEntity requestUSaintSSO(String sToken, String sIdno) { + String url = USaintSSOUrl + "?sToken=" + sToken + "&sIdno=" + sIdno; + + return webClient.get() + .uri(url) + .header("Cookie", "sToken=" + sToken + "; sIdno=" + sIdno) + .retrieve() + .toEntity(String.class) // ResponseEntity 전체 반환 (body + header 포함) + .block(); // 동기 방식 + } + + private ResponseEntity requestUSaintPortal(StringBuilder cookie) { + return webClient.get() + .uri(USaintPortalUrl) + .header(HttpHeaders.COOKIE, cookie.toString()) // StringBuilder → String 변환 + .retrieve() + .toEntity(String.class) + .block(); + } +} diff --git a/src/main/java/com/assu/server/domain/auth/service/SignUpService.java b/src/main/java/com/assu/server/domain/auth/service/SignUpService.java new file mode 100644 index 00000000..8cd8cd0c --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/SignUpService.java @@ -0,0 +1,13 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.auth.dto.signup.AdminSignUpRequest; +import com.assu.server.domain.auth.dto.signup.PartnerSignUpRequest; +import com.assu.server.domain.auth.dto.signup.SignUpResponse; +import com.assu.server.domain.auth.dto.signup.StudentTokenSignUpRequest; +import org.springframework.web.multipart.MultipartFile; + +public interface SignUpService { + SignUpResponse signupSsuStudent(StudentTokenSignUpRequest req); + SignUpResponse signupPartner(PartnerSignUpRequest req, MultipartFile licenseImage); + SignUpResponse signupAdmin(AdminSignUpRequest req, MultipartFile signImage); +} diff --git a/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java new file mode 100644 index 00000000..51cfa441 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/SignUpServiceImpl.java @@ -0,0 +1,337 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.admin.repository.AdminRepository; +import com.assu.server.domain.auth.dto.common.UserBasicInfo; +import com.assu.server.domain.auth.dto.signup.*; +import com.assu.server.domain.auth.dto.signup.common.CommonInfoPayload; +import com.assu.server.domain.auth.dto.ssu.USaintAuthRequest; +import com.assu.server.domain.auth.dto.ssu.USaintAuthResponse; +import com.assu.server.domain.auth.entity.AuthRealm; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.auth.repository.SSUAuthRepository; +import com.assu.server.domain.auth.security.adapter.RealmAuthAdapter; +import com.assu.server.domain.auth.security.jwt.JwtUtil; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partner.repository.PartnerRepository; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.repository.StoreRepository; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.domain.user.entity.enums.EnrollmentStatus; +import com.assu.server.domain.user.entity.enums.University; +import com.assu.server.domain.user.repository.StudentRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.infra.s3.AmazonS3Manager; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class SignUpServiceImpl implements SignUpService { + + private final MemberRepository memberRepository; + private final StudentRepository studentRepository; + private final PartnerRepository partnerRepository; + private final AdminRepository adminRepository; + + // Adapter 들을 주입받아서, signup 시에 사용 + private final List realmAuthAdapters; + + private final AmazonS3Manager amazonS3Manager; + private final JwtUtil jwtUtil; + + private final GeometryFactory geometryFactory; + private final StoreRepository storeRepository; + private final SSUAuthService ssuAuthService; + private final SSUAuthRepository ssuAuthRepository; + + private RealmAuthAdapter pickAdapter(AuthRealm realm) { + return realmAuthAdapters.stream() + .filter(a -> a.supports(realm)) + .findFirst() + .orElseThrow(() -> new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION)); + } + + /* 숭실대 학생: sToken, sIdno 기반 회원가입 */ + @Override + @Transactional + public SignUpResponse signupSsuStudent(StudentTokenSignUpRequest req) { + // 중복 체크 + if (memberRepository.existsByPhoneNum(req.getPhoneNumber())) { + throw new CustomAuthException(ErrorStatus.EXISTED_PHONE); + } + + // 1) 유세인트 인증 및 학생 정보 추출 + USaintAuthRequest authRequest = USaintAuthRequest.builder() + .sToken(req.getStudentTokenAuth().getSToken()) + .sIdno(req.getStudentTokenAuth().getSIdno()) + .build(); + + USaintAuthResponse authResponse = ssuAuthService.uSaintAuth(authRequest); + + // 학번 중복 체크 + if (ssuAuthRepository.existsByStudentNumber(authResponse.getStudentNumber().toString())) { + throw new CustomAuthException(ErrorStatus.EXISTED_STUDENT); + } + + // 2) member 생성 + Member member = memberRepository.save( + Member.builder() + .phoneNum(req.getPhoneNumber()) + .isPhoneVerified(true) + .role(UserRole.STUDENT) + .isActivated(ActivationStatus.ACTIVE) + .build()); + + // 3) SSUAuth 생성 (학번만 저장) + RealmAuthAdapter adapter = pickAdapter(AuthRealm.SSU); + adapter.registerCredentials(member, authResponse.getStudentNumber().toString(), ""); // 더미 패스워드 + + // 4) Student 프로필 생성 (크롤링된 정보 사용) + Student student = Student.builder() + .member(member) + .name(authResponse.getName()) + .department(authResponse.getMajor().getDepartment()) + .major(authResponse.getMajor()) + .enrollmentStatus(parseEnrollmentStatus(authResponse.getEnrollmentStatus())) + .yearSemester(authResponse.getYearSemester()) + .university(University.SSU) // 고정값 + .stamp(0) + .build(); + + studentRepository.save(student); + + // 5) JWT 토큰 발급 + Tokens tokens = jwtUtil.issueTokens( + member.getId(), + authResponse.getStudentNumber().toString(), // studentNumber + UserRole.STUDENT, + "SSU"); + + // 6) Student 정보로 직접 UserBasicInfo 생성 + UserBasicInfo basicInfo = UserBasicInfo.builder() + .name(student.getName()) + .university(student.getUniversity().getDisplayName()) + .department(student.getDepartment().getDisplayName()) + .major(student.getMajor().getDisplayName()) + .build(); + + return SignUpResponse.builder() + .memberId(member.getId()) + .role(UserRole.STUDENT) + .status(member.getIsActivated()) + .tokens(tokens) + .basicInfo(basicInfo) + .build(); + } + + /* 제휴업체: MULTIPART(payload JSON + licenseImage) */ + @Override + @Transactional + public SignUpResponse signupPartner(PartnerSignUpRequest req, MultipartFile licenseImage) { + if (memberRepository.existsByPhoneNum(req.getPhoneNumber())) { + throw new CustomAuthException(ErrorStatus.EXISTED_PHONE); + } + + // 1) member 생성 + Member member = memberRepository.save( + Member.builder() + .phoneNum(req.getPhoneNumber()) + .isPhoneVerified(true) + .role(UserRole.PARTNER) + .isActivated(ActivationStatus.ACTIVE) // Todo 초기에 SUSPEND 로직 추가해야함, 허가 후 ACTIVE + .build()); + + // 2) RealmAuthAdapter 로 Common 자격 저장 + RealmAuthAdapter adapter = pickAdapter(AuthRealm.COMMON); + adapter.registerCredentials(member, req.getCommonAuth().getEmail(), req.getCommonAuth().getPassword()); + + // 파일 업로드 + 파트너 정보 + String keyPath = "partners/" + member.getId() + "/" + licenseImage.getOriginalFilename(); + String keyName = amazonS3Manager.generateKeyName(keyPath); + String licenseUrl = amazonS3Manager.uploadFile(keyName, licenseImage); + CommonInfoPayload info = req.getCommonInfo(); + var sp = Optional.ofNullable(info.getSelectedPlace()) + .orElseThrow(() -> new CustomAuthException(ErrorStatus._BAD_REQUEST)); // selectedPlace 필수 + + // selectedPlace로부터 주소/좌표 생성 + String address = pickDisplayAddress(sp.getRoadAddress(), sp.getAddress()); + Double lat = sp.getLatitude(); + Double lng = sp.getLongitude(); + Point point = toPoint(lat, lng); + + // 3) Partner 프로필 생성 + Partner partner = partnerRepository.save( + Partner.builder() + .member(member) + .name(info.getName()) + .address(address) + .detailAddress(info.getDetailAddress()) + .licenseUrl(licenseUrl) + .point(point) + .latitude(lat) + .longitude(lng) + .build()); + + // store 생성/연결 + Optional storeOpt = storeRepository.findBySameAddress(address, info.getDetailAddress()); + if (storeOpt.isPresent()) { + Store store = storeOpt.get(); + store.linkPartner(partner); + store.setName(info.getName()); + store.setGeo(lat, lng, point); + storeRepository.save(store); + } else { + Store newly = Store.builder() + .partner(partner) + .rate(0) + .isActivate(ActivationStatus.ACTIVE) + .name(info.getName()) + .address(address) + .detailAddress(info.getDetailAddress()) + .latitude(lat) + .longitude(lng) + .point(point) + .build(); + storeRepository.save(newly); + } + + // 4) 토큰 발급 + Tokens tokens = jwtUtil.issueTokens( + member.getId(), + req.getCommonAuth().getEmail(), + UserRole.PARTNER, + adapter.authRealmValue()); + + // 5) Partner 정보로 직접 UserBasicInfo 생성 + UserBasicInfo basicInfo = UserBasicInfo.builder() + .name(partner.getName()) + .build(); + + return SignUpResponse.builder() + .memberId(member.getId()) + .role(UserRole.PARTNER) + .status(member.getIsActivated()) + .tokens(tokens) + .basicInfo(basicInfo) + .build(); + } + + /* 관리자: MULTIPART(payload JSON + signImage) */ + @Override + @Transactional + public SignUpResponse signupAdmin(AdminSignUpRequest req, MultipartFile signImage) { + if (memberRepository.existsByPhoneNum(req.getPhoneNumber())) { + throw new CustomAuthException(ErrorStatus.EXISTED_PHONE); + } + + // 1) member 생성 + Member member = memberRepository.save( + Member.builder() + .phoneNum(req.getPhoneNumber()) + .isPhoneVerified(true) + .role(UserRole.ADMIN) + .isActivated(ActivationStatus.ACTIVE) // Todo 초기에 SUSPEND 로직 추가해야함, 허가 후 ACTIVE + .build()); + + // 2) RealmAuthAdapter 로 Common 자격 저장 + RealmAuthAdapter adapter = pickAdapter(AuthRealm.COMMON); + adapter.registerCredentials(member, req.getCommonAuth().getEmail(), req.getCommonAuth().getPassword()); + + // 파일 업로드 + 관리자 정보 + String keyPath = "admins/" + member.getId() + "/" + signImage.getOriginalFilename(); + String keyName = amazonS3Manager.generateKeyName(keyPath); + String signUrl = amazonS3Manager.uploadFile(keyName, signImage); + CommonInfoPayload info = req.getCommonInfo(); + var sp = Optional.ofNullable(info.getSelectedPlace()) + .orElseThrow(() -> new CustomAuthException(ErrorStatus._BAD_REQUEST)); // selectedPlace 필수 + + // selectedPlace로부터 주소/좌표 생성 + String address = pickDisplayAddress(sp.getRoadAddress(), sp.getAddress()); + Double lat = sp.getLatitude(); + Double lng = sp.getLongitude(); + Point point = toPoint(lat, lng); + + // 3) Admin 프로필 생성 + Admin admin = adminRepository.save( + Admin.builder() + .major(req.getCommonAuth().getMajor()) + .department(req.getCommonAuth().getDepartment()) + .university(req.getCommonAuth().getUniversity()) + .member(member) + .name(info.getName()) + .officeAddress(address) + .detailAddress(info.getDetailAddress()) + .signUrl(signUrl) + .point(point) + .latitude(lat) + .longitude(lng) + .build()); + + // 4) 토큰 발급 + Tokens tokens = jwtUtil.issueTokens( + member.getId(), + req.getCommonAuth().getEmail(), + UserRole.ADMIN, + adapter.authRealmValue()); + + // 5) Admin 정보로 직접 UserBasicInfo 생성 + null check + String department = admin.getDepartment() != null ? admin.getDepartment().getDisplayName() : null; + String major = admin.getMajor() != null ? admin.getMajor().getDisplayName() : null; + UserBasicInfo basicInfo = UserBasicInfo.builder() + .name(admin.getName()) + .university(admin.getUniversity().getDisplayName()) + .department(department) + .major(major) + .build(); + + return SignUpResponse.builder() + .memberId(member.getId()) + .role(UserRole.ADMIN) + .status(member.getIsActivated()) + .tokens(tokens) + .basicInfo(basicInfo) + .build(); + } + + private EnrollmentStatus parseEnrollmentStatus(String status) { + if (status == null || status.isBlank()) { + return EnrollmentStatus.ENROLLED; + } + + if (status.contains("재학")) { + return EnrollmentStatus.ENROLLED; + } else if (status.contains("휴학")) { + return EnrollmentStatus.LEAVE; + } else if (status.contains("졸업")) { + return EnrollmentStatus.GRADUATED; + } else { + // 기본값은 재학으로 설정 + return EnrollmentStatus.ENROLLED; + } + } + + public Point toPoint(Double lat, Double lng) { + if (lat == null || lng == null) + return null; + Point p = geometryFactory.createPoint(new Coordinate(lng, lat)); // x=lng, y=lat + p.setSRID(4326); + return p; + } + + private String pickDisplayAddress(String road, String jibun) { + return (road != null && !road.isBlank()) ? road : jibun; + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/auth/service/VerificationService.java b/src/main/java/com/assu/server/domain/auth/service/VerificationService.java new file mode 100644 index 00000000..aaf28237 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/VerificationService.java @@ -0,0 +1,11 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.auth.dto.verification.VerificationRequestDTO; + +public interface VerificationService { + void checkPhoneNumberAvailability( + VerificationRequestDTO.PhoneVerificationCheckRequest request); + + void checkEmailAvailability( + VerificationRequestDTO.EmailVerificationCheckRequest request); +} diff --git a/src/main/java/com/assu/server/domain/auth/service/VerificationServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/VerificationServiceImpl.java new file mode 100644 index 00000000..85cc43ef --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/VerificationServiceImpl.java @@ -0,0 +1,39 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.auth.dto.verification.VerificationRequestDTO; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.auth.repository.CommonAuthRepository; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class VerificationServiceImpl implements VerificationService { + + private final MemberRepository memberRepository; + private final CommonAuthRepository commonAuthRepository; + + @Override + public void checkPhoneNumberAvailability( + VerificationRequestDTO.PhoneVerificationCheckRequest request) { + + boolean exists = memberRepository.existsByPhoneNum(request.getPhoneNumber()); + + if (exists) { + throw new CustomAuthException(ErrorStatus.EXISTED_PHONE); + } + } + + @Override + public void checkEmailAvailability( + VerificationRequestDTO.EmailVerificationCheckRequest request) { + + boolean exists = commonAuthRepository.existsByEmail(request.getEmail()); + + if (exists) { + throw new CustomAuthException(ErrorStatus.EXISTED_EMAIL); + } + } +} diff --git a/src/main/java/com/assu/server/domain/auth/service/WithdrawalService.java b/src/main/java/com/assu/server/domain/auth/service/WithdrawalService.java new file mode 100644 index 00000000..f7843c4b --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/WithdrawalService.java @@ -0,0 +1,6 @@ +package com.assu.server.domain.auth.service; + +public interface WithdrawalService { + + void withdrawCurrentUser(String authorization); +} diff --git a/src/main/java/com/assu/server/domain/auth/service/WithdrawalServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/WithdrawalServiceImpl.java new file mode 100644 index 00000000..0532d9d2 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/WithdrawalServiceImpl.java @@ -0,0 +1,63 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.auth.security.jwt.JwtUtil; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import io.jsonwebtoken.Claims; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WithdrawalServiceImpl implements WithdrawalService { + + private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; + + @Override + @Transactional + public void withdrawCurrentUser(String authorization) { + String rawAccessToken = jwtUtil.getTokenFromHeader(authorization); + + // Access 토큰에서 memberId 추출 + Claims claims = jwtUtil.validateTokenOnlySignature(rawAccessToken); + Long memberId = ((Number) claims.get("userId")).longValue(); + + log.info("현재 사용자 탈퇴 시작: memberId={}", memberId); + + // 2) 회원 탈퇴 처리 + withdrawMember(memberId); + + // 3) 현재 Access 토큰을 블랙리스트에 등록 + jwtUtil.blacklistAccess(rawAccessToken); + + log.info("현재 사용자 탈퇴 완료: memberId={}", memberId); + } + + private void withdrawMember(Long memberId) { + log.info("회원 탈퇴 시작: memberId={}", memberId); + + // 1) 회원 존재 여부 확인 + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER)); + + // 2) 이미 탈퇴된 회원인지 확인 + if (member.getDeletedAt() != null) { + throw new CustomAuthException(ErrorStatus.MEMBER_ALREADY_WITHDRAWN); + } + + // 3) 소프트 삭제: deletedAt 필드에 현재 시간 설정 + member.setDeletedAt(java.time.LocalDateTime.now()); + memberRepository.save(member); + + // 4) 해당 회원의 모든 토큰 무효화 + jwtUtil.removeAllRefreshTokens(memberId); + + log.info("회원 탈퇴 완료: memberId={}", memberId); + } +} diff --git a/src/main/java/com/assu/server/domain/certification/SessionTimeoutManager.java b/src/main/java/com/assu/server/domain/certification/SessionTimeoutManager.java new file mode 100644 index 00000000..2ec76d72 --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/SessionTimeoutManager.java @@ -0,0 +1,47 @@ +package com.assu.server.domain.certification; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import com.assu.server.domain.certification.component.CertificationSessionManager; +import com.assu.server.domain.certification.entity.AssociateCertification; +import com.assu.server.domain.certification.entity.enums.SessionStatus; +import com.assu.server.domain.certification.repository.AssociateCertificationRepository; + +@Component +public class SessionTimeoutManager { + + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + @Autowired + private AssociateCertificationRepository certificationRepository; + + @Autowired + private CertificationSessionManager sessionManager; + + public void scheduleTimeout(Long sessionId, Duration timeout) { + scheduler.schedule(() -> { + closeSession(sessionId); + }, timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + private void closeSession(Long sessionId) { + Optional certOpt = certificationRepository.findById(sessionId); + certOpt.ifPresent(cert -> { + if (cert.getStatus() == SessionStatus.OPENED) { + cert.setStatus(SessionStatus.EXPIRED); + certificationRepository.save(cert); + } + }); + // 이러면 인증 전에 만료되는 것은 EXPIRED로, 시간안에 인증 된 세션은 COMPLETED로 남음 + + // 메모리에서도 세션 제거 + sessionManager.removeSession(sessionId); + } +} diff --git a/src/main/java/com/assu/server/domain/certification/component/CertificationSessionManager.java b/src/main/java/com/assu/server/domain/certification/component/CertificationSessionManager.java new file mode 100644 index 00000000..91108335 --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/component/CertificationSessionManager.java @@ -0,0 +1,122 @@ +package com.assu.server.domain.certification.component; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + + +import org.springframework.stereotype.Component; +import org.springframework.data.redis.core.StringRedisTemplate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +// +// @Slf4j // ⭐️ SLF4j 로그 사용을 위해 추가 +// @Component +// public class CertificationSessionManager { +// private final Map> sessionUserMap = new ConcurrentHashMap<>(); +// +// public void openSession(Long sessionId) { +// sessionUserMap.put(sessionId, ConcurrentHashMap.newKeySet()); +// // ⭐️ 로그 추가 +// log.info("✅ New certification session opened. SessionID: {}", sessionId); +// } +// +// public void addUserToSession(Long sessionId, Long userId) { +// Set users = sessionUserMap.computeIfAbsent(sessionId, k -> { +// log.warn("Attempted to add user to a non-existent session. Creating new set for SessionID: {}", k); +// return ConcurrentHashMap.newKeySet(); +// }); +// +// boolean isAdded = users.add(userId); +// +// // ⭐️ 요청하신 멤버 추가 확인 로그 +// if (isAdded) { +// log.info("👤 User added to session. SessionID: {}, UserID: {}. Current participants: {}", +// sessionId, userId, users.size()); +// } else { +// log.info("👤 User already in session. SessionID: {}, UserID: {}. Current participants: {}", +// sessionId, userId, users.size()); +// } +// } +// +// public int getCurrentUserCount(Long sessionId) { +// return sessionUserMap.getOrDefault(sessionId, Set.of()).size(); +// } +// +// public boolean hasUser(Long sessionId, Long userId) { +// return sessionUserMap.getOrDefault(sessionId, Set.of()).contains(userId); +// } +// +// public List snapshotUserIds(Long sessionId) { +// return List.copyOf(sessionUserMap.getOrDefault(sessionId, Set.of())); +// } +// +// +// +// public void removeSession(Long sessionId) { +// sessionUserMap.remove(sessionId); +// // ⭐️ 로그 추가 +// log.info("❌ Certification session removed. SessionID: {}", sessionId); +// } +// } +@Component +@RequiredArgsConstructor +public class CertificationSessionManager { + + // RedisTemplate을 주입받습니다. + private final StringRedisTemplate redisTemplate; + + // 세션 ID를 위한 KEY를 만드는 헬퍼 메서드 + private String getKey(Long sessionId) { + return "certification:session:" + sessionId; + } + + public void openSession(Long sessionId) { + String key = getKey(sessionId); + // 세션을 연다는 것은 키를 만드는 것과 같습니다. + // addUserToSession에서 자동으로 키가 생성되므로 이 메서드는 비워두거나, + // 만료 시간 설정 등 초기화 로직을 넣을 수 있습니다. + // 예: 10분 후 만료 + redisTemplate.expire(key, 10, TimeUnit.MINUTES); + } + + public void addUserToSession(Long sessionId, Long userId) { + String key = getKey(sessionId); + // Redis의 Set 자료구조에 userId를 추가합니다. + redisTemplate.opsForSet().add(key, String.valueOf(userId)); + } + + public int getCurrentUserCount(Long sessionId) { + String key = getKey(sessionId); + // Redis Set의 크기를 반환합니다. + Long size = redisTemplate.opsForSet().size(key); + return size != null ? size.intValue() : 0; + } + + public boolean hasUser(Long sessionId, Long userId) { + String key = getKey(sessionId); + // Redis Set에 해당 멤버가 있는지 확인합니다. + return redisTemplate.opsForSet().isMember(key, String.valueOf(userId)); + } + + public List snapshotUserIds(Long sessionId) { + String key = getKey(sessionId); + // Redis Set의 모든 멤버를 가져옵니다. + Set members = redisTemplate.opsForSet().members(key); + if (members == null) { + return List.of(); + } + return members.stream() + .map(Long::valueOf) + .collect(Collectors.toList()); + } + + public void removeSession(Long sessionId) { + String key = getKey(sessionId); + // 세션 키 자체를 삭제합니다. + redisTemplate.delete(key); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/certification/config/CertifyWebSocketConfig.java b/src/main/java/com/assu/server/domain/certification/config/CertifyWebSocketConfig.java new file mode 100644 index 00000000..56da642a --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/config/CertifyWebSocketConfig.java @@ -0,0 +1,37 @@ +package com.assu.server.domain.certification.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import lombok.RequiredArgsConstructor; + +// @EnableWebSocketMessageBroker +// @Configuration +// @RequiredArgsConstructor +// public class CertifyWebSocketConfig implements WebSocketMessageBrokerConfigurer { +// +// private final StompAuthChannelInterceptor stompAuthChannelInterceptor; +// @Override +// public void configureMessageBroker(MessageBrokerRegistry config) { +// config.enableSimpleBroker("/certification"); // 인증현황을 받아보기 위한 구독 주소 +// config.setApplicationDestinationPrefixes("/app"); // 클라이언트가 인증 요청을 보내는 주소 +// } +// +// @Override +// public void registerStompEndpoints(StompEndpointRegistry registry) { +// registry.addEndpoint("/ws-certify").setAllowedOriginPatterns("*"); // 클라이언트 WebSocket 연결 주소 +// // .setAllowedOriginPatterns("http://10.0.2.2:8080", "ws://10.0.2.2:8080");// CORS 허용 +// } +// +// @Override +// public void configureClientInboundChannel(ChannelRegistration registration) { +// registration.interceptors(stompAuthChannelInterceptor); +// } +// +// } diff --git a/src/main/java/com/assu/server/domain/certification/config/StompAuthChannelInterceptor.java b/src/main/java/com/assu/server/domain/certification/config/StompAuthChannelInterceptor.java new file mode 100644 index 00000000..20e33674 --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/config/StompAuthChannelInterceptor.java @@ -0,0 +1,75 @@ +package com.assu.server.domain.certification.config; + +import com.assu.server.domain.auth.security.jwt.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.*; +import org.springframework.messaging.simp.stomp.*; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import lombok.extern.slf4j.Slf4j; // SLF4j 로그 추가 + +@Slf4j // SLF4j 어노테이션 추가 +@Component +@RequiredArgsConstructor +public class StompAuthChannelInterceptor implements ChannelInterceptor { + + private final JwtUtil jwtUtil; + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + String authHeader = accessor.getFirstNativeHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = jwtUtil.getTokenFromHeader(authHeader); + Authentication authentication = jwtUtil.getAuthentication(token); + + // ⭐️ 이 부분을 수정 + accessor.setUser(authentication); + + // ⭐️ 추가: 메시지 헤더에도 Authentication 정보 저장 + accessor.setHeader(StompHeaderAccessor.USER_HEADER, authentication); + + log.info("Authentication set: {}", authentication); + } + } + + return message; + } + + // @Override + // public Message preSend(Message message, MessageChannel channel) { + // StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + // log.info("StompCommand: {}", accessor.getCommand()); // StompCommand 로그 추가 + // + // if (StompCommand.CONNECT.equals(accessor.getCommand())) { + // log.info("CONNECT command received."); + // // 프론트에서 connect 시 Authorization 헤더 넣어야 함 + // String authHeader = accessor.getFirstNativeHeader("Authorization"); + // log.info("Authorization Header: {}", authHeader); // Authorization 헤더 로그 추가 + // + // if (authHeader != null && authHeader.startsWith("Bearer ")) { + // String token = jwtUtil.getTokenFromHeader(authHeader); + // log.info("Extracted Token: {}", token); // 추출된 토큰 로그 추가 + // + // // JwtUtil 이용해서 Authentication 복원 + // Authentication authentication = jwtUtil.getAuthentication(token); + // log.info("Authentication restored: {}", authentication); // 복원된 인증 정보 로그 추가 + // + // // WebSocket 세션에 Authentication(UserPrincipal) 저장 + // accessor.setUser(authentication); + // log.info("User principal set on accessor."); + // } else { + // log.warn("Authorization header is missing or not in Bearer format."); + // } + // } else if (StompCommand.SEND.equals(accessor.getCommand())) { + // // SEND 명령어에 대한 로그 추가 (메시지 전송 시) + // Object payload = message.getPayload(); + // log.info("SEND command received. Destination: {}, Payload: {}", accessor.getDestination(), payload); + // } + // + // return message; + // } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/certification/controller/CertificationController.java b/src/main/java/com/assu/server/domain/certification/controller/CertificationController.java index 25a1bc60..d86c9280 100644 --- a/src/main/java/com/assu/server/domain/certification/controller/CertificationController.java +++ b/src/main/java/com/assu/server/domain/certification/controller/CertificationController.java @@ -1,4 +1,86 @@ package com.assu.server.domain.certification.controller; +import java.time.LocalDateTime; + +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import com.assu.server.domain.certification.dto.CertificationRequestDTO; +import com.assu.server.domain.certification.dto.CertificationResponseDTO; +import com.assu.server.domain.certification.service.CertificationService; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.exception.GeneralException; +import com.assu.server.global.util.PrincipalDetails; + + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@Tag(name = "제휴 인증 api", description = "qr인증과 관련된 api 입니다.") +@RequiredArgsConstructor public class CertificationController { + + private final CertificationService certificationService; + private final MemberRepository memberRepository; // 지금은 그냥 임시 데이터 하드 코딩이라 여기에 둔거여 + + @PostMapping("/certification/session") + @Operation( + summary = "세션 정보 요청 API", + description = "# [v1.0 (2025-09-09)](https://www.notion.so/22b1197c19ed80bb8484d99cc6ce715b?source=copy_link)\n" + + "- `multipart/form-data`로 호출합니다.\n" + + "- 파트: `payload`(JSON, CertificationRequest.groupRequest)\n" + + "- 처리: 정보 바탕으로 sessionManager에 session생성\n" + + "- 성공 시 201(Created)과 생성된 memberId 반환.\n" + + "\n**Request Parts:**\n" + + " - `request` (JSON, required): `CertificationRequest.groupRequest` 객체\n" + + " - `people` (Integer, required): 인증이 필요한 인원\n" + + " - `storeId` (Long, required): 스토어 id\n"+ + " - `adminId` (Long, required): 관리자 id\n"+ + " - `tableNumber` (Integer, required): 테이블 넘버\n"+ + "\n**Response:**\n" + + " - 성공 시 201(Created)와 sessionId, adminId 반환" + ) + public ResponseEntity> getSessionId( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestBody CertificationRequestDTO.groupRequest dto + ) { + + CertificationResponseDTO.getSessionIdResponse result = certificationService.getSessionId(dto, pd.getMember()); + + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus.GROUP_SESSION_CREATE, result)); + } + + // @MessageMapping("/certify") + // @Operation(summary = "그룹 세션 인증 api", description = "그룹에 대한 세션 인증 요청을 보냅니다.") + // public ResponseEntity> certifyGroup( + // CertificationRequestDTO.groupSessionRequest dto , PrincipalDetails pd + // + // ) { + // certificationService.handleCertification(dto, pd.getMember()); + // + // return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus.GROUP_CERTIFICATION_SUCCESS, null)); + // } + + @PostMapping("/certification/personal") + @Operation(summary = "개인 인증 api", description = "사실 크게 필요없는데, 제휴 내역 통계를 위해 데이터를 post하는 api 입니다. " + + "가게 별 제휴를 조회하고 people값이 null 인 제휴를 선택한 경우 그룹 인증 대신 요청하는 api 입니다.") + public ResponseEntity> personalCertification( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestBody CertificationRequestDTO.personalRequest dto + ) { + certificationService.certificatePersonal(dto, pd.getMember()); + + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus.PERSONAL_CERTIFICATION_SUCCESS, "null")); + } + } diff --git a/src/main/java/com/assu/server/domain/certification/controller/GroupCertificationController.java b/src/main/java/com/assu/server/domain/certification/controller/GroupCertificationController.java new file mode 100644 index 00000000..3114836c --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/controller/GroupCertificationController.java @@ -0,0 +1,54 @@ +package com.assu.server.domain.certification.controller; + +import java.security.Principal; + +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; + +import com.assu.server.domain.certification.dto.GroupSessionRequest; +import com.assu.server.domain.certification.service.CertificationService; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.global.util.PrincipalDetails; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Controller // STOMP 메시지 처리를 위한 컨트롤러 +@RequiredArgsConstructor +@Component +@RequestMapping("/app") +public class GroupCertificationController { + + private final CertificationService certificationService; + + @MessageMapping("/certify") + public void certifyGroup(@Payload GroupSessionRequest dto, + Principal principal) { + if (principal instanceof UsernamePasswordAuthenticationToken) { + UsernamePasswordAuthenticationToken auth = (UsernamePasswordAuthenticationToken)principal; + PrincipalDetails principalDetails = (PrincipalDetails)auth.getPrincipal(); + + try { + log.info("### SUCCESS ### 인증 요청 메시지 수신 - user: {}, adminId: {}, sessionId: {}", + principalDetails.getUsername(), dto.getAdminId(), dto.getSessionId()); + + // 헤더를 직접 다룰 필요 없이, 바로 principalDetails 객체를 사용 + if (principalDetails != null) { + certificationService.handleCertification(dto, principalDetails.getMember()); + log.info("### SUCCESS ### 그룹 인증 처리 완료"); + } + } catch (Exception e) { + log.error("### ERROR ### 인증 처리 실패", e); + } + } + } + +} diff --git a/src/main/java/com/assu/server/domain/certification/controller/WebSocketTestController.java b/src/main/java/com/assu/server/domain/certification/controller/WebSocketTestController.java new file mode 100644 index 00000000..7460e5cc --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/controller/WebSocketTestController.java @@ -0,0 +1,16 @@ +package com.assu.server.domain.certification.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; + +@Slf4j +@Controller +public class WebSocketTestController { + + @MessageMapping("/test") + public void test(@Payload String payload) { + log.info("### 테스트용 메시지 수신 성공! 페이로드: {}", payload); + } +} diff --git a/src/main/java/com/assu/server/domain/certification/converter/CertificationConverter.java b/src/main/java/com/assu/server/domain/certification/converter/CertificationConverter.java index 1a293d1f..e4422cbc 100644 --- a/src/main/java/com/assu/server/domain/certification/converter/CertificationConverter.java +++ b/src/main/java/com/assu/server/domain/certification/converter/CertificationConverter.java @@ -1,4 +1,40 @@ package com.assu.server.domain.certification.converter; + +import com.assu.server.domain.certification.dto.CertificationRequestDTO; +import com.assu.server.domain.certification.dto.CertificationResponseDTO; +import com.assu.server.domain.certification.entity.AssociateCertification; +import com.assu.server.domain.certification.entity.enums.SessionStatus; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.store.entity.Store; + public class CertificationConverter { + public static AssociateCertification toAssociateCertification(CertificationRequestDTO.groupRequest dto, Store store, Member member) { + return AssociateCertification.builder() + .store(store) + .partner(store.getPartner()) + .status(SessionStatus.OPENED) + .isCertified(false) + .peopleNumber(dto.getPeople()) + .tableNumber(dto.getTableNumber()) + .student(member.getStudentProfile()) + .build(); + } + + public static CertificationResponseDTO.getSessionIdResponse toSessionIdResponse(Long sessionId){ + return CertificationResponseDTO.getSessionIdResponse.builder() + .sessionId(sessionId) + .build(); + } + + public static AssociateCertification toPersonalCertification(CertificationRequestDTO.personalRequest dto, Store store, Member member) { + return AssociateCertification.builder() + .store(store) + .partner(store.getPartner()) + .isCertified(true) + .tableNumber(dto.getTableNumber()) + .peopleNumber(1) + .student(member.getStudentProfile()) + .build(); + } } diff --git a/src/main/java/com/assu/server/domain/certification/dto/CertificationProgressResponseDTO.java b/src/main/java/com/assu/server/domain/certification/dto/CertificationProgressResponseDTO.java new file mode 100644 index 00000000..510a1b8c --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/dto/CertificationProgressResponseDTO.java @@ -0,0 +1,40 @@ +package com.assu.server.domain.certification.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +// public class CurrentProgress { +// private int count; +// +// +// @Getter +// public static class CertificationNumber{ +// public CertificationNumber(int count){ +// this.count= count; +// } +// +// int count; +// } +// +// @Getter +// public static class CompletedNotification{ +// public CompletedNotification(String message, List userIds){ +// +// this.message= message; +// this.userIds= userIds; +// } +// String message; +// List userIds; +// } + +// } +@Getter +@AllArgsConstructor +public class CertificationProgressResponseDTO { + private String type; + private Integer count; + private String message; + private List userIds; +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/certification/dto/CertificationRequestDTO.java b/src/main/java/com/assu/server/domain/certification/dto/CertificationRequestDTO.java index c9efcf74..0ba578c4 100644 --- a/src/main/java/com/assu/server/domain/certification/dto/CertificationRequestDTO.java +++ b/src/main/java/com/assu/server/domain/certification/dto/CertificationRequestDTO.java @@ -1,4 +1,32 @@ package com.assu.server.domain.certification.dto; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + public class CertificationRequestDTO { + + @Getter + public static class groupRequest{ + Integer people; + Long storeId; + Long adminId; + Integer tableNumber; + } + + @Getter + public static class personalRequest{ + Long storeId; + Long adminId; + Integer tableNumber; + } + + // @Getter + // @NoArgsConstructor(access = AccessLevel.PROTECTED) + // @AllArgsConstructor + // public static class groupSessionRequest { + // private Long adminId; + // private Long sessionId; + // } } diff --git a/src/main/java/com/assu/server/domain/certification/dto/CertificationResponseDTO.java b/src/main/java/com/assu/server/domain/certification/dto/CertificationResponseDTO.java index 9ebea4de..f1f0c504 100644 --- a/src/main/java/com/assu/server/domain/certification/dto/CertificationResponseDTO.java +++ b/src/main/java/com/assu/server/domain/certification/dto/CertificationResponseDTO.java @@ -1,4 +1,16 @@ package com.assu.server.domain.certification.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + public class CertificationResponseDTO { + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class getSessionIdResponse { + Long sessionId; + } } diff --git a/src/main/java/com/assu/server/domain/certification/dto/GroupSessionRequest.java b/src/main/java/com/assu/server/domain/certification/dto/GroupSessionRequest.java new file mode 100644 index 00000000..4099e376 --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/dto/GroupSessionRequest.java @@ -0,0 +1,21 @@ +package com.assu.server.domain.certification.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@NoArgsConstructor +@Setter +public class GroupSessionRequest { + Long adminId; + Long sessionId; + + @Override + public String toString() { + return "GroupSessionRequest{" + + "adminId=" + adminId + + ", sessionId=" + sessionId + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/certification/entity/AssociateCertification.java b/src/main/java/com/assu/server/domain/certification/entity/AssociateCertification.java new file mode 100644 index 00000000..24d261e3 --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/entity/AssociateCertification.java @@ -0,0 +1,60 @@ +package com.assu.server.domain.certification.entity; +import com.assu.server.domain.certification.entity.enums.SessionStatus; +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.user.entity.Student; + +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class AssociateCertification extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Integer tableNumber; + private Boolean isCertified; + private Integer peopleNumber; + + @Enumerated(EnumType.STRING) + private SessionStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id") + private Store store; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "partner_id") + private Partner partner; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "student_id") + private Student student; + + public void setStatus(SessionStatus status) { + this.status = status; + } + public void setIsCertified(Boolean isCertified) { + this.isCertified = isCertified; + } + + +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/certification/entity/Certification.java b/src/main/java/com/assu/server/domain/certification/entity/Certification.java deleted file mode 100644 index 33d16951..00000000 --- a/src/main/java/com/assu/server/domain/certification/entity/Certification.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.certification.entity; - -public class Certification { -} diff --git a/src/main/java/com/assu/server/domain/certification/entity/QRCertification.java b/src/main/java/com/assu/server/domain/certification/entity/QRCertification.java new file mode 100644 index 00000000..89f3df93 --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/entity/QRCertification.java @@ -0,0 +1,41 @@ +package com.assu.server.domain.certification.entity; + +import java.time.LocalDateTime; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.user.entity.Student; + +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class QRCertification extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Student student; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "certification_id") + private AssociateCertification certification; + + private Boolean isVerified; + private LocalDateTime verifiedTime; +} diff --git a/src/main/java/com/assu/server/domain/certification/entity/enums/SessionStatus.java b/src/main/java/com/assu/server/domain/certification/entity/enums/SessionStatus.java new file mode 100644 index 00000000..3a7091e1 --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/entity/enums/SessionStatus.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.certification.entity.enums; + +public enum SessionStatus { + OPENED, COMPLETED, EXPIRED +} diff --git a/src/main/java/com/assu/server/domain/certification/handler/CertificationWebsocketHandler.java b/src/main/java/com/assu/server/domain/certification/handler/CertificationWebsocketHandler.java new file mode 100644 index 00000000..7009e674 --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/handler/CertificationWebsocketHandler.java @@ -0,0 +1,41 @@ +// package com.assu.server.domain.certification.handler; +// +// import org.springframework.stereotype.Component; +// import org.springframework.web.socket.CloseStatus; +// import org.springframework.web.socket.TextMessage; +// import org.springframework.web.socket.WebSocketSession; +// import org.springframework.web.socket.handler.TextWebSocketHandler; +// +// @Component +// public class CertificationWebsocketHandler extends TextWebSocketHandler { +// +// @Override +// public void afterConnectionEstablished(WebSocketSession session) throws Exception { +// // 클라이언트 연결이 성공적으로 수립되었을 때 호출됩니다. +// System.out.println("Client connected: " + session.getId()); +// } +// +// @Override +// protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { +// // 클라이언트로부터 텍스트 메시지를 받았을 때 호출됩니다. +// String payload = message.getPayload(); +// System.out.println("Message received from " + session.getId() + ": " + payload); +// +// // 받은 메시지를 다시 클라이언트에게 보내거나 다른 로직을 처리합니다. +// session.sendMessage(new TextMessage("Echo: " + payload)); +// } +// +// @Override +// public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { +// // 클라이언트 연결이 종료되었을 때 호출됩니다. +// System.out.println("Client disconnected: " + session.getId() + " with status " + status.getCode()); +// } +// +// @Override +// public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { +// // 전송 오류가 발생했을 때 호출됩니다. +// System.err.println("Transport error for session " + session.getId() + ": " + exception.getMessage()); +// // 필요한 경우 연결을 종료하거나 오류를 처리합니다. +// session.close(CloseStatus.SERVER_ERROR); +// } +// } \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/certification/repository/AssociateCertificationRepository.java b/src/main/java/com/assu/server/domain/certification/repository/AssociateCertificationRepository.java new file mode 100644 index 00000000..945cedce --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/repository/AssociateCertificationRepository.java @@ -0,0 +1,9 @@ +package com.assu.server.domain.certification.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.assu.server.domain.certification.entity.AssociateCertification; + +public interface AssociateCertificationRepository extends JpaRepository { + +} diff --git a/src/main/java/com/assu/server/domain/certification/repository/QRCertificationRepository.java b/src/main/java/com/assu/server/domain/certification/repository/QRCertificationRepository.java new file mode 100644 index 00000000..ff7029fb --- /dev/null +++ b/src/main/java/com/assu/server/domain/certification/repository/QRCertificationRepository.java @@ -0,0 +1,8 @@ +package com.assu.server.domain.certification.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.assu.server.domain.certification.entity.QRCertification; + +public interface QRCertificationRepository extends JpaRepository { +} diff --git a/src/main/java/com/assu/server/domain/certification/service/CertificationService.java b/src/main/java/com/assu/server/domain/certification/service/CertificationService.java index ffb49a80..cde9e47c 100644 --- a/src/main/java/com/assu/server/domain/certification/service/CertificationService.java +++ b/src/main/java/com/assu/server/domain/certification/service/CertificationService.java @@ -1,4 +1,15 @@ package com.assu.server.domain.certification.service; +import com.assu.server.domain.certification.dto.CertificationRequestDTO; +import com.assu.server.domain.certification.dto.CertificationResponseDTO; +import com.assu.server.domain.certification.dto.GroupSessionRequest; +import com.assu.server.domain.member.entity.Member; + public interface CertificationService { + + CertificationResponseDTO.getSessionIdResponse getSessionId(CertificationRequestDTO.groupRequest dto, Member member); + + void handleCertification(GroupSessionRequest dto, Member member); + + void certificatePersonal(CertificationRequestDTO.personalRequest dto, Member member); } diff --git a/src/main/java/com/assu/server/domain/certification/service/CertificationServiceImpl.java b/src/main/java/com/assu/server/domain/certification/service/CertificationServiceImpl.java index c5fc2fd4..65f9244c 100644 --- a/src/main/java/com/assu/server/domain/certification/service/CertificationServiceImpl.java +++ b/src/main/java/com/assu/server/domain/certification/service/CertificationServiceImpl.java @@ -1,4 +1,149 @@ package com.assu.server.domain.certification.service; -public class CertificationServiceImpl { +import java.time.Duration; +import java.util.List; + + +import org.springframework.stereotype.Service; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.admin.repository.AdminRepository; +import com.assu.server.domain.admin.service.AdminService; +import com.assu.server.domain.certification.SessionTimeoutManager; +import com.assu.server.domain.certification.component.CertificationSessionManager; +import com.assu.server.domain.certification.converter.CertificationConverter; +import com.assu.server.domain.certification.dto.CertificationProgressResponseDTO; +import com.assu.server.domain.certification.dto.CertificationRequestDTO; +import com.assu.server.domain.certification.dto.CertificationResponseDTO; +import com.assu.server.domain.certification.dto.GroupSessionRequest; +import com.assu.server.domain.certification.entity.AssociateCertification; +import com.assu.server.domain.certification.entity.enums.SessionStatus; +import com.assu.server.domain.certification.repository.AssociateCertificationRepository; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.repository.StoreRepository; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.GeneralException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +// AdminService 참조, 순환 참조 문제 주의 +@Transactional +@Service +@RequiredArgsConstructor +public class CertificationServiceImpl implements CertificationService { + private final AdminRepository adminRepository; + private final StoreRepository storeRepository; + private final AssociateCertificationRepository associateCertificationRepository; + + // 세션 메니저 + private final CertificationSessionManager sessionManager; + private final SessionTimeoutManager timeoutManager; + + // AdminService 참조 + private final AdminService adminService; + private final SimpMessagingTemplate messagingTemplate; + + + + @Override + public CertificationResponseDTO.getSessionIdResponse getSessionId( + CertificationRequestDTO.groupRequest dto, Member member){ + Long userId = member.getId(); + + // admin id 추출 + Admin admin = adminRepository.findById(dto.getAdminId()).orElseThrow( + () -> new GeneralException(ErrorStatus.NO_SUCH_ADMIN) + ); + + // store id 추출 + Store store = storeRepository.findById(dto.getStoreId()).orElseThrow( + () -> new GeneralException(ErrorStatus.NO_SUCH_STORE) + ); + + + // 세션 생성 및 구독 로직 + AssociateCertification ownerCertification = associateCertificationRepository.save( + CertificationConverter.toAssociateCertification(dto, store, member)); + Long sessionId = ownerCertification.getId(); + + sessionManager.openSession(sessionId); + // 세션 생성 직후 만료 시간을 5분으로 설정 + timeoutManager.scheduleTimeout(sessionId, Duration.ofMinutes(10));// TODO: 나중에 5분으로 변경 + + // 세션 여는 대표자는 제일 먼저 인증 + sessionManager.addUserToSession(sessionId, userId); + + return CertificationConverter.toSessionIdResponse(sessionId); + + } + + @Override + public void handleCertification(GroupSessionRequest dto, Member member) { + Long userId = member.getId(); + + // 제휴 대상인지 확인하기 + Long adminId = dto.getAdminId(); + Student student = member.getStudentProfile(); + List admins = adminService.findMatchingAdmins(student.getUniversity(), student.getDepartment(), student.getMajor()); + + boolean matched = admins.stream() + .anyMatch(admin -> admin.getId().equals(adminId)); + + if (!matched) { + throw new IllegalArgumentException("학생과 매치되지 않는 정보입니다."); + } + + + // session 존재 여부 확인 + Long sessionId = dto.getSessionId(); + AssociateCertification session = associateCertificationRepository.findById(sessionId).orElseThrow( + () -> new GeneralException(ErrorStatus.NO_SUCH_SESSION) + ); + + // 세션 활성화 여부 확인 + if(session.getStatus() != SessionStatus.OPENED) + throw new GeneralException(ErrorStatus.SESSION_NOT_OPENED); + + boolean isDoubledUser= sessionManager.hasUser(sessionId, userId); + if(isDoubledUser) { + messagingTemplate.convertAndSend("/certification/progress/"+sessionId, + new CertificationProgressResponseDTO("progress", null, + "doubled member", sessionManager.snapshotUserIds(sessionId))); + throw new GeneralException(ErrorStatus.DOUBLE_CERTIFIED_USER); + } + + sessionManager.addUserToSession(sessionId, userId); + int currentCertifiedNumber = sessionManager.getCurrentUserCount(sessionId); + + if(currentCertifiedNumber >= session.getPeopleNumber()){ + session.setIsCertified(true); + session.setStatus(SessionStatus.COMPLETED); + associateCertificationRepository.save(session); + + // 완료 알림에 현재 인원수도 포함 + messagingTemplate.convertAndSend("/certification/progress/" + sessionId, + new CertificationProgressResponseDTO("completed", currentCertifiedNumber, "인증이 완료되었습니다.", sessionManager.snapshotUserIds(sessionId))); + } else { + messagingTemplate.convertAndSend("/certification/progress/" + sessionId, + new CertificationProgressResponseDTO("progress", currentCertifiedNumber, null, sessionManager.snapshotUserIds(sessionId))); + } + } + + @Override + public void certificatePersonal(CertificationRequestDTO.personalRequest dto, Member member){ + // store id 추출 + Store store = storeRepository.findById(dto.getStoreId()).orElseThrow( + () -> new GeneralException(ErrorStatus.NO_SUCH_STORE) + ); + + AssociateCertification personalCertificationData = CertificationConverter.toPersonalCertification(dto, store, member); + associateCertificationRepository.save(personalCertificationData); + + } + + + } diff --git a/src/main/java/com/assu/server/domain/chat/config/WebSocketConfig.java b/src/main/java/com/assu/server/domain/chat/config/WebSocketConfig.java new file mode 100644 index 00000000..8026ce34 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/config/WebSocketConfig.java @@ -0,0 +1,93 @@ +// package com.assu.server.domain.chat.config; +// +// import org.springframework.context.annotation.Configuration; +// import org.springframework.messaging.simp.config.ChannelRegistration; +// import org.springframework.messaging.simp.config.MessageBrokerRegistry; +// import org.springframework.web.socket.config.annotation.*; +// +// @Configuration +// @EnableWebSocketMessageBroker +// public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { +// +// @Override +// public void registerStompEndpoints(StompEndpointRegistry registry) { +// registry.addEndpoint("/ws") // 클라이언트 WebSocket 연결 지점 +// .setAllowedOriginPatterns( +// "*", +// "https://assu.shop", +// "http://localhost:63342", +// "http://localhost:5173", // Vite 기본 +// "http://localhost:3000", // CRA/Next 기본 +// "http://127.0.0.1:*", +// "http://192.168.*.*:*"); // 같은 LAN의 실제 기기 테스트용// fallback for old browsers +// // 같은 LAN의 실제 기기 테스트용 +// // fallback for old browsers +// +// // ✅ 모바일/안드로이드용 (네이티브 WebSocket) +// registry.addEndpoint("/ws") +// .setAllowedOriginPatterns("*"); // wss 사용 시 TLS 세팅 +// } +// +// @Override +// public void configureMessageBroker(MessageBrokerRegistry registry) { +// registry.setApplicationDestinationPrefixes("/pub"); // 클라이언트가 보내는 prefix +// registry.enableSimpleBroker("/sub"); // 서버가 보내는 prefix +// } +// } +package com.assu.server.domain.chat.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import com.assu.server.domain.certification.config.StompAuthChannelInterceptor; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompAuthChannelInterceptor stompAuthChannelInterceptor; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + + registry.addEndpoint("/ws") // 클라이언트 WebSocket 연결 지점 + .setAllowedOriginPatterns( + "*", + "https://assu.shop", + "http://localhost:63342", + "http://localhost:5173", // Vite 기본 + "http://localhost:3000", // CRA/Next 기본 + "http://127.0.0.1:*", + "http://192.168.*.*:*"); + // 채팅용 엔드포인트 + + // 인증용 엔드포인트 + registry.addEndpoint("/ws-certify") + .setAllowedOriginPatterns("*"); + + + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + // 채팅용 + registry.setApplicationDestinationPrefixes("/pub"); + registry.enableSimpleBroker("/sub"); + + // 인증용 추가 + registry.setApplicationDestinationPrefixes("/pub", "/app"); // 둘 다 추가 + registry.enableSimpleBroker("/sub", "/certification"); // 둘 다 추가 + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompAuthChannelInterceptor); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/chat/controller/ChatController.java b/src/main/java/com/assu/server/domain/chat/controller/ChatController.java index 027e6b57..53af1a40 100644 --- a/src/main/java/com/assu/server/domain/chat/controller/ChatController.java +++ b/src/main/java/com/assu/server/domain/chat/controller/ChatController.java @@ -1,4 +1,237 @@ package com.assu.server.domain.chat.controller; +import com.assu.server.domain.chat.dto.*; +import com.assu.server.domain.chat.repository.MessageRepository; +import com.assu.server.domain.chat.service.BlockService; +import com.assu.server.domain.chat.service.ChatService; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.notification.service.NotificationCommandService; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PresenceTracker; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import com.assu.server.global.apiPayload.BaseResponse; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/chat") public class ChatController { + private final ChatService chatService; + private final SimpMessagingTemplate simpMessagingTemplate; +// private final PresenceTracker presenceTracker; +// private final MessageRepository messageRepository; +// private final MemberRepository memberRepository; + private final BlockService blockService; +// private final NotificationCommandService notificationCommandService; + + @Operation( + summary = "채팅방을 생성하는 API", + description = "# [v1.0 (2025-08-05)](https://clumsy-seeder-416.notion.site/2241197c19ed80c38871ec77deced713) 채팅방을 생성합니다.\n"+ + "- storeId: Request Body, Long\n" + + "- partnerId: Request Body, Long\n" + ) + @PostMapping("/rooms") + public BaseResponse createChatRoom( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestBody ChatRequestDTO.CreateChatRoomRequestDTO request) { + Long memberId = pd.getMember().getId(); + return BaseResponse.onSuccess(SuccessStatus._OK, chatService.createChatRoom(request, memberId)); + } + + @Operation( + summary = "채팅방 목록을 조회하는 API", + description = "# [v1.0 (2025-08-05)](https://clumsy-seeder-416.notion.site/API-1d71197c19ed819f8f70fb437e9ce62b?p=2241197c19ed816993c3c5ae17d6f099&pm=s) 채팅방 목록을 조회합니다.\n" + ) + @GetMapping("/rooms") + public BaseResponse> getChatRoomList( + @AuthenticationPrincipal PrincipalDetails pd + ) { + Long memberId = pd.getMember().getId(); + return BaseResponse.onSuccess(SuccessStatus._OK, chatService.getChatRoomList(memberId)); + } + + @Operation( + summary = "채팅 API", + description = "# [v1.0 (2025-08-05)](https://clumsy-seeder-416.notion.site/2241197c19ed800eab45c35073761c97?v=2241197c19ed8134b64f000cc26c5d31&p=2371197c19ed80968342e2bc8fe88cee&pm=s) 메시지를 전송합니다.\n"+ + "- roomId: Request Body, Long\n" + + "- senderId: Request Body, Long\n"+ + "- receiverId: Request Body, Long\n" + + "- message: Request Body, String\n" + ) + @MessageMapping("/send") + public void handleMessage(@Payload ChatRequestDTO.ChatMessageRequestDTO request) { + + // 1. 서비스 호출 (모든 비즈니스 로직 위임) + MessageHandlingResult result = chatService.handleMessage(request); + + // 2. [항상 전송] 채팅방 메시지 전송 + simpMessagingTemplate.convertAndSend("/sub/chat/" + request.getRoomId(), result.sendMessageResponseDTO()); + + // 3. [조건부 전송] 채팅방 목록 업데이트 전송 + if (result.hasRoomUpdates()) { + simpMessagingTemplate.convertAndSendToUser( + result.receiverId().toString(), + "/queue/updates", + result.chatRoomUpdateDTO() + ); + } + } + +// @Transactional +// @MessageMapping("/send") +// public void handleMessage(@Payload ChatRequestDTO.ChatMessageRequestDTO request) { +// // 먼저 접속 여부 확인 후 unreadCount 계산 +// boolean receiverInRoom = presenceTracker.isInRoom(request.getReceiverId(), request.getRoomId()); +// int unreadForSender = receiverInRoom ? 0 : 1; +// request.setUnreadCountForSender(unreadForSender); +// +// ChatResponseDTO.SendMessageResponseDTO saved = chatService.handleMessage(request); +// simpMessagingTemplate.convertAndSend("/sub/chat/" + request.getRoomId(), saved); +// +// if (!receiverInRoom) { +// Long totalUnreadCount = messageRepository.countUnreadMessagesByRoomAndReceiver( +// request.getRoomId(), +// request.getReceiverId() +// ); +// +// ChatRoomUpdateDTO updateDTO = ChatRoomUpdateDTO.builder() +// .roomId(request.getRoomId()) +// .lastMessage(saved.message()) +// .lastMessageTime(saved.sentAt()) +// .unreadCount(totalUnreadCount) +// .build(); +// +// simpMessagingTemplate.convertAndSendToUser( +// request.getReceiverId().toString(), +// "/queue/updates", +// updateDTO +// ); +// Member sender = memberRepository.findById(request.getSenderId()).orElse(null); +// String senderName; +// if (sender.getRole()== UserRole.ADMIN) { +// senderName = sender.getAdminProfile().getName(); +// } else { +// senderName = sender.getPartnerProfile().getName(); +// } +// +// log.info(">>>>>>>>메시지 전송은 될걸"); +// notificationCommandService.sendChat(request.getReceiverId(), request.getRoomId(), senderName, request.getMessage()); +// log.info(">>>>>>>>알림이 가나"); +// } +// } + + @Operation( + summary = "메시지 읽음 처리 API", + description = "# [v1.0 (2025-08-05)](https://clumsy-seeder-416.notion.site/2241197c19ed800eab45c35073761c97?v=2241197c19ed8134b64f000cc26c5d31&p=2241197c19ed81ffa771cb18ab157b54&pm=s) 메시지를 읽음처리합니다.\n"+ + "- roomId: Path Variable, Long\n" + ) + @PatchMapping("rooms/{roomId}/read") + public BaseResponse readMessage( + @AuthenticationPrincipal PrincipalDetails pd, + @PathVariable Long roomId + ) { + Long memberId = pd.getMember().getId(); + ChatResponseDTO.ReadMessageResponseDTO response = chatService.readMessage(roomId, memberId); + return BaseResponse.onSuccess(SuccessStatus._OK, response); + } + + @Operation( + summary = "채팅방 상세 조회 API", + description = "# [v1.0 (2025-08-05)](https://clumsy-seeder-416.notion.site/2241197c19ed800eab45c35073761c97?v=2241197c19ed8134b64f000cc26c5d31&p=2241197c19ed81399395fd66f73730af&pm=s) 채팅방을 클릭했을 때 메시지를 조회합니다.\n"+ + "- roomId: Path Variable, Long\n" + ) + @GetMapping("rooms/{roomId}/messages") + public BaseResponse getChatHistory( + @AuthenticationPrincipal PrincipalDetails pd, + @PathVariable Long roomId + ) { + Long memberId = pd.getMember().getId(); + ChatResponseDTO.ChatHistoryResponseDTO response = chatService.readHistory(roomId, memberId); + return BaseResponse.onSuccess(SuccessStatus._OK, response); + } + + @Operation( + summary = "채팅방을 나가는 API" + + "참여자가 2명이면 채팅방이 살아있지만, 이미 한 명이 나갔다면 채팅방이 삭제됩니다.", + description = "# [v1.0 (2025-08-05)](https://clumsy-seeder-416.notion.site/2241197c19ed800eab45c35073761c97?v=2241197c19ed8134b64f000cc26c5d31&p=2371197c19ed8079a6e1c2331cb4f534&pm=s) 채팅방을 나갑니다.\n"+ + "- roomId: Path Variable, Long\n" + ) + @DeleteMapping("rooms/{roomId}/leave") + public BaseResponse leaveChattingRoom( + @AuthenticationPrincipal PrincipalDetails pd, + @PathVariable Long roomId + ) { + Long memberId = pd.getMember().getId(); + return BaseResponse.onSuccess(SuccessStatus._OK, chatService.leaveChattingRoom(roomId, memberId)); + } + + @Operation( + summary = "상대방을 차단하는 API" + + "상대방을 차단합니다. 메시지를 주고받을 수 없습니다.", + description = "# [v1.0 (2025-09-25)]() 상대방을 차단합니다.\n"+ + "- memberId: Request Body, Long\n" + ) + @PostMapping("/block") + public BaseResponse block( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestBody BlockRequestDTO.BlockMemberRequestDTO request + ) { + Long memberId = pd.getMember().getId(); + return BaseResponse.onSuccess(SuccessStatus._OK, blockService.blockMember(memberId, request.getOpponentId())); + } + + @Operation( + summary = "상대방을 차단했는지 확인하는 API" + + "상대방을 차단했는지 여부를 알려줍니다.", + description = "# [v1.0 (2025-09-25)]() 상대방을 차단했는지 검사합니다.\n"+ + "- memberId: Request Body, Long\n" + ) + @GetMapping("/check/block/{opponentId}") + public BaseResponse checkBlock( + @AuthenticationPrincipal PrincipalDetails pd, + @PathVariable Long opponentId + ) { + Long memberId = pd.getMember().getId(); + return BaseResponse.onSuccess(SuccessStatus._OK, blockService.checkBlock(memberId, opponentId)); + } + + @Operation( + summary = "상대방을 차단 해제하는 API" + + "상대방을 차단해제합니다. 앞으로 다시 메시지를 주고받을 수 있습니다.", + description = "# [v1.0 (2025-09-25)]() 상대방을 차단 해제합니다.\n"+ + "- memberId: Request Body, Long\n" + ) + @DeleteMapping("/unblock") + public BaseResponse unblock( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestParam Long opponentId + ) { + Long memberId = pd.getMember().getId(); + return BaseResponse.onSuccess(SuccessStatus._OK, blockService.unblockMember(memberId, opponentId)); + } + + @Operation( + summary = "차단한 대상을 조회합니다." + + "본인이 차단한 대상을 모두 조회합니다.", + description = "# [v1.0 (2025-09-25)]() 차단한 대상을 조회합니다..\n"+ + "- memberId: Request Body, Long\n" + ) + @GetMapping("/blockList") + public BaseResponse> getBlockList( + @AuthenticationPrincipal PrincipalDetails pd + ) { + Long memberId = pd.getMember().getId(); + return BaseResponse.onSuccess(SuccessStatus._OK, blockService.getMyBlockList(memberId)); + } + } diff --git a/src/main/java/com/assu/server/domain/chat/converter/BlockConverter.java b/src/main/java/com/assu/server/domain/chat/converter/BlockConverter.java new file mode 100644 index 00000000..aea2bebc --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/converter/BlockConverter.java @@ -0,0 +1,51 @@ +package com.assu.server.domain.chat.converter; + +import com.assu.server.domain.chat.dto.BlockResponseDTO; +import com.assu.server.domain.chat.entity.Block; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; + +import java.util.List; +import java.util.stream.Collectors; + +public class BlockConverter { + public static BlockResponseDTO.BlockMemberDTO toBlockDTO(Long blockedId, String blockedName) { + return BlockResponseDTO.BlockMemberDTO.builder() + .memberId(blockedId) + .name(blockedName) + .build(); + } + + public static BlockResponseDTO.CheckBlockMemberDTO toCheckBlockDTO(Long blockedId, String blockedName, boolean blocked) { + return BlockResponseDTO.CheckBlockMemberDTO.builder() + .memberId(blockedId) + .name(blockedName) + .blocked(blocked) + .build(); + } + + public static BlockResponseDTO.BlockMemberDTO toBlockedMemberDTO(Block block) { + // Block 엔티티에서 차단된 사용자(Member) 정보를 꺼냅니다. + Member blockedMember = block.getBlocked(); + UserRole blockedRole = blockedMember.getRole(); + String blockedName; + if (blockedRole == UserRole.ADMIN) { + blockedName = blockedMember.getAdminProfile().getName(); + } else { + blockedName = blockedMember.getPartnerProfile().getName(); + } + + return BlockResponseDTO.BlockMemberDTO.builder() + .memberId(blockedMember.getId()) + .name(blockedName) // 또는 getNickname() 등 실제 필드명 사용 + .blockDate(block.getCreatedAt()) + .build(); + } + + public static List toBlockedMemberListDTO(List blockList) { + return blockList.stream() + .map(BlockConverter::toBlockedMemberDTO) // 각 Block 객체에 대해 위 헬퍼 메소드를 호출 + .collect(Collectors.toList()); + } + +} diff --git a/src/main/java/com/assu/server/domain/chat/converter/ChatConverter.java b/src/main/java/com/assu/server/domain/chat/converter/ChatConverter.java index 4f654dbd..1d38eae3 100644 --- a/src/main/java/com/assu/server/domain/chat/converter/ChatConverter.java +++ b/src/main/java/com/assu/server/domain/chat/converter/ChatConverter.java @@ -1,4 +1,121 @@ package com.assu.server.domain.chat.converter; +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.chat.entity.enums.MessageType; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.chat.dto.ChatMessageDTO; +import com.assu.server.domain.chat.dto.ChatRequestDTO; +import com.assu.server.domain.chat.dto.ChatResponseDTO; +import com.assu.server.domain.chat.dto.ChatRoomListResultDTO; +import com.assu.server.domain.chat.entity.ChattingRoom; +import com.assu.server.domain.chat.entity.Message; + +import com.assu.server.domain.partner.entity.Partner; + +import java.util.List; +import java.util.stream.Collectors; + public class ChatConverter { + + // 채팅방 리스트 아이템 하나 + public static ChatRoomListResultDTO toChatRoomResultDTO(ChatRoomListResultDTO request) { + return ChatRoomListResultDTO.builder() + .roomId(request.getRoomId()) + .lastMessage(request.getLastMessage()) + .lastMessageTime(request.getLastMessageTime()) + .unreadMessagesCount(request.getUnreadMessagesCount()) + .opponentId(request.getOpponentId()) + .opponentName(request.getOpponentName()) + .opponentProfileImage(request.getOpponentProfileImage()) + .phoneNumber(request.getPhoneNumber()) + .build(); + } + + // 리스트 변환 + public static List toChatRoomListResultDTO(List dto) { + return dto.stream() + .map(ChatConverter::toChatRoomResultDTO) + .collect(Collectors.toList()); + } + + public static ChattingRoom toCreateChattingRoom(Admin admin, Partner partner) { + return ChattingRoom.builder() + .admin(admin) + .partner(partner) + .build(); + } + + public static ChatResponseDTO.CreateChatRoomResponseDTO toCreateChatRoomIdDTO(ChattingRoom room) { + return ChatResponseDTO.CreateChatRoomResponseDTO.builder() + .roomId(room.getId()) + .adminViewName(room.getPartner().getName()) + .partnerViewName(room.getAdmin().getName()) + .isNew(true) + .build(); + } + + public static ChatResponseDTO.CreateChatRoomResponseDTO toEnterChatRoomDTO(ChattingRoom room) { + return ChatResponseDTO.CreateChatRoomResponseDTO.builder() + .roomId(room.getId()) + .adminViewName(room.getPartner().getName()) + .partnerViewName(room.getAdmin().getName()) + .isNew(false) + .build(); + } + + public static Message toMessageEntity(ChatRequestDTO.ChatMessageRequestDTO request, ChattingRoom room, Member sender, Member receiver) { + return Message.builder() + .chattingRoom(room) + .sender(sender) + .receiver(receiver) + .message(request.getMessage()) + .unreadCount(request.getUnreadCountForSender()) + .type(MessageType.TEXT) + .build(); + } + + public static Message toGuideMessageEntity(ChatRequestDTO.ChatMessageRequestDTO request, ChattingRoom room, Member sender, Member receiver) { + return Message.builder() + .chattingRoom(room) + .sender(sender) + .receiver(receiver) + .message(request.getMessage()) + .unreadCount(0) + .type(MessageType.GUIDE) + .build(); + } + + public static ChatResponseDTO.SendMessageResponseDTO toSendMessageDTO(Message message) { + return ChatResponseDTO.SendMessageResponseDTO.builder() + .messageId(message.getId()) + .roomId(message.getChattingRoom().getId()) + .senderId(message.getSender().getId()) + .receiverId(message.getReceiver().getId()) + .message(message.getMessage()) + .sentAt(message.getCreatedAt()) + .messageType(message.getType()) + .unreadCountForSender(message.getUnreadCount()) + .build(); + } + +// public static ChatMessageDTO toChatMessageDTO(Message message, Long currentUserId) { +// return ChatMessageDTO.builder() +// .messageId(message.getId()) +// .message(message.getMessage()) +// .sendTime(message.getCreatedAt()) +// .isRead(message.isRead()) +// .isMyMessage(message.getSender().getId().equals(currentUserId)) +// .build(); +// } + + public static ChatResponseDTO.ChatHistoryResponseDTO toChatHistoryDTO( + Long roomId, + List messages) { + + // ③ 최종 DTO 빌드 + return ChatResponseDTO.ChatHistoryResponseDTO.builder() + .roomId(roomId) + .messages(messages) + .build(); + } } diff --git a/src/main/java/com/assu/server/domain/chat/dto/BlockRequestDTO.java b/src/main/java/com/assu/server/domain/chat/dto/BlockRequestDTO.java new file mode 100644 index 00000000..938b4f8f --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/dto/BlockRequestDTO.java @@ -0,0 +1,13 @@ +package com.assu.server.domain.chat.dto; + +import lombok.Getter; +import lombok.Setter; + +public class BlockRequestDTO { + + @Getter + @Setter + public static class BlockMemberRequestDTO { + private Long opponentId; + } +} diff --git a/src/main/java/com/assu/server/domain/chat/dto/BlockResponseDTO.java b/src/main/java/com/assu/server/domain/chat/dto/BlockResponseDTO.java new file mode 100644 index 00000000..e13d1db8 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/dto/BlockResponseDTO.java @@ -0,0 +1,32 @@ +package com.assu.server.domain.chat.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +public class BlockResponseDTO { + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class BlockMemberDTO { + private Long memberId; + private String name; + private LocalDateTime blockDate; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class CheckBlockMemberDTO { + private Long memberId; + private String name; + private boolean blocked; + } + +} diff --git a/src/main/java/com/assu/server/domain/chat/dto/ChatMessageDTO.java b/src/main/java/com/assu/server/domain/chat/dto/ChatMessageDTO.java new file mode 100644 index 00000000..9886cba2 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/dto/ChatMessageDTO.java @@ -0,0 +1,36 @@ +package com.assu.server.domain.chat.dto; + +import com.assu.server.domain.chat.entity.enums.MessageType; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChatMessageDTO { + @JsonIgnore + private Long roomId; + // 메시지 삭제 시 사용 가능 + private Long messageId; + + private String message; + private LocalDateTime sendTime; + + @JsonProperty("unreadCountForSender") + private Integer unreadCount; + + @JsonProperty("isRead") + private boolean isRead; + + @JsonProperty("isMyMessage") + private boolean isMyMessage; + + private MessageType messageType; +} diff --git a/src/main/java/com/assu/server/domain/chat/dto/ChatRequestDTO.java b/src/main/java/com/assu/server/domain/chat/dto/ChatRequestDTO.java index e4e172b9..c5180559 100644 --- a/src/main/java/com/assu/server/domain/chat/dto/ChatRequestDTO.java +++ b/src/main/java/com/assu/server/domain/chat/dto/ChatRequestDTO.java @@ -1,4 +1,27 @@ package com.assu.server.domain.chat.dto; +import com.assu.server.domain.chat.entity.enums.MessageType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + public class ChatRequestDTO { -} + @Getter + public static class CreateChatRoomRequestDTO { + private Long adminId; + private Long partnerId; + } + + @Getter + @Setter + @AllArgsConstructor + @NoArgsConstructor + public static class ChatMessageRequestDTO { + private Long roomId; + private Long senderId; + private Long receiverId; + private String message; + private int unreadCountForSender; + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/chat/dto/ChatResponseDTO.java b/src/main/java/com/assu/server/domain/chat/dto/ChatResponseDTO.java index b494949d..9a8f3d97 100644 --- a/src/main/java/com/assu/server/domain/chat/dto/ChatResponseDTO.java +++ b/src/main/java/com/assu/server/domain/chat/dto/ChatResponseDTO.java @@ -1,4 +1,76 @@ package com.assu.server.domain.chat.dto; +import com.assu.server.domain.chat.entity.enums.MessageType; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.google.protobuf.Enum; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; +import java.util.List; + public class ChatResponseDTO { + + // 채팅방 목록 조회 + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CreateChatRoomResponseDTO { + private Long roomId; + private String adminViewName; + private String partnerViewName; + private Boolean isNew; + } + + // 메시지 전송 + @Builder + public record SendMessageResponseDTO( + Long messageId, + Long roomId, + Long senderId, + Long receiverId, + String message, + MessageType messageType, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime sentAt, + Integer unreadCountForSender + ) { + public SendMessageResponseDTO withUnreadCountForSender(Integer count) { + return new SendMessageResponseDTO( + messageId, roomId, senderId, receiverId, message, messageType, sentAt, count + ); + } + } + + // 메시지 읽음 처리 + public record ReadMessageResponseDTO( + Long roomId, + Long readerId, + List readMessagesId, + int readCount, + boolean isRead + ) {} + + // 채팅방 들어갔을 때 조회 + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ChatHistoryResponseDTO { + private Long roomId; + private List messages; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class LeaveChattingRoomResponseDTO { + private Long roomId; + private boolean isLeftSuccessfully; + private boolean isRoomDeleted; + } } + diff --git a/src/main/java/com/assu/server/domain/chat/dto/ChatRoomListResultDTO.java b/src/main/java/com/assu/server/domain/chat/dto/ChatRoomListResultDTO.java new file mode 100644 index 00000000..4e57cb8c --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/dto/ChatRoomListResultDTO.java @@ -0,0 +1,23 @@ +package com.assu.server.domain.chat.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ChatRoomListResultDTO { + private Long roomId; + private String lastMessage; + private LocalDateTime lastMessageTime; + private Long unreadMessagesCount; + private Long opponentId; + private String opponentName; + private String opponentProfileImage; + private String phoneNumber; +} diff --git a/src/main/java/com/assu/server/domain/chat/dto/ChatRoomUpdateDTO.java b/src/main/java/com/assu/server/domain/chat/dto/ChatRoomUpdateDTO.java new file mode 100644 index 00000000..b83ee0c0 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/dto/ChatRoomUpdateDTO.java @@ -0,0 +1,15 @@ +package com.assu.server.domain.chat.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class ChatRoomUpdateDTO { + private Long roomId; + private String lastMessage; + private LocalDateTime lastMessageTime; + private Long unreadCount; // 해당 채팅방의 총 안읽은 메시지 수 +} diff --git a/src/main/java/com/assu/server/domain/chat/dto/MessageHandlingResult.java b/src/main/java/com/assu/server/domain/chat/dto/MessageHandlingResult.java new file mode 100644 index 00000000..56c142e9 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/dto/MessageHandlingResult.java @@ -0,0 +1,26 @@ +package com.assu.server.domain.chat.dto; + +public record MessageHandlingResult( + ChatResponseDTO.SendMessageResponseDTO sendMessageResponseDTO, + ChatRoomUpdateDTO chatRoomUpdateDTO, + Long receiverId +) { + + // 정적 팩토리 메소드 1 + public static MessageHandlingResult of(ChatResponseDTO.SendMessageResponseDTO sendMessageDTO) { + // record의 기본 생성자를 호출합니다. + return new MessageHandlingResult(sendMessageDTO, null, null); + } + + // 정적 팩토리 메소드 2 + public static MessageHandlingResult withUpdates(ChatResponseDTO.SendMessageResponseDTO sendMessageDTO, ChatRoomUpdateDTO updateDTO, Long receiverId) { + // record의 기본 생성자를 호출합니다. + return new MessageHandlingResult(sendMessageDTO, updateDTO, receiverId); + } + + // 헬퍼(Helper) 메소드 + public boolean hasRoomUpdates() { + // record는 'get' 접두사 없는 접근자(chatRoomUpdateDTO())를 사용합니다. + return chatRoomUpdateDTO != null; + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/chat/entity/Block.java b/src/main/java/com/assu/server/domain/chat/entity/Block.java new file mode 100644 index 00000000..7aa99d1c --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/entity/Block.java @@ -0,0 +1,31 @@ +package com.assu.server.domain.chat.entity; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Block extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "blocker_id", nullable = false) + private Member blocker; // 차단한 사람 + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "blocked_id", nullable = false) + private Member blocked; // 차단당한 사람 + + @Builder + public Block(Member blocker, Member blocked) { + this.blocker = blocker; + this.blocked = blocked; + } +} diff --git a/src/main/java/com/assu/server/domain/chat/entity/Chat.java b/src/main/java/com/assu/server/domain/chat/entity/Chat.java deleted file mode 100644 index 3cc45c0d..00000000 --- a/src/main/java/com/assu/server/domain/chat/entity/Chat.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.chat.entity; - -public class Chat { -} diff --git a/src/main/java/com/assu/server/domain/chat/entity/ChattingRoom.java b/src/main/java/com/assu/server/domain/chat/entity/ChattingRoom.java new file mode 100644 index 00000000..e88c405e --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/entity/ChattingRoom.java @@ -0,0 +1,67 @@ +package com.assu.server.domain.chat.entity; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partner.entity.Partner; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class ChattingRoom extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @Enumerated(EnumType.STRING) + private ActivationStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_id") + private Admin admin; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "partner_id") + private Partner partner; + + @OneToMany(mappedBy = "chattingRoom", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List messages; + + private String adminViewName; + private String partnerViewName; + + private int memberCount; + + public void updateStatus(ActivationStatus status) { + this.status = status; + } + + public void updateName(String adminViewName, String partnerViewName) { + this.adminViewName = adminViewName; + this.partnerViewName = partnerViewName; + } + + public void updateMemberCount(int memberCount) { + this.memberCount = memberCount; + } + + public void setAdmin(Admin admin) { + this.admin = admin; + } + + public void setPartner(Partner partner) { + this.partner = partner; + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/chat/entity/Message.java b/src/main/java/com/assu/server/domain/chat/entity/Message.java new file mode 100644 index 00000000..ba06e811 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/entity/Message.java @@ -0,0 +1,54 @@ +package com.assu.server.domain.chat.entity; + +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.chat.entity.enums.MessageType; + +import com.assu.server.domain.common.entity.BaseEntity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class Message extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id") + private ChattingRoom chattingRoom; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id", nullable = false) + private Member sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id", nullable = true) // 그룹 채팅이면 nullable + private Member receiver; + + + @Enumerated(EnumType.STRING) + private MessageType type; + + private String message; + + private Integer unreadCount; + +// private LocalDateTime sendTime; +// private LocalDateTime readTime; + + @Column(nullable = false) + private boolean isRead = false; + + @Builder.Default + private Boolean deleted = false; + + public void markAsRead() { + this.isRead = true; + this.unreadCount = 0; + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/chat/entity/enums/MessageType.java b/src/main/java/com/assu/server/domain/chat/entity/enums/MessageType.java new file mode 100644 index 00000000..94c6c4b6 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/entity/enums/MessageType.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.chat.entity.enums; + +public enum MessageType { + TEXT, PROPOSAL, SYSTEM, GUIDE +} diff --git a/src/main/java/com/assu/server/domain/chat/handler/ChatWebSocketHandler.java b/src/main/java/com/assu/server/domain/chat/handler/ChatWebSocketHandler.java new file mode 100644 index 00000000..56ee07d2 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/handler/ChatWebSocketHandler.java @@ -0,0 +1,35 @@ +package com.assu.server.domain.chat.handler; + +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +@Component +public class ChatWebSocketHandler extends TextWebSocketHandler { + private final Set sessions = new CopyOnWriteArraySet<>(); + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + sessions.add(session); + System.out.println("Connected to " + session.getId()); + } + + @Override + public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + System.out.println("Received: " + message.getPayload()); + + for (WebSocketSession s : sessions) { + s.sendMessage(new TextMessage(message.getPayload())); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, org.springframework.web.socket.CloseStatus status) throws Exception { + sessions.remove(session); + System.out.println("Disconnected from " + session.getId()); + } +} diff --git a/src/main/java/com/assu/server/domain/chat/repository/BlockRepository.java b/src/main/java/com/assu/server/domain/chat/repository/BlockRepository.java new file mode 100644 index 00000000..197b25ce --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/repository/BlockRepository.java @@ -0,0 +1,26 @@ +package com.assu.server.domain.chat.repository; + + +import com.assu.server.domain.chat.entity.Block; +import com.assu.server.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface BlockRepository extends JpaRepository { + boolean existsByBlockerAndBlocked(Member blocker, Member blocked); + + void deleteByBlockerAndBlocked(Member blocker, Member blocked); + + List findByBlocker(Member blocker); + + // BlockRepository.java + @Query("SELECT COUNT(b) > 0 FROM Block b " + + "WHERE (b.blocker = :user1 AND b.blocked = :user2) " + + "OR (b.blocker = :user2 AND b.blocked = :user1)" + + "ORDER BY b.createdAt DESC") + boolean existsBlockRelationBetween(@Param("user1") Member user1, @Param("user2") Member user2); + +} diff --git a/src/main/java/com/assu/server/domain/chat/repository/ChatRepository.java b/src/main/java/com/assu/server/domain/chat/repository/ChatRepository.java index a609ec35..053b7f45 100644 --- a/src/main/java/com/assu/server/domain/chat/repository/ChatRepository.java +++ b/src/main/java/com/assu/server/domain/chat/repository/ChatRepository.java @@ -1,4 +1,92 @@ package com.assu.server.domain.chat.repository; -public class ChatRepository { +import com.assu.server.domain.chat.dto.ChatRoomListResultDTO; +import com.assu.server.domain.chat.entity.ChattingRoom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ChatRepository extends JpaRepository { + @Query(""" + SELECT new com.assu.server.domain.chat.dto.ChatRoomListResultDTO ( + r.id, + (SELECT m.message + FROM Message m + WHERE m.chattingRoom.id = r.id + AND m.createdAt = ( + SELECT MAX(m2.createdAt) + FROM Message m2 + WHERE m2.chattingRoom.id = r.id + ) + ), + (SELECT MAX(m.createdAt) + FROM Message m + WHERE m.chattingRoom.id = r.id + ), + (SELECT COUNT(m) + FROM Message m + WHERE m.chattingRoom.id = r.id + AND m.receiver.id = :memberId + AND m.isRead = false), + CASE + WHEN pm.id IS NULL AND am.id = :memberId THEN -1 + WHEN am.id IS NULL AND pm.id = :memberId THEN -1 + WHEN pm.id = :memberId THEN a.id + ELSE p.id + END, + CASE + WHEN pm.id IS NULL AND am.id = :memberId THEN -1 + WHEN am.id IS NULL AND pm.id = :memberId THEN -1 + WHEN pm.id = :memberId THEN a.name + ELSE p.name + END, + CASE + WHEN pm.id IS NULL AND am.id = :memberId THEN -1 + WHEN am.id IS NULL AND pm.id = :memberId THEN -1 + WHEN pm.id = :memberId THEN am.profileUrl + ELSE pm.profileUrl + END, + CASE + WHEN pm.id IS NULL AND am.id = :memberId THEN '-1' + WHEN am.id IS NULL AND pm.id = :memberId THEN '-1' + WHEN pm.id = :memberId THEN am.phoneNum + ELSE pm.phoneNum + END + ) + FROM ChattingRoom r + LEFT JOIN r.partner p + LEFT JOIN p.member pm + LEFT JOIN r.admin a + LEFT JOIN a.member am + WHERE pm.id = :memberId + OR am.id = :memberId + ORDER BY + (SELECT MAX(m.createdAt) + FROM Message m + WHERE m.chattingRoom.id = r.id) DESC + """) + List findChattingRoomsByMemberId(@Param("memberId") Long memberId); + + @Query(""" + SELECT CASE WHEN COUNT(c) > 0 THEN true ELSE false END + FROM ChattingRoom c + WHERE c.admin.id = :adminId AND c.partner.id = :partnerId + """) + Boolean checkChattingRoomByAdminIdAndPartnerId( + @Param("adminId") Long adminId, + @Param("partnerId") Long partnerId + ); + + + @Query(""" + SELECT c + FROM ChattingRoom c + WHERE c.admin.id = :adminId AND c.partner.id = :partnerId + """) + ChattingRoom findChattingRoomByAdminIdAndPartnerId( + @Param("adminId") Long adminId, + @Param("partnerId")Long partnerId + ); } diff --git a/src/main/java/com/assu/server/domain/chat/repository/MessageRepository.java b/src/main/java/com/assu/server/domain/chat/repository/MessageRepository.java new file mode 100644 index 00000000..53e12d37 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/repository/MessageRepository.java @@ -0,0 +1,53 @@ +package com.assu.server.domain.chat.repository; + +import com.assu.server.domain.chat.dto.ChatMessageDTO; +import com.assu.server.domain.chat.entity.Message; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MessageRepository extends JpaRepository { + @Query(""" + SELECT m FROM Message m + WHERE m.chattingRoom.id = :roomId + AND m.receiver.id = :receiverId + AND m.isRead = false +""") + List findUnreadMessagesByRoomAndReceiver(Long roomId, Long receiverId); + + @Query(""" + SELECT COUNT(m) + FROM Message m + WHERE m.chattingRoom.id = :roomId + AND m.receiver.id = :receiverId + AND m.isRead = false + """) + Long countUnreadMessagesByRoomAndReceiver(@Param("roomId") Long roomId, @Param("receiverId") Long receiverId); + + + @Query(""" + SELECT new com.assu.server.domain.chat.dto.ChatMessageDTO ( + m.chattingRoom.id, + m.id, + m.message, + m.createdAt, + m.unreadCount, + m.isRead, + CASE WHEN m.sender.id = :memberId THEN true + ELSE false + END, + m.type + ) + FROM Message m + WHERE m.chattingRoom.id = :roomId + AND (m.sender.id = :memberId OR m.receiver.id = :memberId) + ORDER BY m.createdAt ASC +""") + List findAllMessagesByRoomAndMemberId( + @Param("roomId") Long roomId, + @Param("memberId") Long memberId + ); + +} diff --git a/src/main/java/com/assu/server/domain/chat/service/BlockService.java b/src/main/java/com/assu/server/domain/chat/service/BlockService.java new file mode 100644 index 00000000..27c0ac89 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/service/BlockService.java @@ -0,0 +1,12 @@ +package com.assu.server.domain.chat.service; + +import com.assu.server.domain.chat.dto.BlockResponseDTO; + +import java.util.List; + +public interface BlockService { + BlockResponseDTO.BlockMemberDTO blockMember(Long blockerId, Long blockedId); + BlockResponseDTO.CheckBlockMemberDTO checkBlock(Long blockerId, Long blockedId); + BlockResponseDTO.BlockMemberDTO unblockMember(Long blockerId, Long blockedId); + List getMyBlockList(Long blockerId); +} diff --git a/src/main/java/com/assu/server/domain/chat/service/BlockServiceImpl.java b/src/main/java/com/assu/server/domain/chat/service/BlockServiceImpl.java new file mode 100644 index 00000000..b9edc568 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/service/BlockServiceImpl.java @@ -0,0 +1,124 @@ +package com.assu.server.domain.chat.service; + +import com.assu.server.domain.chat.converter.BlockConverter; +import com.assu.server.domain.chat.converter.ChatConverter; +import com.assu.server.domain.chat.dto.BlockResponseDTO; +import com.assu.server.domain.chat.entity.Block; +import com.assu.server.domain.chat.repository.BlockRepository; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.GeneralException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BlockServiceImpl implements BlockService { + private final BlockRepository blockRepository; + private final MemberRepository memberRepository; + + @Transactional + @Override + public BlockResponseDTO.BlockMemberDTO blockMember(Long blockerId, Long blockedId) { + if (blockerId.equals(blockedId)) { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + + Member blocker = memberRepository.findById(blockerId) + .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); + Member blocked = memberRepository.findById(blockedId) + .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); + + + // 이미 차단했는지 확인 + if (blockRepository.existsByBlockerAndBlocked(blocker, blocked)) { + // 이미 차단한 경우, 아무것도 하지 않거나 예외 처리 + return null; + } + + UserRole blockedRole = blocked.getRole(); + String blockedName; + if (blockedRole == UserRole.ADMIN) { + blockedName = blocked.getAdminProfile().getName(); + } else if (blockedRole == UserRole.PARTNER) { + blockedName = blocked.getPartnerProfile().getName(); + } else { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + + Block block = Block.builder() + .blocker(blocker) + .blocked(blocked) + .build(); + + blockRepository.save(block); + + return BlockConverter.toBlockDTO(blockedId, blockedName); + } + + @Override + public BlockResponseDTO.CheckBlockMemberDTO checkBlock(Long blockerId, Long blockedId) { + + Member blocker = memberRepository.findById(blockerId) + .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); + Member blocked = memberRepository.findById(blockedId) + .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); + + UserRole blockedRole = blocked.getRole(); + String blockedName; + if (blockedRole == UserRole.ADMIN) { + blockedName = blocked.getAdminProfile().getName(); + } else if (blockedRole == UserRole.PARTNER) { + blockedName = blocked.getPartnerProfile().getName(); + } else { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + + if (blockRepository.existsBlockRelationBetween(blocker, blocked)) { + return BlockConverter.toCheckBlockDTO(blockedId, blockedName, true); + } + else { + return BlockConverter.toCheckBlockDTO(blockedId, blockedName, false); + } + } + + @Transactional + @Override + public BlockResponseDTO.BlockMemberDTO unblockMember(Long blockerId, Long blockedId) { + Member blocker = memberRepository.findById(blockerId) + .orElseThrow(() -> new GeneralException(ErrorStatus.NO_SUCH_MEMBER)); + Member blocked = memberRepository.findById(blockedId) + .orElseThrow(() -> new GeneralException(ErrorStatus.NO_SUCH_MEMBER)); + + UserRole blockedRole = blocked.getRole(); + String blockedName; + if (blockedRole == UserRole.ADMIN) { + blockedName = blocked.getAdminProfile().getName(); + } else if (blockedRole == UserRole.PARTNER) { + blockedName = blocked.getPartnerProfile().getName(); + } else { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + + // Transactional 환경에서는 Dirty-checking으로 delete 쿼리가 나갑니다. + blockRepository.deleteByBlockerAndBlocked(blocker, blocked); + return BlockConverter.toBlockDTO(blockedId, blockedName); + } + + @Transactional + @Override + public List getMyBlockList(Long blockerId) { + Member blocker = memberRepository.findById(blockerId) + .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST)); + + List blockList = blockRepository.findByBlocker(blocker); + + return BlockConverter.toBlockedMemberListDTO(blockList); + } +} diff --git a/src/main/java/com/assu/server/domain/chat/service/ChatService.java b/src/main/java/com/assu/server/domain/chat/service/ChatService.java index e3499a16..10c84fbb 100644 --- a/src/main/java/com/assu/server/domain/chat/service/ChatService.java +++ b/src/main/java/com/assu/server/domain/chat/service/ChatService.java @@ -1,4 +1,19 @@ package com.assu.server.domain.chat.service; +import com.assu.server.domain.chat.dto.ChatRequestDTO; +import com.assu.server.domain.chat.dto.ChatResponseDTO; +import com.assu.server.domain.chat.dto.ChatRoomListResultDTO; +import com.assu.server.domain.chat.dto.MessageHandlingResult; + +import java.util.List; + public interface ChatService { + List getChatRoomList(Long memberId); + ChatResponseDTO.CreateChatRoomResponseDTO createChatRoom(ChatRequestDTO.CreateChatRoomRequestDTO request, Long memberId); +// ChatResponseDTO.SendMessageResponseDTO handleMessage(ChatRequestDTO.ChatMessageRequestDTO request); + MessageHandlingResult handleMessage(ChatRequestDTO.ChatMessageRequestDTO request); + ChatResponseDTO.ReadMessageResponseDTO readMessage(Long roomId, Long memberId); + ChatResponseDTO.ChatHistoryResponseDTO readHistory(Long roomId, Long memberId); + ChatResponseDTO.LeaveChattingRoomResponseDTO leaveChattingRoom(Long roomId, Long memberId); + ChatResponseDTO.SendMessageResponseDTO sendGuideMessage(ChatRequestDTO.ChatMessageRequestDTO request); } diff --git a/src/main/java/com/assu/server/domain/chat/service/ChatServiceImpl.java b/src/main/java/com/assu/server/domain/chat/service/ChatServiceImpl.java index 4f30c993..c3e25581 100644 --- a/src/main/java/com/assu/server/domain/chat/service/ChatServiceImpl.java +++ b/src/main/java/com/assu/server/domain/chat/service/ChatServiceImpl.java @@ -1,4 +1,273 @@ package com.assu.server.domain.chat.service; -public class ChatServiceImpl { +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.admin.repository.AdminRepository; +import com.assu.server.domain.chat.converter.ChatConverter; +import com.assu.server.domain.chat.dto.*; +import com.assu.server.domain.chat.entity.ChattingRoom; +import com.assu.server.domain.chat.entity.Message; +import com.assu.server.domain.chat.repository.ChatRepository; +import com.assu.server.domain.chat.repository.MessageRepository; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.notification.service.NotificationCommandService; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partner.repository.PartnerRepository; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.repository.StoreRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import com.assu.server.global.util.PresenceTracker; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatServiceImpl implements ChatService { + private final ChatRepository chatRepository; + private final MemberRepository memberRepository; + private final PartnerRepository partnerRepository; + private final AdminRepository adminRepository; + private final MessageRepository messageRepository; + private final StoreRepository storeRepository; + private final SimpMessagingTemplate simpMessagingTemplate; + private final NotificationCommandService notificationCommandService; + private final PresenceTracker presenceTracker; + + + @Override + public List getChatRoomList(Long memberId) { + + List chatRoomList = chatRepository.findChattingRoomsByMemberId(memberId); + return ChatConverter.toChatRoomListResultDTO(chatRoomList); + } + + @Override + public ChatResponseDTO.CreateChatRoomResponseDTO createChatRoom(ChatRequestDTO.CreateChatRoomRequestDTO request, Long memberId) { + + Long adminId = request.getAdminId(); + Long partnerId = request.getPartnerId(); + + Admin admin = adminRepository.findById(adminId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); + Partner partner = partnerRepository.findById(partnerId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + Store store = storeRepository.findByPartnerId(partnerId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + + + if (!store.getPartner().getMember().getId().equals(partner.getMember().getId())) { + throw new DatabaseException(ErrorStatus.NO_SUCH_STORE_WITH_THAT_PARTNER); + } + + boolean isExist = chatRepository.checkChattingRoomByAdminIdAndPartnerId(admin.getId(), partner.getId()); + + if(!isExist) { + ChattingRoom room = ChatConverter.toCreateChattingRoom(admin, partner); + + room.updateStatus(ActivationStatus.ACTIVE); + + room.updateMemberCount(2); + + room.updateName( + partner.getName(), + admin.getName() + ); + ChattingRoom savedRoom = chatRepository.save(room); + return ChatConverter.toCreateChatRoomIdDTO(savedRoom); + } else { + ChattingRoom existChatRoom = chatRepository.findChattingRoomByAdminIdAndPartnerId(admin.getId(), partner.getId()); + return ChatConverter.toEnterChatRoomDTO(existChatRoom); + } + } + +// @Override +// @Transactional +// public ChatResponseDTO.SendMessageResponseDTO handleMessage(ChatRequestDTO.ChatMessageRequestDTO request) { +// // 유효성 검사 +// ChattingRoom room = chatRepository.findById(request.getRoomId()) +// .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ROOM)); +// Member sender = memberRepository.findById(request.getSenderId()) +// .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); +// Member receiver = memberRepository.findById(request.getReceiverId()) +// .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); +// +// Message message = ChatConverter.toMessageEntity(request, room, sender, receiver); +// Message saved = messageRepository.saveAndFlush(message); +// log.info("saved message id={}, roomId={}, senderId={}, receiverId={}", +// saved.getId(), room.getId(), sender.getId(), receiver.getId()); +// +// return ChatConverter.toSendMessageDTO(saved); +// } + + // ChatService의 handleMessage 메서드 (수정) + + @Override + @Transactional + public MessageHandlingResult handleMessage(ChatRequestDTO.ChatMessageRequestDTO request) { + // 1. 유효성 검사 (기존 로직) + ChattingRoom room = chatRepository.findById(request.getRoomId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ROOM)); + Member sender = memberRepository.findById(request.getSenderId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); + Member receiver = memberRepository.findById(request.getReceiverId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); + + // 2. 컨트롤러에서 가져온 비즈니스 로직 (접속 확인) + boolean receiverInRoom = presenceTracker.isInRoom(request.getReceiverId(), request.getRoomId()); + int unreadForSender = receiverInRoom ? 0 : 1; + request.setUnreadCountForSender(unreadForSender); + + // 3. 메시지 저장 (기존 로직) + Message message = ChatConverter.toMessageEntity(request, room, sender, receiver); + Message saved = messageRepository.saveAndFlush(message); + log.info("saved message id={}, roomId={}, senderId={}, receiverId={}", + saved.getId(), room.getId(), sender.getId(), receiver.getId()); + + ChatResponseDTO.SendMessageResponseDTO savedDTO = ChatConverter.toSendMessageDTO(saved); + + // 4. 컨트롤러에서 가져온 비즈니스 로직 (수신자 부재 시) + if (!receiverInRoom) { + // 4-1. 안 읽은 수 계산 + Long totalUnreadCount = messageRepository.countUnreadMessagesByRoomAndReceiver( + request.getRoomId(), + request.getReceiverId() + ); + + // 4-2. 채팅방 목록 업데이트 DTO 생성 + ChatRoomUpdateDTO updateDTO = ChatRoomUpdateDTO.builder() + .roomId(request.getRoomId()) + .lastMessage(savedDTO.message()) + .lastMessageTime(savedDTO.sentAt()) + .unreadCount(totalUnreadCount) + .build(); + + // 4-3. 발신자 이름 찾기 (기존 컨트롤러 로직) + String senderName; + if (sender.getRole() == UserRole.ADMIN) { // 이미 sender 객체가 있으므로 재활용 + senderName = sender.getAdminProfile().getName(); + } else { + senderName = sender.getPartnerProfile().getName(); + } + + // 4-4. 알림 전송 + notificationCommandService.sendChat(request.getReceiverId(), request.getRoomId(), senderName, request.getMessage()); + + // 5. [업데이트 포함] 결과 반환 + return MessageHandlingResult.withUpdates(savedDTO, updateDTO, request.getReceiverId()); + } + + // 5. [일반 메시지] 결과 반환 + return MessageHandlingResult.of(savedDTO); + } + + + @Override + @Transactional + public ChatResponseDTO.SendMessageResponseDTO sendGuideMessage(ChatRequestDTO.ChatMessageRequestDTO request) { + // 유효성 검사 + ChattingRoom room = chatRepository.findById(request.getRoomId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ROOM)); + Member sender = memberRepository.findById(request.getSenderId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); + Member receiver = memberRepository.findById(request.getReceiverId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); + + Message message = ChatConverter.toGuideMessageEntity(request, room, sender, receiver); + Message saved = messageRepository.saveAndFlush(message); + + ChatResponseDTO.SendMessageResponseDTO responseDTO = ChatConverter.toSendMessageDTO(saved); + simpMessagingTemplate.convertAndSend("/sub/chat/" + request.getRoomId(), responseDTO); + + return responseDTO; + } + + @Transactional + @Override + public ChatResponseDTO.ReadMessageResponseDTO readMessage(Long roomId, Long memberId) { + + List unreadMessages = messageRepository.findUnreadMessagesByRoomAndReceiver(roomId, memberId); + List readMessagesIdList = new ArrayList<>(); + + for(Message unreadMessage : unreadMessages) { + readMessagesIdList.add(unreadMessage.getId()); + } + unreadMessages.forEach(Message::markAsRead); + + + return new ChatResponseDTO.ReadMessageResponseDTO(roomId, memberId,readMessagesIdList, unreadMessages.size(), true); + } + + @Override + public ChatResponseDTO.ChatHistoryResponseDTO readHistory(Long roomId, Long memberId) { + + ChattingRoom room = chatRepository.findById(roomId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ROOM)); + + List allMessages = messageRepository.findAllMessagesByRoomAndMemberId(room.getId(), memberId); + + return ChatConverter.toChatHistoryDTO(room.getId(), allMessages); + } + + @Override + public ChatResponseDTO.LeaveChattingRoomResponseDTO leaveChattingRoom(Long roomId, Long memberId) { + // 멤버 조회 + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); + + // 채팅방 조회 + ChattingRoom chattingRoom = chatRepository.findById(roomId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_MEMBER_IN_THE_ROOM)); + + boolean isAdmin = chattingRoom.getAdmin() != null && + chattingRoom.getAdmin().getMember().getId().equals(member.getId()); + boolean isPartner = chattingRoom.getPartner() != null && + chattingRoom.getPartner().getMember().getId().equals(member.getId()); + + int memberCount = chattingRoom.getMemberCount(); + boolean isRoomDeleted = false; + boolean isLeftSuccessfully = false; + + if(memberCount == 2) { + if (isAdmin) { + chattingRoom.setAdmin(null); + } else if (isPartner) { + chattingRoom.setPartner(null); + } else { + throw new DatabaseException(ErrorStatus.NO_SUCH_MEMBER); + } + chattingRoom.updateMemberCount(1); + isLeftSuccessfully = true; + chatRepository.save(chattingRoom); + } else if(memberCount == 1) { + if (isAdmin) { + chattingRoom.setAdmin(null); + } else if (isPartner) { + chattingRoom.setPartner(null); + } + chattingRoom.updateMemberCount(0); + isLeftSuccessfully = true; + + // ✅ 방에 아무도 안 남았을 때만 삭제 + if (chattingRoom.getAdmin() == null && chattingRoom.getPartner() == null) { + isRoomDeleted = true; + chatRepository.delete(chattingRoom); + } else { + chatRepository.save(chattingRoom); + } + + } else if(memberCount == 0) { + throw new DatabaseException(ErrorStatus.NO_MEMBER); + } + return new ChatResponseDTO.LeaveChattingRoomResponseDTO(roomId, isLeftSuccessfully,isRoomDeleted); + } } diff --git a/src/main/java/com/assu/server/domain/common/entity/AdminUser.java b/src/main/java/com/assu/server/domain/common/entity/AdminUser.java new file mode 100644 index 00000000..3b5a5a6d --- /dev/null +++ b/src/main/java/com/assu/server/domain/common/entity/AdminUser.java @@ -0,0 +1,22 @@ +package com.assu.server.domain.common.entity; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.user.entity.Student; +import jakarta.persistence.*; + +@Entity +public class AdminUser extends BaseEntity{ + + @Id + @GeneratedValue + private Long id; + + @ManyToOne + @JoinColumn(name = "admin_id") + private Admin admin; + + @ManyToOne + @JoinColumn(name = "student_id") + private Student student; + +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/common/entity/enums/ReportedStatus.java b/src/main/java/com/assu/server/domain/common/entity/enums/ReportedStatus.java new file mode 100644 index 00000000..52301eee --- /dev/null +++ b/src/main/java/com/assu/server/domain/common/entity/enums/ReportedStatus.java @@ -0,0 +1,14 @@ +package com.assu.server.domain.common.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ReportedStatus { + NORMAL("정상"), + REPORTED("신고됨"), + DELETED("삭제됨"); + + private final String description; +} diff --git a/src/main/java/com/assu/server/domain/common/enums/ActivationStatus.java b/src/main/java/com/assu/server/domain/common/enums/ActivationStatus.java new file mode 100644 index 00000000..efeef3f6 --- /dev/null +++ b/src/main/java/com/assu/server/domain/common/enums/ActivationStatus.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.common.enums; + +public enum ActivationStatus { + ACTIVE, INACTIVE, SUSPEND, BLANK +} diff --git a/src/main/java/com/assu/server/domain/common/enums/UserRole.java b/src/main/java/com/assu/server/domain/common/enums/UserRole.java new file mode 100644 index 00000000..c505d566 --- /dev/null +++ b/src/main/java/com/assu/server/domain/common/enums/UserRole.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.common.enums; + +public enum UserRole { + STUDENT, ADMIN, PARTNER +} diff --git a/src/main/java/com/assu/server/domain/deviceToken/controller/DeviceTokenController.java b/src/main/java/com/assu/server/domain/deviceToken/controller/DeviceTokenController.java new file mode 100644 index 00000000..aed85d24 --- /dev/null +++ b/src/main/java/com/assu/server/domain/deviceToken/controller/DeviceTokenController.java @@ -0,0 +1,48 @@ +package com.assu.server.domain.deviceToken.controller; + +import com.assu.server.domain.deviceToken.service.DeviceTokenService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Device Token", description = "디바이스 토큰 등록/해제 API") +@RestController +@RequestMapping("/device-tokens") +@RequiredArgsConstructor +public class DeviceTokenController { + + private final DeviceTokenService service; + + @Operation( + summary = "Device Token 등록 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/24e1197c19ed8092864ac5a1ddc88d07?source=copy_link)\n" + + "- device Token을 등록하고 등록된 Token의 ID를 반환합니다.\n" + + " - 'token': Request Param, String\n" + ) + @PostMapping + public BaseResponse register(@AuthenticationPrincipal PrincipalDetails pd, + @RequestParam String token) { + Long tokenId = service.register(token, pd.getId()); + return BaseResponse.onSuccess(SuccessStatus._OK, tokenId); + } + @Operation( + summary = "Device Token 등록 해제 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/24e1197c19ed80b8b26be9e01d24c929?source=copy_link)\n" + + "- 로그아웃/탈퇴 시 호출해 device Token 등록을 해제합니다. 자신의 토큰만 해제가 가능합니다.\n"+ + " - 'token-id': Path Variavle, Long\n" + ) + @DeleteMapping("/{token-id}") + public BaseResponse unregister(@AuthenticationPrincipal PrincipalDetails pd, + @PathVariable("token-id") Long tokenId) { + service.unregister(tokenId, pd.getId()); + return BaseResponse.onSuccess( + SuccessStatus._OK, + "Device token unregistered successfully. tokenId=" + tokenId + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/deviceToken/entity/DeviceToken.java b/src/main/java/com/assu/server/domain/deviceToken/entity/DeviceToken.java new file mode 100644 index 00000000..f0ce00f5 --- /dev/null +++ b/src/main/java/com/assu/server/domain/deviceToken/entity/DeviceToken.java @@ -0,0 +1,27 @@ +package com.assu.server.domain.deviceToken.entity; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeviceToken extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name="member_id", nullable=false) + private Member member; + + @Column(nullable=false, length=200) + private String token; + + @Setter + @Column(nullable=false) + private boolean active; +} diff --git a/src/main/java/com/assu/server/domain/deviceToken/repository/DeviceTokenRepository.java b/src/main/java/com/assu/server/domain/deviceToken/repository/DeviceTokenRepository.java new file mode 100644 index 00000000..95c1fcfb --- /dev/null +++ b/src/main/java/com/assu/server/domain/deviceToken/repository/DeviceTokenRepository.java @@ -0,0 +1,29 @@ +package com.assu.server.domain.deviceToken.repository; + +import com.assu.server.domain.deviceToken.entity.DeviceToken; +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.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +public interface DeviceTokenRepository extends JpaRepository { + @Query("select dt.token from DeviceToken dt where dt.member.id =:memberId and dt.active=true") + List findActiveTokensByMemberId(@Param("memberId") Long memberId); + + @Transactional + @Modifying + @Query("update DeviceToken dt set dt.active = false where dt.token in :tokens") + void deactivateTokens(@Param("tokens") List tokens); + + Optional findByToken(String token); + + // 같은 회원 + 같은 토큰 있는지 확인 + Optional findByMemberIdAndToken(Long memberId, String token); + + // 같은 회원이 가진 모든 토큰 (비활성화용) + List findAllByMemberId(Long memberId); +} diff --git a/src/main/java/com/assu/server/domain/deviceToken/service/DeviceTokenService.java b/src/main/java/com/assu/server/domain/deviceToken/service/DeviceTokenService.java new file mode 100644 index 00000000..db415250 --- /dev/null +++ b/src/main/java/com/assu/server/domain/deviceToken/service/DeviceTokenService.java @@ -0,0 +1,6 @@ +package com.assu.server.domain.deviceToken.service; + +public interface DeviceTokenService { + Long register(String tokenId, Long memberId); + void unregister(Long tokenId, Long memberId); +} diff --git a/src/main/java/com/assu/server/domain/deviceToken/service/DeviceTokenServiceImpl.java b/src/main/java/com/assu/server/domain/deviceToken/service/DeviceTokenServiceImpl.java new file mode 100644 index 00000000..fe37ddd4 --- /dev/null +++ b/src/main/java/com/assu/server/domain/deviceToken/service/DeviceTokenServiceImpl.java @@ -0,0 +1,74 @@ +package com.assu.server.domain.deviceToken.service; + +import com.assu.server.domain.deviceToken.entity.DeviceToken; +import com.assu.server.domain.deviceToken.repository.DeviceTokenRepository; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import com.assu.server.global.exception.GeneralException; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DeviceTokenServiceImpl implements DeviceTokenService { + private final DeviceTokenRepository deviceTokenRepository; + private final MemberRepository memberRepository; + + @Transactional + @Override + public Long register(String tokenId, Long memberId) { + Member member = memberRepository.findMemberById(memberId) + .orElseThrow(() -> new GeneralException(ErrorStatus.NO_SUCH_MEMBER)); + + // 1) 같은 회원 + 같은 토큰이 이미 있으면 → active = true 로만 복구 + // (가장 정확한 쿼리: findByMemberIdAndToken) + var sameTokenOpt = deviceTokenRepository.findByMemberIdAndToken(memberId, tokenId); + if (sameTokenOpt.isPresent()) { + DeviceToken exist = sameTokenOpt.get(); + exist.setActive(true); + deviceTokenRepository.save(exist); + return exist.getId(); + } + + // 2) 같은 회원 + 다른 토큰 → 그 회원의 기존 active 토큰 전부 비활성화 + // (현재 보유 메서드 활용: 활성 토큰 문자열 가져와 deactivate) + var activeTokens = deviceTokenRepository.findActiveTokensByMemberId(memberId); + if (!activeTokens.isEmpty()) { + // 현재 등록하려는 tokenId 와 다른 것들만 비활성화 + var toDeactivate = activeTokens.stream() + .filter(t -> !t.equals(tokenId)) + .toList(); + if (!toDeactivate.isEmpty()) { + deviceTokenRepository.deactivateTokens(toDeactivate); + } + } + + // 3) 새 토큰 insert (다른 회원이 같은 토큰을 갖고 있어도 상관 없이 insert) + DeviceToken newToken = DeviceToken.builder() + .member(member) + .token(tokenId) + .active(true) + .build(); + deviceTokenRepository.save(newToken); + + return newToken.getId(); + } + + @Transactional + @Override + public void unregister(Long tokenId, Long memberId) { + deviceTokenRepository.findById(tokenId) + .ifPresentOrElse(deviceToken -> { + if (!deviceToken.getMember().getId().equals(memberId)) { + throw new DatabaseException(ErrorStatus.DEVICE_TOKEN_NOT_OWNED); + } + deviceToken.setActive(false); + }, () -> { + throw new DatabaseException(ErrorStatus.DEVICE_TOKEN_NOT_FOUND); + }); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java b/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java new file mode 100644 index 00000000..93d7f588 --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java @@ -0,0 +1,140 @@ +package com.assu.server.domain.inquiry.controller; + +import com.assu.server.domain.inquiry.dto.profileImage.ProfileImageResponse; +import com.assu.server.domain.inquiry.dto.InquiryAnswerRequestDTO; +import com.assu.server.domain.inquiry.dto.InquiryCreateRequestDTO; +import com.assu.server.domain.inquiry.dto.InquiryResponseDTO; +import com.assu.server.domain.inquiry.service.InquiryService; +import com.assu.server.domain.inquiry.service.ProfileImageService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; + +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +@Tag(name = "MyPage", description = "마이페이지 API") +@RestController +@RequestMapping("/member") +@RequiredArgsConstructor +public class InquiryController { + + private final InquiryService inquiryService; + private final ProfileImageService profileImageService; + + @Operation( + summary = "문의 생성 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/2441197c19ed800688f0cfb304dead63?source=copy_link)\n" + + "- 문의를 생성하고 해당 문의의 id를 반환합니다.\n"+ + " - InquiryCreateRequestDTO: title, content, email\n" + ) + @PostMapping("/inquiries") + public BaseResponse create( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestBody @Valid InquiryCreateRequestDTO req + ) { + Long id = inquiryService.create(req, pd.getId()); + return BaseResponse.onSuccess(SuccessStatus._OK, id); + } + + @Operation( + summary = "문의 목록을 조회하는 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/2441197c19ed803eba4af9598484e5c5?source=copy_link)\n" + + "- 본인의 문의 목록을 상태별로 조회합니다.\n"+ + " - status: Request Param, String, [all/waiting/answered]\n" + + " - page: Request Param, Integer, 1 이상\n" + + " - size: Request Param, Integer, default = 20" + ) + @GetMapping("/inquiries") + public BaseResponse> list( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestParam(defaultValue = "all") String status, // all | waiting | answered + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "20") Integer size + ) { + Map response = inquiryService.getInquiries(status, page, size, pd.getId()); + return BaseResponse.onSuccess(SuccessStatus._OK, response); + } + + /** 단건 상세 조회 */ + @Operation( + summary = "문의 단건 상세 조회 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/24e1197c19ed800f8a1fffc5a101f3c0?source=copy_link)\n" + + "- 본인의 단건 문의를 상세 조회합니다.\n"+ + " - inquiry-id: Path Variable, Long\n" + ) + @GetMapping("/inquiries/{inquiry-id}") + public BaseResponse get( + @AuthenticationPrincipal PrincipalDetails pd, + @PathVariable("inquiry-id") Long inquiryId + ) { + InquiryResponseDTO response = inquiryService.get(inquiryId, pd.getMemberId()); + return BaseResponse.onSuccess(SuccessStatus._OK, response); + } + + /** 문의 답변 (운영자) */ + @Operation( + summary = "운영자 답변 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/24e1197c19ed8064808fcca568b8912a?source=copy_link)\n" + + "- 문의에 답변을 저장하고 상태를 ANSWERED로 변경합니다.\n"+ + " - inquiry-id: Path Variable, Long\n" + ) + @PatchMapping("/inquiries/{inquiry-id}/answer") + public BaseResponse answer( + @PathVariable("inquiry-id") Long inquiryId, + @RequestBody @Valid InquiryAnswerRequestDTO req + ) { + inquiryService.answer(inquiryId, req.getAnswer()); + return BaseResponse.onSuccess(SuccessStatus._OK, "The inquiry answered successfully. id=" + inquiryId); + } + + @Operation( + summary = "프로필 사진 업로드/교체 API", + description = "# [v1.0 (2025-09-15)](https://clumsy-seeder-416.notion.site/26f1197c19ed8031bc50e3571e8ea18f?source=copy_link)\n" + + "- `multipart/form-data`로 프로필 이미지를 업로드합니다.\n" + + "- 기존 이미지가 있으면 S3에서 삭제 후 새 이미지로 교체합니다.\n" + + "- 성공 시 업로드된 이미지 key를 반환합니다." + ) + @PutMapping(value = "/profile/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public BaseResponse uploadOrReplaceProfileImage( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestPart("image") + @Parameter( + description = "프로필 이미지 파일 (jpg/png/webp 등)", + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE, + schema = @Schema(type = "string", format = "binary") + ) + ) + MultipartFile image + ) { + String key = profileImageService.updateProfileImage(pd.getMemberId(), image); + return BaseResponse.onSuccess(SuccessStatus._OK, new ProfileImageResponse(key)); + } + + @Operation( + summary = "프로필 이미지 조회 API", + description = "# [v1.0 (2025-09-18)](https://clumsy-seeder-416.notion.site/2711197c19ed8039bbe2c48380c9f4c8?source=copy_link)\n" + + "- 로그인한 사용자의 프로필 이미지 presigned URL을 반환합니다.\n" + + "- URL은 일정 시간 동안만 유효합니다." + ) + @GetMapping("/profile/image") + public BaseResponse getProfileImage( + @AuthenticationPrincipal PrincipalDetails pd + ) { + String url = profileImageService.getProfileImageUrl(pd.getMemberId()); + return BaseResponse.onSuccess(SuccessStatus._OK, new ProfileImageResponse(url)); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/inquiry/converter/InquiryConverter.java b/src/main/java/com/assu/server/domain/inquiry/converter/InquiryConverter.java new file mode 100644 index 00000000..0a7e98dc --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/converter/InquiryConverter.java @@ -0,0 +1,18 @@ +package com.assu.server.domain.inquiry.converter; + +import com.assu.server.domain.inquiry.dto.InquiryResponseDTO; +import com.assu.server.domain.inquiry.entity.Inquiry; + +public class InquiryConverter { + public static InquiryResponseDTO toDto(Inquiry i) { + return InquiryResponseDTO.builder() + .id(i.getId()) + .title(i.getTitle()) + .content(i.getContent()) + .email(i.getEmail()) + .status(i.getStatus().name()) + .answer(i.getAnswer()) + .createdAt(i.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/assu/server/domain/inquiry/dto/InquiryAnswerRequestDTO.java b/src/main/java/com/assu/server/domain/inquiry/dto/InquiryAnswerRequestDTO.java new file mode 100644 index 00000000..0d2de37e --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/dto/InquiryAnswerRequestDTO.java @@ -0,0 +1,10 @@ +package com.assu.server.domain.inquiry.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class InquiryAnswerRequestDTO { + @NotBlank(message = "answer는 비어 있을 수 없습니다.") + private String answer; +} diff --git a/src/main/java/com/assu/server/domain/inquiry/dto/InquiryCreateRequestDTO.java b/src/main/java/com/assu/server/domain/inquiry/dto/InquiryCreateRequestDTO.java new file mode 100644 index 00000000..24a958b6 --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/dto/InquiryCreateRequestDTO.java @@ -0,0 +1,12 @@ +package com.assu.server.domain.inquiry.dto; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor @Builder +public class InquiryCreateRequestDTO { + private String title; + private String content; + private String email; +} diff --git a/src/main/java/com/assu/server/domain/inquiry/dto/InquiryResponseDTO.java b/src/main/java/com/assu/server/domain/inquiry/dto/InquiryResponseDTO.java new file mode 100644 index 00000000..85bfe0a9 --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/dto/InquiryResponseDTO.java @@ -0,0 +1,30 @@ +package com.assu.server.domain.inquiry.dto; +import com.assu.server.domain.inquiry.entity.Inquiry; +import lombok.*; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InquiryResponseDTO { + private Long id; + private String title; + private String content; + private String email; + private String status; + private String answer; + private LocalDateTime createdAt; + + public static InquiryResponseDTO from(Inquiry inquiry) { + return InquiryResponseDTO.builder() + .id(inquiry.getId()) + .title(inquiry.getTitle()) + .content(inquiry.getContent()) + .email(inquiry.getEmail()) + .status(inquiry.getStatus().name()) + .answer(inquiry.getAnswer()) + .createdAt(inquiry.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/inquiry/dto/profileImage/ProfileImageResponse.java b/src/main/java/com/assu/server/domain/inquiry/dto/profileImage/ProfileImageResponse.java new file mode 100644 index 00000000..e8867f2e --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/dto/profileImage/ProfileImageResponse.java @@ -0,0 +1,12 @@ +package com.assu.server.domain.inquiry.dto.profileImage; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProfileImageResponse { + @Schema(description = "업로드된 프로필 이미지 URL") + private String url; +} diff --git a/src/main/java/com/assu/server/domain/inquiry/entity/Inquiry.java b/src/main/java/com/assu/server/domain/inquiry/entity/Inquiry.java new file mode 100644 index 00000000..3f5da964 --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/entity/Inquiry.java @@ -0,0 +1,50 @@ +package com.assu.server.domain.inquiry.entity; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Inquiry extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(nullable = false, length = 120) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false, length = 120) + private String email; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private Status status; + + @Column(nullable = true, columnDefinition = "TEXT") + private String answer; + + private LocalDateTime answeredAt; + + public enum Status { WAITING, ANSWERED } + + public void answer(String answerText) { + this.answer = answerText; + this.status = Status.ANSWERED; + this.answeredAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/assu/server/domain/inquiry/repository/InquiryRepository.java b/src/main/java/com/assu/server/domain/inquiry/repository/InquiryRepository.java new file mode 100644 index 00000000..32d0e87b --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/repository/InquiryRepository.java @@ -0,0 +1,11 @@ +package com.assu.server.domain.inquiry.repository; + +import com.assu.server.domain.inquiry.entity.Inquiry; +import com.assu.server.domain.inquiry.entity.Inquiry.Status; +import org.springframework.data.domain.*; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InquiryRepository extends JpaRepository { + Page findByMemberId(Long memberId, Pageable pageable); + Page findByMemberIdAndStatus(Long memberId, Status status, Pageable pageable); +} diff --git a/src/main/java/com/assu/server/domain/inquiry/service/InquiryService.java b/src/main/java/com/assu/server/domain/inquiry/service/InquiryService.java new file mode 100644 index 00000000..f267e4a9 --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/service/InquiryService.java @@ -0,0 +1,17 @@ +package com.assu.server.domain.inquiry.service; + +import com.assu.server.domain.inquiry.dto.InquiryCreateRequestDTO; +import com.assu.server.domain.inquiry.dto.InquiryResponseDTO; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Map; + + +public interface InquiryService { + Long create(InquiryCreateRequestDTO req, Long memberId); + Map getInquiries(String status, int page, int size, Long memberId); + InquiryResponseDTO get(Long id, Long memberId); + void answer(Long inquiryId, String answerText); + Page list(String status, Pageable pageable, Long memberId); +} diff --git a/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java b/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java new file mode 100644 index 00000000..e386a363 --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java @@ -0,0 +1,109 @@ +package com.assu.server.domain.inquiry.service; + + +import com.assu.server.domain.inquiry.converter.InquiryConverter; +import com.assu.server.domain.inquiry.dto.InquiryCreateRequestDTO; +import com.assu.server.domain.inquiry.dto.InquiryResponseDTO; +import com.assu.server.domain.inquiry.entity.Inquiry; +import com.assu.server.domain.inquiry.entity.Inquiry.Status; +import com.assu.server.domain.inquiry.repository.InquiryRepository; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class InquiryServiceImpl implements InquiryService { + + private final InquiryRepository inquiryRepository; + private final MemberRepository memberRepository; + + /** 문의 등록 */ + @Transactional + @Override + public Long create(InquiryCreateRequestDTO req, Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); + + Inquiry inquiry = Inquiry.builder() + .member(member) + .title(req.getTitle()) + .content(req.getContent()) + .email(req.getEmail()) + .status(Status.WAITING) + .build(); + + inquiryRepository.save(inquiry); + return inquiry.getId(); + } + + /** 문의 내역 조회 (status=all|waiting|answered) */ + @Transactional(readOnly = true) + @Override + public Map getInquiries(String status, int page, int size, Long memberId) { + if (page < 1) throw new DatabaseException(ErrorStatus.PAGE_UNDER_ONE); + if (size < 1 || size > 200) throw new DatabaseException(ErrorStatus.PAGE_SIZE_INVALID); + + String s = status.toLowerCase(); + if (!s.equals("all") && !s.equals("waiting") && !s.equals("answered")) { + throw new DatabaseException(ErrorStatus.INVALID_INQUIRY_STATUS_FILTER); + } + + Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "id")); + Page p = list(s, pageable, memberId); + + Map body = new LinkedHashMap<>(); + body.put("items", p.getContent()); + body.put("page", p.getNumber() + 1); + body.put("size", p.getSize()); + body.put("totalPages", p.getTotalPages()); + body.put("totalElements", p.getTotalElements()); + return body; + } + + @Override + public Page list(String status, Pageable pageable, Long memberId) { + Page page = switch (status.toLowerCase()) { + case "waiting" -> inquiryRepository.findByMemberIdAndStatus(memberId, Status.WAITING, pageable); + case "answered" -> inquiryRepository.findByMemberIdAndStatus(memberId, Status.ANSWERED, pageable); + case "all" -> inquiryRepository.findByMemberId(memberId, pageable); + default -> throw new DatabaseException(ErrorStatus.INVALID_INQUIRY_STATUS_FILTER); + }; + return page.map(InquiryConverter::toDto); + } + + /** 단건 상세 조회 */ + @Transactional(readOnly = true) + @Override + public InquiryResponseDTO get(Long id, Long memberId) { + Inquiry inquiry = inquiryRepository.findById(id) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_INQUIRY)); + + if (!inquiry.getMember().getId().equals(memberId)) { + throw new DatabaseException(ErrorStatus.FORBIDDEN_INQUIRY); + } + return InquiryConverter.toDto(inquiry); + } + + /** 답변 저장(상태 ANSWERED 전환) */ + @Transactional + @Override + public void answer(Long inquiryId, String answerText) { + Inquiry inquiry = inquiryRepository.findById(inquiryId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_INQUIRY)); + + if (inquiry.getStatus() == Inquiry.Status.ANSWERED) { + throw new DatabaseException(ErrorStatus.ALREADY_ANSWERED); + } + + inquiry.answer(answerText); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageService.java b/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageService.java new file mode 100644 index 00000000..09a22822 --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageService.java @@ -0,0 +1,8 @@ +package com.assu.server.domain.inquiry.service; + +import org.springframework.web.multipart.MultipartFile; + +public interface ProfileImageService { + String updateProfileImage(Long memberId, MultipartFile image); + String getProfileImageUrl(Long memberId); +} diff --git a/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageServiceImpl.java b/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageServiceImpl.java new file mode 100644 index 00000000..89a3b156 --- /dev/null +++ b/src/main/java/com/assu/server/domain/inquiry/service/ProfileImageServiceImpl.java @@ -0,0 +1,71 @@ +package com.assu.server.domain.inquiry.service; + +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.infra.s3.AmazonS3Manager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProfileImageServiceImpl implements ProfileImageService{ + + private static final long MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB + private static final String[] ALLOWED_EXT = {"jpg", "jpeg", "png", "webp"}; + + private final MemberRepository memberRepository; + private final AmazonS3Manager amazonS3Manager; + + @Override + @Transactional + public String updateProfileImage(Long memberId, MultipartFile image) { + if (image == null || image.isEmpty()) { + throw new CustomAuthException(ErrorStatus.PROFILE_IMAGE_NOT_FOUND); + } + + // 1) 멤버 조회 + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER)); + + // 2) 업로드 (generateKeyName + uploadFile 만 사용) + String keyPath = "members/" + member.getId() + "/profile/" + image.getOriginalFilename(); + String keyName = amazonS3Manager.generateKeyName(keyPath); + String uploadedKey = amazonS3Manager.uploadFile(keyName, image); // S3에 올린 후 key 반환 + + // 3) 기존 파일 있으면 삭제 (기존 값이 key 라는 전제) + String oldKey = member.getProfileUrl(); + if (oldKey != null && !oldKey.isBlank()) { + try { amazonS3Manager.deleteFile(oldKey); } + catch (Exception e) { log.warn("이전 프로필 삭제 실패 key={}", oldKey, e); } + } + + // 4) DB 업데이트 (key 저장) + member.setProfileUrl(uploadedKey); + + // 5) 호출자에 key 반환 (FE는 필요 시 presigned URL 생성해 사용) + return uploadedKey; + } + + @Override + @Transactional(readOnly = true) + public String getProfileImageUrl(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomAuthException(ErrorStatus.NO_SUCH_MEMBER)); + + String key = member.getProfileUrl(); // DB에 저장된 경로 (ex. members/17/profile/aaa.png) + + if (key == null || key.isBlank()) { + throw new CustomAuthException(ErrorStatus.PROFILE_IMAGE_NOT_FOUND); + } + + // S3 주소 리턴 + return "https://assu-bucket.s3.ap-northeast-2.amazonaws.com/" + key; + } +} + diff --git a/src/main/java/com/assu/server/domain/map/controller/MapController.java b/src/main/java/com/assu/server/domain/map/controller/MapController.java new file mode 100644 index 00000000..e22be087 --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/controller/MapController.java @@ -0,0 +1,90 @@ +package com.assu.server.domain.map.controller; + +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.map.dto.MapRequestDTO; +import com.assu.server.domain.map.dto.MapResponseDTO; +import com.assu.server.domain.map.service.MapService; +import com.assu.server.domain.map.service.PlaceSearchService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/map") +public class MapController { + + private final MapService mapService; + private final PlaceSearchService placeSearchService; + + @Operation( + summary = "주변 장소 조회 API", + description = "공간 인덱싱에 들어갈 좌표 4개를 경도, 위도 순서로 입력해주세요 (user -> store 조회 / admin -> partner 조회 / partner -> admin 조회)" + ) + @GetMapping("/nearby") + public BaseResponse getLocations( + @ModelAttribute MapRequestDTO.ViewOnMapDTO viewport, + @AuthenticationPrincipal PrincipalDetails pd + ) { + Long memberId = pd.getMember().getId(); + UserRole role = pd.getMember().getRole(); + + return switch (role) { + case STUDENT -> BaseResponse.onSuccess(SuccessStatus._OK, mapService.getStores(viewport, memberId)); + case ADMIN -> BaseResponse.onSuccess(SuccessStatus._OK, mapService.getPartners(viewport, memberId)); + case PARTNER -> BaseResponse.onSuccess(SuccessStatus._OK, mapService.getAdmins(viewport, memberId)); + default -> BaseResponse.onFailure(ErrorStatus._BAD_REQUEST, null); + }; + } + + @Operation( + summary = "검색어 기반 장소 조회 API", + description = "검색어를 입력해주세요. (user → store 전체조회 / admin → partner 전체조회 / partner → admin 전체조회)" + ) + @GetMapping("/search") + public BaseResponse getLocationsByKeyword( + @RequestParam("searchKeyword") @NotNull String keyword, + @AuthenticationPrincipal PrincipalDetails pd + ) { + Long memberId = pd.getMember().getId(); + UserRole role = pd.getMember().getRole(); + + return switch (role) { + case STUDENT -> { + List list = mapService.searchStores(keyword); + yield BaseResponse.onSuccess(SuccessStatus._OK, list); + } + case ADMIN -> { + List list = mapService.searchPartner(keyword, memberId); + yield BaseResponse.onSuccess(SuccessStatus._OK, list); + } + case PARTNER -> { + List list = mapService.searchAdmin(keyword, memberId); + yield BaseResponse.onSuccess(SuccessStatus._OK, list); + } + default -> BaseResponse.onFailure(ErrorStatus._BAD_REQUEST, null); + }; + } + + @Operation( + summary = "주소 입력 시 장소 검색용 API", + description = "검색어를 기반으로 장소를 검색하여 리스트로 반환합니다. limit로 개수를 제한할 수 있습니다." + ) + @GetMapping("/place") + public BaseResponse> search( + @RequestParam("searchKeyword") String query, + @RequestParam(value = "limit", required = false) Integer size + ) { + List list = placeSearchService.unifiedSearch(query, size); + return BaseResponse.onSuccess(SuccessStatus._OK, list); + } + +} diff --git a/src/main/java/com/assu/server/domain/map/converter/MapConverter.java b/src/main/java/com/assu/server/domain/map/converter/MapConverter.java new file mode 100644 index 00000000..1d1a9af6 --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/converter/MapConverter.java @@ -0,0 +1,74 @@ +package com.assu.server.domain.map.converter; + +import java.util.List; + +import com.assu.server.domain.partnership.entity.Goods; +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; + +public class MapConverter { + + + + + private static List extractGoods(PaperContent content) { + if (content.getOptionType() == OptionType.SERVICE ) { + return content.getGoods().stream() + .map(Goods::getBelonging) + .toList(); + } + return null; + } + + private static Integer extractPeople(PaperContent content) { + if (content.getCriterionType() == CriterionType.HEADCOUNT) { + return content.getPeople(); + } + return null; + } + + private static String buildPaperContentText(PaperContent content, List goodsList, Integer peopleValue) { + String result = ""; + + boolean isGoodsSingle = goodsList != null && goodsList.size() == 1; + boolean isGoodsMultiple = goodsList != null && goodsList.size() > 1; + + // 1. HEADCOUNT + SERVICE + 여러 개 goods + if (content.getCriterionType() == CriterionType.HEADCOUNT && + content.getOptionType() == OptionType.SERVICE && + isGoodsMultiple) { + result = peopleValue + "명 이상 식사 시 " + content.getCategory() + " 제공"; + } + // 2. HEADCOUNT + SERVICE + 단일 goods + else if (content.getCriterionType() == CriterionType.HEADCOUNT && + content.getOptionType() == OptionType.SERVICE && + isGoodsSingle) { + result = peopleValue + "명 이상 식사 시 " + goodsList.get(0) + " 제공"; + } + // 3. HEADCOUNT + DISCOUNT + else if (content.getCriterionType() == CriterionType.HEADCOUNT && + content.getOptionType() == OptionType.DISCOUNT) { + result = peopleValue + "명 이상 식사 시 " + content.getDiscount() + "% 할인"; + } + // 4. PRICE + SERVICE + 여러 개 goods + else if (content.getCriterionType() == CriterionType.PRICE && + content.getOptionType() == OptionType.SERVICE && + isGoodsMultiple) { + result = content.getCost() + "원 이상 주문 시 " + content.getCategory() + " 제공"; + } + // 5. PRICE + SERVICE + 단일 goods + else if (content.getCriterionType() == CriterionType.PRICE && + content.getOptionType() == OptionType.SERVICE && + isGoodsSingle) { + result = content.getCost() + "원 이상 주문 시 " + goodsList.get(0) + " 제공"; + } + // 6. PRICE + DISCOUNT + else if (content.getCriterionType() == CriterionType.PRICE && + content.getOptionType() == OptionType.DISCOUNT) { + result = content.getCost() + "원 이상 주문 시 " + content.getDiscount() + "% 할인"; + } + + return result; + } +} diff --git a/src/main/java/com/assu/server/domain/map/dto/MapRequestDTO.java b/src/main/java/com/assu/server/domain/map/dto/MapRequestDTO.java new file mode 100644 index 00000000..6b97d05c --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/dto/MapRequestDTO.java @@ -0,0 +1,30 @@ +package com.assu.server.domain.map.dto; + +import lombok.*; + +public class MapRequestDTO { + + @Getter + @Setter + @NoArgsConstructor + public static class ViewOnMapDTO { + private double lng1; + private double lat1; + private double lng2; + private double lat2; + private double lng3; + private double lat3; + private double lng4; + private double lat4; + } + + @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder + public static class ConfirmRequest { + private String placeId; + private String name; + private String address; // 지번 + private String roadAddress; // 도로명 + private Double longitude; // x + private Double latitude; // y + } +} diff --git a/src/main/java/com/assu/server/domain/map/dto/MapResponseDTO.java b/src/main/java/com/assu/server/domain/map/dto/MapResponseDTO.java new file mode 100644 index 00000000..23d63f46 --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/dto/MapResponseDTO.java @@ -0,0 +1,86 @@ +package com.assu.server.domain.map.dto; + +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; +import lombok.*; + +import java.time.LocalDate; +import java.util.List; + +public class MapResponseDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class PartnerMapResponseDTO { + private Long partnerId; + private String name; + private String address; + private boolean isPartnered; + private Long partnershipId; + private LocalDate partnershipStartDate; + private LocalDate partnershipEndDate; + private Double latitude; + private Double longitude; + private String profileUrl; + private String phoneNumber; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AdminMapResponseDTO { + private Long adminId; + private String name; + private String address; + private boolean isPartnered; + private Long partnershipId; + private LocalDate partnershipStartDate; + private LocalDate partnershipEndDate; + private Double latitude; + private Double longitude; + private String profileUrl; + private String phoneNumber; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class StoreMapResponseDTO { + private Long storeId; + private Long adminId; + private String adminName; + private String name; + private String address; + private Integer rate; + private CriterionType criterionType; + private OptionType optionType; + private Integer people; + private Long cost; + private String category; + private String note; + private Long discountRate; + private boolean hasPartner; + private Double latitude; + private Double longitude; + private String profileUrl; + private String phoneNumber; + } + + @Getter @NoArgsConstructor @AllArgsConstructor @Builder + public static class PlaceSuggestionDTO { + private String placeId; // kakao place id + private String name; // place_name + private String category; // category_name or category_group_name + private String address; // 지번 주소 + private String roadAddress; // 도로명 주소 + private String phone; // 전화 + private String placeUrl; // 카카오 상세 URL + private Double latitude; // y + private Double longitude; // x + private Integer distance; // m (좌표바이어스/카테고리 검색 시 제공) + } +} diff --git a/src/main/java/com/assu/server/domain/map/dto/SelectedPlacePayload.java b/src/main/java/com/assu/server/domain/map/dto/SelectedPlacePayload.java new file mode 100644 index 00000000..fc2119ca --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/dto/SelectedPlacePayload.java @@ -0,0 +1,18 @@ +package com.assu.server.domain.map.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SelectedPlacePayload { + + private String placeId; + private String name; + private String address; + private String roadAddress; + private Double latitude; + private Double longitude; +} diff --git a/src/main/java/com/assu/server/domain/map/service/MapService.java b/src/main/java/com/assu/server/domain/map/service/MapService.java new file mode 100644 index 00000000..24482939 --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/service/MapService.java @@ -0,0 +1,16 @@ +package com.assu.server.domain.map.service; + +import com.assu.server.domain.map.dto.MapRequestDTO; +import com.assu.server.domain.map.dto.MapResponseDTO; + +import java.util.List; + +public interface MapService { + List getAdmins(MapRequestDTO.ViewOnMapDTO viewport, Long memberId); + List getPartners(MapRequestDTO.ViewOnMapDTO viewport, Long memberId); + List getStores(MapRequestDTO.ViewOnMapDTO viewport, Long memberId); + + List searchStores(String keyword); + List searchPartner(String keyword, Long memberId); + List searchAdmin(String keyword, Long memberId); +} diff --git a/src/main/java/com/assu/server/domain/map/service/MapServiceImpl.java b/src/main/java/com/assu/server/domain/map/service/MapServiceImpl.java new file mode 100644 index 00000000..2e936056 --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/service/MapServiceImpl.java @@ -0,0 +1,321 @@ +package com.assu.server.domain.map.service; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.admin.repository.AdminRepository; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.map.dto.MapRequestDTO; +import com.assu.server.domain.map.dto.MapResponseDTO; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partner.repository.PartnerRepository; +import com.assu.server.domain.partnership.entity.Goods; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; +import com.assu.server.domain.partnership.repository.GoodsRepository; +import com.assu.server.domain.partnership.repository.PaperContentRepository; +import com.assu.server.domain.partnership.repository.PaperRepository; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.repository.StoreRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.config.KakaoLocalClient; +import com.assu.server.global.exception.DatabaseException; +import com.assu.server.global.exception.GeneralException; + +import com.assu.server.infra.s3.AmazonS3Manager; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.auth.scheme.internal.S3EndpointResolverAware; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class MapServiceImpl implements MapService { + + private final AdminRepository adminRepository; + private final PartnerRepository partnerRepository; + private final StoreRepository storeRepository; + private final PaperContentRepository paperContentRepository; + private final PaperRepository paperRepository; + private final GeometryFactory geometryFactory; + private final GoodsRepository goodsRepository; + private final AmazonS3Manager amazonS3Manager; + + @Override + public List getPartners(MapRequestDTO.ViewOnMapDTO viewport, Long memberId) { + + String wkt = toWKT(viewport); + List partners = partnerRepository.findAllWithinViewport(wkt); + + return partners.stream().map(p -> { + Paper active = paperRepository.findTopByAdmin_IdAndPartner_IdAndIsActivatedOrderByIdDesc(memberId, p.getId(), ActivationStatus.ACTIVE) + .orElse(null); + + String key = (p.getMember() != null) ? p.getMember().getProfileUrl() : null; + String url = amazonS3Manager.generatePresignedUrl(key); + + return MapResponseDTO.PartnerMapResponseDTO.builder() + .partnerId(p.getId()) + .name(p.getName()) + .address(p.getAddress() != null ? p.getAddress() : p.getDetailAddress()) + .isPartnered(active != null) + .partnershipId(active != null ? active.getId() : null) + .partnershipStartDate(active != null ? active.getPartnershipPeriodStart() : null) + .partnershipEndDate(active != null ? active.getPartnershipPeriodEnd() : null) + .latitude(p.getLatitude()) + .longitude(p.getLongitude()) + .profileUrl(url) + .phoneNumber(p.getMember().getPhoneNum()) + .build(); + }).toList(); + } + + @Override + public List getAdmins(MapRequestDTO.ViewOnMapDTO viewport, Long memberId) { + String wkt = toWKT(viewport); + List admins = adminRepository.findAllWithinViewport(wkt); + + return admins.stream().map(a -> { + Paper active = paperRepository.findTopByAdmin_IdAndPartner_IdAndIsActivatedOrderByIdDesc(a.getId(), memberId, ActivationStatus.ACTIVE) + .orElse(null); + + String key = (a.getMember() != null) ? a.getMember().getProfileUrl() : null; + String url = amazonS3Manager.generatePresignedUrl(key); + + return MapResponseDTO.AdminMapResponseDTO.builder() + .adminId(a.getId()) + .name(a.getName()) + .address(a.getOfficeAddress() != null ? a.getOfficeAddress() : a.getDetailAddress()) + .isPartnered(active != null) + .partnershipId(active != null ? active.getId() : null) + .partnershipStartDate(active != null ? active.getPartnershipPeriodStart() : null) + .partnershipEndDate(active != null ? active.getPartnershipPeriodEnd() : null) + .latitude(a.getLatitude()) + .longitude(a.getLongitude()) + .profileUrl(url) + .phoneNumber(a.getMember().getPhoneNum()) + .build(); + }).toList(); + } + + @Override + public List getStores(MapRequestDTO.ViewOnMapDTO viewport, Long memberId) { + final String wkt = toWKT(viewport); + + // 1) 뷰포트 내 매장 조회 + final List stores = storeRepository.findAllWithinViewport(wkt); + + // 2) 매장별 content는 "있으면 사용, 없으면 null" 전략 + return stores.stream().map(s -> { + final boolean hasPartner = (s.getPartner() != null); + + // 2-1) 유효한 paper_content만 조회 (없으면 null 허용) + final PaperContent content = paperContentRepository.findLatestValidByStoreIdNative( + s.getId(), + ActivationStatus.ACTIVE.name(), + OptionType.SERVICE.name(), + OptionType.DISCOUNT.name(), + CriterionType.PRICE.name(), + CriterionType.HEADCOUNT.name() + ).orElse(null); + + // 2-2) admin 정보 (null-safe) + final Long adminId = paperRepository.findTopPaperByStoreId(s.getId()) + .map(p -> p.getAdmin() != null ? p.getAdmin().getId() : null) + .orElse(null); + + String adminName = null; + if (adminId != null) { + final Admin admin = adminRepository.findById(adminId).orElse(null); + adminName = (admin != null ? admin.getName() : null); + } + + // 2-3) S3 presigned URL (키가 없으면 null) + final String key = (s.getPartner() != null && s.getPartner().getMember() != null) + ? s.getPartner().getMember().getProfileUrl() + : null; + final String profileUrl = (key != null ? amazonS3Manager.generatePresignedUrl(key) : null); + + // phoneNumber null-safe 처리 (빈 문자열로 변환) + final String phoneNumber = (s.getPartner() != null + && s.getPartner().getMember() != null + && s.getPartner().getMember().getPhoneNum() != null) + ? s.getPartner().getMember().getPhoneNum() + : ""; + + // 2-4) DTO 빌드 (content null 허용) + return MapResponseDTO.StoreMapResponseDTO.builder() + .storeId(s.getId()) + .adminId(adminId) + .adminName(adminName) + .name(s.getName()) + .address(s.getAddress() != null ? s.getAddress() : s.getDetailAddress()) + .rate(s.getRate()) + .criterionType(content != null ? content.getCriterionType() : null) + .optionType(content != null ? content.getOptionType() : null) + .people(content != null ? content.getPeople() : null) + .cost(content != null ? content.getCost() : null) + .category(content != null ? content.getCategory() : null) + .discountRate(content != null ? content.getDiscount() : null) + .hasPartner(hasPartner) + .latitude(s.getLatitude()) + .longitude(s.getLongitude()) + .profileUrl(profileUrl) + .phoneNumber(phoneNumber) + .build(); + }).toList(); + } + + @Override + public List searchStores(String keyword) { + List stores = storeRepository.findByNameContainingIgnoreCaseOrderByIdDesc(keyword); + + return stores.stream().map(s -> { + boolean hasPartner = s.getPartner() != null; + PaperContent content = paperContentRepository.findTopByPaperStoreIdOrderByIdDesc(s.getId()) + .orElse(null); + + String key = (s.getPartner() != null) ? s.getPartner().getMember().getProfileUrl() : null; + String url = amazonS3Manager.generatePresignedUrl(key); + + Long adminId = paperRepository.findTopPaperByStoreId(s.getId()) + .map(p -> p.getAdmin() != null ? p.getAdmin().getId() : null) + .orElse(null); + + Admin admin = adminRepository.findById(adminId).orElse(null); + + String finalCategory = null; + + if (content != null) { + // 2. content에 카테고리가 이미 존재하면 그 값을 사용합니다. + if (content.getCategory() != null) { + finalCategory = content.getCategory(); + } + // 3. 카테고리가 없고, 옵션 타입이 SERVICE인 경우 Goods를 조회합니다. + else if (content.getOptionType() == OptionType.SERVICE) { + List goods = goodsRepository.findByContentId(content.getId()); + + // 4. (가장 중요) goods 리스트가 비어있지 않은지 반드시 확인합니다. + if (!goods.isEmpty()) { + finalCategory = goods.get(0).getBelonging(); + } + // goods가 비어있으면 finalCategory는 그대로 null로 유지됩니다. + } + } + + // phoneNumber null-safe 처리 (빈 문자열로 변환) + String phoneNumber = (s.getPartner() != null + && s.getPartner().getMember() != null + && s.getPartner().getMember().getPhoneNum() != null) + ? s.getPartner().getMember().getPhoneNum() + : ""; + + return MapResponseDTO.StoreMapResponseDTO.builder() + .storeId(s.getId()) + .adminName(admin != null ? admin.getName() : null) + .adminId(adminId) + .name(s.getName()) + .note(content.getNote()) + .address(s.getAddress() != null ? s.getAddress() : s.getDetailAddress()) + .rate(s.getRate()) + .criterionType(content != null ? content.getCriterionType() : null) + .optionType(content != null ? content.getOptionType() : null) + .people(content != null ? content.getPeople() : null) + .cost(content != null ? content.getCost() : null) + .category(finalCategory) + .discountRate(content != null ? content.getDiscount() : null) + .hasPartner(hasPartner) + .latitude(s.getLatitude()) + .longitude(s.getLongitude()) + .profileUrl(url) + .phoneNumber(phoneNumber) + .build(); + }).toList(); + } + + @Override + public List searchPartner(String keyword, Long memberId) { + List partners = partnerRepository.searchPartnerByKeyword(keyword); + + return partners.stream().map(p -> { + Paper active = paperRepository + .findTopByAdmin_IdAndPartner_IdAndIsActivatedOrderByIdDesc(memberId, p.getId(), ActivationStatus.ACTIVE) + .orElse(null); + + String key = (p.getMember() != null) ? p.getMember().getProfileUrl() : null; + String url = amazonS3Manager.generatePresignedUrl(key); + + return MapResponseDTO.PartnerMapResponseDTO.builder() + .partnerId(p.getId()) + .name(p.getName()) + .address(p.getAddress() != null ? p.getAddress() : p.getDetailAddress()) + .isPartnered(active != null) + .partnershipId(active != null ? active.getId() : null) + .partnershipStartDate(active != null ? active.getPartnershipPeriodStart() : null) + .partnershipEndDate(active != null ? active.getPartnershipPeriodEnd() : null) + .latitude(p.getLatitude()) + .longitude(p.getLongitude()) + .profileUrl(url) + .phoneNumber(p.getMember().getPhoneNum()) + .build(); + }).toList(); + } + + @Override + public List searchAdmin(String keyword, Long memberId) { + List admins = adminRepository.searchAdminByKeyword(keyword); + + return admins.stream().map(a -> { + Paper active = paperRepository + .findTopByAdmin_IdAndPartner_IdAndIsActivatedOrderByIdDesc(a.getId(), memberId, ActivationStatus.ACTIVE) + .orElse(null); + + String key = (a.getMember() != null) ? a.getMember().getProfileUrl() : null; + String url = amazonS3Manager.generatePresignedUrl(key); + + return MapResponseDTO.AdminMapResponseDTO.builder() + .adminId(a.getId()) + .name(a.getName()) + .address(a.getOfficeAddress() != null ? a.getOfficeAddress() : a.getDetailAddress()) + .isPartnered(active != null) + .partnershipId(active != null ? active.getId() : null) + .partnershipStartDate(active != null ? active.getPartnershipPeriodStart() : null) + .partnershipEndDate(active != null ? active.getPartnershipPeriodEnd() : null) + .latitude(a.getLatitude()) + .longitude(a.getLongitude()) + .profileUrl(url) + .phoneNumber(a.getMember().getPhoneNum()) + .build(); + }).toList(); + } + + private String toWKT(MapRequestDTO.ViewOnMapDTO v) { + return String.format( + "POLYGON((%f %f, %f %f, %f %f, %f %f, %f %f))", + v.getLng1(), v.getLat1(), + v.getLng2(), v.getLat2(), + v.getLng3(), v.getLat3(), + v.getLng4(), v.getLat4(), + v.getLng1(), v.getLat1() + ); + } + + private Point toPoint(Double lng, Double lat) { + if (lng == null || lat == null) return null; + Point p = geometryFactory.createPoint(new Coordinate(lng, lat)); + p.setSRID(4326); + return p; + } + + private String pickDisplayAddress(String road, String jibun) { + return (road != null && !road.isBlank()) ? road : jibun; + } +} diff --git a/src/main/java/com/assu/server/domain/map/service/PlaceSearchService.java b/src/main/java/com/assu/server/domain/map/service/PlaceSearchService.java new file mode 100644 index 00000000..f16f4fd2 --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/service/PlaceSearchService.java @@ -0,0 +1,10 @@ +package com.assu.server.domain.map.service; + +import com.assu.server.domain.map.dto.MapResponseDTO; + +import java.util.List; + +public interface PlaceSearchService { + + List unifiedSearch(String query, Integer size); +} diff --git a/src/main/java/com/assu/server/domain/map/service/PlaceSearchServiceImpl.java b/src/main/java/com/assu/server/domain/map/service/PlaceSearchServiceImpl.java new file mode 100644 index 00000000..243d509a --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/service/PlaceSearchServiceImpl.java @@ -0,0 +1,98 @@ +package com.assu.server.domain.map.service; + +import com.assu.server.domain.map.dto.MapResponseDTO; +import com.assu.server.global.config.KakaoLocalClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +public class PlaceSearchServiceImpl implements PlaceSearchService { + + private final KakaoLocalClient kakaoLocalClient; + private static final int NEARBY_DEFAULT_RADIUS = 500; + + @Override + public List unifiedSearch(String query, Integer size) { + int kSize = (size == null ? 15 : size); + + // 1) 주소로도 시도 → 좌표 얻기 (성공/실패 무관) + Double x = null, y = null; // 경도/위도 + KakaoLocalClient.KakaoAddressResp addrResp = kakaoLocalClient.searchByAddress(query, 1, 5); + if (addrResp != null && addrResp.getDocuments() != null && !addrResp.getDocuments().isEmpty()) { + var d = addrResp.getDocuments().get(0); + // road_address 우선, 없으면 address + String sx = d.getRoad_address() != null ? d.getRoad_address().getX() + : (d.getAddress() != null ? d.getAddress().getX() : d.getX()); + String sy = d.getRoad_address() != null ? d.getRoad_address().getY() + : (d.getAddress() != null ? d.getAddress().getY() : d.getY()); + if (sx != null && sy != null) { + try { + x = Double.parseDouble(sx); + y = Double.parseDouble(sy); + } catch (NumberFormatException ignore) {} + } + } + + // 2) 키워드 검색 (좌표가 있으면 바이어스) + var kw = kakaoLocalClient.searchByKeyword(query, x, y, null, 1, kSize); + List kwList = convertKeyword(kw); + + // 3) 좌표가 있으면 카테고리 근접 검색 보강 (음식점/카페 등) + List nearby = Collections.emptyList(); + if (x != null && y != null) { + List cats = List.of("FD6", "CE7"); // 음식점/카페 (필요시 카테고리 추가) + List merged = new ArrayList<>(); + for (String c : cats) { + var r = kakaoLocalClient.searchByCategory(c, x, y, NEARBY_DEFAULT_RADIUS, 1, kSize); + merged.addAll(convertKeyword(r)); + } + // 거리 오름차순 + merged.sort(Comparator.comparing(dto -> Optional.ofNullable(dto.getDistance()).orElse(Integer.MAX_VALUE))); + nearby = merged; + } + + // 4) 결과 합치기 (키워드 우선 → 근접 결과 추가, id로 dedupe) + Map dedupe = new LinkedHashMap<>(); + Stream.concat(kwList.stream(), nearby.stream()) + .forEach(dto -> dedupe.putIfAbsent(dto.getPlaceId(), dto)); + + // 최종 상위 size 개 제한 + return dedupe.values().stream().limit(kSize).toList(); + } + + private List convertKeyword(KakaoLocalClient.KakaoKeywordResp resp) { + if (resp == null || resp.getDocuments() == null) return List.of(); + List out = new ArrayList<>(); + for (var d : resp.getDocuments()) { + Double x = safeParse(d.getX()); + Double y = safeParse(d.getY()); + Integer dist = safeParseInt(d.getDistance()); // null 가능 + out.add(MapResponseDTO.PlaceSuggestionDTO.builder() + .placeId(d.getId()) + .name(d.getPlace_name()) + .category(d.getCategory_group_name() != null ? d.getCategory_group_name() : d.getCategory_name()) + .address(d.getAddress_name()) + .roadAddress(d.getRoad_address_name()) + .phone(d.getPhone()) + .placeUrl(d.getPlace_url()) + .longitude(x) + .latitude(y) + .distance(dist) + .build()); + } + return out; + } + + private Double safeParse(String s) { + if (s == null) return null; + try { return Double.parseDouble(s); } catch (NumberFormatException e) { return null; } + } + private Integer safeParseInt(String s) { + if (s == null) return null; + try { return Integer.parseInt(s); } catch (NumberFormatException e) { return null; } + } +} diff --git a/src/main/java/com/assu/server/domain/mapping/controller/StudentAdminController.java b/src/main/java/com/assu/server/domain/mapping/controller/StudentAdminController.java new file mode 100644 index 00000000..9487feb1 --- /dev/null +++ b/src/main/java/com/assu/server/domain/mapping/controller/StudentAdminController.java @@ -0,0 +1,76 @@ +package com.assu.server.domain.mapping.controller; + +import com.assu.server.domain.mapping.dto.StudentAdminResponseDTO; +import com.assu.server.domain.mapping.service.StudentAdminService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin/dashBoard") +public class StudentAdminController { + private final StudentAdminService studentAdminService; + @Operation( + summary = "누적 가입자 수 조회 API", + description = "admin으로 접근해주세요." + ) + @GetMapping + public BaseResponse getCountAdmin( + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, studentAdminService.getCountAdminAuth(pd.getId())); + } + @Operation( + summary = "신규 한 달 가입자 수 조회 API", + description = "admin으로 접근해주세요." + ) + @GetMapping("/new") + public BaseResponse getNewStudentCountAdmin( + @AuthenticationPrincipal PrincipalDetails pd + ){ + return BaseResponse.onSuccess(SuccessStatus._OK, studentAdminService.getNewStudentCountAdmin(pd.getId())); + } + + @Operation( + summary = "오늘 제휴 사용자 수 조회 API", + description = "admin으로 접근해주세요." + ) + @GetMapping("/countUser") + public BaseResponse getCountUser( + @AuthenticationPrincipal PrincipalDetails pd + ){ + return BaseResponse.onSuccess(SuccessStatus._OK, studentAdminService.getCountUsagePerson(pd.getId())); + } + @Operation( + summary = "제휴업체 누적별 1위 업체 조회 API", + description = "adminId로 접근해주세요." + ) + @GetMapping("/top") + public BaseResponse getTopUsage( + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, studentAdminService.getCountUsage(pd.getId())); + } + + /** + * 제휴 업체별 누적 제휴 이용 현황 리스트 반환 (사용량 내림차순) + */ + @Operation( + summary = "제휴업체 누적 사용 수 내림차순 조회 API", + description = "adminId로 접근해주세요." + ) + @GetMapping("/usage") + public BaseResponse getUsageList( + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, studentAdminService.getCountUsageList(pd.getId())); + } + +} diff --git a/src/main/java/com/assu/server/domain/mapping/converter/StudentAdminConverter.java b/src/main/java/com/assu/server/domain/mapping/converter/StudentAdminConverter.java new file mode 100644 index 00000000..1af32e80 --- /dev/null +++ b/src/main/java/com/assu/server/domain/mapping/converter/StudentAdminConverter.java @@ -0,0 +1,50 @@ +package com.assu.server.domain.mapping.converter; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.mapping.dto.StudentAdminResponseDTO; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.user.entity.PartnershipUsage; + +import java.util.List; + +public class StudentAdminConverter { + + public static StudentAdminResponseDTO.CountAdminAuthResponseDTO countAdminAuthDTO(Long adminId, Long total, String adminName) { + return StudentAdminResponseDTO.CountAdminAuthResponseDTO.builder() + .adminId(adminId) + .studentCount(total) + .adminName(adminName) + .build(); + } + + public static StudentAdminResponseDTO.NewCountAdminResponseDTO newCountAdminResponseDTO(Long adminId, Long total, String adminName){ + return StudentAdminResponseDTO.NewCountAdminResponseDTO.builder() + .adminId(adminId) + .newStudentCount(total) + .adminName(adminName) + .build(); + } + //오늘 사용자수 + public static StudentAdminResponseDTO.CountUsagePersonResponseDTO countUsagePersonDTO(Long adminId, Long total, String adminName){ + return StudentAdminResponseDTO.CountUsagePersonResponseDTO.builder() + .adminId(adminId) + .usagePersonCount(total) + .adminName(adminName) + .build(); + } + //업체별 누적 사용건수 + public static StudentAdminResponseDTO.CountUsageResponseDTO countUsageResponseDTO(Admin admin, Paper paper, Long total) { + return StudentAdminResponseDTO.CountUsageResponseDTO.builder() + .usageCount(total) + .adminId(admin.getId()) + .adminName(admin.getName()) + .storeId(paper.getStore().getId()) + .storeName(paper.getStore().getName()) + .build(); + } + public static StudentAdminResponseDTO.CountUsageListResponseDTO countUsageListResponseDTO(List countUsageList) { + return StudentAdminResponseDTO.CountUsageListResponseDTO.builder() + .items(countUsageList) + .build(); + } +} diff --git a/src/main/java/com/assu/server/domain/mapping/dto/StudentAdminRequestDTO.java b/src/main/java/com/assu/server/domain/mapping/dto/StudentAdminRequestDTO.java new file mode 100644 index 00000000..b4f2c060 --- /dev/null +++ b/src/main/java/com/assu/server/domain/mapping/dto/StudentAdminRequestDTO.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.mapping.dto; + +public class StudentAdminRequestDTO { + +} diff --git a/src/main/java/com/assu/server/domain/mapping/dto/StudentAdminResponseDTO.java b/src/main/java/com/assu/server/domain/mapping/dto/StudentAdminResponseDTO.java new file mode 100644 index 00000000..11145298 --- /dev/null +++ b/src/main/java/com/assu/server/domain/mapping/dto/StudentAdminResponseDTO.java @@ -0,0 +1,60 @@ +package com.assu.server.domain.mapping.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class StudentAdminResponseDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CountAdminAuthResponseDTO{ // admin에 따른 총 누적 가입자 수 + private Long studentCount; + private Long adminId; + private String adminName; + } + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class NewCountAdminResponseDTO{ //신규 가입자수 (매달 1일 초기화) + private Long newStudentCount; + private Long adminId; + private String adminName; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CountUsagePersonResponseDTO{ + private Long usagePersonCount; + private Long adminId; + private String adminName; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CountUsageResponseDTO{ //제휴 업체별 누적 제휴 이용현황 + private Long usageCount; + private Long adminId; + private String adminName; + private Long storeId; + private String storeName; + + } + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CountUsageListResponseDTO { + private List items; // 사용량 내림차순 정렬됨 + } + +} diff --git a/src/main/java/com/assu/server/domain/mapping/entity/StudentAdmin.java b/src/main/java/com/assu/server/domain/mapping/entity/StudentAdmin.java new file mode 100644 index 00000000..c0bc842e --- /dev/null +++ b/src/main/java/com/assu/server/domain/mapping/entity/StudentAdmin.java @@ -0,0 +1,31 @@ +package com.assu.server.domain.mapping.entity; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.domain.admin.entity.Admin; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "student_admin_mapping") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class StudentAdmin extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // Student 연결 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "student_id", nullable = false) + private Student student; + + // Admin 연결 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_id", nullable = false) + private Admin admin; + +} diff --git a/src/main/java/com/assu/server/domain/mapping/repository/StudentAdminRepository.java b/src/main/java/com/assu/server/domain/mapping/repository/StudentAdminRepository.java new file mode 100644 index 00000000..bac1def6 --- /dev/null +++ b/src/main/java/com/assu/server/domain/mapping/repository/StudentAdminRepository.java @@ -0,0 +1,95 @@ +package com.assu.server.domain.mapping.repository; + +import com.assu.server.domain.mapping.entity.StudentAdmin; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.Collection; +import java.util.List; + +public interface StudentAdminRepository extends JpaRepository { + + // 총 누적 가입자 수 + @Query(""" + select count(sa) + from StudentAdmin sa + where sa.admin.id = :adminId + """) + Long countAllByAdminId(@Param("adminId") Long adminId); + + // 기간별 가입자 수 + @Query(""" + select count(sa) + from StudentAdmin sa + where sa.admin.id = :adminId + and sa.createdAt >= :from + and sa.createdAt < :to + """) + Long countByAdminIdBetween(@Param("adminId") Long adminId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + // 이번 달 신규 가입자 수 + default Long countThisMonthByAdminId(Long adminId) { + LocalDateTime from = YearMonth.now().atDay(1).atStartOfDay(); + LocalDateTime to = LocalDateTime.now(); + return countByAdminIdBetween(adminId, from, to); + } + + // 오늘 제휴 사용 고유 사용자 수 + @Query(value = """ + SELECT COUNT(DISTINCT pu.student_id) + FROM partnership_usage pu + JOIN paper_content pc ON pc.id = pu.paper_id + JOIN paper p ON p.id = pc.paper_id + WHERE p.admin_id = :adminId + AND pu.created_at >= CURRENT_DATE + AND pu.created_at < CURRENT_DATE + INTERVAL 1 DAY + """, nativeQuery = true) + Long countTodayUsersByAdmin(@Param("adminId") Long adminId); + + @Query(value = """ + SELECT + p.id AS paperId, + p.store_id AS storeId, + s.name AS storeName, + CAST(COUNT(pu.id) AS UNSIGNED) AS usageCount + FROM paper p + JOIN store s ON s.id = p.store_id + JOIN paper_content pc ON pc.paper_id = p.id + JOIN partnership_usage pu ON pu.paper_id = pc.id + WHERE p.admin_id = :adminId + GROUP BY p.id, p.store_id, s.name + HAVING usageCount > 0 + ORDER BY usageCount DESC, p.id ASC + """, nativeQuery = true) + List findUsageByStoreWithPaper(@Param("adminId") Long adminId); + + // 0건 포함 조회 (대시보드에서 모든 제휴 업체를 보여줘야 하는 경우) + @Query(value = """ + SELECT + p.id AS paperId, + p.store_id AS storeId, + s.name AS storeName, + CAST(COALESCE(COUNT(pu.id), 0) AS UNSIGNED) AS usageCount + FROM paper p + JOIN store s ON s.id = p.store_id + LEFT JOIN paper_content pc ON pc.paper_id = p.id + LEFT JOIN partnership_usage pu ON pu.paper_id = pc.id + WHERE p.admin_id = :adminId + GROUP BY p.id, p.store_id, s.name + ORDER BY usageCount DESC, p.id ASC + """, nativeQuery = true) + List findUsageByStoreIncludingZero(@Param("adminId") Long adminId); + + interface StoreUsageWithPaper { + Long getPaperId(); // 🆕 추가: Paper ID + Long getStoreId(); + String getStoreName(); + Long getUsageCount(); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/mapping/service/StudentAdminService.java b/src/main/java/com/assu/server/domain/mapping/service/StudentAdminService.java new file mode 100644 index 00000000..9504e7e8 --- /dev/null +++ b/src/main/java/com/assu/server/domain/mapping/service/StudentAdminService.java @@ -0,0 +1,11 @@ +package com.assu.server.domain.mapping.service; + +import com.assu.server.domain.mapping.dto.StudentAdminResponseDTO; + +public interface StudentAdminService { + StudentAdminResponseDTO.CountAdminAuthResponseDTO getCountAdminAuth(Long memberId); + StudentAdminResponseDTO.NewCountAdminResponseDTO getNewStudentCountAdmin(Long memberId); + StudentAdminResponseDTO.CountUsagePersonResponseDTO getCountUsagePerson(Long memberId); + StudentAdminResponseDTO.CountUsageResponseDTO getCountUsage(Long memberId); + StudentAdminResponseDTO.CountUsageListResponseDTO getCountUsageList(Long memberId); +} diff --git a/src/main/java/com/assu/server/domain/mapping/service/StudentAdminServiceImpl.java b/src/main/java/com/assu/server/domain/mapping/service/StudentAdminServiceImpl.java new file mode 100644 index 00000000..2eb03ee3 --- /dev/null +++ b/src/main/java/com/assu/server/domain/mapping/service/StudentAdminServiceImpl.java @@ -0,0 +1,116 @@ +package com.assu.server.domain.mapping.service; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.admin.repository.AdminRepository; +import com.assu.server.domain.mapping.converter.StudentAdminConverter; +import com.assu.server.domain.mapping.dto.StudentAdminResponseDTO; +import com.assu.server.domain.mapping.repository.StudentAdminRepository; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.repository.PaperRepository; +import com.assu.server.domain.partnership.repository.PartnershipRepository; +import com.assu.server.domain.user.service.StudentService; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +public class StudentAdminServiceImpl implements StudentAdminService { + private final StudentAdminRepository studentAdminRepository; + private final AdminRepository adminRepository; + private final PaperRepository paperRepository; + + @Override + @Transactional + public StudentAdminResponseDTO.CountAdminAuthResponseDTO getCountAdminAuth(Long memberId) { + Admin admin = getAdminOrThrow(memberId); + Long total = studentAdminRepository.countAllByAdminId(memberId); + + return StudentAdminConverter.countAdminAuthDTO(memberId, total, admin.getName()); + } + + @Override + @Transactional + public StudentAdminResponseDTO.NewCountAdminResponseDTO getNewStudentCountAdmin(Long memberId) { + Admin admin = getAdminOrThrow(memberId); + Long total = studentAdminRepository.countThisMonthByAdminId(memberId); + + return StudentAdminConverter.newCountAdminResponseDTO(memberId, total, admin.getName()); + } + + @Override + @Transactional + public StudentAdminResponseDTO.CountUsagePersonResponseDTO getCountUsagePerson(Long memberId) { + Admin admin = getAdminOrThrow(memberId); + Long total = studentAdminRepository.countTodayUsersByAdmin(memberId); + + return StudentAdminConverter.countUsagePersonDTO(memberId, total, admin.getName()); + } + + @Override + @Transactional + public StudentAdminResponseDTO.CountUsageResponseDTO getCountUsage(Long memberId) { + Admin admin = getAdminOrThrow(memberId); + + List storeUsages = + studentAdminRepository.findUsageByStoreWithPaper(memberId); + + //예외 처리 + if (storeUsages.isEmpty()) { + throw new DatabaseException(ErrorStatus.NO_USAGE_DATA); + } + + // 첫 번째가 가장 사용량이 많은 업체 (ORDER BY usageCount DESC) + var top = storeUsages.get(0); + + Paper paper = paperRepository.findById(top.getPaperId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_PAPER_FOR_STORE)); + + return StudentAdminConverter.countUsageResponseDTO(admin, paper, top.getUsageCount()); + } + + @Override + @Transactional + public StudentAdminResponseDTO.CountUsageListResponseDTO getCountUsageList(Long memberId) { + Admin admin = getAdminOrThrow(memberId); + + // 🔧 핵심 수정: Paper 정보를 포함한 조회 (N+1 해결) + List storeUsages = + studentAdminRepository.findUsageByStoreWithPaper(memberId); + + if (storeUsages.isEmpty()) { + // 빈 리스트 반환 (선택: 예외 처리도 가능) + return StudentAdminConverter.countUsageListResponseDTO(List.of()); + } + + List paperIds = storeUsages.stream() + .map(StudentAdminRepository.StoreUsageWithPaper::getPaperId) + .toList(); + + Map paperMap = paperRepository.findAllById(paperIds).stream() + .collect(Collectors.toMap(Paper::getId, paper -> paper)); + + var items = storeUsages.stream().map(row -> { + Paper paper = paperMap.get(row.getPaperId()); + if (paper == null) { + throw new DatabaseException(ErrorStatus.NO_PAPER_FOR_STORE); + } + return StudentAdminConverter.countUsageResponseDTO(admin, paper, row.getUsageCount()); + }).toList(); + + return StudentAdminConverter.countUsageListResponseDTO(items); + } + + // Admin 조회 중복 제거 + private Admin getAdminOrThrow(Long adminId) { + return adminRepository.findById(adminId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/member/entity/Member.java b/src/main/java/com/assu/server/domain/member/entity/Member.java new file mode 100644 index 00000000..552e57d3 --- /dev/null +++ b/src/main/java/com/assu/server/domain/member/entity/Member.java @@ -0,0 +1,70 @@ +package com.assu.server.domain.member.entity; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.auth.entity.CommonAuth; +import com.assu.server.domain.auth.entity.SSUAuth; +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.user.entity.Student; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; + +@Getter +@Setter +@DynamicUpdate +@DynamicInsert +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String phoneNum; + + private Boolean isPhoneVerified; + + private LocalDateTime phoneVerifiedAt; + + private String profileUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + @JdbcTypeCode(SqlTypes.VARCHAR) + private UserRole role; // STUDENT, ADMIN, PARTNER + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ActivationStatus isActivated; // ACTIVE, INACTIVE, SUSPEND + + // 소프트 삭제를 위한 삭제 시점 + private LocalDateTime deletedAt; + + // 역할별 프로필 - 선택적으로 연관 + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL) + private Student studentProfile; + + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL) + private Admin adminProfile; + + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL) + private Partner partnerProfile; + + // 연관관계 (1:1) — 양방향 필요 없으면 아래 필드 제거해도 됨 + @OneToOne(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private SSUAuth ssuAuth; + + @OneToOne(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private CommonAuth commonAuth; +} diff --git a/src/main/java/com/assu/server/domain/member/repository/MemberRepository.java b/src/main/java/com/assu/server/domain/member/repository/MemberRepository.java new file mode 100644 index 00000000..ac82f1ed --- /dev/null +++ b/src/main/java/com/assu/server/domain/member/repository/MemberRepository.java @@ -0,0 +1,23 @@ +package com.assu.server.domain.member.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.member.entity.Member; +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 MemberRepository extends JpaRepository { + boolean existsByPhoneNum(String phoneNum); + + Optional findMemberById(Long id); + + List findByDeletedAtBefore(LocalDateTime deletedAt); + + Member findByAdminProfile(Admin adminProfile); +} diff --git a/src/main/java/com/assu/server/domain/notificaiton/controller/NotificationController.java b/src/main/java/com/assu/server/domain/notificaiton/controller/NotificationController.java deleted file mode 100644 index f292f8a7..00000000 --- a/src/main/java/com/assu/server/domain/notificaiton/controller/NotificationController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.notificaiton.controller; - -public class NotificationController { -} diff --git a/src/main/java/com/assu/server/domain/notificaiton/converter/NotificationConverter.java b/src/main/java/com/assu/server/domain/notificaiton/converter/NotificationConverter.java deleted file mode 100644 index c3327346..00000000 --- a/src/main/java/com/assu/server/domain/notificaiton/converter/NotificationConverter.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.notificaiton.converter; - -public class NotificationConverter { -} diff --git a/src/main/java/com/assu/server/domain/notificaiton/dto/NotificaitonRequestDTO.java b/src/main/java/com/assu/server/domain/notificaiton/dto/NotificaitonRequestDTO.java deleted file mode 100644 index 8306a486..00000000 --- a/src/main/java/com/assu/server/domain/notificaiton/dto/NotificaitonRequestDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.notificaiton.dto; - -public class NotificaitonRequestDTO { -} diff --git a/src/main/java/com/assu/server/domain/notificaiton/dto/NotificaitonResponseDTO.java b/src/main/java/com/assu/server/domain/notificaiton/dto/NotificaitonResponseDTO.java deleted file mode 100644 index af3579dd..00000000 --- a/src/main/java/com/assu/server/domain/notificaiton/dto/NotificaitonResponseDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.notificaiton.dto; - -public class NotificaitonResponseDTO { -} diff --git a/src/main/java/com/assu/server/domain/notificaiton/entity/Notification.java b/src/main/java/com/assu/server/domain/notificaiton/entity/Notification.java deleted file mode 100644 index aa4f0815..00000000 --- a/src/main/java/com/assu/server/domain/notificaiton/entity/Notification.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.notificaiton.entity; - -public class Notification { -} diff --git a/src/main/java/com/assu/server/domain/notificaiton/repository/NotificationRepository.java b/src/main/java/com/assu/server/domain/notificaiton/repository/NotificationRepository.java deleted file mode 100644 index fe4cf1af..00000000 --- a/src/main/java/com/assu/server/domain/notificaiton/repository/NotificationRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.notificaiton.repository; - -public class NotificationRepository { -} diff --git a/src/main/java/com/assu/server/domain/notificaiton/service/NotificationService.java b/src/main/java/com/assu/server/domain/notificaiton/service/NotificationService.java deleted file mode 100644 index 1a00b110..00000000 --- a/src/main/java/com/assu/server/domain/notificaiton/service/NotificationService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.notificaiton.service; - -public interface NotificationService { -} diff --git a/src/main/java/com/assu/server/domain/notificaiton/service/NotificationServiceImpl.java b/src/main/java/com/assu/server/domain/notificaiton/service/NotificationServiceImpl.java deleted file mode 100644 index ae73fcab..00000000 --- a/src/main/java/com/assu/server/domain/notificaiton/service/NotificationServiceImpl.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.notificaiton.service; - -public class NotificationServiceImpl { -} diff --git a/src/main/java/com/assu/server/domain/notification/controller/NotificationController.java b/src/main/java/com/assu/server/domain/notification/controller/NotificationController.java new file mode 100644 index 00000000..cf3860ba --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/controller/NotificationController.java @@ -0,0 +1,118 @@ +package com.assu.server.domain.notification.controller; + +import com.assu.server.domain.notification.dto.*; +import com.assu.server.domain.notification.entity.NotificationType; +import com.assu.server.domain.notification.service.NotificationCommandService; +import com.assu.server.domain.notification.service.NotificationQueryService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.data.domain.Pageable; +import java.nio.file.AccessDeniedException; +import java.util.HashMap; +import java.util.Map; + +@Tag(name = "Notification", description = "알림 API") +@RestController +@RequestMapping("/notifications") +@RequiredArgsConstructor +public class NotificationController { + private final NotificationQueryService query; + private final NotificationCommandService command; + + @Operation( + summary = "알림 목록 조회 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/2491197c19ed8091b349ef0ef4bb0f60?source=copy_link)\n" + + "- 본인의 알림 목록을 상태별로 조회합니다.\n"+ + " - status: Request Param, String, [all/unread]\n" + + " - page: Request Param, Integer, 1 이상\n" + + " - size: Request Param, Integer, default = 20" + ) + @GetMapping + public BaseResponse> list( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestParam(defaultValue = "all") String status, // all | unread + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "20") Integer size + ) { + Map body = query.getNotifications(status, page, size, pd.getMemberId()); + return BaseResponse.onSuccess(SuccessStatus._OK, body); + } + + @Operation( + summary = "알림 읽음 처리 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/2491197c19ed80a89ff0c03bc150460f?source=copy_link) \n" + + "- 알림 아이디에 해당하는 알림을 읽음 처리합니다.\n"+ + " - notification-id: Path Variable, Long\n" + ) + @PostMapping("/{notification-id}/read") + public BaseResponse markRead(@AuthenticationPrincipal PrincipalDetails pd, + @PathVariable("notification-id") Long notificationId) throws AccessDeniedException { + command.markRead(notificationId, pd.getMemberId()); + return BaseResponse.onSuccess(SuccessStatus._OK, + "The notification has been marked as read successfully. id=" + notificationId); + } + + @Operation( + summary = "알림 전송 테스트 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/2511197c19ed8051bc93d95f0b216543?source=copy_link)\n" + + "- deviceToken을 등록한 이후에 사용 가능합니다." + ) + @PostMapping("/queue") + public BaseResponse queue(@Valid @RequestBody QueueNotificationRequest req) { + command.queue(req); + return BaseResponse.onSuccess(SuccessStatus._OK, "Notification delivery succeeded."); + } + + @Operation( + summary = "알림 유형별 ON/OFF 토글 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/on-off-2511197c19ed80aeb4eed3c502691361?source=copy_link)\n" + + "- 토글 형식으로 유형별 알림을 ON/OFF 합니다.\n" + + " - type: Path Variable, NotificationType \n" + + " - 지원 값: [CHAT / PARTNER_SUGGESTION / PARTNER_PROPOSAL / ORDER / PARTNER_ALL / ADMIN_ALL]\n" + + " - PARTNER_ALL: CHAT + ORDER를 함께 토글\n" + + " - ADMIN_ALL: CHAT + PARTNER_SUGGESTION + PARTNER_PROPOSAL을 함께 토글" + ) + @PutMapping("/{type}") + public BaseResponse toggle( + @AuthenticationPrincipal PrincipalDetails pd, + @PathVariable("type") NotificationType type + ) { + Map settings = command.toggle(pd.getMemberId(), type); + return BaseResponse.onSuccess(SuccessStatus._OK, new NotificationSettingsResponse(settings)); + } + + @Operation( + summary = "알림 현재 설정 조회 API", + description = "# [v1.0 (2025-09-02)](https://clumsy-seeder-416.notion.site/2691197c19ed80de9b92d96db3608cdf?source=copy_link)\n" + + "- 현재 로그인 사용자의 알림 설정 상태를 반환합니다.\n" + ) + @GetMapping("/settings") + public BaseResponse getSettings( + @AuthenticationPrincipal PrincipalDetails pd + ) { + NotificationSettingsResponse res = query.loadSettings(pd.getMemberId()); + return BaseResponse.onSuccess(SuccessStatus._OK, res); + } + + @Operation( + summary = "읽지 않은 알림 존재 여부 조회 API", + description = "# [v1.0 (2025-09-02)](https://clumsy-seeder-416.notion.site/2691197c19ed809a81fec6eb3282ec3a?source=copy_link)\n" + + "- 현재 로그인 사용자의 읽지 않은 알림 존재 여부를 반환합니다.\n" + + "- 결과: true | false" + ) + @GetMapping("/unread-exists") + public BaseResponse unreadExists(@AuthenticationPrincipal PrincipalDetails pd) { + boolean exists = query.hasUnread(pd.getMemberId()); + return BaseResponse.onSuccess(SuccessStatus._OK, exists); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/notification/converter/NotificationConverter.java b/src/main/java/com/assu/server/domain/notification/converter/NotificationConverter.java new file mode 100644 index 00000000..898a25a1 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/converter/NotificationConverter.java @@ -0,0 +1,59 @@ +package com.assu.server.domain.notification.converter; + +import com.assu.server.domain.notification.dto.NotificationResponseDTO; +import com.assu.server.domain.notification.dto.NotificationSettingsResponse; +import com.assu.server.domain.notification.entity.Notification; +import com.assu.server.domain.notification.entity.NotificationSetting; +import com.assu.server.domain.notification.entity.NotificationType; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class NotificationConverter { + public static NotificationResponseDTO toDto(Notification n) { + return NotificationResponseDTO.builder() + .id(n.getId()) + .type(n.getType().name()) + .refId(n.getRefId()) + .title(n.getTitle()) + .messagePreview(n.getMessagePreview()) + .deeplink(n.getDeeplink()) + .isRead(n.isRead()) + .createdAt(n.getCreatedAt()) + .readAt(n.getReadAt()) + .timeAgo(toTimeAgo(n.getCreatedAt())) + .build(); + } + + /** 1시간 전까지 분 단위, 그 이후는 시간 단위(24h 초과 시 일 단위) */ + public static String toTimeAgo(LocalDateTime createdAt) { + if (createdAt == null) return ""; + + LocalDateTime now = LocalDateTime.now(); + if (createdAt.isAfter(now)) return "방금 전"; + + Duration d = Duration.between(createdAt, now); + long minutes = d.toMinutes(); + + if (minutes < 1) return "방금 전"; + if (minutes < 60) return minutes + "분 전"; + + long hours = minutes / 60; + if (hours < 24) return hours + "시간 전"; + + long days = hours / 24; + return days + "일 전"; + } + + // 저장 대상: 개별 타입만 (그룹 제외) + private static final EnumSet BASE_TYPES = EnumSet.of( + NotificationType.CHAT, + NotificationType.ORDER, + NotificationType.PARTNER_SUGGESTION, + NotificationType.PARTNER_PROPOSAL + ); +} diff --git a/src/main/java/com/assu/server/domain/notification/dto/NotificationMessageDTO.java b/src/main/java/com/assu/server/domain/notification/dto/NotificationMessageDTO.java new file mode 100644 index 00000000..e8bfd20c --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/dto/NotificationMessageDTO.java @@ -0,0 +1,18 @@ +package com.assu.server.domain.notification.dto; + +import lombok.*; + +import java.util.Map; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NotificationMessageDTO { + private String idempotencyKey; + private Long receiverId; + private String title; + private String body; + private Map data; +} diff --git a/src/main/java/com/assu/server/domain/notification/dto/NotificationRequestDTO.java b/src/main/java/com/assu/server/domain/notification/dto/NotificationRequestDTO.java new file mode 100644 index 00000000..7c21b4a4 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/dto/NotificationRequestDTO.java @@ -0,0 +1,4 @@ +package com.assu.server.domain.notification.dto; + +public class NotificationRequestDTO { +} diff --git a/src/main/java/com/assu/server/domain/notification/dto/NotificationResponseDTO.java b/src/main/java/com/assu/server/domain/notification/dto/NotificationResponseDTO.java new file mode 100644 index 00000000..bd4ffec2 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/dto/NotificationResponseDTO.java @@ -0,0 +1,25 @@ +package com.assu.server.domain.notification.dto; + +import com.assu.server.domain.notification.entity.Notification; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationResponseDTO { + + private Long id; + private String type; + private Long refId; + private String title; + private String messagePreview; + private String deeplink; + private boolean isRead; + private LocalDateTime createdAt; + private LocalDateTime readAt; + private String timeAgo; +} + diff --git a/src/main/java/com/assu/server/domain/notification/dto/NotificationSettingRequestDTO.java b/src/main/java/com/assu/server/domain/notification/dto/NotificationSettingRequestDTO.java new file mode 100644 index 00000000..3ba6c881 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/dto/NotificationSettingRequestDTO.java @@ -0,0 +1,12 @@ +package com.assu.server.domain.notification.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class NotificationSettingRequestDTO { + @NotNull + private Boolean enabled; +} diff --git a/src/main/java/com/assu/server/domain/notification/dto/NotificationSettingsResponse.java b/src/main/java/com/assu/server/domain/notification/dto/NotificationSettingsResponse.java new file mode 100644 index 00000000..1fd4bbd1 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/dto/NotificationSettingsResponse.java @@ -0,0 +1,14 @@ +package com.assu.server.domain.notification.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class NotificationSettingsResponse { + private Map settings; +} diff --git a/src/main/java/com/assu/server/domain/notification/dto/QueueNotificationRequest.java b/src/main/java/com/assu/server/domain/notification/dto/QueueNotificationRequest.java new file mode 100644 index 00000000..11eec78d --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/dto/QueueNotificationRequest.java @@ -0,0 +1,64 @@ +package com.assu.server.domain.notification.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class QueueNotificationRequest { + + @Schema(description = "알림을 받을 멤버 ID", example = "1") + @NotNull + private Long receiverId; + + @Schema(description = "알림 타입 (CHAT, PARTNER_SUGGESTION, ORDER, PARTNER_PROPOSAL)", example = "CHAT") + @NotNull + private String type; + + // 공통(선택) + @Schema(description = "알림 내용", example = "새로운 메시지가 있습니다.") + private String content; + + @Schema(description = "알림 제목", example = "채팅 알림") + private String title; + + @Schema(description = "앱 내 이동할 경로 (deeplink)", example = "app://chat/10") + private String deeplink; + + // CHAT + @Schema(description = "채팅방 ID", example = "101") + private Long roomId; + + @Schema(description = "보낸 사람 이름", example = "홍길동") + private String senderName; + + @Schema(description = "메시지 내용", example = "안녕하세요! 오늘 일정 확인 부탁드려요.") + private String message; + + // PARTNER_SUGGESTION + @Schema(description = "제휴 제안 ID", example = "2001") + private Long suggestionId; + + // ORDER + @Schema(description = "주문 ID", example = "3001") + private Long orderId; + + @Schema(description = "테이블 번호", example = "11") + private String table_num; + + @Schema(description = "전단지 내용", example = "20,000원 이상 구매 시 10% 할인") + private String paper_content; + + // PARTNER_PROPOSAL + @Schema(description = "제휴 제안 ID", example = "4001") + private Long proposalId; + + @Schema(description = "파트너 이름", example = "역전할머니맥주 송신대점") + private String partner_name; + + // 기타 타입 공용으로 쓰고 싶으면 유지 + @Schema(description = "참조 ID (타입별로 roomId/orderId 등 대신 사용할 수 있음)", example = "9999") + private Long refId; +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/notification/entity/Notification.java b/src/main/java/com/assu/server/domain/notification/entity/Notification.java new file mode 100644 index 00000000..045ac4d9 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/entity/Notification.java @@ -0,0 +1,58 @@ +package com.assu.server.domain.notification.entity; +import com.assu.server.domain.common.entity.BaseEntity; + +import com.assu.server.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id", nullable = false) + private Member receiver; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 40) + private NotificationType type; + + // 원천 도메인의 식별자(폴리모픽 FK를 애플리케이션 레벨에서 관리) + @Column(nullable = false) + private Long refId; + + // 목록용 스냅샷(조인 없이 렌더) + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String messagePreview; + + private String deeplink; // ex) /chat/rooms/123 + + @Column(nullable = false) + private boolean isRead = false; + + @Column(nullable = true) + private LocalDateTime readAt; + + public void markRead() { + if (!this.isRead) { + this.isRead = true; + this.readAt = LocalDateTime.now(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/notification/entity/NotificationOutbox.java b/src/main/java/com/assu/server/domain/notification/entity/NotificationOutbox.java new file mode 100644 index 00000000..ae0e26b3 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/entity/NotificationOutbox.java @@ -0,0 +1,37 @@ +package com.assu.server.domain.notification.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationOutbox { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name="notification_id", nullable=false, unique=true) + private Notification notification; + + @Enumerated(EnumType.STRING) @Column(nullable=false) + private Status status; // PENDING, SENT, FAILED + + @Column(nullable=false) private int retryCount; + + public enum Status { PENDING, SENDING, DISPATCHED, SENT, FAILED } + + public void markSending() { this.status = Status.SENDING; } + public void markDispatched() { this.status = Status.DISPATCHED; } + public void markSent() { this.status = Status.SENT; } + public void markFailed() { this.status = Status.FAILED; } + public void incRetry() { this.retryCount++; } +} diff --git a/src/main/java/com/assu/server/domain/notification/entity/NotificationSetting.java b/src/main/java/com/assu/server/domain/notification/entity/NotificationSetting.java new file mode 100644 index 00000000..aa75cf9e --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/entity/NotificationSetting.java @@ -0,0 +1,32 @@ +package com.assu.server.domain.notification.entity; + +import com.assu.server.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table(name = "notification_setting", + uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "type"})) +public class NotificationSetting { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "member_id") + private Member member; + + // CHAT, PARTNER_SUGGESTION, PARTNER_PROPOSAL ... + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private com.assu.server.domain.notification.entity.NotificationType type; + + @Column(nullable = false) + private Boolean enabled; +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/notification/entity/NotificationType.java b/src/main/java/com/assu/server/domain/notification/entity/NotificationType.java new file mode 100644 index 00000000..0eceb515 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/entity/NotificationType.java @@ -0,0 +1,23 @@ +package com.assu.server.domain.notification.entity; + +import java.util.Arrays; + +public enum NotificationType { + CHAT("chat"), + PARTNER_SUGGESTION("partner_suggestion"), + PARTNER_PROPOSAL("partner_proposal"), + ORDER("order"), + PARTNER_ALL("partner_all"), // 채팅, 주문 안내 + ADMIN_ALL("admin_all"); // 채팅, 제휴 건의, 제휴 제안 + + private final String code; + NotificationType(String code) { this.code = code; } + public String code() { return code; } + + public static NotificationType from(String code) { + return Arrays.stream(values()) + .filter(t -> t.code.equalsIgnoreCase(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported type: " + code)); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/notification/entity/OutboxCreatedEvent.java b/src/main/java/com/assu/server/domain/notification/entity/OutboxCreatedEvent.java new file mode 100644 index 00000000..0a37f3dd --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/entity/OutboxCreatedEvent.java @@ -0,0 +1,10 @@ +package com.assu.server.domain.notification.entity; + +import com.assu.server.domain.notification.entity.Notification; +import lombok.Value; + +@Value +public class OutboxCreatedEvent { + Long outboxId; + Notification notification; +} diff --git a/src/main/java/com/assu/server/domain/notification/repository/NotificationOutboxRepository.java b/src/main/java/com/assu/server/domain/notification/repository/NotificationOutboxRepository.java new file mode 100644 index 00000000..abbcfa1d --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/repository/NotificationOutboxRepository.java @@ -0,0 +1,42 @@ +package com.assu.server.domain.notification.repository; + +import com.assu.server.domain.notification.entity.NotificationOutbox; +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 java.util.List; + +public interface NotificationOutboxRepository extends JpaRepository { + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update NotificationOutbox o + set o.status = com.assu.server.domain.notification.entity.NotificationOutbox.Status.DISPATCHED + where o.id = :id + and o.status = com.assu.server.domain.notification.entity.NotificationOutbox.Status.PENDING + """) + int markDispatchedById(@Param("id") Long id); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update NotificationOutbox o + set o.status = com.assu.server.domain.notification.entity.NotificationOutbox.Status.SENT + where o.id = :id + and o.status <> com.assu.server.domain.notification.entity.NotificationOutbox.Status.SENT +""") + int markSentById(@Param("id") Long id); + + @org.springframework.data.jpa.repository.Modifying(clearAutomatically = true, flushAutomatically = true) + @org.springframework.data.jpa.repository.Query(""" + update NotificationOutbox o + set o.status = com.assu.server.domain.notification.entity.NotificationOutbox.Status.FAILED + where o.id = :id + and o.status <> com.assu.server.domain.notification.entity.NotificationOutbox.Status.FAILED + """) + int markFailedById(@org.springframework.data.repository.query.Param("id") Long id); + + boolean existsByIdAndStatus(Long id, NotificationOutbox.Status status); + + List findTop50ByStatusOrderByIdAsc(NotificationOutbox.Status status); +} diff --git a/src/main/java/com/assu/server/domain/notification/repository/NotificationRepository.java b/src/main/java/com/assu/server/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..3254a14a --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,22 @@ +package com.assu.server.domain.notification.repository; + +import com.assu.server.domain.deviceToken.entity.DeviceToken; +import com.assu.server.domain.notification.entity.Notification; +import com.assu.server.domain.notification.entity.NotificationSetting; +import com.assu.server.domain.notification.entity.NotificationType; +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.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface NotificationRepository extends JpaRepository { + Page findByReceiverIdAndTypeNot(Long receiverId, NotificationType type, Pageable pageable); + Page findByReceiverIdAndIsReadFalseAndTypeNot(Long receiverId, NotificationType type, Pageable pageable); + boolean existsByReceiverIdAndIsReadFalseAndTypeNot(Long receiverId, NotificationType type); // ← 핵심 +} diff --git a/src/main/java/com/assu/server/domain/notification/repository/NotificationSettingRepository.java b/src/main/java/com/assu/server/domain/notification/repository/NotificationSettingRepository.java new file mode 100644 index 00000000..46144e01 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/repository/NotificationSettingRepository.java @@ -0,0 +1,13 @@ +package com.assu.server.domain.notification.repository; + +import com.assu.server.domain.notification.entity.NotificationSetting; +import com.assu.server.domain.notification.entity.NotificationType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface NotificationSettingRepository extends JpaRepository { + Optional findByMemberIdAndType(Long memberId, NotificationType type); + List findAllByMemberId(Long memberId); +} diff --git a/src/main/java/com/assu/server/domain/notification/service/NotificationCommandService.java b/src/main/java/com/assu/server/domain/notification/service/NotificationCommandService.java new file mode 100644 index 00000000..bbb3d8d7 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/service/NotificationCommandService.java @@ -0,0 +1,21 @@ +package com.assu.server.domain.notification.service; + +import com.assu.server.domain.notification.dto.QueueNotificationRequest; +import com.assu.server.domain.notification.entity.Notification; +import com.assu.server.domain.notification.entity.NotificationType; + +import java.nio.file.AccessDeniedException; +import java.util.Map; + +public interface NotificationCommandService { + Notification createAndQueue(Long receiverId, NotificationType type, Long refId, Map ctx); + void markRead(Long notificationId, Long currentMemberId) throws AccessDeniedException; + void queue(QueueNotificationRequest req); + Map toggle(Long memberId, NotificationType type); + boolean isEnabled(Long memberId, NotificationType type); + + void sendChat(Long receiverId, Long roomId, String senderName, String message); + void sendPartnerSuggestion(Long receiverId, Long suggestionId); + void sendOrder(Long receiverId, Long orderId, String tableNum, String paperContent); + void sendPartnerProposal(Long receiverId, Long proposalId, String partnerName); +} diff --git a/src/main/java/com/assu/server/domain/notification/service/NotificationCommandServiceImpl.java b/src/main/java/com/assu/server/domain/notification/service/NotificationCommandServiceImpl.java new file mode 100644 index 00000000..37895cd8 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/service/NotificationCommandServiceImpl.java @@ -0,0 +1,271 @@ +package com.assu.server.domain.notification.service; + + +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.notification.dto.QueueNotificationRequest; +import com.assu.server.domain.notification.entity.*; +import com.assu.server.domain.notification.repository.NotificationOutboxRepository; +import com.assu.server.domain.notification.repository.NotificationRepository; +import com.assu.server.domain.notification.repository.NotificationSettingRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import com.assu.server.global.exception.GeneralException; +import com.assu.server.infra.firebase.NotificationFactory; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class NotificationCommandServiceImpl implements NotificationCommandService { + private final NotificationRepository notificationRepository; + private final NotificationOutboxRepository outboxRepository; + private final NotificationSettingRepository notificationSettingRepository; + private final NotificationFactory notificationFactory; + private final MemberRepository memberRepository; + private final ApplicationEventPublisher events; + + + @Transactional + @Override + public Notification createAndQueue(Long receiverId, NotificationType type, Long refId, Map ctx) { + Member member = memberRepository.findMemberById(receiverId).orElseThrow( + () -> new GeneralException(ErrorStatus.NO_SUCH_MEMBER) + ); + if (member == null) { + throw new DatabaseException(ErrorStatus.NO_SUCH_MEMBER); + } + + Notification notification = notificationFactory.create(member, type, refId, ctx); + + notificationRepository.save(notification); + NotificationOutbox outbox = outboxRepository.save( + NotificationOutbox.builder() + .notification(notification) + .status(NotificationOutbox.Status.PENDING) + .retryCount(0) + .build() + ); + + events.publishEvent(new OutboxCreatedEvent(outbox.getId(), notification)); + return notification; + } + + @Transactional + @Override + public void markRead(Long notificationId, Long currentMemberId) { + Notification n = notificationRepository.findById(notificationId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NOTIFICATION_NOT_FOUND)); + + if (!n.getReceiver().getId().equals(currentMemberId)) { + throw new DatabaseException(ErrorStatus.NOTIFICATION_ACCESS_DENIED); + } + n.markRead(); + } + + @Transactional + @Override + public void queue(QueueNotificationRequest req) { + if (req.getType() == null) { + throw new DatabaseException(ErrorStatus.INVALID_NOTIFICATION_TYPE); + } + if (req.getReceiverId() == null) { + throw new DatabaseException(ErrorStatus.MISSING_NOTIFICATION_FIELD); + } + + final NotificationType type; + try { + type = NotificationType.valueOf(req.getType().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new DatabaseException(ErrorStatus.INVALID_NOTIFICATION_TYPE); + } + + final Long receiverId = req.getReceiverId(); + + switch (type) { + case CHAT -> { + // refId 우선순위: refId > roomId + Long roomId = (req.getRefId() != null) ? req.getRefId() : req.getRoomId(); + if (roomId == null || req.getSenderName() == null || req.getMessage() == null) { + throw new DatabaseException(ErrorStatus.MISSING_NOTIFICATION_FIELD); + } + // 퍼사드 호출: 내부에서 ON/OFF 자동 반영 + sendChat(receiverId, roomId, req.getSenderName(), req.getMessage()); + } + + case PARTNER_SUGGESTION -> { + Long suggestionId = (req.getRefId() != null) ? req.getRefId() : req.getSuggestionId(); + if (suggestionId == null) { + throw new DatabaseException(ErrorStatus.MISSING_NOTIFICATION_FIELD); + } + sendPartnerSuggestion(receiverId, suggestionId); + } + + case ORDER -> { + Long orderId = (req.getRefId() != null) ? req.getRefId() : req.getOrderId(); + if (orderId == null || req.getTable_num() == null || req.getPaper_content() == null) { + throw new DatabaseException(ErrorStatus.MISSING_NOTIFICATION_FIELD); + } + sendOrder(receiverId, orderId, req.getTable_num(), req.getPaper_content()); + } + + case PARTNER_PROPOSAL -> { + Long proposalId = (req.getRefId() != null) ? req.getRefId() : req.getProposalId(); + if (proposalId == null || req.getPartner_name() == null) { + throw new DatabaseException(ErrorStatus.MISSING_NOTIFICATION_FIELD); + } + sendPartnerProposal(receiverId, proposalId, req.getPartner_name()); + } + + default -> throw new DatabaseException(ErrorStatus.INVALID_NOTIFICATION_TYPE); + } + } + + @Transactional + @Override + public Map toggle(Long memberId, NotificationType type) { + Member member = memberRepository.findMemberById(memberId) + .orElseThrow(() -> new GeneralException(ErrorStatus.NO_SUCH_MEMBER)); + + // 그룹 타입 처리 (기존 그대로) + if (type == NotificationType.PARTNER_ALL) { + toggleSingle(member, NotificationType.CHAT); + toggleSingle(member, NotificationType.ORDER); + } else if (type == NotificationType.ADMIN_ALL) { + toggleSingle(member, NotificationType.CHAT); + toggleSingle(member, NotificationType.PARTNER_SUGGESTION); + toggleSingle(member, NotificationType.PARTNER_PROPOSAL); + } else { + toggleSingle(member, type); + } + + boolean isAdmin = member.getRole() == UserRole.ADMIN; // Role enum을 쓰는 경우 + + // ADMIN: CHAT, PARTNER_SUGGESTION, PARTNER_PROPOSAL + // PARTNER: CHAT, ORDER + EnumSet visibleTypes = isAdmin + ? EnumSet.of(NotificationType.CHAT, NotificationType.PARTNER_SUGGESTION, NotificationType.PARTNER_PROPOSAL) + : EnumSet.of(NotificationType.CHAT, NotificationType.ORDER); + + // 기본값 true로 모두 채운 후, DB 값으로 덮어쓰기 + Map result = new LinkedHashMap<>(); + for (NotificationType t : visibleTypes) { + result.put(t.name(), true); // DB에 없으면 true + } + + List rows = notificationSettingRepository.findAllByMemberId(memberId); + for (NotificationSetting s : rows) { + if (visibleTypes.contains(s.getType())) { + result.put(s.getType().name(), Boolean.TRUE.equals(s.getEnabled())); + } + } + + return result; + } + + private boolean toggleSingle(Member member, NotificationType type) { + NotificationSetting setting = notificationSettingRepository + .findByMemberIdAndType(member.getId(), type) + .orElse(NotificationSetting.builder() + .member(member) + .type(type) + .enabled(true) // 기본값 + .build()); + + setting.setEnabled(!setting.getEnabled()); + notificationSettingRepository.save(setting); + + return setting.getEnabled(); + } + + @Transactional + @Override + public boolean isEnabled(Long memberId, NotificationType type) { + return notificationSettingRepository.findByMemberIdAndType(memberId, type) + .map(ns -> Boolean.TRUE.equals(ns.getEnabled())) // null → false 처리 + .orElse(true); // 설정 없으면 기본 허용 + } + + + @Transactional + protected void sendIfEnabled(Long receiverId, NotificationType type, Long refId, Map ctx) { + // OFF면 기록만 남기고 종료, ON이면 Outbox 적재 + if (!isEnabled(receiverId, type)) { + Member member = memberRepository.findMemberById(receiverId) + .orElseThrow(() -> new GeneralException(ErrorStatus.NO_SUCH_MEMBER)); + notificationRepository.save(notificationFactory.create(member, type, refId, ctx)); + return; + } + createAndQueue(receiverId, type, refId, ctx); + } + + @Transactional + @Override + public void sendChat(Long receiverId, Long roomId, String senderName, String message) { + if (receiverId == null || roomId == null || senderName == null || message == null) { + throw new DatabaseException(ErrorStatus.MISSING_NOTIFICATION_FIELD); + } + sendIfEnabled( + receiverId, + NotificationType.CHAT, + roomId, // Factory가 /chat/rooms/{refId}로 딥링크 생성 + Map.of( + "senderName", senderName, // Factory가 title/preview 생성에 사용 + "message", message // Factory가 미리보기 생성에 사용 + ) + ); + } + + @Transactional + @Override + public void sendPartnerSuggestion(Long receiverId, Long suggestionId) { + if (receiverId == null || suggestionId == null) { + throw new DatabaseException(ErrorStatus.MISSING_NOTIFICATION_FIELD); + } + sendIfEnabled( + receiverId, + NotificationType.PARTNER_SUGGESTION, + suggestionId, // /partner/suggestions/{refId} + Map.of() // 추가 ctx 없음 + ); + } + + @Transactional + @Override + public void sendOrder(Long receiverId, Long orderId, String tableNum, String paperContent) { + if (receiverId == null || orderId == null || tableNum == null || paperContent == null) { + throw new DatabaseException(ErrorStatus.MISSING_NOTIFICATION_FIELD); + } + sendIfEnabled( + receiverId, + NotificationType.ORDER, + orderId, // /orders/{refId} + Map.of( + "table_num", tableNum, // Factory preview: "{table_num}번 테이블..." + "paper_content", paperContent + ) + ); + } + + @Transactional + @Override + public void sendPartnerProposal(Long receiverId, Long proposalId, String partnerName) { + if (receiverId == null || proposalId == null || partnerName == null) { + throw new DatabaseException(ErrorStatus.MISSING_NOTIFICATION_FIELD); + } + sendIfEnabled( + receiverId, + NotificationType.PARTNER_PROPOSAL, + proposalId, // /partner/proposals/{refId} + Map.of( + "partner_name", partnerName // Factory preview: "{partner_name}에서..." + ) + ); + } +} diff --git a/src/main/java/com/assu/server/domain/notification/service/NotificationListener.java b/src/main/java/com/assu/server/domain/notification/service/NotificationListener.java new file mode 100644 index 00000000..30d40a1b --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/service/NotificationListener.java @@ -0,0 +1,101 @@ +package com.assu.server.domain.notification.service; + +import com.assu.server.infra.firebase.AmqpConfig; +import com.assu.server.infra.firebase.FcmClient; +import com.assu.server.domain.notification.dto.NotificationMessageDTO; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.rabbitmq.client.Channel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.support.AmqpHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationListener { + + private final FcmClient fcmClient; + private final OutboxStatusService outboxStatus; + + @RabbitListener(queues = AmqpConfig.QUEUE, ackMode = "MANUAL") + public void onMessage(@Payload NotificationMessageDTO payload, + Channel ch, + @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws Exception { + + final Long outboxId = safeParseLong(payload.getIdempotencyKey()); + + try { + // 0) Outbox 선확인: 이미 처리되었으면 중복 전송/SELECT 자체를 스킵 + if (outboxId != null && outboxStatus.isAlreadySent(outboxId)) { + log.debug("[Notify] already-sent outboxId={}, ACK", outboxId); + ch.basicAck(tag, false); + return; + } + + // 1) 전송 + FcmClient.FcmResult result = fcmClient.sendToMemberId( + payload.getReceiverId(), payload.getTitle(), payload.getBody(), payload.getData()); + + // 2) 성공 처리 + if (outboxId != null) outboxStatus.markSent(outboxId); + ch.basicAck(tag, false); + + // 3) 관측용 로그 + log.info("[Notify] sent outboxId={} memberId={} success={} fail={} invalidTokens={}", + outboxId, payload.getReceiverId(), + result.successCount(), result.failureCount(), result.invalidTokens()); + + } catch (FirebaseMessagingException fme) { + boolean permanent = isPermanent(fme); + log.error("[Notify] FCM failure outboxId={} memberId={} permanent={} http={} code={} root={}", + outboxId, payload.getReceiverId(), permanent, + FcmClient.httpStatusOf(fme), fme.getMessagingErrorCode(), rootSummary(fme), fme); + + if (outboxId != null) outboxStatus.markFailed(outboxId); + ch.basicNack(tag, false, false); // requeue 금지(지연 재시도 큐 권장) + + } catch (java.net.UnknownHostException | javax.net.ssl.SSLHandshakeException e) { + // 환경 문제(DNS/CA)는 영구 취급(루프 방지) + log.error("[Notify] ENV failure outboxId={} memberId={} root={}", + outboxId, payload.getReceiverId(), rootSummary(e), e); + if (outboxId != null) outboxStatus.markFailed(outboxId); + ch.basicNack(tag, false, false); + + } catch (java.util.concurrent.TimeoutException | java.net.SocketTimeoutException e) { + // 타임아웃 → 일시 장애. 그래도 requeue(true)는 쓰지 않음 + log.warn("[Notify] TIMEOUT outboxId={} memberId={} root={}", + outboxId, payload.getReceiverId(), rootSummary(e), e); + if (outboxId != null) outboxStatus.markFailed(outboxId); + ch.basicNack(tag, false, false); + + } catch (Exception e) { + // 알 수 없는 오류 → DLQ + log.error("[Notify] UNKNOWN failure outboxId={} memberId={} root={}", + outboxId, payload.getReceiverId(), rootSummary(e), e); + if (outboxId != null) outboxStatus.markFailed(outboxId); + ch.basicNack(tag, false, false); + } + } + + private boolean isPermanent(FirebaseMessagingException fme) { + var code = fme.getMessagingErrorCode(); + Integer http = FcmClient.httpStatusOf(fme); + if (code == com.google.firebase.messaging.MessagingErrorCode.UNREGISTERED + || code == com.google.firebase.messaging.MessagingErrorCode.INVALID_ARGUMENT) return true; + if (http != null && (http == 401 || http == 403)) return true; + return false; + } + + private String rootSummary(Throwable t) { + Throwable r = t; while (r.getCause() != null) r = r.getCause(); + return r.getClass().getName() + ": " + String.valueOf(r.getMessage()); + } + + private Long safeParseLong(String s) { + try { return s == null ? null : Long.valueOf(s); } catch (Exception ignore) { return null; } + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/notification/service/NotificationQueryService.java b/src/main/java/com/assu/server/domain/notification/service/NotificationQueryService.java new file mode 100644 index 00000000..6d7f4e41 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/service/NotificationQueryService.java @@ -0,0 +1,14 @@ +package com.assu.server.domain.notification.service; + +import com.assu.server.domain.notification.dto.NotificationResponseDTO; +import com.assu.server.domain.notification.dto.NotificationSettingsResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Map; + +public interface NotificationQueryService { + Map getNotifications(String status, int page, int size, Long memberId); + NotificationSettingsResponse loadSettings(Long memberId); + boolean hasUnread(Long memberId); +} diff --git a/src/main/java/com/assu/server/domain/notification/service/NotificationQueryServiceImpl.java b/src/main/java/com/assu/server/domain/notification/service/NotificationQueryServiceImpl.java new file mode 100644 index 00000000..011d11d8 --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/service/NotificationQueryServiceImpl.java @@ -0,0 +1,101 @@ +package com.assu.server.domain.notification.service; + +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.notification.converter.NotificationConverter; +import com.assu.server.domain.notification.dto.NotificationResponseDTO; +import com.assu.server.domain.notification.dto.NotificationSettingsResponse; +import com.assu.server.domain.notification.entity.Notification; +import com.assu.server.domain.notification.entity.NotificationSetting; +import com.assu.server.domain.notification.entity.NotificationType; +import com.assu.server.domain.notification.repository.NotificationRepository; +import com.assu.server.domain.notification.repository.NotificationSettingRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import com.assu.server.global.exception.GeneralException; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + + +@Service +@RequiredArgsConstructor +public class NotificationQueryServiceImpl implements NotificationQueryService { + private final NotificationRepository notificationRepository; + private final MemberRepository memberRepository; + private final NotificationSettingRepository notificationSettingRepository; + + @Transactional + @Override + public Map getNotifications(String status, int page, int size, Long memberId) { + // 1) 파라미터 검증 + if (page < 1) throw new DatabaseException(ErrorStatus.PAGE_UNDER_ONE); + if (size < 1 || size > 200) throw new DatabaseException(ErrorStatus.PAGE_SIZE_INVALID); + + if (!memberRepository.existsById(memberId)) { + throw new DatabaseException(ErrorStatus.NO_SUCH_MEMBER); + } + + String s = (status == null ? "all" : status.toLowerCase()); + if (!s.equals("all") && !s.equals("unread")) { + throw new DatabaseException(ErrorStatus.INVALID_NOTIFICATION_STATUS_FILTER); + } + + // 2) 조회 + Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "id")); + Page rawPage = s.equals("unread") + ? notificationRepository.findByReceiverIdAndIsReadFalseAndTypeNot(memberId, NotificationType.CHAT, pageable) + : notificationRepository.findByReceiverIdAndTypeNot(memberId, NotificationType.CHAT, pageable); + + Page p = rawPage.map(NotificationConverter::toDto); + + // 3) 응답 포맷 + Map body = new LinkedHashMap<>(); + body.put("items", p.getContent()); + body.put("page", p.getNumber() + 1); + body.put("size", p.getSize()); + body.put("totalPages", p.getTotalPages()); + body.put("totalElements", p.getTotalElements()); + return body; + } + + @Override + public NotificationSettingsResponse loadSettings(Long memberId) { + Member member = memberRepository.findMemberById(memberId) + .orElseThrow(() -> new GeneralException(ErrorStatus.NO_SUCH_MEMBER)); + + // 역할별로 노출할 타입 고정 + Set visible = member.getRole() == UserRole.ADMIN + ? EnumSet.of(NotificationType.CHAT, NotificationType.PARTNER_SUGGESTION, NotificationType.PARTNER_PROPOSAL) + : EnumSet.of(NotificationType.CHAT, NotificationType.ORDER); + + // 기본 true로 채워두고, DB 값으로 덮어쓰기 + Map map = new LinkedHashMap<>(); + for (NotificationType t : visible) map.put(t.name(), true); + + for (NotificationSetting s : notificationSettingRepository.findAllByMemberId(memberId)) { + if (visible.contains(s.getType())) { + map.put(s.getType().name(), Boolean.TRUE.equals(s.getEnabled())); + } + } + return new NotificationSettingsResponse(map); + } + + @Override + public boolean hasUnread(Long memberId) { + if (!memberRepository.existsById(memberId)) { + throw new DatabaseException(ErrorStatus.NO_SUCH_MEMBER); + } + return notificationRepository.existsByReceiverIdAndIsReadFalseAndTypeNot(memberId, NotificationType.CHAT); + } +} diff --git a/src/main/java/com/assu/server/domain/notification/service/OutboxAfterCommitPublisher.java b/src/main/java/com/assu/server/domain/notification/service/OutboxAfterCommitPublisher.java new file mode 100644 index 00000000..c719419c --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/service/OutboxAfterCommitPublisher.java @@ -0,0 +1,45 @@ +package com.assu.server.domain.notification.service; + + +import com.assu.server.domain.notification.dto.NotificationMessageDTO; +import com.assu.server.domain.notification.entity.OutboxCreatedEvent; +import com.assu.server.infra.firebase.AmqpConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxAfterCommitPublisher { + private final RabbitTemplate rabbit; + private final OutboxStatusService outboxStatus; // ← 여기! + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onOutboxCreated(OutboxCreatedEvent e) { + var n = e.getNotification(); + + var dto = NotificationMessageDTO.builder() + .idempotencyKey(String.valueOf(e.getOutboxId())) + .receiverId(n.getReceiver().getId()) + .title(n.getTitle()) + .body(n.getMessagePreview()) + .data(Map.of( + "type", n.getType().name(), + "refId", String.valueOf(n.getRefId()), + "deeplink", n.getDeeplink() == null ? "" : n.getDeeplink(), + "notificationId", String.valueOf(n.getId()) + )) + .build(); + + rabbit.convertAndSend(AmqpConfig.EXCHANGE, AmqpConfig.ROUTING_KEY, dto); + + // ★ 새 트랜잭션에서 상태 전이 + outboxStatus.markDispatched(e.getOutboxId()); + } +} diff --git a/src/main/java/com/assu/server/domain/notification/service/OutboxStatusService.java b/src/main/java/com/assu/server/domain/notification/service/OutboxStatusService.java new file mode 100644 index 00000000..80e4778a --- /dev/null +++ b/src/main/java/com/assu/server/domain/notification/service/OutboxStatusService.java @@ -0,0 +1,40 @@ +package com.assu.server.domain.notification.service; + +import com.assu.server.domain.notification.entity.NotificationOutbox; +import com.assu.server.domain.notification.repository.NotificationOutboxRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OutboxStatusService { + private final NotificationOutboxRepository repo; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markDispatched(Long id) { + int updated = repo.markDispatchedById(id); + log.info("[OutboxStatus] DISPATCHED updated={} outboxId={}", updated, id); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markSent(Long id) { + int updated = repo.markSentById(id); + log.info("[OutboxStatus] SENT updated={} outboxId={}", updated, id); + } + + @Transactional(readOnly = true) + public boolean isAlreadySent(Long id) { + return repo.existsByIdAndStatus(id, NotificationOutbox.Status.SENT); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markFailed(Long id) { + int updated = repo.markFailedById(id); + log.info("[OutboxStatus] FAILED updated={} outboxId={}", updated, id); + } + +} diff --git a/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java b/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java index ec3d25d1..cfd7e55f 100644 --- a/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java +++ b/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java @@ -1,4 +1,30 @@ package com.assu.server.domain.partner.controller; +import com.assu.server.domain.partner.dto.PartnerResponseDTO; +import com.assu.server.domain.partner.service.PartnerService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/partner") +@RequiredArgsConstructor public class PartnerController { + + private final PartnerService partnerService; + + @Operation( + summary = "어드민 추천 API", + description = "제휴하지 않은 어드민 중 두 곳을 랜덤으로 조회합니다." + ) + @GetMapping("/admin-recommend") + public BaseResponse randomAdminRecommend( + @AuthenticationPrincipal PrincipalDetails pd + ){ + return BaseResponse.onSuccess(SuccessStatus._OK, partnerService.getRandomAdmin(pd.getId())); + } } diff --git a/src/main/java/com/assu/server/domain/partner/dto/PartnerResponseDTO.java b/src/main/java/com/assu/server/domain/partner/dto/PartnerResponseDTO.java index 8e5e0af3..cdc2f2a7 100644 --- a/src/main/java/com/assu/server/domain/partner/dto/PartnerResponseDTO.java +++ b/src/main/java/com/assu/server/domain/partner/dto/PartnerResponseDTO.java @@ -1,4 +1,32 @@ package com.assu.server.domain.partner.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + public class PartnerResponseDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class RandomAdminResponseDTO { + private List admins; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AdminLiteDTO { + private Long adminId; + private String adminAddress; + private String adminDetailAddress; + private String adminName; + private String adminUrl; + private String adminPhone; + } } diff --git a/src/main/java/com/assu/server/domain/partner/entity/Partner.java b/src/main/java/com/assu/server/domain/partner/entity/Partner.java new file mode 100644 index 00000000..c0aecd2b --- /dev/null +++ b/src/main/java/com/assu/server/domain/partner/entity/Partner.java @@ -0,0 +1,54 @@ +package com.assu.server.domain.partner.entity; + + +import com.assu.server.domain.member.entity.Member; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Id; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.locationtech.jts.geom.Point; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Setter +public class Partner { + + @Id + private Long id; // member_id와 동일 + + @OneToOne + @MapsId + @JoinColumn(name = "id") + private Member member; + + private String name; + + private String address; + + private String detailAddress; + + private String licenseUrl; + + private Boolean isLicenseVerified; + + private LocalDateTime licenseVerifiedAt; + + @JdbcTypeCode(SqlTypes.GEOMETRY) + private Point point; + + private double latitude; + private double longitude; + + public void setMember(Member member) { + this.member = member; + } +} diff --git a/src/main/java/com/assu/server/domain/partner/repository/PartnerRepository.java b/src/main/java/com/assu/server/domain/partner/repository/PartnerRepository.java index f6f0ce48..df9aa420 100644 --- a/src/main/java/com/assu/server/domain/partner/repository/PartnerRepository.java +++ b/src/main/java/com/assu/server/domain/partner/repository/PartnerRepository.java @@ -1,4 +1,59 @@ package com.assu.server.domain.partner.repository; -public class PartnerRepository { +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partner.entity.Partner; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface PartnerRepository extends JpaRepository { + + // 현재 admin과 'ACTIVE' 상태로 제휴 중인 partner를 제외한 후보 수 + @Query(value = """ + SELECT COUNT(*) + FROM partner p + LEFT JOIN paper pa + ON pa.partner_id = p.id + AND pa.admin_id = :adminId + AND pa.is_activated = 'ACTIVE' + WHERE pa.id IS NULL + """, nativeQuery = true) + long countUnpartneredActiveByAdmin(@Param("adminId") Long adminId); + + // 위 후보들 중에서 offset 하나만 가져오기 (랜덤 오프셋으로 1건) + @Query(value = """ + SELECT p.* + FROM partner p + LEFT JOIN paper pa + ON pa.partner_id = p.id + AND pa.admin_id = :adminId + AND pa.is_activated = 'ACTIVE' + WHERE pa.id IS NULL + LIMIT :offset, 1 + """, nativeQuery = true) + Partner findUnpartneredActiveByAdminWithOffset(@Param("adminId") Long adminId, + @Param("offset") int offset); + + @Query(value = """ + SELECT p.* + FROM partner p + WHERE p.point IS NOT NULL + AND ST_Contains(ST_GeomFromText(:wkt, 4326), p.point) + """, nativeQuery = true) + List findAllWithinViewport(@Param("wkt") String wkt); + + @Query(""" + select distinct p + from Partner p + where lower(p.name) like lower(concat('%', :keyword, '%')) + """) + List searchPartnerByKeyword( + @Param("keyword") String keyword + ); + + } diff --git a/src/main/java/com/assu/server/domain/partner/service/PartnerService.java b/src/main/java/com/assu/server/domain/partner/service/PartnerService.java index 0922855a..7edd9165 100644 --- a/src/main/java/com/assu/server/domain/partner/service/PartnerService.java +++ b/src/main/java/com/assu/server/domain/partner/service/PartnerService.java @@ -1,4 +1,9 @@ package com.assu.server.domain.partner.service; +import com.assu.server.domain.partner.dto.PartnerResponseDTO; + public interface PartnerService { + + PartnerResponseDTO.RandomAdminResponseDTO getRandomAdmin(Long partnerId); + } diff --git a/src/main/java/com/assu/server/domain/partner/service/PartnerServiceImpl.java b/src/main/java/com/assu/server/domain/partner/service/PartnerServiceImpl.java index 25139225..f865fc28 100644 --- a/src/main/java/com/assu/server/domain/partner/service/PartnerServiceImpl.java +++ b/src/main/java/com/assu/server/domain/partner/service/PartnerServiceImpl.java @@ -1,4 +1,60 @@ package com.assu.server.domain.partner.service; -public class PartnerServiceImpl { +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.admin.repository.AdminRepository; +import com.assu.server.domain.partner.dto.PartnerResponseDTO; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partner.repository.PartnerRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; + +import com.assu.server.global.exception.DatabaseException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PartnerServiceImpl implements PartnerService { + + private final PartnerRepository partnerRepository; + private final AdminRepository adminRepository; + + @Override + public PartnerResponseDTO.RandomAdminResponseDTO getRandomAdmin(Long partnerId) { + Partner partner = partnerRepository.findById(partnerId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + + long total = adminRepository.countPartner(partnerId); + if (total == 0) { + throw new DatabaseException(ErrorStatus.NO_SUCH_ADMIN); + } + + int limit = (int) Math.min(2, total); + + int offset = 0; + if (total > 2) { + offset = ThreadLocalRandom.current().nextInt(0, (int)(total - limit + 1)); + } + + List picked = adminRepository.findPartnerWithOffset(partner.getId(), offset, limit); + + List admins = picked.stream() + .map(a -> PartnerResponseDTO.AdminLiteDTO.builder() + .adminId(a.getId()) + .adminAddress(a.getOfficeAddress()) + .adminDetailAddress(a.getDetailAddress()) + .adminName(a.getName()) + .adminUrl(a.getMember().getProfileUrl()) + .adminPhone(a.getMember().getPhoneNum()) + .build()) + .collect(Collectors.toList()); + + return PartnerResponseDTO.RandomAdminResponseDTO.builder() + .admins(admins) + .build(); + } + } diff --git a/src/main/java/com/assu/server/domain/partnership/controller/PaperController.java b/src/main/java/com/assu/server/domain/partnership/controller/PaperController.java new file mode 100644 index 00000000..6ae860c1 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/controller/PaperController.java @@ -0,0 +1,43 @@ +package com.assu.server.domain.partnership.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.partnership.dto.PaperResponseDTO; +import com.assu.server.domain.partnership.service.PaperQueryService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +@RestController +@Tag(name = "제휴 관련 내용 '조회' api", description = "상세 설명") +@RequiredArgsConstructor +public class PaperController { + + private final PaperQueryService paperQueryService; + + @GetMapping("/store/{storeId}/papers") + @Operation(summary = "유저에게 적용 가능한 제휴 컨텐츠 조회", description = "유저가 속한 단과대, 학부 admin_id과 store_id 를 가진 제휴 컨텐츠 제공") + @Parameters({ + @Parameter(name = "storeId", description = "QR에서 추출한 storeId를 입력해주세요") + }) + public ResponseEntity> getStorePaperContent(@PathVariable Long storeId, + @AuthenticationPrincipal PrincipalDetails pd + ) { + PaperResponseDTO.partnershipContent result = paperQueryService.getStorePaperContent(storeId, pd.getMember()); + + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus.PAPER_STORE_HISTORY_SUCCESS, result)); + } + +} diff --git a/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java b/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java new file mode 100644 index 00000000..c8dbd95b --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java @@ -0,0 +1,187 @@ +package com.assu.server.domain.partnership.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.notification.service.NotificationCommandService; +import com.assu.server.domain.partnership.dto.PartnershipRequestDTO; +import com.assu.server.domain.partnership.dto.PartnershipResponseDTO; +import com.assu.server.domain.partnership.service.PartnershipService; +import com.assu.server.domain.store.repository.StoreRepository; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +@RestController +@Tag(name = "제휴 요청 api", description = "최종적으로 @@ 제휴를 요청할때 사용하는 api ") +@RequiredArgsConstructor +@RequestMapping("/partnership") +public class PartnershipController { + + private final PartnershipService partnershipService; + private final NotificationCommandService notificationCommandService; + + private final StoreRepository storeRepository; + + @PostMapping("/usage") + @Operation(summary= "유저의 인증 후 최종적으로 호출", description = "인증완료 화면 전에 바로 호출되어 유저의 제휴 내역에 데이터가 들어가게 됩니다. (개인 인증인 경우도 포함됩니다.)") + public ResponseEntity> finalPartnershipRequest( + @AuthenticationPrincipal PrincipalDetails userDetails,@RequestBody PartnershipRequestDTO.finalRequest dto + ) { + Member member = userDetails.getMember(); + partnershipService.recordPartnershipUsage(dto, member); + + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus.USER_PAPER_REQUEST_SUCCESS, null)); + } + + @PatchMapping("/proposal") + @Operation( + summary = "제휴 제안서 내용 수정 API", + description = "제공 서비스 종류(SERVICE, DISCOUNT), 서비스 제공 기준(PRICE, HEADCOUNT), 서비스 제공 항목, 카테고리, 할인율을 상황에 맞게 작성해주세요." + ) + public BaseResponse updatePartnership( + @RequestBody PartnershipRequestDTO.WritePartnershipRequestDTO request, + @AuthenticationPrincipal PrincipalDetails pd + ){ + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.updatePartnership(request, pd.getId())); + } + + @Operation( + summary = "제휴 제안서 수동 등록 API", + description = "제공 서비스 종류(SERVICE, DISCOUNT), 서비스 제공 기준(PRICE, HEADCOUNT), 서비스 제공 항목, 카테고리, 할인율을 상황에 맞게 작성하고, 계약서 이미지를 업로드하세요." + ) + @PostMapping(value = "/passivity", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public BaseResponse createManualPartnership( + @RequestPart("request") @Parameter PartnershipRequestDTO.ManualPartnershipRequestDTO request, + @Parameter( + description = "계약서 이미지 파일", + required = true, + content = @Content(mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE, + schema = @Schema(type = "string", format = "binary")) + ) + MultipartFile contractImage, + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.createManualPartnership(request, pd.getId(), contractImage)); + } + + @Operation( + summary = "제휴 중인 가게 조회 API", + description = "전체를 조회하려면 all을 true로, 가장 최근 두 건을 조회하려면 all을 false로 설정해주세요." + ) + @GetMapping("/admin") + public BaseResponse> listForAdmin( + @RequestParam(name = "all", defaultValue = "false") boolean all, + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.listPartnershipsForAdmin(all, pd.getId())); + } + + @Operation( + summary = "제휴 중인 관리자 조회 API", + description = "전체를 조회하려면 all을 true로, 가장 최근 두 건을 조회하려면 all을 false로 설정해주세요." + ) + @GetMapping("/partner") + public BaseResponse> listForPartner( + @RequestParam(name = "all", defaultValue = "false") boolean all, + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.listPartnershipsForPartner(all, pd.getId())); + } + + @Operation( + summary = "제휴 상세조회 API", + description = "제휴 아이디를 입력하세요." + ) + @GetMapping("/{partnershipId}") + public BaseResponse getPartnership( + @PathVariable Long partnershipId + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.getPartnership(partnershipId)); + } + + @Operation( + summary = "제휴 상태 업데이트 API", + description = "제휴 ID와 바꾸고 싶은 상태를 입력하세요(SUSPEND/ACTIVE/INACTIVE/BLANK)" + ) + @PatchMapping("/{partnershipId}/status") + public BaseResponse updatePartnershipStatus( + @PathVariable("partnershipId") Long partnershipId, + @RequestBody PartnershipRequestDTO.UpdateRequestDTO request + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.updatePartnershipStatus(partnershipId, request)); + } + + @PostMapping("/proposal/draft") + @Operation( + summary = "제휴 제안서 초안 생성 API", + description = "현재 로그인한 관리자(Admin)가 내용이 비어있는 제휴 제안서를 초안 상태로 생성합니다." + ) + public BaseResponse createDraftPartnership( + @RequestBody PartnershipRequestDTO.CreateDraftRequestDTO request, + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.createDraftPartnership(request, pd.getId())); + } + + @DeleteMapping("/proposal/delete/{paperId}") + @Operation( + summary = "제휴 제안서 삭제 API", + description = "특정 제휴 제안서(paperId)와 관련된 모든 데이터를 삭제합니다." + ) + public BaseResponse deletePartnership( + @PathVariable Long paperId + ) { + partnershipService.deletePartnership(paperId); + return BaseResponse.onSuccess(SuccessStatus._OK, null); + } + + @GetMapping("/suspended") + @Operation( + summary = "대기 중인 제휴 계약서 조회 API", + description = "현재 로그인한 관리자(Admin)가 대기 중인 제휴 계약서를 모두 조회하여 리스트로 반환합니다." + ) + public BaseResponse> suspendPartnership( + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.getSuspendedPapers(pd.getId())); + } + + @GetMapping("/check/admin") + @Operation( + summary = "관리자 채팅방 내 제휴 확인 API", + description = "현재 로그인한 관리자(Admin)가 파라미터로 받은 partnerId를 가진 상대 제휴업체(Partner)와 맺고 있는 제휴를 조회합니다. 비활성화되지 않은 가장 최근 제휴 1건을 조회합니다." + ) + public BaseResponse checkAdminPartnership( + @RequestParam("partnerId") Long partnerId, + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.checkPartnershipWithPartner(pd.getId(), partnerId)); + } + + @GetMapping("/check/partner") + @Operation( + summary = "제휴업체 채팅방 내 제휴 확인 API", + description = "현재 로그인한 제휴업체(Partner)가 파라미터로 받은 AdminId를 가진 상대 관리자(Admin)과 맺고 있는 제휴를 조회합니다. 비활성화되지 않은 가장 최근 제휴 1건을 조회합니다." + ) + public BaseResponse checkPartnerPartnership( + @RequestParam("adminId") Long adminId, + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.checkPartnershipWithAdmin(pd.getId(), adminId)); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/controller/PatnershipController.java b/src/main/java/com/assu/server/domain/partnership/controller/PatnershipController.java deleted file mode 100644 index 117e59c4..00000000 --- a/src/main/java/com/assu/server/domain/partnership/controller/PatnershipController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.partnership.controller; - -public class PatnershipController { -} diff --git a/src/main/java/com/assu/server/domain/partnership/converter/PartnershipConverter.java b/src/main/java/com/assu/server/domain/partnership/converter/PartnershipConverter.java new file mode 100644 index 00000000..e62f2637 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/converter/PartnershipConverter.java @@ -0,0 +1,371 @@ +package com.assu.server.domain.partnership.converter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.partnership.dto.PaperContentResponseDTO; +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partnership.dto.PartnershipRequestDTO; +import com.assu.server.domain.partnership.dto.PartnershipResponseDTO; +import com.assu.server.domain.partnership.entity.Goods; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; +import com.assu.server.domain.user.entity.PartnershipUsage; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.domain.store.entity.Store; + +public class PartnershipConverter { + + public static PartnershipUsage toPartnershipUsage(PartnershipRequestDTO.finalRequest dto, Student student, Long paperId) { + return PartnershipUsage.builder() + .adminName(dto.getAdminName()) + .date(LocalDate.now()) + .place(dto.getPlaceName()) + .student(student) + .paperId(paperId) + .isReviewed(false) + .contentId(dto.getContentId()) + .partnershipContent(dto.getPartnershipContent()) + .build(); + } + + public static Paper toDraftPaperEntity(Admin admin, Partner partner, Store store) { + return Paper.builder() + .admin(admin) + .partner(partner) + .store(store) + .partnershipPeriodStart(null) + .partnershipPeriodEnd(null) + .isActivated(ActivationStatus.BLANK) + .contractImageKey(null) + .build(); + } + + public static List toPaperContents( + PartnershipRequestDTO.WritePartnershipRequestDTO partnershipRequestDTO, + Paper paper + ) { + if (partnershipRequestDTO.getOptions() == null || partnershipRequestDTO.getOptions().isEmpty()) { + return Collections.emptyList(); + } + return partnershipRequestDTO.getOptions().stream() + .map(optionDto -> PaperContent.builder() + .note(optionDto.getNote()) // 일단 노트까지 받아서 변환 + .paper(paper) // 어떤 Paper에 속하는지 연결 + .optionType(optionDto.getOptionType()) + .criterionType(optionDto.getCriterionType()) + .people(optionDto.getPeople()) + .cost(optionDto.getCost()) + .category(optionDto.getCategory()) + .discount(optionDto.getDiscountRate()) // DTO의 discountRate를 Entity의 discount에 매핑 + .build()) + .toList(); + } + + + + + public static List> toGoodsBatches( + PartnershipRequestDTO.WritePartnershipRequestDTO partnershipRequestDTO + ) { + if (partnershipRequestDTO == null || partnershipRequestDTO.getOptions().isEmpty()) { + return Collections.emptyList(); + } + return partnershipRequestDTO.getOptions().stream() + .map(optionDto -> { + if (optionDto.getGoods() == null || optionDto.getGoods().isEmpty()) { + return Collections.emptyList(); + } + return optionDto.getGoods().stream() + .map(goodsDto -> Goods.builder() + .belonging(goodsDto.getGoodsName()) // DTO의 goodsName을 엔티티의 belonging에 매핑 + .build()) + .toList(); + }) + .toList(); + } + + + + public static List toContentResponseList(List contents) { + return contents.stream() + .map(PartnershipConverter::toContentResponse) + .toList(); + } + + + public static PaperContentResponseDTO.storePaperContentResponse toContentResponse(PaperContent content) { + List goodsList = extractGoods(content); + Integer peopleValue = extractPeople(content); + + String paperContentText; + if(content.getNote()!= null){ + paperContentText = content.getNote(); + }else{ + paperContentText = buildPaperContentText(content, goodsList, peopleValue); + } + + + return PaperContentResponseDTO.storePaperContentResponse.builder() + .adminId(content.getPaper().getAdmin().getId()) + .adminName(content.getPaper().getAdmin().getName()) + .cost(content.getCost()) + .paperContent(paperContentText) + .contentId(content.getId()) + .goods(goodsList) + .people(peopleValue) + .build(); + } + + + private static List extractGoods(PaperContent content) { + if (content.getOptionType() == OptionType.SERVICE ) { + return content.getGoods().stream() + .map(Goods::getBelonging) + .toList(); + } + return null; + } + + private static Integer extractPeople(PaperContent content) { + if (content.getCriterionType() == CriterionType.HEADCOUNT) { + return content.getPeople(); + } + return null; + } + + private static String buildPaperContentText(PaperContent content, List goodsList, Integer peopleValue) { + String result = ""; + + boolean isGoodsSingle = goodsList != null && goodsList.size() == 1; + boolean isGoodsMultiple = goodsList != null && goodsList.size() > 1; + + // 1. HEADCOUNT + SERVICE + 여러 개 goods + if (content.getCriterionType() == CriterionType.HEADCOUNT && + content.getOptionType() == OptionType.SERVICE && + isGoodsMultiple) { + result = peopleValue + "명 이상 식사 시 " + content.getCategory() + " 제공"; + } + // 2. HEADCOUNT + SERVICE + 단일 goods + else if (content.getCriterionType() == CriterionType.HEADCOUNT && + content.getOptionType() == OptionType.SERVICE && + isGoodsSingle) { + result = peopleValue + "명 이상 식사 시 " + goodsList.get(0) + " 제공"; + } + // 3. HEADCOUNT + DISCOUNT + else if (content.getCriterionType() == CriterionType.HEADCOUNT && + content.getOptionType() == OptionType.DISCOUNT) { + result = peopleValue + "명 이상 식사 시 " + content.getDiscount() + "% 할인"; + } + // 4. PRICE + SERVICE + 여러 개 goods + else if (content.getCriterionType() == CriterionType.PRICE && + content.getOptionType() == OptionType.SERVICE && + isGoodsMultiple) { + result = content.getCost() + "원 이상 주문 시 " + content.getCategory() + " 제공"; + } + // 5. PRICE + SERVICE + 단일 goods + else if (content.getCriterionType() == CriterionType.PRICE && + content.getOptionType() == OptionType.SERVICE && + isGoodsSingle) { + result = content.getCost() + "원 이상 주문 시 " + goodsList.get(0) + " 제공"; + } + // 6. PRICE + DISCOUNT + else if (content.getCriterionType() == CriterionType.PRICE && + content.getOptionType() == OptionType.DISCOUNT) { + result = content.getCost() + "원 이상 주문 시 " + content.getDiscount() + "% 할인"; + } + + return result; + } + + + public static Paper toPaperForManual( + Admin admin, Store store, + LocalDate start, LocalDate end, + ActivationStatus status + ) { + return Paper.builder() + .admin(admin) + .store(store) + .partner(null) + .isActivated(status) + .partnershipPeriodStart(start) + .partnershipPeriodEnd(end) + .build(); + } + + + public static List toPaperContentsForManual( + List options, + Paper paper + ) { + if (options == null || options.isEmpty()) return List.of(); + List list = new ArrayList<>(options.size()); + for (var o : options) { + list.add(PaperContent.builder() + .paper(paper) + .optionType(o.getOptionType()) + .criterionType(o.getCriterionType()) + .note(o.getNote()) + .people(o.getPeople()) + .cost(o.getCost()) + .category(o.getCategory()) + .discount(o.getDiscountRate()) + .build()); + } + return list; + } + + public static List toGoodsForContent( + PartnershipRequestDTO.PartnershipOptionRequestDTO option, + PaperContent content + ) { + if (option.getGoods() == null || option.getGoods().isEmpty()) return List.of(); + List batch = new ArrayList<>(option.getGoods().size()); + for (var g : option.getGoods()) { + batch.add(Goods.builder() + .content(content) + .belonging(g.getGoodsName()) + .build()); + } + return batch; + } + + + public static PartnershipResponseDTO.WritePartnershipResponseDTO writePartnershipResultDTO( + Paper paper, + List contents, + List> goodsBatches + ) { + List optionDTOS = new ArrayList<>(); + if (contents != null) { + for (int i = 0; i < contents.size(); i++) { + PaperContent pc = contents.get(i); + + String note = null; + if(pc.getNote()!= null){ + note = pc.getNote(); + } + List goods = (goodsBatches != null && goodsBatches.size() > i) + ? goodsBatches.get(i) : List.of(); + optionDTOS.add( + PartnershipResponseDTO.PartnershipOptionResponseDTO.builder() + .optionType(pc.getOptionType()) + .criterionType(pc.getCriterionType()) + .people(pc.getPeople()) + .note(note) + .cost(pc.getCost()) + .category(pc.getCategory()) + .discountRate(pc.getDiscount()) + .goods(goodsResultDTO(goods)) + .build() + ); + } + } + + + return PartnershipResponseDTO.WritePartnershipResponseDTO.builder() + .partnershipId(paper.getId()) + .partnershipPeriodStart(paper.getPartnershipPeriodStart()) + .partnershipPeriodEnd(paper.getPartnershipPeriodEnd()) + .adminId(paper.getAdmin() != null ? paper.getAdmin().getId() : null) + .partnerId(paper.getPartner()!= null ? paper.getPartner().getId() : null) // 수동등록이면 null + .storeId(paper.getStore() != null ? paper.getStore().getId() : null) + .storeName(paper.getStore().getName()) + .adminName(paper.getAdmin().getName()) + .isActivated(paper.getIsActivated()) + .options(optionDTOS) + .build(); + } + + public static List goodsResultDTO(List goods) { + if (goods == null || goods.isEmpty()) return List.of(); + return goods.stream() + .map(g -> PartnershipResponseDTO.PartnershipGoodsResponseDTO.builder() + .goodsId(g.getId()) + .goodsName(g.getBelonging()) + .build()) + .toList(); + } + + public static PartnershipResponseDTO.CreateDraftResponseDTO toCreateDraftResponseDTO(Paper paper) { + return PartnershipResponseDTO.CreateDraftResponseDTO.builder() + .paperId(paper.getId()) + .build(); + } + + public static void updatePaperFromDto(Paper paper, PartnershipRequestDTO.WritePartnershipRequestDTO dto) { + paper.setPartnershipPeriodStart(dto.getPartnershipPeriodStart()); + paper.setPartnershipPeriodEnd(dto.getPartnershipPeriodEnd()); + paper.setIsActivated(ActivationStatus.SUSPEND); + } + + public static PartnershipResponseDTO.GetPartnershipDetailResponseDTO getPartnershipResultDTO( + Paper paper, + List contents, + List> goodsBatches + ) { + List allTimestamps = new ArrayList<>(); + + if (paper.getUpdatedAt() != null) allTimestamps.add(paper.getUpdatedAt()); + if (contents != null) { + contents.stream() + .map(BaseEntity::getUpdatedAt) + .filter(Objects::nonNull) + .forEach(allTimestamps::add); + } + if (goodsBatches != null) { + goodsBatches.stream() + .flatMap(List::stream) + .map(BaseEntity::getUpdatedAt) + .filter(Objects::nonNull) + .forEach(allTimestamps::add); + } + + LocalDateTime mostRecentUpdatedAt = allTimestamps.stream() + .max(Comparator.naturalOrder()) + .orElse(paper.getUpdatedAt()); + + List optionDTOS = new ArrayList<>(); + if (contents != null) { + for (int i = 0; i < contents.size(); i++) { + PaperContent pc = contents.get(i); + String note = null; + if(pc.getNote()!= null){ + note = pc.getNote(); + } + List goods = (goodsBatches != null && goodsBatches.size() > i) + ? goodsBatches.get(i) : List.of(); + optionDTOS.add( + PartnershipResponseDTO.PartnershipOptionResponseDTO.builder() + .optionType(pc.getOptionType()) + .criterionType(pc.getCriterionType()) + .people(pc.getPeople()) + .cost(pc.getCost()) + .note(note) + .category(pc.getCategory()) + .discountRate(pc.getDiscount()) + .goods(goodsResultDTO(goods)) + .build() + ); + } + } + + return PartnershipResponseDTO.GetPartnershipDetailResponseDTO.builder() + .partnershipId(paper.getId()) + .updatedAt(mostRecentUpdatedAt) // 가장 최근 UpdatedAt 값 가져오기 + .partnershipPeriodStart(paper.getPartnershipPeriodStart()) + .partnershipPeriodEnd(paper.getPartnershipPeriodEnd()) + .adminId(paper.getAdmin() != null ? paper.getAdmin().getId() : null) + .partnerId(paper.getPartner()!= null ? paper.getPartner().getId() : null) // 수동등록이면 null + .storeId(paper.getStore() != null ? paper.getStore().getId() : null) + .options(optionDTOS) + .build(); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/converter/PatnershipConverter.java b/src/main/java/com/assu/server/domain/partnership/converter/PatnershipConverter.java deleted file mode 100644 index 9f9cbc58..00000000 --- a/src/main/java/com/assu/server/domain/partnership/converter/PatnershipConverter.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.partnership.converter; - -public class PatnershipConverter { -} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PatnershipRequestDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PaperContentRequestDTO.java similarity index 57% rename from src/main/java/com/assu/server/domain/partnership/dto/PatnershipRequestDTO.java rename to src/main/java/com/assu/server/domain/partnership/dto/PaperContentRequestDTO.java index 99e4c2b1..c7596636 100644 --- a/src/main/java/com/assu/server/domain/partnership/dto/PatnershipRequestDTO.java +++ b/src/main/java/com/assu/server/domain/partnership/dto/PaperContentRequestDTO.java @@ -1,4 +1,4 @@ package com.assu.server.domain.partnership.dto; -public class PatnershipRequestDTO { +public class PaperContentRequestDTO { } diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PaperContentResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PaperContentResponseDTO.java new file mode 100644 index 00000000..46dd3255 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PaperContentResponseDTO.java @@ -0,0 +1,24 @@ +package com.assu.server.domain.partnership.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class PaperContentResponseDTO { + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class storePaperContentResponse{ + Long adminId; + String adminName; + String paperContent; + Long contentId; + List goods; + Integer people; + Long cost; + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PaperResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PaperResponseDTO.java new file mode 100644 index 00000000..85427c94 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PaperResponseDTO.java @@ -0,0 +1,19 @@ +package com.assu.server.domain.partnership.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class PaperResponseDTO { + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class partnershipContent{ + String storeName; + Long storeId; + List contents; + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipRequestDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipRequestDTO.java new file mode 100644 index 00000000..65dea839 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipRequestDTO.java @@ -0,0 +1,76 @@ +package com.assu.server.domain.partnership.dto; +import java.util.List; +import lombok.Getter; + +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.map.dto.SelectedPlacePayload; +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.time.LocalDate; + +public class PartnershipRequestDTO { + @Getter + public static class finalRequest{ + Long storeId; + String tableNumber; + String adminName; + String placeName; + String partnershipContent; + Long contentId; + Long discount; + List userIds; + } + + @Getter + public static class WritePartnershipRequestDTO { + private Long paperId; // 제휴 제안서 아이디 + private LocalDate partnershipPeriodStart; + private LocalDate partnershipPeriodEnd; + private List options; // 동적으로 받는 제안 항목 + } + + @Getter + public static class PartnershipOptionRequestDTO { + private OptionType optionType; // 제공 서비스 종류 (서비스 제공, 할인) + private CriterionType criterionType; // 서비스 제공 기준 (금액, 인원) + private Integer people; + private Long cost; + private String category; + private Long discountRate; + private String note; + private List goods; // 서비스 제공 항목 + + } + + @Getter + public static class PartnershipGoodsRequestDTO { + private String goodsName; + } + + @Getter + @Setter + @NoArgsConstructor + public static class UpdateRequestDTO { + private String status; + } + + @Getter + @Setter + @NoArgsConstructor + public static class ManualPartnershipRequestDTO { + private String storeName; + @NotNull private SelectedPlacePayload selectedPlace; + private String storeDetailAddress; + private LocalDate partnershipPeriodStart; + private LocalDate partnershipPeriodEnd; + private List options; + } + + @Getter + public static class CreateDraftRequestDTO { + private Long partnerId; // 제안서를 보낼 제휴업체 ID + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipResponseDTO.java new file mode 100644 index 00000000..712273cf --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipResponseDTO.java @@ -0,0 +1,147 @@ +package com.assu.server.domain.partnership.dto; + + +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public class PartnershipResponseDTO { + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class WritePartnershipResponseDTO { + private Long partnershipId; + private LocalDate partnershipPeriodStart; + private LocalDate partnershipPeriodEnd; + private Long adminId; + private Long partnerId; + private Long storeId; + private String storeName; + private String adminName; + private ActivationStatus isActivated; + private List options; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class PartnershipOptionResponseDTO { + private OptionType optionType; + private CriterionType criterionType; + private Integer people; + private Long cost; + private String note; + private String category; + private Long discountRate; + + private List goods; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class PartnershipGoodsResponseDTO { + private Long goodsId; + private String goodsName; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class UpdateResponseDTO { + private Long partnershipId; + private String prevStatus; + private String newStatus; + private LocalDateTime changedAt; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ManualPartnershipResponseDTO { + private Long storeId; + private boolean storeCreated; + private boolean storeActivated; + private String status; + private String contractImageUrl; + private WritePartnershipResponseDTO partnership; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CreateDraftResponseDTO { + private Long paperId; // 생성된 빈 제안서의 ID + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SuspendedPaperDTO { + private Long paperId; + private String partnerName; + private LocalDateTime createdAt; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AdminPartnershipWithPartnerResponseDTO { + private Long paperId; + private boolean isPartnered; // 제휴 여부 + private String status; // 제휴 상태 + private Long partnerId; + private String partnerName; + private String partnerAddress; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class PartnerPartnershipWithAdminResponseDTO { + private Long paperId; + private boolean isPartnered; // 제휴 여부 + private String status; // 제휴 상태 + private Long adminId; + private String adminName; + private String adminAddress; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class GetPartnershipDetailResponseDTO { + private Long partnershipId; + private LocalDateTime updatedAt; + private LocalDate partnershipPeriodStart; + private LocalDate partnershipPeriodEnd; + private Long adminId; + private Long partnerId; + private Long storeId; + private List options; + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PatnershipResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PatnershipResponseDTO.java deleted file mode 100644 index 4c6f5e05..00000000 --- a/src/main/java/com/assu/server/domain/partnership/dto/PatnershipResponseDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.partnership.dto; - -public class PatnershipResponseDTO { -} diff --git a/src/main/java/com/assu/server/domain/partnership/entity/Goods.java b/src/main/java/com/assu/server/domain/partnership/entity/Goods.java new file mode 100644 index 00000000..9a2da2ac --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/entity/Goods.java @@ -0,0 +1,34 @@ +package com.assu.server.domain.partnership.entity; + +import com.assu.server.domain.common.entity.BaseEntity; +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class Goods extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") + private PaperContent content; + + private String belonging; + +} diff --git a/src/main/java/com/assu/server/domain/partnership/entity/Paper.java b/src/main/java/com/assu/server/domain/partnership/entity/Paper.java new file mode 100644 index 00000000..eea1810e --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/entity/Paper.java @@ -0,0 +1,52 @@ +package com.assu.server.domain.partnership.entity; +import java.time.LocalDate; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.store.entity.Store; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class Paper extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + + @Setter + private LocalDate partnershipPeriodStart; // LocalDate vs String + + @Setter + private LocalDate partnershipPeriodEnd; + + @Setter + @Enumerated(EnumType.STRING) + private ActivationStatus isActivated; + + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_id") + private Admin admin; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "partner_id") + private Partner partner; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id") + private Store store; + + @Column(name = "contract_image_key", length = 512) + private String contractImageKey; + + public void updateContractImageKey(String key) { this.contractImageKey = key; } +} diff --git a/src/main/java/com/assu/server/domain/partnership/entity/PaperContent.java b/src/main/java/com/assu/server/domain/partnership/entity/PaperContent.java new file mode 100644 index 00000000..94cd9738 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/entity/PaperContent.java @@ -0,0 +1,49 @@ +package com.assu.server.domain.partnership.entity; +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class PaperContent extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "paper_id") + private Paper paper; + + @Enumerated(EnumType.STRING) + private CriterionType criterionType; + + @Enumerated(EnumType.STRING) + private OptionType optionType; + + private String note; + + private Integer people; + + private Long cost; + + private String category; + + private Long discount; + + @OneToMany(mappedBy = "content", cascade = CascadeType.ALL, orphanRemoval = true) + private List goods = new ArrayList<>(); + +} diff --git a/src/main/java/com/assu/server/domain/partnership/entity/Patnership.java b/src/main/java/com/assu/server/domain/partnership/entity/Patnership.java deleted file mode 100644 index 55d828a2..00000000 --- a/src/main/java/com/assu/server/domain/partnership/entity/Patnership.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.partnership.entity; - -public class Patnership { -} diff --git a/src/main/java/com/assu/server/domain/partnership/entity/enums/CriterionType.java b/src/main/java/com/assu/server/domain/partnership/entity/enums/CriterionType.java new file mode 100644 index 00000000..bf77b82e --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/entity/enums/CriterionType.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.partnership.entity.enums; + +public enum CriterionType { + PRICE, HEADCOUNT +} diff --git a/src/main/java/com/assu/server/domain/partnership/entity/enums/OptionType.java b/src/main/java/com/assu/server/domain/partnership/entity/enums/OptionType.java new file mode 100644 index 00000000..cd5db5fd --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/entity/enums/OptionType.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.partnership.entity.enums; + +public enum OptionType { + SERVICE, DISCOUNT +} diff --git a/src/main/java/com/assu/server/domain/partnership/repository/GoodsRepository.java b/src/main/java/com/assu/server/domain/partnership/repository/GoodsRepository.java new file mode 100644 index 00000000..389f700b --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/repository/GoodsRepository.java @@ -0,0 +1,23 @@ +package com.assu.server.domain.partnership.repository; + +import java.util.List; +import java.util.Optional; + +import com.assu.server.domain.partnership.entity.Goods; +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 java.util.List; + +public interface GoodsRepository extends JpaRepository { + + List findByContentId(Long contentId); + + List findByContentIdIn(List contentIds); + + @Modifying + @Query("delete from Goods g where g.content.id in :contentIds") + void deleteAllByContentIds(@Param("contentIds") List contentIds); +} diff --git a/src/main/java/com/assu/server/domain/partnership/repository/PaperContentRepository.java b/src/main/java/com/assu/server/domain/partnership/repository/PaperContentRepository.java new file mode 100644 index 00000000..945d3b81 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/repository/PaperContentRepository.java @@ -0,0 +1,73 @@ +package com.assu.server.domain.partnership.repository; + +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface PaperContentRepository extends JpaRepository { + + Optional findTopByPaperStoreIdOrderByIdDesc(Long storeId); + + List findByPaperId(Long paperId); + + @Query(""" + select distinct pc + from PaperContent pc + left join fetch pc.goods g + where pc.paper.id in :paperIds + """) + List findAllByPaperIdInFetchGoods(@Param("paperIds") List paperIds); + + @Query(""" + select distinct pc + from PaperContent pc + left join fetch pc.goods g + where pc.paper.id in :paperIds + """) + List findAllByOnePaperIdInFetchGoods(@Param("paperIds") Long paperIds); + + Optional findById(Long id); + + @Query(value = """ +SELECT pc.* +FROM paper_content pc +JOIN paper p ON p.id = pc.paper_id +WHERE p.store_id = :storeId + AND p.is_activated = :active + AND CURRENT_DATE BETWEEN p.partnership_period_start AND p.partnership_period_end + AND ( + (pc.option_type = :service AND + ((pc.criterion_type = :price AND pc.cost IS NOT NULL) + OR (pc.criterion_type = :headcount AND pc.cost IS NOT NULL AND pc.people IS NOT NULL))) + OR (pc.option_type = :discount AND pc.discount IS NOT NULL) + ) +ORDER BY + CASE pc.option_type + WHEN :service THEN 0 ELSE 1 END, -- SERVICE 우선 + CASE pc.criterion_type + WHEN :price THEN 0 + WHEN :headcount THEN 1 + ELSE 2 END, -- PRICE > HEADCOUNT > 기타 + pc.updated_at DESC, + pc.id DESC +LIMIT 1 +""", nativeQuery = true) + Optional findLatestValidByStoreIdNative( + @Param("storeId") Long storeId, + @Param("active") String active, // ActivationStatus.ACTIVE.name() + @Param("service") String service, // OptionType.SERVICE.name() + @Param("discount") String discount, // OptionType.DISCOUNT.name() + @Param("price") String price, // CriterionType.PRICE.name() + @Param("headcount") String headcount // CriterionType.HEADCOUNT.name() + ); + + Optional findTopByPaperIdOrderByIdDesc(Long paperId); + +} diff --git a/src/main/java/com/assu/server/domain/partnership/repository/PaperRepository.java b/src/main/java/com/assu/server/domain/partnership/repository/PaperRepository.java new file mode 100644 index 00000000..8c67b899 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/repository/PaperRepository.java @@ -0,0 +1,65 @@ +package com.assu.server.domain.partnership.repository; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partnership.entity.Paper; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface PaperRepository extends JpaRepository { + + @Query("SELECT p FROM Paper p " + + "WHERE p.store.id = :storeId " + + "AND p.admin.id = :adminId " + + "AND p.isActivated = :status") + List findByStoreIdAndAdminIdAndStatus( + @Param("storeId")Long storeId, + @Param("adminId")Long adminId, + @Param("status")ActivationStatus status); + + + // Admin 기준 (ACTIVE) + List findByAdmin_IdAndIsActivated(Long adminId, ActivationStatus status, Sort sort); + Page findByAdmin_IdAndIsActivated(Long adminId, ActivationStatus status, Pageable pageable); + Optional findTopByAdmin_IdAndPartner_IdAndIsActivatedOrderByIdDesc( + Long adminId, Long partnerId, ActivationStatus isActivated + ); + + boolean existsByAdmin_IdAndPartner_IdAndIsActivatedIn(Long adminId, Long partnerId, List statuses); + Optional findTopByAdmin_IdAndPartner_IdAndIsActivatedInOrderByIdDesc(Long adminId, Long partnerId, List statuses); + + // Admin 기준 (SUSPEND) + @Query(" select p from Paper p left join fetch p.partner pt left join fetch p.store s where p.isActivated = :status and p.admin.id = :adminId and p.partner is null order by p.createdAt desc") + List findAllSuspendedByAdminWithNoPartner( + @Param("status") ActivationStatus status, + @Param("adminId") Long adminId + ); + + // Partner 기준 (ACTIVE) + List findByPartner_IdAndIsActivated(Long partnerId, ActivationStatus status, Sort sort); + Page findByPartner_IdAndIsActivated(Long partnerId, ActivationStatus status, Pageable pageable); + Optional findTopPaperByStoreId(Long storeId); + long countByStore_Id(Long storeId); + + @Query(""" + SELECT p FROM Paper p + WHERE p.admin.id IN :adminIds + AND p.isActivated = :status + AND p.partnershipPeriodStart <= :today + AND p.partnershipPeriodEnd >= :today + """) + List findActivePapersByAdminIds(@Param("adminIds") List adminIds, + @Param("today") LocalDate today, + @Param("status") ActivationStatus status); + + List findByStoreIdAndAdminIdAndIsActivated(Long storeId, Long adminId, ActivationStatus isActivated); +} diff --git a/src/main/java/com/assu/server/domain/partnership/repository/PartnershipRepository.java b/src/main/java/com/assu/server/domain/partnership/repository/PartnershipRepository.java new file mode 100644 index 00000000..0d080d9a --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/repository/PartnershipRepository.java @@ -0,0 +1,12 @@ +package com.assu.server.domain.partnership.repository; + +import com.assu.server.domain.partnership.entity.Paper; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +public interface PartnershipRepository extends JpaRepository { + + Optional findFirstByAdmin_IdAndStore_IdOrderByIdAsc(Long adminId, Long storeId); +} diff --git a/src/main/java/com/assu/server/domain/partnership/repository/PatnershipRepository.java b/src/main/java/com/assu/server/domain/partnership/repository/PatnershipRepository.java deleted file mode 100644 index 7971fb1a..00000000 --- a/src/main/java/com/assu/server/domain/partnership/repository/PatnershipRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.partnership.repository; - -public class PatnershipRepository { -} diff --git a/src/main/java/com/assu/server/domain/partnership/service/PaperContentService.java b/src/main/java/com/assu/server/domain/partnership/service/PaperContentService.java new file mode 100644 index 00000000..2c2051a8 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/service/PaperContentService.java @@ -0,0 +1,9 @@ +package com.assu.server.domain.partnership.service; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.assu.server.domain.partnership.entity.PaperContent; + +public interface PaperContentService { + +} diff --git a/src/main/java/com/assu/server/domain/partnership/service/PaperContentServiceImpl.java b/src/main/java/com/assu/server/domain/partnership/service/PaperContentServiceImpl.java new file mode 100644 index 00000000..38720e52 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/service/PaperContentServiceImpl.java @@ -0,0 +1,12 @@ +package com.assu.server.domain.partnership.service; + +import org.springframework.stereotype.Service; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + + +@Service +@RequiredArgsConstructor +public class PaperContentServiceImpl implements PaperContentService { +} diff --git a/src/main/java/com/assu/server/domain/partnership/service/PaperQueryService.java b/src/main/java/com/assu/server/domain/partnership/service/PaperQueryService.java new file mode 100644 index 00000000..bdc9ebe7 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/service/PaperQueryService.java @@ -0,0 +1,8 @@ +package com.assu.server.domain.partnership.service; + +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.partnership.dto.PaperResponseDTO; + +public interface PaperQueryService { + PaperResponseDTO.partnershipContent getStorePaperContent(Long storeId, Member member); +} diff --git a/src/main/java/com/assu/server/domain/partnership/service/PaperQueryServiceImpl.java b/src/main/java/com/assu/server/domain/partnership/service/PaperQueryServiceImpl.java new file mode 100644 index 00000000..8d32e652 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/service/PaperQueryServiceImpl.java @@ -0,0 +1,85 @@ +package com.assu.server.domain.partnership.service; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.admin.service.AdminService; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.partnership.converter.PartnershipConverter; +import com.assu.server.domain.partnership.dto.PaperContentResponseDTO; +import com.assu.server.domain.partnership.dto.PaperResponseDTO; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.partnership.repository.PaperContentRepository; +import com.assu.server.domain.partnership.repository.PaperRepository; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.repository.StoreRepository; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.GeneralException; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class PaperQueryServiceImpl implements PaperQueryService { + + private final AdminService adminService; + private final PaperRepository paperRepository; + private final PaperContentRepository contentRepository; + private final StoreRepository storeRepository; + + @Override + public PaperResponseDTO.partnershipContent getStorePaperContent(Long storeId, Member member){ + + // 역할이 학생이 아닌 경우 : 이미 type별로 ui를 분기 시켜놔서 그럴일 없을 것 같긴 하지만 혹시 몰라서 처리함 + if(member.getRole() != UserRole.STUDENT) + throw new GeneralException(ErrorStatus.NO_STUDENT_TYPE); + + Student student = member.getStudentProfile(); + + // 유저의 학교, 단과대, 학부 정보를 조회하여 일치하는 admin을 찾습니다. + List adminList = adminService.findMatchingAdmins( + student.getUniversity(), + student.getDepartment(), + student.getMajor()); + + // 추출한 admin, store와 일치하는 paperId 를 추출합니다. + List paperList = adminList.stream() + .flatMap(admin -> + paperRepository.findByStoreIdAndAdminIdAndStatus(storeId, admin.getId(), ActivationStatus.ACTIVE) + .stream()).toList(); + + //paperId로 paperContent 를 조회 + List contentList = paperList.stream() + .flatMap(paper-> + contentRepository.findByPaperId(paper.getId()).stream() + ).toList(); + + + + Store store = storeRepository.findById(storeId).orElseThrow( + () -> new GeneralException(ErrorStatus.NO_SUCH_STORE) + ); + + // dto 변환 + List contents = + PartnershipConverter.toContentResponseList(contentList); + + // partnershipContent DTO 생성 + return PaperResponseDTO.partnershipContent.builder() + .storeName(store.getName()) + .storeId(storeId) + .contents(contents) + .build(); + + + } + +} diff --git a/src/main/java/com/assu/server/domain/partnership/service/PartnershipService.java b/src/main/java/com/assu/server/domain/partnership/service/PartnershipService.java new file mode 100644 index 00000000..783a57a6 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/service/PartnershipService.java @@ -0,0 +1,47 @@ +package com.assu.server.domain.partnership.service; + +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.partnership.dto.PartnershipRequestDTO; +import com.assu.server.domain.partnership.dto.PartnershipResponseDTO; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public interface PartnershipService { + + // 제휴 제안서 수정 + PartnershipResponseDTO.WritePartnershipResponseDTO updatePartnership( + PartnershipRequestDTO.WritePartnershipRequestDTO request, + Long memberId + ); + + void recordPartnershipUsage(PartnershipRequestDTO.finalRequest dto, Member member); + + // 제휴업체/관리자 맺은 제휴 리스트 + List listPartnershipsForAdmin(boolean all, Long partnerId); + List listPartnershipsForPartner(boolean all, Long adminId); + + // 제휴 제안서 조회 + PartnershipResponseDTO.GetPartnershipDetailResponseDTO getPartnership(Long partnershipId); + List getSuspendedPapers(Long adminId); + + // 제휴 상태 업데이트 + PartnershipResponseDTO.UpdateResponseDTO updatePartnershipStatus(Long partnershipId, PartnershipRequestDTO.UpdateRequestDTO request); + + // 제휴 수동 등록 + PartnershipResponseDTO.ManualPartnershipResponseDTO createManualPartnership( + PartnershipRequestDTO.ManualPartnershipRequestDTO request, + Long adminId, + MultipartFile contractImage + ); + + // 빈 제휴제안서 만들기 + PartnershipResponseDTO.CreateDraftResponseDTO createDraftPartnership(PartnershipRequestDTO.CreateDraftRequestDTO request, Long adminId); + + // 제휴 계약서 삭제 + void deletePartnership(Long paperId); + + // 채팅방 내 제휴 계약서 상태 확인 + PartnershipResponseDTO.AdminPartnershipWithPartnerResponseDTO checkPartnershipWithPartner(Long adminId, Long partnerId); // 관리자가 조회 + PartnershipResponseDTO.PartnerPartnershipWithAdminResponseDTO checkPartnershipWithAdmin(Long partnerId, Long adminId); // 제휴업체가 조회 +} diff --git a/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java b/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java new file mode 100644 index 00000000..76679a2b --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java @@ -0,0 +1,558 @@ +package com.assu.server.domain.partnership.service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import com.assu.server.domain.chat.dto.ChatRequestDTO; +import com.assu.server.domain.chat.entity.ChattingRoom; +import com.assu.server.domain.chat.repository.ChatRepository; +import com.assu.server.domain.chat.service.ChatService; +import org.springframework.stereotype.Service; + +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.notification.service.NotificationCommandService; +import com.assu.server.domain.partnership.converter.PartnershipConverter; +import com.assu.server.domain.partnership.dto.PartnershipRequestDTO; +import com.assu.server.domain.user.entity.PartnershipUsage; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.domain.user.repository.PartnershipUsageRepository; +import com.assu.server.domain.user.repository.StudentRepository; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.admin.repository.AdminRepository; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partner.repository.PartnerRepository; +import com.assu.server.domain.partnership.dto.PartnershipResponseDTO; +import com.assu.server.domain.partnership.entity.Goods; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.partnership.repository.GoodsRepository; +import com.assu.server.domain.partnership.repository.PaperContentRepository; +import com.assu.server.domain.partnership.repository.PaperRepository; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.repository.StoreRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import com.assu.server.global.exception.GeneralException; +import com.assu.server.infra.s3.AmazonS3Manager; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + + + +@Service +@Transactional +@RequiredArgsConstructor +public class PartnershipServiceImpl implements PartnershipService { + + private final PartnershipUsageRepository partnershipUsageRepository; + private final StudentRepository studentRepository; + private final PaperContentRepository contentRepository; + private final NotificationCommandService notificationService; + private final ChatService chatService; + private final ChatRepository chatRepository; + + + @Override + @Transactional + public void recordPartnershipUsage(PartnershipRequestDTO.finalRequest dto, Member member) { + // 1. 제휴 내용(PaperContent) 조회 + PaperContent content = contentRepository.findById(dto.getContentId()).orElseThrow( + () -> new GeneralException(ErrorStatus.NO_SUCH_CONTENT) + ); + Long paperId = content.getPaper().getId(); + + // 2. 중복을 허용하지 않는 Set을 사용하여 모든 사용자 ID를 수집 + Set uniqueUserIds = new HashSet<>(); + // 요청자 본인 ID 추가 + uniqueUserIds.add(member.getId()); + // DTO에 포함된 사용자 ID들 추가 (null일 경우 무시) + if (dto.getUserIds() != null) { + uniqueUserIds.addAll(dto.getUserIds()); + } + + // 3. 모든 학생 정보를 DB에서 한 번의 쿼리로 조회 (N+1 문제 해결) + List studentsToUpdate = studentRepository.findAllById(uniqueUserIds); + + // 4. 조회된 학생들에 대해 PartnershipUsage 생성 및 스탬프 업데이트 + List usages = studentsToUpdate.stream() + .map(student -> { + student.setStamp(); + return PartnershipConverter.toPartnershipUsage(dto, student, paperId); + }) + .collect(Collectors.toList()); + + // 5. 생성된 모든 Usage 기록을 한 번에 저장 + partnershipUsageRepository.saveAll(usages); + Store store = storeRepository.findById(dto.getStoreId()).orElseThrow( + () -> new GeneralException(ErrorStatus.NO_SUCH_STORE) + ); + Partner partner = store.getPartner(); + Long partnerId = partner.getId(); + notificationService.sendOrder(partnerId, 0L, dto.getTableNumber(), dto.getPartnershipContent()); + + // @Transactional 환경에서는 studentsToUpdate의 변경 사항(스탬프)이 자동으로 DB에 반영됩니다. + } + + + + + private final PaperRepository paperRepository; + private final PaperContentRepository paperContentRepository; + private final GoodsRepository goodsRepository; + + private final AdminRepository adminRepository; + private final PartnerRepository partnerRepository; + private final StoreRepository storeRepository; + + private final AmazonS3Manager amazonS3Manager; + + @Override + @Transactional + public PartnershipResponseDTO.WritePartnershipResponseDTO updatePartnership( + PartnershipRequestDTO.WritePartnershipRequestDTO request, + Long memberId + ) { + if (request == null || memberId == null) { + throw new DatabaseException(ErrorStatus._BAD_REQUEST); + } + + Paper paper = paperRepository.findById(request.getPaperId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PAPER)); + + Partner partner = partnerRepository.findById(memberId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + + Admin admin = adminRepository.findById(paper.getAdmin().getId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); + + Store store = storeRepository.findByPartner(partner) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + + PartnershipConverter.updatePaperFromDto(paper, request); + + List existingContents = paperContentRepository.findByPaperId(request.getPaperId()); + if (!existingContents.isEmpty()) { + List contentIds = existingContents.stream().map(PaperContent::getId).toList(); + goodsRepository.deleteAllByContentIds(contentIds); + paperContentRepository.deleteAll(existingContents); + } + + List newContents = PartnershipConverter.toPaperContents(request, paper); + newContents = newContents.isEmpty() ? newContents : paperContentRepository.saveAll(newContents); + + List> requestGoodsBatches = PartnershipConverter.toGoodsBatches(request); + + List> attachedGoodsBatches = new ArrayList<>(); + List toPersist = new ArrayList<>(); + + for (int i = 0; i < newContents.size(); i++) { + PaperContent content = newContents.get(i); + List batch = (requestGoodsBatches.size() > i) ? requestGoodsBatches.get(i) : Collections.emptyList(); + List attached = new ArrayList<>(); + for (Goods g : batch) { + Goods entity = Goods.builder() + .content(content) + .belonging(g.getBelonging()) + .build(); + attached.add(entity); + toPersist.add(entity); + } + attachedGoodsBatches.add(attached); + } + if (!toPersist.isEmpty()) { + goodsRepository.saveAll(toPersist); + } + + return PartnershipConverter.writePartnershipResultDTO(paper, newContents, attachedGoodsBatches); + } + + @Override + public List listPartnershipsForAdmin(boolean all, Long adminId) { + Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); + List papers = all + ? paperRepository.findByAdmin_IdAndIsActivated(adminId, ActivationStatus.ACTIVE, sort) + : paperRepository.findByAdmin_IdAndIsActivated(adminId, ActivationStatus.ACTIVE, PageRequest.of(0, 2, sort)).getContent(); + + papers = papers.stream() + .filter(p -> p.getStore() != null) + .toList(); + + return buildPartnershipDTOs(papers); + } + + @Override + public List listPartnershipsForPartner(boolean all, Long partnerId) { + Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); + List papers = all + ? paperRepository.findByPartner_IdAndIsActivated(partnerId, ActivationStatus.ACTIVE, sort) + : paperRepository.findByPartner_IdAndIsActivated(partnerId, ActivationStatus.ACTIVE, PageRequest.of(0, 2, sort)).getContent(); + + papers = papers.stream() + .filter(p -> p.getAdmin() != null) + .toList(); + + return buildPartnershipDTOs(papers); + } + + @Override + @Transactional + public PartnershipResponseDTO.GetPartnershipDetailResponseDTO getPartnership(Long partnershipId) { + Paper paper = paperRepository.findById(partnershipId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PAPER)); + + List contents = paperContentRepository.findAllByOnePaperIdInFetchGoods(partnershipId); + + List> goodsBatches = contents.stream() + .map(pc -> pc.getGoods() == null ? Collections.emptyList() : pc.getGoods()) + .toList(); + + return PartnershipConverter.getPartnershipResultDTO(paper, contents, goodsBatches); + } + + @Override + @Transactional + public List getSuspendedPapers(Long adminId) { + List suspendedPapers = + paperRepository.findAllSuspendedByAdminWithNoPartner(ActivationStatus.SUSPEND, adminId); + + return suspendedPapers.stream() + .map(paper -> PartnershipResponseDTO.SuspendedPaperDTO.builder() + .paperId(paper.getId()) + .partnerName( + paper.getPartner() != null + ? paper.getPartner().getName() + : (paper.getStore() != null ? paper.getStore().getName() : "미등록") + ) + .createdAt(paper.getCreatedAt()) + .build()) + .toList(); + } + + @Override + @Transactional + public PartnershipResponseDTO.UpdateResponseDTO updatePartnershipStatus(Long partnershipId, PartnershipRequestDTO.UpdateRequestDTO request) { + Paper paper = paperRepository.findById(partnershipId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PAPER)); + + if(request == null || request.getStatus() == null){ + throw new DatabaseException(ErrorStatus._BAD_REQUEST); + } + + ActivationStatus prev = paper.getIsActivated(); + ActivationStatus next = parseStatus(request.getStatus()); + + paper.setIsActivated(next); + + Long adminId = paper.getAdmin().getId(); + Admin admin = adminRepository.findById(adminId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); + Long partnerId = paper.getPartner().getId(); + Partner partner = partnerRepository.findById(partnerId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + ChattingRoom chattingRoom = chatRepository.findChattingRoomByAdminIdAndPartnerId(admin.getId(), partner.getId()); + + if (next.equals(ActivationStatus.SUSPEND)) { + String guideMessage = partner.getName() + "님이 제휴 제안서를 전송했어요!\n내용을 확인 후 동의해 주세요"; + ChatRequestDTO.ChatMessageRequestDTO guideMessageRequest = new ChatRequestDTO.ChatMessageRequestDTO( + chattingRoom.getId(), + partnerId, + adminId, + guideMessage, + 0 + ); + chatService.sendGuideMessage(guideMessageRequest); + notificationService.sendChat(adminId, chattingRoom.getId(), partner.getName(), guideMessage); + + } else if (next.equals(ActivationStatus.ACTIVE)) { + String guideMessage = "축하드립니다!\n" + "제휴 계약이 성립되었습니다. 제휴 계약서를 다시한번 확인해 보세요!"; + ChatRequestDTO.ChatMessageRequestDTO guideMessageRequest = new ChatRequestDTO.ChatMessageRequestDTO( + chattingRoom.getId(), + adminId, + partnerId, + guideMessage, + 0 + ); + chatService.sendGuideMessage(guideMessageRequest); + notificationService.sendChat(partnerId, chattingRoom.getId(), admin.getName(), guideMessage); + } + + return PartnershipResponseDTO.UpdateResponseDTO.builder() + .partnershipId(paper.getId()) + .prevStatus(prev == null ? null : prev.name()) + .newStatus(next.name()) + .changedAt(LocalDateTime.now()) + .build(); + } + + @Override + @Transactional + public PartnershipResponseDTO.ManualPartnershipResponseDTO createManualPartnership( + PartnershipRequestDTO.ManualPartnershipRequestDTO request, + Long adminId, + MultipartFile contractImage) { + + if (request == null || adminId == null) + throw new DatabaseException(ErrorStatus._BAD_REQUEST); + + Admin admin = adminRepository.findById(adminId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); + + String address = pickDisplayAddress(request.getSelectedPlace().getRoadAddress(), request.getSelectedPlace().getAddress()); + + Store store = storeRepository + .findByNameAndAddressAndDetailAddress(request.getStoreName(), address, request.getStoreDetailAddress()) + .orElse(null); + + boolean created = false; + boolean reactivated = false; + + if (store == null) { + store = Store.builder() + .name(request.getStoreName()) + .address(address) + .detailAddress(request.getStoreDetailAddress()) + .rate(0) + .isActivate(ActivationStatus.SUSPEND) + .build(); + store = storeRepository.save(store); + created = true; + } else if (store.getIsActivate() == ActivationStatus.INACTIVE) { + store.setIsActivate(ActivationStatus.SUSPEND); + reactivated = true; + } + + Paper paper = PartnershipConverter.toPaperForManual( + admin, store, + request.getPartnershipPeriodStart(), + request.getPartnershipPeriodEnd(), + ActivationStatus.SUSPEND + ); + paper = paperRepository.save(paper); + + if (contractImage != null && !contractImage.isEmpty()) { + try { + String keyName = amazonS3Manager.generateKeyName("contract-images"); + amazonS3Manager.uploadFile(keyName, contractImage); + paper.updateContractImageKey(keyName); + paperRepository.save(paper); + } catch (Exception e) { + throw new DatabaseException(ErrorStatus.IMAGE_UPLOAD_FAILED); + } + } + + List savedContents = new ArrayList<>(); + if (request.getOptions() != null && !request.getOptions().isEmpty()) { + List contents = PartnershipConverter.toPaperContentsForManual(request.getOptions(), paper); + savedContents = paperContentRepository.saveAll(contents); + + List toPersist = new ArrayList<>(); + for (int i = 0; i < savedContents.size(); i++) { + var opt = request.getOptions().get(i); + var content = savedContents.get(i); + var batch = PartnershipConverter.toGoodsForContent(opt, content); + if (!batch.isEmpty()) toPersist.addAll(batch); + } + if (!toPersist.isEmpty()) goodsRepository.saveAll(toPersist); + } + + List contentsWithGoods = paperContentRepository.findAllByOnePaperIdInFetchGoods(paper.getId()); + List> goodsBatches = contentsWithGoods.stream() + .map(pc -> pc.getGoods() == null ? List.of() : pc.getGoods()) + .toList(); + + var partnership = PartnershipConverter.writePartnershipResultDTO(paper, contentsWithGoods, goodsBatches); + + String url = (paper.getContractImageKey() == null) + ? null + :amazonS3Manager.generatePresignedUrl(paper.getContractImageKey()); + + return PartnershipResponseDTO.ManualPartnershipResponseDTO.builder() + .storeId(store.getId()) + .storeCreated(created) + .storeActivated(reactivated) + .status(store.getIsActivate() == null ? null : store.getIsActivate().name()) + .contractImageUrl(url) + .partnership(partnership) + .build(); + } + + @Override + @Transactional + public PartnershipResponseDTO.CreateDraftResponseDTO createDraftPartnership(PartnershipRequestDTO.CreateDraftRequestDTO request, Long adminId) { + Admin admin = adminRepository.findById(adminId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); + Partner partner = partnerRepository.findById(request.getPartnerId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + Store store = storeRepository.findByPartner(partner) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + + Paper draftPaper = PartnershipConverter.toDraftPaperEntity(admin, partner, store); + paperRepository.save(draftPaper); + + notificationService.sendPartnerProposal(partner.getId(), draftPaper.getId(), admin.getName()); + + ChattingRoom chattingRoom = chatRepository.findChattingRoomByAdminIdAndPartnerId(admin.getId(), partner.getId()); + + String guideMessage = admin.getName() + "님이 제휴 제안서 초안을 전송했어요! 확인 후 제휴 제안서를 작성해 주세요"; + ChatRequestDTO.ChatMessageRequestDTO guideMessageRequest = new ChatRequestDTO.ChatMessageRequestDTO( + chattingRoom.getId(), + admin.getId(), + partner.getId(), + guideMessage, + 0 + ); + + // 5. 완성된 DTO를 사용해서 안내 메시지를 전송합니다. + chatService.sendGuideMessage(guideMessageRequest); + notificationService.sendChat(partner.getId(), chattingRoom.getId(), admin.getName(), guideMessage); + + return PartnershipConverter.toCreateDraftResponseDTO(draftPaper); + } + + @Override + @Transactional + public void deletePartnership(Long paperId) { + Paper paper = paperRepository.findById(paperId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PAPER)); + + // 0. paperContent + goods 삭제 + List contentsToDelete = paperContentRepository.findByPaperId(paperId); + if (contentsToDelete != null && !contentsToDelete.isEmpty()) { + List contentIds = contentsToDelete.stream() + .map(PaperContent::getId) + .toList(); + + goodsRepository.deleteAllByContentIds(contentIds); + paperContentRepository.deleteAll(contentsToDelete); + } + + // 1. store 참조를 미리 잡아두기 (paper 삭제 후 사용) + Store store = paper.getStore(); + boolean isTempStore = (store != null && paper.getPartner() == null); + + // 2. paper 삭제 + paperRepository.delete(paper); + + // 3) 임시 store 삭제 (재사용 중이면 보존) + if (isTempStore) { + Long storeId = store.getId(); + + // 남은 paper 참조 수 + long remainingPaperRefs = paperRepository.countByStore_Id(storeId); + + if (remainingPaperRefs == 0) { + storeRepository.delete(store); + } + } + + } + + @Override + @Transactional + public PartnershipResponseDTO.AdminPartnershipWithPartnerResponseDTO checkPartnershipWithPartner(Long adminId, Long partnerId) { + + Partner partner = partnerRepository.findById(partnerId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + + List targetStatuses = List.of(ActivationStatus.ACTIVE, ActivationStatus.SUSPEND, ActivationStatus.BLANK); + boolean isPartnered = paperRepository.existsByAdmin_IdAndPartner_IdAndIsActivatedIn(adminId, partnerId, targetStatuses); + + Long paperId = null; + String status = "NONE"; + + if (isPartnered) { + Optional latestActiveOrSuspendPaper = paperRepository + .findTopByAdmin_IdAndPartner_IdAndIsActivatedInOrderByIdDesc(adminId, partnerId, targetStatuses); + + if (latestActiveOrSuspendPaper.isPresent()) { + Paper paper = latestActiveOrSuspendPaper.get(); + paperId = paper.getId(); + status = paper.getIsActivated().name(); + } + } + + return PartnershipResponseDTO.AdminPartnershipWithPartnerResponseDTO.builder() + .paperId(paperId) + .isPartnered(isPartnered) + .status(status) + .partnerId(partner.getId()) + .partnerName(partner.getName()) + .partnerAddress(partner.getAddress()) + .build(); + } + + @Override + @Transactional + public PartnershipResponseDTO.PartnerPartnershipWithAdminResponseDTO checkPartnershipWithAdmin(Long partnerId, Long adminId) { + + Admin admin = adminRepository.findById(adminId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); + + List targetStatuses = List.of(ActivationStatus.ACTIVE, ActivationStatus.SUSPEND, ActivationStatus.BLANK); + boolean isPartnered = paperRepository.existsByAdmin_IdAndPartner_IdAndIsActivatedIn(adminId, partnerId, targetStatuses); + + Long paperId = null; + String status = "NONE"; + + if (isPartnered) { + Optional latestActiveOrSuspendPaper = paperRepository + .findTopByAdmin_IdAndPartner_IdAndIsActivatedInOrderByIdDesc(adminId, partnerId, targetStatuses); + + if (latestActiveOrSuspendPaper.isPresent()) { + Paper paper = latestActiveOrSuspendPaper.get(); + paperId = paper.getId(); + status = paper.getIsActivated().name(); + } + } + + return PartnershipResponseDTO.PartnerPartnershipWithAdminResponseDTO.builder() + .paperId(paperId) + .isPartnered(isPartnered) + .status(status) + .adminId(admin.getId()) + .adminName(admin.getName()) + .adminAddress(admin.getOfficeAddress()) + .build(); + } + + private List buildPartnershipDTOs(List papers) { + if (papers == null || papers.isEmpty()) return List.of(); + + List paperIds = papers.stream().map(Paper::getId).toList(); + List allContents = paperContentRepository.findAllByPaperIdInFetchGoods(paperIds); + + Map> byPaperId = allContents.stream() + .collect(Collectors.groupingBy(pc -> pc.getPaper().getId())); + + List result = new ArrayList<>(papers.size()); + for (Paper p : papers) { + List contents = byPaperId.getOrDefault(p.getId(), List.of()); + List> goodsBatches = contents.stream() + .map(pc -> pc.getGoods() == null ? List.of() : pc.getGoods()) + .toList(); + result.add(PartnershipConverter.writePartnershipResultDTO(p, contents, goodsBatches)); + } + return result; + } + + private ActivationStatus parseStatus(String raw) { + try { + return ActivationStatus.valueOf(raw.trim().toUpperCase()); + } catch (Exception e) { + throw new DatabaseException(ErrorStatus._BAD_REQUEST); + } + } + + private String pickDisplayAddress(String road, String jibun) { + return (road != null && !road.isBlank()) ? road : jibun; + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/service/PatnershipService.java b/src/main/java/com/assu/server/domain/partnership/service/PatnershipService.java deleted file mode 100644 index 1437bad5..00000000 --- a/src/main/java/com/assu/server/domain/partnership/service/PatnershipService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.partnership.service; - -public interface PatnershipService { -} diff --git a/src/main/java/com/assu/server/domain/partnership/service/PatnershipServiceImpl.java b/src/main/java/com/assu/server/domain/partnership/service/PatnershipServiceImpl.java deleted file mode 100644 index 80c89abe..00000000 --- a/src/main/java/com/assu/server/domain/partnership/service/PatnershipServiceImpl.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.partnership.service; - -public class PatnershipServiceImpl { -} diff --git a/src/main/java/com/assu/server/domain/report/controller/ReportController.java b/src/main/java/com/assu/server/domain/report/controller/ReportController.java new file mode 100644 index 00000000..dbe3420d --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/controller/ReportController.java @@ -0,0 +1,70 @@ +package com.assu.server.domain.report.controller; + +import com.assu.server.domain.report.dto.ReportRequestDTO; +import com.assu.server.domain.report.dto.ReportResponseDTO; +import com.assu.server.domain.report.entity.enums.ReportTargetType; +import com.assu.server.domain.report.service.ReportService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Report", description = "신고 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/reports") +public class ReportController { + + private final ReportService reportService; + + @Operation(summary = "콘텐츠 신고 API", + description = "# [v1.0 (2025-09-24)](https://clumsy-seeder-416.notion.site/API-2771197c19ed80b79afbf3d8d8d82c15?source=copy_link)\n" + + "- 신고자는 본인 Member ID로 자동 설정됩니다.\n" + + "- 자기 자신의 콘텐츠를 신고할 수 없습니다.\n" + + "- 동일한 대상을 중복 신고할 수 없습니다.\n\n" + + "**Request Body:**\n" + + "- `targetType` (String, required): 신고 대상 타입 (REVIEW, SUGGESTION)\n" + + "- `targetId` (Long, required): 리뷰 ID 또는 건의글 ID\n" + + "- `reportType` (String, required): 신고 유형\n" + + " - 리뷰 신고: REVIEW_INAPPROPRIATE_CONTENT, REVIEW_FALSE_INFORMATION, REVIEW_SPAM\n" + + " - 건의글 신고: SUGGESTION_INAPPROPRIATE_CONTENT, SUGGESTION_FALSE_INFORMATION, SUGGESTION_SPAM\n\n" + + "**Response:**\n" + + "- 성공 시 201(CREATED)과 신고 ID 반환") + @PostMapping + public BaseResponse reportContent( + @AuthenticationPrincipal PrincipalDetails principalDetails, + @RequestBody @Valid ReportRequestDTO.CreateContentReportRequest request + ) { + Long reporterId = principalDetails.getMember().getId(); + ReportResponseDTO.CreateReportResponse response = reportService.reportContent(reporterId, request); + return BaseResponse.onSuccess(SuccessStatus.REPORT_SUCCESS, response); + } + + @Operation(summary = "작성자 신고 API", + description = "# [v1.0 (2025-09-24)](https://clumsy-seeder-416.notion.site/API-2771197c19ed80f8ab45e70772fcfc58?source=copy_link)\n" + + "- 신고자는 본인 Member ID로 자동 설정됩니다.\n" + + "- 자기 자신을 신고할 수 없습니다.\n" + + "- 동일한 작성자를 중복 신고할 수 없습니다.\n\n" + + "**Request Body:**\n" + + "- `targetType` (String, required): 신고 대상 타입 (REVIEW, SUGGESTION)\n" + + "- `targetId` (Long, required): 리뷰 ID 또는 건의글 ID\n" + + "- `reportType` (String, required): 신고 유형\n" + + " - 사용자 신고: USER_SPAM, USER_INAPPROPRIATE_CONTENT, USER_HARASSMENT, USER_FRAUD, USER_PRIVACY_VIOLATION, USER_OTHER\n\n" + + + "**Response:**\n" + + "- 성공 시 201(CREATED)과 신고 ID 반환") + @PostMapping("/students") + public BaseResponse reportStudent( + @AuthenticationPrincipal PrincipalDetails principalDetails, + @RequestBody @Valid ReportRequestDTO.CreateStudentReportRequest request + ) { + Long reporterId = principalDetails.getMember().getId(); + ReportResponseDTO.CreateReportResponse response = reportService.reportStudent(reporterId, request); + return BaseResponse.onSuccess(SuccessStatus.REPORT_SUCCESS, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/report/dto/ReportRequestDTO.java b/src/main/java/com/assu/server/domain/report/dto/ReportRequestDTO.java new file mode 100644 index 00000000..932676bb --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/dto/ReportRequestDTO.java @@ -0,0 +1,42 @@ +package com.assu.server.domain.report.dto; + +import com.assu.server.domain.report.entity.enums.ReportTargetType; +import com.assu.server.domain.report.entity.enums.ReportType; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class ReportRequestDTO { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CreateContentReportRequest { + @NotNull(message = "신고 대상 타입은 필수입니다.") + private ReportTargetType targetType; // REVIEW, SUGGESTION + + @NotNull(message = "신고 대상 ID는 필수입니다.") + private Long targetId; // 리뷰 ID 또는 건의글 ID + + @NotNull(message = "신고 유형은 필수입니다.") + private ReportType reportType; // REVIEW_*, SUGGESTION_* + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CreateStudentReportRequest { + @NotNull(message = "신고 대상의 작성 컨텐츠의 타입은 필수입니다.") + private ReportTargetType targetType; // REVIEW, SUGGESTION + + @NotNull(message = "신고 대상의 작성 컨텐츠 ID는 필수입니다.") + private Long targetId; // 리뷰 ID 또는 건의글 ID + + @NotNull(message = "유저 신고 유형은 필수입니다.") + private ReportType reportType; // USER_* + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/report/dto/ReportResponseDTO.java b/src/main/java/com/assu/server/domain/report/dto/ReportResponseDTO.java new file mode 100644 index 00000000..4f506b50 --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/dto/ReportResponseDTO.java @@ -0,0 +1,23 @@ +package com.assu.server.domain.report.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class ReportResponseDTO { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CreateReportResponse { + private Long reportId; + + public static CreateReportResponse of(Long reportId) { + return CreateReportResponse.builder() + .reportId(reportId) + .build(); + } + } +} diff --git a/src/main/java/com/assu/server/domain/report/entity/Report.java b/src/main/java/com/assu/server/domain/report/entity/Report.java new file mode 100644 index 00000000..6e2b9a12 --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/entity/Report.java @@ -0,0 +1,52 @@ +package com.assu.server.domain.report.entity; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.report.entity.enums.ReportStatus; +import com.assu.server.domain.report.entity.enums.ReportTargetType; +import com.assu.server.domain.report.entity.enums.ReportType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Report extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id", nullable = false) + private Member reporter; // 신고자 + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReportTargetType targetType; // 신고 대상 타입 (STUDENT_USER, REVIEW, SUGGESTION) + + @Column(nullable = false) + private Long targetId; // 신고 대상 ID (사용자 ID, 리뷰 ID, 건의 ID 등) + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_id") + private Member reported; // 피신고자 (사용자 신고인 경우에만) + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReportType reportType; // 신고 유형 + + @Enumerated(EnumType.STRING) + @Builder.Default + private ReportStatus status = ReportStatus.PENDING; // 신고 상태 + + // Todo 관리자용 업데이트 로직 추가 + // 신고 상태 업데이트 메서드 + public void updateStatus(ReportStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/assu/server/domain/report/entity/enums/ReportStatus.java b/src/main/java/com/assu/server/domain/report/entity/enums/ReportStatus.java new file mode 100644 index 00000000..f9348acb --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/entity/enums/ReportStatus.java @@ -0,0 +1,15 @@ +package com.assu.server.domain.report.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ReportStatus { + PENDING("대기중"), + PROCESSED("처리완료"), + REJECTED("기각"), + UNDER_REVIEW("검토중"); + + private final String description; +} diff --git a/src/main/java/com/assu/server/domain/report/entity/enums/ReportTargetType.java b/src/main/java/com/assu/server/domain/report/entity/enums/ReportTargetType.java new file mode 100644 index 00000000..fff8958f --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/entity/enums/ReportTargetType.java @@ -0,0 +1,14 @@ +package com.assu.server.domain.report.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ReportTargetType { + STUDENT_USER("학생 사용자"), + REVIEW("리뷰"), + SUGGESTION("건의글"); + + private final String description; +} diff --git a/src/main/java/com/assu/server/domain/report/entity/enums/ReportType.java b/src/main/java/com/assu/server/domain/report/entity/enums/ReportType.java new file mode 100644 index 00000000..fcd9bc6c --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/entity/enums/ReportType.java @@ -0,0 +1,28 @@ +package com.assu.server.domain.report.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ReportType { + // 사용자 신고용 + STUDENT_USER_SPAM("스팸/홍보"), + STUDENT_USER_INAPPROPRIATE_CONTENT("부적절한 내용"), + STUDENT_USER_HARASSMENT("괴롭힘/욕설"), + STUDENT_USER_FRAUD("사기/부정행위"), + STUDENT_USER_PRIVACY_VIOLATION("개인정보 침해"), + STUDENT_USER_OTHER("기타"), + + // 리뷰 신고용 + REVIEW_INAPPROPRIATE_CONTENT("부적절한 내용 및 욕설이 포함된 리뷰에요"), + REVIEW_FALSE_INFORMATION("허위사실 / 거짓이 포함된 리뷰에요"), + REVIEW_SPAM("홍보 / 광고를 위한 리뷰에요"), + + // 건의글 신고용 + SUGGESTION_INAPPROPRIATE_CONTENT("부적절한 내용 및 욕설이 포함된 건의글이에요"), + SUGGESTION_FALSE_INFORMATION("허위사실 / 거짓이 포함된 건의글에요"), + SUGGESTION_SPAM("홍보/광고를 위한 건의글이에요 "); + + private final String description; +} diff --git a/src/main/java/com/assu/server/domain/report/event/ReportProcessedEvent.java b/src/main/java/com/assu/server/domain/report/event/ReportProcessedEvent.java new file mode 100644 index 00000000..44af9971 --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/event/ReportProcessedEvent.java @@ -0,0 +1,15 @@ +package com.assu.server.domain.report.event; + +import com.assu.server.domain.report.entity.enums.ReportTargetType; +import com.assu.server.domain.report.entity.enums.ReportStatus; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ReportProcessedEvent { + private final Long reportId; + private final ReportTargetType targetType; + private final Long targetId; + private final ReportStatus status; +} diff --git a/src/main/java/com/assu/server/domain/report/exception/ReportException.java b/src/main/java/com/assu/server/domain/report/exception/ReportException.java new file mode 100644 index 00000000..2eb8749d --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/exception/ReportException.java @@ -0,0 +1,8 @@ +package com.assu.server.domain.report.exception; + +import com.assu.server.global.apiPayload.code.BaseErrorCode; +import com.assu.server.global.exception.GeneralException; + +public class ReportException extends GeneralException { + public ReportException(BaseErrorCode errorStatus) { super(errorStatus); } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/report/repository/ReportRepository.java b/src/main/java/com/assu/server/domain/report/repository/ReportRepository.java new file mode 100644 index 00000000..155e6a90 --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/repository/ReportRepository.java @@ -0,0 +1,12 @@ +package com.assu.server.domain.report.repository; + +import com.assu.server.domain.report.entity.Report; +import com.assu.server.domain.report.entity.enums.ReportTargetType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ReportRepository extends JpaRepository { + // 특정 사용자가 특정 대상을 신고했는지 확인 + boolean existsByReporterIdAndTargetTypeAndTargetId(Long reporterId, ReportTargetType targetType, Long targetId); +} diff --git a/src/main/java/com/assu/server/domain/report/service/ReportService.java b/src/main/java/com/assu/server/domain/report/service/ReportService.java new file mode 100644 index 00000000..5ffaec7a --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/service/ReportService.java @@ -0,0 +1,13 @@ +package com.assu.server.domain.report.service; + +import com.assu.server.domain.report.dto.ReportRequestDTO; +import com.assu.server.domain.report.dto.ReportResponseDTO; + +public interface ReportService { + + // 콘텐츠 신고 생성 (리뷰, 건의글) + ReportResponseDTO.CreateReportResponse reportContent(Long reporterId, ReportRequestDTO.CreateContentReportRequest request); + + // 작성자 신고 생성 (리뷰/건의글 작성자) + ReportResponseDTO.CreateReportResponse reportStudent(Long reporterId, ReportRequestDTO.CreateStudentReportRequest request); +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java b/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java new file mode 100644 index 00000000..622a1814 --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/service/ReportServiceImpl.java @@ -0,0 +1,166 @@ +package com.assu.server.domain.report.service; + +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.report.dto.ReportRequestDTO; +import com.assu.server.domain.report.dto.ReportResponseDTO; +import com.assu.server.domain.report.entity.Report; +import com.assu.server.domain.report.entity.enums.ReportStatus; +import com.assu.server.domain.report.entity.enums.ReportTargetType; +import com.assu.server.domain.report.repository.ReportRepository; +import com.assu.server.domain.review.entity.Review; +import com.assu.server.domain.review.repository.ReviewRepository; +import com.assu.server.domain.suggestion.entity.Suggestion; +import com.assu.server.domain.suggestion.repository.SuggestionRepository; +import com.assu.server.domain.report.exception.ReportException; +import com.assu.server.domain.report.event.ReportProcessedEvent; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import org.springframework.context.ApplicationEventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ReportServiceImpl implements ReportService { + + private final ReportRepository reportRepository; + private final MemberRepository memberRepository; + private final ReviewRepository reviewRepository; + private final SuggestionRepository suggestionRepository; + private final ApplicationEventPublisher eventPublisher; + + @Override + @Transactional + public ReportResponseDTO.CreateReportResponse reportContent(Long reporterId, + ReportRequestDTO.CreateContentReportRequest request) { + // 신고자 존재 확인 + Member reporter = memberRepository.findById(reporterId) + .orElseThrow(() -> new ReportException(ErrorStatus.NO_MEMBER)); + + // 신고 대상 존재 확인 및 자기 자신 신고 방지 + validateContentReportTarget(reporterId, request.getTargetType(), request.getTargetId()); + + // 중복 신고 확인 + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetId(reporterId, request.getTargetType(), + request.getTargetId())) { + throw new ReportException(ErrorStatus.REPORT_DUPLICATE); + } + + // 콘텐츠 신고 생성 + Report report = Report.builder() + .reporter(reporter) + .targetType(request.getTargetType()) + .targetId(request.getTargetId()) + .reported(null) // 콘텐츠 신고는 피신고자 없음 + .reportType(request.getReportType()) + .status(ReportStatus.PENDING) + .build(); + + Report savedReport = reportRepository.save(report); + + // 신고 생성 이벤트 발행 + eventPublisher.publishEvent(new ReportProcessedEvent( + savedReport.getId(), + savedReport.getTargetType(), + savedReport.getTargetId(), + savedReport.getStatus())); + + return ReportResponseDTO.CreateReportResponse.of(savedReport.getId()); + } + + @Override + @Transactional + public ReportResponseDTO.CreateReportResponse reportStudent(Long reporterId, + ReportRequestDTO.CreateStudentReportRequest request) { + // 신고자 존재 확인 + Member reporter = memberRepository.findById(reporterId) + .orElseThrow(() -> new ReportException(ErrorStatus.NO_MEMBER)); + + // 신고 대상 존재 확인 및 자기 자신 신고 방지 + Student reportedStudent = validateStudentReportTarget(reporterId, request.getTargetType(), + request.getTargetId()); + + // 중복 신고 확인 (작성자 기준) + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + reporterId, + ReportTargetType.STUDENT_USER, + reportedStudent.getId()) + ) { + throw new ReportException(ErrorStatus.REPORT_DUPLICATE); + } + + // 작성자 신고 생성 + Report report = Report.builder() + .reporter(reporter) + .targetType(ReportTargetType.STUDENT_USER) + .targetId(reportedStudent.getId()) + .reported(reportedStudent.getMember()) + .reportType(request.getReportType()) + .status(ReportStatus.PENDING) + .build(); + + Report savedReport = reportRepository.save(report); + + // 신고 생성 이벤트 발행 + eventPublisher.publishEvent(new ReportProcessedEvent( + savedReport.getId(), + savedReport.getTargetType(), + savedReport.getTargetId(), + savedReport.getStatus())); + + return ReportResponseDTO.CreateReportResponse.of(savedReport.getId()); + } + + // 콘텐츠 신고 대상 검증 메서드 + private void validateContentReportTarget(Long reporterId, ReportTargetType targetType, Long targetId) { + switch (targetType) { + case REVIEW: + Review review = reviewRepository.findById(targetId) + .orElseThrow(() -> new ReportException(ErrorStatus.NO_SUCH_MEMBER)); + if (reporterId.equals(review.getStudent().getId())) { + throw new ReportException(ErrorStatus.REVIEW_REPORT_SELF_NOT_ALLOWED); + } + break; + case SUGGESTION: + Suggestion suggestion = suggestionRepository.findById(targetId) + .orElseThrow(() -> new ReportException(ErrorStatus.NO_SUCH_SUGGESTION)); + if (reporterId.equals(suggestion.getStudent().getId())) { + throw new ReportException(ErrorStatus.SUGGESTION_REPORT_SELF_NOT_ALLOWED); + } + break; + default: + throw new ReportException(ErrorStatus.INVALID_REPORT_TYPE); + } + } + + // 작성자 신고 대상 검증 메서드 + private Student validateStudentReportTarget(Long reporterId, ReportTargetType targetType, Long targetId) { + Student student; + + switch (targetType) { + case REVIEW: + Review review = reviewRepository.findById(targetId) + .orElseThrow(() -> new ReportException(ErrorStatus.NO_SUCH_MEMBER)); + student = review.getStudent(); + break; + case SUGGESTION: + Suggestion suggestion = suggestionRepository.findById(targetId) + .orElseThrow(() -> new ReportException(ErrorStatus.NO_SUCH_SUGGESTION)); + student = suggestion.getStudent(); + break; + default: + throw new ReportException(ErrorStatus.INVALID_REPORT_TYPE); + } + + // 자기 자신 신고 방지 + if (reporterId.equals(student.getId())) { + throw new ReportException(ErrorStatus.REPORT_SELF_NOT_ALLOWED); + } + + return student; + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/report/service/ReportStatusSyncService.java b/src/main/java/com/assu/server/domain/report/service/ReportStatusSyncService.java new file mode 100644 index 00000000..f7d94a87 --- /dev/null +++ b/src/main/java/com/assu/server/domain/report/service/ReportStatusSyncService.java @@ -0,0 +1,99 @@ +package com.assu.server.domain.report.service; + +import com.assu.server.domain.common.entity.enums.ReportedStatus; +import com.assu.server.domain.report.entity.enums.ReportStatus; +import com.assu.server.domain.report.event.ReportProcessedEvent; +import com.assu.server.domain.review.entity.Review; +import com.assu.server.domain.review.repository.ReviewRepository; +import com.assu.server.domain.suggestion.entity.Suggestion; +import com.assu.server.domain.suggestion.repository.SuggestionRepository; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.domain.user.repository.StudentRepository; +import com.assu.server.domain.report.exception.ReportException; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ReportStatusSyncService { + + private final ReviewRepository reviewRepository; + private final SuggestionRepository suggestionRepository; + private final StudentRepository studentRepository; + + @EventListener + @Async + @Transactional + public void handleReportProcessed(ReportProcessedEvent event) { + log.info("신고 처리 이벤트 수신: Report ID: {}, Target Type: {}, Target ID: {}, Status: {}", + event.getReportId(), event.getTargetType(), event.getTargetId(), event.getStatus()); + + try { + switch (event.getTargetType()) { + case REVIEW: + syncReviewStatus(event); + break; + case SUGGESTION: + syncSuggestionStatus(event); + break; + case STUDENT_USER: + syncStudentUserStatus(event); + break; + default: + log.warn("알 수 없는 신고 대상 타입: {}", event.getTargetType()); + } + } catch (Exception e) { + log.error("신고 상태 동기화 실패: Report ID: {}, Error: {}", event.getReportId(), e.getMessage(), e); + } + } + + private void syncReviewStatus(ReportProcessedEvent event) { + Review review = reviewRepository.findById(event.getTargetId()) + .orElseThrow(() -> new ReportException(ErrorStatus.NO_SUCH_MEMBER)); + + ReportedStatus newStatus = mapReportStatusToReportedStatus(event.getStatus()); + if (newStatus != null) { + review.updateReportedStatus(newStatus); + reviewRepository.save(review); + log.info("리뷰 상태 동기화 완료: Review ID: {}, Status: {}", event.getTargetId(), newStatus); + } + } + + private void syncSuggestionStatus(ReportProcessedEvent event) { + Suggestion suggestion = suggestionRepository.findById(event.getTargetId()) + .orElseThrow(() -> new ReportException(ErrorStatus.NO_SUCH_SUGGESTION)); + + ReportedStatus newStatus = mapReportStatusToReportedStatus(event.getStatus()); + if (newStatus != null) { + suggestion.updateReportedStatus(newStatus); + suggestionRepository.save(suggestion); + log.info("건의글 상태 동기화 완료: Suggestion ID: {}, Status: {}", event.getTargetId(), newStatus); + } + } + + private void syncStudentUserStatus(ReportProcessedEvent event) { + Student student = studentRepository.findById(event.getTargetId()) + .orElseThrow(() -> new ReportException(ErrorStatus.NO_SUCH_MEMBER)); + + ReportedStatus newStatus = mapReportStatusToReportedStatus(event.getStatus()); + if (newStatus != null) { + student.updateReportedStatus(newStatus); + studentRepository.save(student); + log.info("학생 상태 동기화 완료: Student ID: {}, Status: {}", event.getTargetId(), newStatus); + } + } + + private ReportedStatus mapReportStatusToReportedStatus(ReportStatus reportStatus) { + return switch (reportStatus) { + case PROCESSED -> ReportedStatus.REPORTED; + case REJECTED -> ReportedStatus.NORMAL; + case PENDING, UNDER_REVIEW -> null; // PENDING 상태는 상태 변경하지 않음 + }; + } +} diff --git a/src/main/java/com/assu/server/domain/review/controller/ReviewController.java b/src/main/java/com/assu/server/domain/review/controller/ReviewController.java index de405876..ca5b4fd3 100644 --- a/src/main/java/com/assu/server/domain/review/controller/ReviewController.java +++ b/src/main/java/com/assu/server/domain/review/controller/ReviewController.java @@ -1,4 +1,108 @@ package com.assu.server.domain.review.controller; +import com.assu.server.domain.review.dto.ReviewRequestDTO; +import com.assu.server.domain.review.dto.ReviewResponseDTO; +import com.assu.server.domain.review.service.ReviewService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import org.springframework.web.multipart.MultipartFile; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/reviews") public class ReviewController { + private final ReviewService reviewService; + @Operation( + summary = "리뷰 작성 API", + description = "리뷰 내용과 별점, 리뷰 이미지를 입력해주세요." + + ) + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public BaseResponse writeReview( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestPart("request") ReviewRequestDTO.WriteReviewRequestDTO request, + @RequestPart(value = "reviewImages", required = false) List reviewImages + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, reviewService.writeReview(request, pd.getId(), reviewImages)); + } + + @Operation( + summary = "내가 쓴 리뷰 조회 API", + description = "Authorization 후에 사용해주세요." + ) + @GetMapping("/student") + public BaseResponse> checkStudent( + @AuthenticationPrincipal PrincipalDetails pd, Pageable pageable + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, reviewService.checkStudentReview(pd.getId(), pageable)); + } + + @Operation( + summary = "내 가게 리뷰 조회 API", + description = "Authorization 후에 사용해주세요." + ) + @GetMapping("/partner") + public BaseResponse> checkPartnerReview( + @AuthenticationPrincipal PrincipalDetails pd, Pageable pageable + ){ + return BaseResponse.onSuccess(SuccessStatus._OK, reviewService.checkPartnerReview(pd.getId(), pageable)); + } + + @Operation( + summary = "가게 리뷰 조회 API", + description = "storeId 기반으로 가게 리뷰를 조회하는 API 입니다." + ) + @GetMapping("/store/{storeId}") + public BaseResponse> checkStoreReview( + Pageable pageable, @PathVariable Long storeId + ){ + return BaseResponse.onSuccess(SuccessStatus._OK, reviewService.checkStoreReview(storeId, pageable)); + } + + @Operation( + summary = "내가 쓴 리뷰 삭제 API", + description = "삭제할 리뷰 ID를 입력해주세요." + ) + @DeleteMapping("/{reviewId}") + public ResponseEntity> deleteReview( + @PathVariable Long reviewId) { + + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus._OK, reviewService.deleteReview(reviewId))); + } + + @Operation( + summary = "store 리뷰 평균 조회 API", + description = "storeId 기반으로 조회하는 API 입니다." + ) + @GetMapping("/average/{storeId}") + public ResponseEntity> getStandardScore( + @PathVariable Long storeId + ){ + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus._OK, reviewService.standardScore(storeId))); + } + + @Operation( + summary = "store 리뷰 평균 조회 API", + description = "partner 로그인 시 자신의 가게 평균을 조회하는 api 입니다." + ) + @GetMapping("/average") + public ResponseEntity> getMyStoreAverage( + @AuthenticationPrincipal PrincipalDetails pd + ){ + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus._OK, reviewService.myStoreAverage(pd.getId()))); + } + + } diff --git a/src/main/java/com/assu/server/domain/review/converter/ReviewConverter.java b/src/main/java/com/assu/server/domain/review/converter/ReviewConverter.java index ea023d04..234247de 100644 --- a/src/main/java/com/assu/server/domain/review/converter/ReviewConverter.java +++ b/src/main/java/com/assu/server/domain/review/converter/ReviewConverter.java @@ -1,4 +1,89 @@ package com.assu.server.domain.review.converter; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.review.dto.ReviewRequestDTO; +import com.assu.server.domain.review.dto.ReviewResponseDTO; +import com.assu.server.domain.review.entity.Review; +import com.assu.server.domain.review.entity.ReviewPhoto; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.user.entity.Student; + +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; + public class ReviewConverter { -} + public static ReviewResponseDTO.WriteReviewResponseDTO writeReviewResultDTO(Review review){ + //enti -> dto + return ReviewResponseDTO.WriteReviewResponseDTO.builder() + .reviewId(review.getId())// 리스폰스 dto로 아이디를 바꿔줄거다. + .rate(review.getRate()) + .content(review.getContent()) +// .memberId(review.getStudent().getId()) + .createdAt(review.getCreatedAt()) + .reviewImageUrls(review.getImageList().stream() + .map(ReviewPhoto::getPhotoUrl) + .collect(Collectors.toList())) + //한 리뷰 여러개 사진 but 하나로 묶임 추가 고려해보기 --추후에 !! + .build(); //리스폰스 리턴 + } + public static Review toReviewEntity(ReviewRequestDTO.WriteReviewRequestDTO request, Store store, Partner partner, Student student, String affiliation) { + //request + return Review.builder() + .rate(request.getRate()) + .content(request.getContent()) + .store(store) + .affiliation(affiliation) + .partner(partner) + .student(student) + // .imageList(request.getReviewImage()) + .build(); + } + public static ReviewResponseDTO.CheckReviewResponseDTO checkReviewResultDTO(Review review){ + return ReviewResponseDTO.CheckReviewResponseDTO.builder() + .reviewId(review.getId()) + .rate(review.getRate()) + .content(review.getContent()) + .createdAt(review.getCreatedAt()) + .storeName(review.getStore().getName()) + .affiliation(review.getAffiliation()) + .storeId(review.getStore().getId()) + .reviewImageUrls(review.getImageList().stream() + .map(ReviewPhoto::getPhotoUrl) + .collect(Collectors.toList())) + .build(); + } + // public static List checkStudentReviewResultDTO(List reviews){ + // return reviews.stream() + // .map(ReviewConverter::checkStudentReviewResultDTO) + // .collect(Collectors.toList()); + // } + + public static Page checkReviewResultDTO(Page reviews){ + return reviews.map(ReviewConverter::checkReviewResultDTO); + } + // + // public static ReviewResponseDTO.CheckPartnerReviewResponseDTO checkPartnerReviewResultDTO(Review review){ + // return ReviewResponseDTO.CheckPartnerReviewResponseDTO.builder() + // .reviewId(review.getId()) + // .storeId(review.getStore().getId()) + // .reviewerId(review.getStudent().getId()) + // .content(review.getContent()) + // .rate(review.getRate()) + // .createdAt(review.getCreatedAt()) + // .reviewImageUrls(review.getImageList().stream() + // .map(ReviewPhoto::getPhotoUrl) + // .collect(Collectors.toList())) + // .build(); + // + // } + // + // public static Page checkPartnerReviewResultDTO(Page reviews){ + // return reviews.map(ReviewConverter::checkPartnerReviewResultDTO); + // } + // public static ReviewResponseDTO.DeleteReviewResponseDTO deleteReviewResultDTO(Long reviewId){ + // return ReviewResponseDTO.DeleteReviewResponseDTO.builder() + // .reviewId(reviewId) + // .build(); + // } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/review/dto/ReviewRequestDTO.java b/src/main/java/com/assu/server/domain/review/dto/ReviewRequestDTO.java index 0d1d2587..e8db66ad 100644 --- a/src/main/java/com/assu/server/domain/review/dto/ReviewRequestDTO.java +++ b/src/main/java/com/assu/server/domain/review/dto/ReviewRequestDTO.java @@ -1,4 +1,35 @@ package com.assu.server.domain.review.dto; -public class ReviewRequestDTO { +import com.assu.server.domain.review.entity.ReviewPhoto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public class ReviewRequestDTO { + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class WriteReviewRequestDTO { + @Schema(description = "리뷰 내용", example = "정말 맛있었어요!") + private String content; + + @Schema(description = "별점 (1-10)", example = "5", minimum = "1", maximum = "10") + private Integer rate; + + @Schema(hidden = true) + private List reviewImage; + + @Schema(description = "가게 ID", example = "3") + private Long storeId; + + @Schema(description = "파트너 ID", example = "2") + private Long partnerId; + + private Long partnershipUsageId; + private String adminName; + } } diff --git a/src/main/java/com/assu/server/domain/review/dto/ReviewResponseDTO.java b/src/main/java/com/assu/server/domain/review/dto/ReviewResponseDTO.java index 3604229f..13e84b92 100644 --- a/src/main/java/com/assu/server/domain/review/dto/ReviewResponseDTO.java +++ b/src/main/java/com/assu/server/domain/review/dto/ReviewResponseDTO.java @@ -1,4 +1,69 @@ package com.assu.server.domain.review.dto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + public class ReviewResponseDTO { + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class WriteReviewResponseDTO { + private Long reviewId; //entity 보고 형 맞추기 + private String content; + private Integer rate; + private LocalDateTime createdAt; + private Long memberId; + private List reviewImageUrls; + } + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CheckReviewResponseDTO { //내가 작성한 리뷰 + private Long reviewId; + private Long storeId; + private String affiliation; // store 기준 조회시 필요... + private String storeName; + private String content; + private Integer rate; + private LocalDateTime createdAt; + private List reviewImageUrls; + } + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CheckPartnerReviewResponseDTO {//partner의 리뷰 확인 + private Long reviewId; + private Long storeId; //현재 파트너의 가게 아이디 + private Long reviewerId; + private String content; + private Integer rate; + private LocalDateTime createdAt; + private List reviewImageUrls; + } + + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class DeleteReviewResponseDTO { + private Long reviewId; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class StandardScoreResponseDTO { + private Float score; + } + } diff --git a/src/main/java/com/assu/server/domain/review/entity/Review.java b/src/main/java/com/assu/server/domain/review/entity/Review.java index 01603a41..b82f758e 100644 --- a/src/main/java/com/assu/server/domain/review/entity/Review.java +++ b/src/main/java/com/assu/server/domain/review/entity/Review.java @@ -1,4 +1,72 @@ package com.assu.server.domain.review.entity; +import java.util.ArrayList; +import java.util.List; -public class Review { +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.common.entity.enums.ReportedStatus; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.user.entity.Student; + +import jakarta.persistence.CascadeType; +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 jakarta.persistence.OneToMany; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class Review extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "student_id") + private Student student; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "partner_id") + private Partner partner; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id") + private Store store; + + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private List imageList = new ArrayList<>(); + + public List getImageList() { + if (imageList == null) { + imageList = new ArrayList<>(); + } + return imageList; + } + + private Integer rate; + private String content; + + private String affiliation; + + @Enumerated(EnumType.STRING) + @Builder.Default + private ReportedStatus status = ReportedStatus.NORMAL; + + // 상태 업데이트 메서드 + public void updateReportedStatus(ReportedStatus status) { + this.status = status; + } } diff --git a/src/main/java/com/assu/server/domain/review/entity/ReviewPhoto.java b/src/main/java/com/assu/server/domain/review/entity/ReviewPhoto.java new file mode 100644 index 00000000..892e70df --- /dev/null +++ b/src/main/java/com/assu/server/domain/review/entity/ReviewPhoto.java @@ -0,0 +1,44 @@ +package com.assu.server.domain.review.entity; +import com.assu.server.domain.common.entity.BaseEntity; + +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class ReviewPhoto extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id") + private Review review; + + @Column(length =2000) + private String photoUrl; + + @JoinColumn(name = "key_name") // S3 키 이름 저장 (조회 시 새 URL 생성용) + private String keyName; + + public void updatePhotoUrl(String newPhotoUrl) { + this.photoUrl = newPhotoUrl; //일시적 저장 + } + +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/review/repository/ReviewRepository.java b/src/main/java/com/assu/server/domain/review/repository/ReviewRepository.java index 0207d172..98f8d330 100644 --- a/src/main/java/com/assu/server/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/assu/server/domain/review/repository/ReviewRepository.java @@ -1,4 +1,68 @@ package com.assu.server.domain.review.repository; -public class ReviewRepository { +import com.assu.server.domain.review.entity.Review; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.common.entity.enums.ReportedStatus; + +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 java.util.List; + +public interface ReviewRepository extends JpaRepository { + @Query(""" + SELECT r + FROM Review r + WHERE r.student.id = :memberId + AND r.status = :status + AND r.student.status = :studentStatus + ORDER BY r.createdAt DESC + """) + Page findByMemberIdAndStatusAndStudentStatus( + @Param("memberId") Long memberId, + @Param("status") ReportedStatus status, + @Param("studentStatus") ReportedStatus studentStatus, + Pageable pageable + ); + + @Query(""" + SELECT r + FROM Review r + WHERE r.student.id = :memberId + """) + Page findByMemberId(@Param("memberId") Long memberId, Pageable pageable); + + @Query(""" + SELECT r + FROM Review r + WHERE r.store.id = :storeId + AND r.status = :status + AND r.student.status = :studentStatus + ORDER BY r.createdAt DESC + """) + Page findByStoreIdAndStatusAndStudentStatus( + @Param("storeId") Long storeId, + @Param("status") ReportedStatus status, + @Param("studentStatus") ReportedStatus studentStatus, + Pageable pageable + ); + + Page findByStoreIdOrderByCreatedAtDesc(Long id, Pageable pageable);// 최신순 정렬 + + Page findByStoreId(Long id, Pageable pageable); + + @Query("SELECT AVG(r.rate) FROM Review r WHERE r.store.id = :storeId AND r.status = :status AND r.student.status = :studentStatus") + Float standardScoreWithStatus( + @Param("storeId") Long storeId, + @Param("status") ReportedStatus status, + @Param("studentStatus") ReportedStatus studentStatus + ); + + @Query("SELECT AVG(r.rate) FROM Review r WHERE r.store.id = :storeId") + Float standardScore(Long storeId); + + Long store(Store store); } diff --git a/src/main/java/com/assu/server/domain/review/service/ReviewService.java b/src/main/java/com/assu/server/domain/review/service/ReviewService.java index 3686da37..699d3bcd 100644 --- a/src/main/java/com/assu/server/domain/review/service/ReviewService.java +++ b/src/main/java/com/assu/server/domain/review/service/ReviewService.java @@ -1,4 +1,24 @@ package com.assu.server.domain.review.service; +import com.assu.server.domain.review.dto.ReviewRequestDTO; +import com.assu.server.domain.review.dto.ReviewResponseDTO; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + public interface ReviewService { + ReviewResponseDTO.WriteReviewResponseDTO writeReview(@RequestBody ReviewRequestDTO.WriteReviewRequestDTO request, Long memberId, List reviewImages); + Page checkStudentReview(Long memberId, Pageable pageable); + Page checkPartnerReview(Long memberId, Pageable pageable); + Page checkStoreReview(Long storeId, Pageable pageable); + + ReviewResponseDTO.DeleteReviewResponseDTO deleteReview(@PathVariable Long reviewId); + + ReviewResponseDTO.StandardScoreResponseDTO standardScore(@PathVariable Long storeId); + ReviewResponseDTO.StandardScoreResponseDTO myStoreAverage(@PathVariable Long memberId); } diff --git a/src/main/java/com/assu/server/domain/review/service/ReviewServiceImpl.java b/src/main/java/com/assu/server/domain/review/service/ReviewServiceImpl.java index 12d2f73d..8865e50c 100644 --- a/src/main/java/com/assu/server/domain/review/service/ReviewServiceImpl.java +++ b/src/main/java/com/assu/server/domain/review/service/ReviewServiceImpl.java @@ -1,4 +1,245 @@ package com.assu.server.domain.review.service; -public class ReviewServiceImpl { +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partner.repository.PartnerRepository; +import com.assu.server.domain.review.converter.ReviewConverter; +import com.assu.server.domain.review.dto.ReviewRequestDTO; +import com.assu.server.domain.review.dto.ReviewResponseDTO; +import com.assu.server.domain.review.entity.Review; +import com.assu.server.domain.review.entity.ReviewPhoto; +import com.assu.server.domain.review.repository.ReviewRepository; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.user.entity.PartnershipUsage; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.domain.store.repository.StoreRepository; +import com.assu.server.domain.user.repository.PartnershipUsageRepository; +import com.assu.server.domain.user.repository.StudentRepository; +import com.assu.server.domain.common.entity.enums.ReportedStatus; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import com.assu.server.global.exception.GeneralException; +import com.assu.server.infra.s3.AmazonS3Manager; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ReviewServiceImpl implements ReviewService { + private final ReviewRepository reviewRepository; + private final StoreRepository storeRepository; + private final PartnerRepository partnerRepository; + private final StudentRepository studentRepository; + private final AmazonS3Manager amazonS3Manager; + private final PartnershipUsageRepository partnershipUsageRepository; + + @Override + public ReviewResponseDTO.WriteReviewResponseDTO writeReview(ReviewRequestDTO.WriteReviewRequestDTO request, Long memberId, List reviewImages) { + // createReview 메서드 호출로 통합 + String affiliation = adminNameToAffliation(request.getAdminName()); + Review review = createReview(memberId, request.getStoreId(), request, reviewImages, affiliation); + PartnershipUsage pu = partnershipUsageRepository.findById(request.getPartnershipUsageId()).orElseThrow( + () -> new GeneralException(ErrorStatus.NO_SUCH_USAGE) + ); + pu.setIsReviewed(true); + partnershipUsageRepository.save(pu); + recalcAndUpdateStoreRate(review.getStore().getId()); + return ReviewConverter.writeReviewResultDTO(review); + } + + private String adminNameToAffliation(String adminName) { + if(adminName.equals("총학생회")|| adminName.equals("숭실대학교 총학생회")) + return "숭실대학교 재학생"; + else + return adminName.replace(" 학생회"," 재학생"); + } + + private Review createReview(Long memberId, Long storeId, ReviewRequestDTO.WriteReviewRequestDTO request, List images, String affiliation) { + // 존재여부 검증 + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + Partner partner = partnerRepository.findById(request.getPartnerId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + Student student = studentRepository.findById(memberId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STUDENT)); + + // 리뷰 엔티티 생성 및 저장 + Review review = ReviewConverter.toReviewEntity(request, store, partner, student, affiliation); + reviewRepository.save(review); // ID 생성을 위해 먼저 저장 + + // 이미지 처리 + if (images != null && !images.isEmpty()) { + try { + for (int i = 0; i < images.size(); i++) { + String keyName = generateReviewImageKeyName(memberId, review.getId(), i + 1); + amazonS3Manager.uploadFile(keyName, images.get(i)); + String presignedUrl = amazonS3Manager.generatePresignedUrl(keyName); + + ReviewPhoto reviewPhoto = ReviewPhoto.builder() + .review(review) + .photoUrl(presignedUrl) + .keyName(keyName) + .build(); + + review.getImageList().add(reviewPhoto); + } + } catch (Exception e) { + throw new DatabaseException(ErrorStatus.IMAGE_UPLOAD_FAILED); + } + } + + return reviewRepository.save(review); + } + + private String generateReviewImageKeyName(Long memberId, Long reviewId, int imageIndex) { + LocalDateTime now = LocalDateTime.now(); + String year = String.valueOf(now.getYear()); + String month = String.format("%02d", now.getMonthValue()); + + // 기존 generateKeyName 방식을 참고하되 더 체계적으로 + return String.format("reviews/images/%s/%s/user%d/review%d_img%d_%s", + year, month, memberId, reviewId, imageIndex, UUID.randomUUID()); + } + + @Override + public Page checkStudentReview(Long memberId, Pageable pageable) { + pageable = PageRequest.of(pageable.getPageNumber() - 1, pageable.getPageSize(), pageable.getSort()); + Page reviews = reviewRepository.findByMemberId(memberId, pageable); + + for (Review review : reviews) { + updateReviewImageUrls(review); + } + + return ReviewConverter.checkReviewResultDTO(reviews); + } + + @Override + @Transactional + public Page checkPartnerReview(Long memberId, Pageable pageable) { + pageable = PageRequest.of(pageable.getPageNumber() - 1, pageable.getPageSize(), pageable.getSort()); + Partner partner = partnerRepository.findById(memberId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + Store store = storeRepository.findByPartner(partner) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + + // 신고되지 않은 리뷰와 신고되지 않은 학생이 작성한 리뷰만 조회 + Page reviews = reviewRepository.findByStoreIdAndStatusAndStudentStatus( + store.getId(), + ReportedStatus.NORMAL, + ReportedStatus.NORMAL, + pageable); + + for (Review review : reviews) { + updateReviewImageUrls(review); + } + + return ReviewConverter.checkReviewResultDTO(reviews); + } + + @Override + @Transactional + public ReviewResponseDTO.DeleteReviewResponseDTO deleteReview(Long reviewId) { + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new DatabaseException(ErrorStatus._BAD_REQUEST)); + + Long storeId = review.getStore().getId(); + recalcAndUpdateStoreRate(storeId); + + reviewRepository.deleteById(reviewId); + return ReviewResponseDTO.DeleteReviewResponseDTO.builder() + .reviewId(reviewId) + .build(); + } + + private void updateReviewImageUrls(Review review) { + for (ReviewPhoto reviewPhoto : review.getImageList()) { + if (reviewPhoto.getKeyName() != null) { + String freshUrl = amazonS3Manager.generatePresignedUrl(reviewPhoto.getKeyName()); + // ReviewPhoto 엔티티에 URL 업데이트 (일시적으로, DB에는 저장하지 않음) + reviewPhoto.updatePhotoUrl(freshUrl); + } + } + } + + @Override + @Transactional + public Page checkStoreReview(Long storeId, Pageable pageable) { + pageable = PageRequest.of(pageable.getPageNumber() - 1, pageable.getPageSize(), pageable.getSort()); + Store store = storeRepository.findById(storeId).orElseThrow( + () -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + + // 신고되지 않은 리뷰와 신고되지 않은 학생이 작성한 리뷰만 조회 + Page reviews = reviewRepository.findByStoreIdAndStatusAndStudentStatus( + store.getId(), + ReportedStatus.NORMAL, + ReportedStatus.NORMAL, + pageable); + + for (Review review : reviews) { + updateReviewImageUrls(review); + } + + return ReviewConverter.checkReviewResultDTO(reviews); + } + + @Override + @Transactional + public ReviewResponseDTO.StandardScoreResponseDTO standardScore(Long storeId) { + // 신고되지 않은 리뷰와 신고되지 않은 학생이 작성한 리뷰만으로 평균 계산 + Float score = reviewRepository.standardScoreWithStatus(storeId, ReportedStatus.NORMAL, ReportedStatus.NORMAL); + if(score == null){ + score = 0f; + } + return ReviewResponseDTO.StandardScoreResponseDTO.builder() + .score(score) + .build(); + } + + @Override + @Transactional + public ReviewResponseDTO.StandardScoreResponseDTO myStoreAverage(Long memberId) { + Partner partner = partnerRepository.findById(memberId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PARTNER)); + Store store = storeRepository.findByPartner(partner) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + + // 신고되지 않은 리뷰와 신고되지 않은 학생이 작성한 리뷰만으로 평균 계산 + Float score = reviewRepository.standardScoreWithStatus(store.getId(), ReportedStatus.NORMAL, + ReportedStatus.NORMAL); + if (score == null) { + score = 0f; + } + System.out.println(store.getId()); + return ReviewResponseDTO.StandardScoreResponseDTO + .builder() + .score(score) + .build(); + + } + + private void recalcAndUpdateStoreRate(Long storeId) { + // 이 시점에 영속성 컨텍스트의 변경분을 DB로 내보내 평균에 반영 + reviewRepository.flush(); + + Float avg = reviewRepository.standardScoreWithStatus( + storeId, ReportedStatus.NORMAL, ReportedStatus.NORMAL + ); + if (avg == null) avg = 0f; + + int rounded = (int) (Math.round(avg * 10f) / 10f); + + storeRepository.findById(storeId).ifPresent(s -> { + s.setRate(rounded); + storeRepository.save(s); + }); + } } diff --git a/src/main/java/com/assu/server/domain/store/controller/StoreController.java b/src/main/java/com/assu/server/domain/store/controller/StoreController.java index 1f9d61b5..51fd8668 100644 --- a/src/main/java/com/assu/server/domain/store/controller/StoreController.java +++ b/src/main/java/com/assu/server/domain/store/controller/StoreController.java @@ -1,4 +1,58 @@ package com.assu.server.domain.store.controller; +import java.awt.*; +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.RestController; +import com.assu.server.domain.store.dto.StoreResponseDTO; +import com.assu.server.domain.store.service.StoreService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import com.assu.server.global.util.PrincipalDetails; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import java.util.List; +@RestController +@RequiredArgsConstructor +@Tag(name = "가게 관련 api", description = "가게와 관련된 api") +@RequestMapping("/store") public class StoreController { + + private final StoreService storeService; + + @GetMapping("/best") + @Operation(summary = "홈화면의 현재 인기 매장 조회 api", description = "관리자, 사용자, 제휴업체 모두 사용하는 api") + public ResponseEntity> getTodayBestStore() { + StoreResponseDTO.todayBest result = storeService.getTodayBestStore(); + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus.BEST_STORE_SUCCESS, result)); + } + + + @Operation( + summary = "내 가게 순위 조회 API", + description = "partnerId로 접근해주세요." + ) + @GetMapping("/ranking") + public ResponseEntity> getWeeklyRank( + @AuthenticationPrincipal PrincipalDetails pd) { + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus._OK, storeService.getWeeklyRank(pd.getId()))); + } + + @Operation( + summary = "내 가게 순위 6주치 조회 API", + description = "partnerId로 접근해주세요" + ) + @GetMapping("/ranking/weekly") + public BaseResponse> getWeeklyRankByPartnerId( + @AuthenticationPrincipal PrincipalDetails pd + ){ + return BaseResponse.onSuccess(SuccessStatus._OK, storeService.getListWeeklyRank(pd.getId()).getItems()); + } + + } diff --git a/src/main/java/com/assu/server/domain/store/converter/StoreConverter.java b/src/main/java/com/assu/server/domain/store/converter/StoreConverter.java index fdaffa25..c61f0fac 100644 --- a/src/main/java/com/assu/server/domain/store/converter/StoreConverter.java +++ b/src/main/java/com/assu/server/domain/store/converter/StoreConverter.java @@ -1,4 +1,40 @@ package com.assu.server.domain.store.converter; +import com.assu.server.domain.store.dto.StoreResponseDTO; +import com.assu.server.domain.store.repository.StoreRepository; + +import java.util.List; +import java.util.stream.Collectors; + public class StoreConverter { + // 단건(이번 주) 변환: Row -> Response + public static StoreResponseDTO.WeeklyRankResponseDTO weeklyRankResponseDTO(StoreRepository.GlobalWeeklyRankRow r) { + return StoreResponseDTO.WeeklyRankResponseDTO.builder() + .usageCount(r.getUsageCount()) + .rank(r.getStoreRank()) + .build(); + } + + // 리스트 아이템 변환용: Row -> WeeklyRankResponseDTO + public static StoreResponseDTO.WeeklyRankResponseDTO weeklyRankItem(StoreRepository.GlobalWeeklyRankRow r) { + return StoreResponseDTO.WeeklyRankResponseDTO.builder() + .usageCount(r.getUsageCount()) + .rank(r.getStoreRank()) + .build(); + } + + // 리스트 래핑: storeId, storeName, items를 받아 최종 DTO 조립 + public static StoreResponseDTO.ListWeeklyRankResponseDTO listWeeklyRankResponseDTO( + Long storeId, String storeName, List rows + ) { + List items = rows.stream() + .map(StoreConverter::weeklyRankItem) + .collect(Collectors.toList()); + + return StoreResponseDTO.ListWeeklyRankResponseDTO.builder() + .storeId(storeId) + .storeName(storeName) + .items(items) + .build(); + } } diff --git a/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java b/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java index bbe84ba6..5a28476b 100644 --- a/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java +++ b/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java @@ -1,4 +1,40 @@ package com.assu.server.domain.store.dto; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + + + public class StoreResponseDTO { + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class WeeklyRankResponseDTO { + private Long rank; // 그 주 순위(1부터) + private Long usageCount; // 그 주 사용 건수 + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ListWeeklyRankResponseDTO { + private Long storeId; + private String storeName; + private List items; // 과거→현재 (6개) + } + @AllArgsConstructor + @RequiredArgsConstructor + @Builder + @Getter + public static class todayBest{ + List bestStores; + } + } diff --git a/src/main/java/com/assu/server/domain/store/entity/Store.java b/src/main/java/com/assu/server/domain/store/entity/Store.java index 40005b6d..f348d99f 100644 --- a/src/main/java/com/assu/server/domain/store/entity/Store.java +++ b/src/main/java/com/assu/server/domain/store/entity/Store.java @@ -1,4 +1,56 @@ package com.assu.server.domain.store.entity; +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partner.entity.Partner; -public class Store { -} +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.locationtech.jts.geom.Point; + + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class Store extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "partner_id") + private Partner partner; + + @Setter + private Integer rate; + + @Setter + @Enumerated(EnumType.STRING) + private ActivationStatus isActivate; + + @Setter + private String name; + + private String address; + + private String detailAddress; + + @JdbcTypeCode(SqlTypes.GEOMETRY) + private Point point; + + private double latitude; + private double longitude; + + public void linkPartner(Partner partner) { + this.partner = partner; + } + public void setGeo(Double lat, Double lng, Point point) { + this.latitude = lat; + this.longitude = lng; + this.point = point; + } + +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java b/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java index 5b7f9588..7114810e 100644 --- a/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java @@ -1,4 +1,138 @@ package com.assu.server.domain.store.repository; -public class StoreRepository { -} + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.partner.entity.Partner; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.LocalDate; +import java.util.List; + +public interface StoreRepository extends JpaRepository { + + Optional findByPartner(Partner partner); + + Optional findByNameAndAddressAndDetailAddress(String name, String address, String detailAddress); + + // [이번 주] 전체 스토어 중 특정 storeId의 주간 순위/건수 1건 (ACTIVE만) + @Query(value = """ + WITH w AS ( + SELECT DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AS week_start + ), + per_store AS ( + SELECT + s.id AS storeId, + s.name AS storeName, + CAST(COALESCE(COUNT(pu.id), 0) AS UNSIGNED) AS usageCount, + (SELECT week_start FROM w) AS weekStart + FROM store s + LEFT JOIN paper p ON p.store_id = s.id AND p.is_activated = 'ACTIVE' + LEFT JOIN partnership_usage pu + ON pu.paper_id = p.id + AND pu.created_at >= (SELECT week_start FROM w) + AND pu.created_at < (SELECT week_start FROM w) + INTERVAL 7 DAY + GROUP BY s.id, s.name + ) + SELECT + ps.weekStart AS weekStart, + ps.storeId AS storeId, + ps.storeName AS storeName, + ps.usageCount AS usageCount, + CAST( + DENSE_RANK() OVER (ORDER BY ps.usageCount DESC, ps.storeId ASC) + AS UNSIGNED + ) AS storeRank + FROM per_store ps + WHERE ps.storeId = :storeId + """, nativeQuery = true) + List findGlobalWeeklyRankForStore(@Param("storeId") Long storeId); + + interface GlobalWeeklyRankRow { + LocalDate getWeekStart(); + Long getStoreId(); + String getStoreName(); + Long getUsageCount(); + Long getStoreRank(); + } + + // [최근 6주] 전체 스토어 기준, 특정 storeId의 주간 순위/건수(월요일 시작) 추세 (ACTIVE만) + @Query(value = """ + WITH RECURSIVE weeks AS ( + SELECT DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AS week_start + UNION ALL + SELECT week_start - INTERVAL 7 DAY FROM weeks + WHERE week_start > DATE_SUB(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 5 WEEK) + ), + per_store_week AS ( + SELECT + w.week_start AS weekStart, + s.id AS storeId, + s.name AS storeName, + CAST(COALESCE(COUNT(pu.id), 0) AS UNSIGNED) AS usageCount + FROM weeks w + JOIN store s ON 1=1 + LEFT JOIN paper p ON p.store_id = s.id AND p.is_activated = 'ACTIVE' + LEFT JOIN partnership_usage pu + ON pu.paper_id = p.id + AND pu.created_at >= w.week_start + AND pu.created_at < w.week_start + INTERVAL 7 DAY + GROUP BY w.week_start, s.id, s.name + ) + SELECT + pw.weekStart AS weekStart, + pw.storeId AS storeId, + pw.storeName AS storeName, + pw.usageCount AS usageCount, + CAST( + DENSE_RANK() OVER ( + PARTITION BY pw.weekStart + ORDER BY pw.usageCount DESC, pw.storeId ASC + ) AS UNSIGNED + ) AS storeRank + FROM per_store_week pw + WHERE pw.storeId = :storeId + ORDER BY pw.weekStart ASC + """, nativeQuery = true) + List findGlobalWeeklyTrendLast6Weeks(@Param("storeId") Long storeId); + + @Query(""" + SELECT s FROM Store s + WHERE s.address = :address + AND ((:detail IS NULL AND s.detailAddress IS NULL) OR s.detailAddress = :detail) + """) + Optional findBySameAddress( + @Param("address") String address, + @Param("detail") String detail + ); + + @Query(value = """ + SELECT s.* + FROM store s + WHERE s.point IS NOT NULL + AND ST_Contains(ST_GeomFromText(:wkt, 4326), s.point) + """, nativeQuery = true) + List findAllWithinViewport(@Param("wkt") String wkt); + + List findByNameContainingIgnoreCaseOrderByIdDesc(String name); + Optional findByName(String name); + Optional findById(Long id); + Optional findByPartnerId(Long partnerId); + + // [오늘] 전체 스토어 중 사용 건수 상위 10개 (ACTIVE만) + @Query(value = """ + SELECT s.name + FROM store s + LEFT JOIN paper p ON p.store_id = s.id AND p.is_activated = 'ACTIVE' + LEFT JOIN partnership_usage pu + ON pu.paper_id = p.id + AND pu.created_at >= CURDATE() + AND pu.created_at < CURDATE() + INTERVAL 1 DAY + GROUP BY s.id, s.name + HAVING COUNT(pu.id) > 0 + ORDER BY COUNT(pu.id) DESC, s.id ASC + LIMIT 10 + """, nativeQuery = true) + List findTodayBestStoreNames(); +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/store/service/StoreService.java b/src/main/java/com/assu/server/domain/store/service/StoreService.java index 15ad373a..d21b3ee5 100644 --- a/src/main/java/com/assu/server/domain/store/service/StoreService.java +++ b/src/main/java/com/assu/server/domain/store/service/StoreService.java @@ -1,4 +1,10 @@ package com.assu.server.domain.store.service; +import com.assu.server.domain.partnership.dto.PartnershipResponseDTO; +import com.assu.server.domain.store.dto.StoreResponseDTO; +import com.assu.server.domain.user.dto.StudentResponseDTO; public interface StoreService { + StoreResponseDTO.todayBest getTodayBestStore(); + StoreResponseDTO.WeeklyRankResponseDTO getWeeklyRank(Long memberId); + StoreResponseDTO.ListWeeklyRankResponseDTO getListWeeklyRank(Long memberId); } diff --git a/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java b/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java index a92599ea..232c21ba 100644 --- a/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java +++ b/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java @@ -1,4 +1,70 @@ package com.assu.server.domain.store.service; -public class StoreServiceImpl { + +import java.util.List; +import org.springframework.stereotype.Service; +import com.assu.server.domain.store.dto.StoreResponseDTO; +import com.assu.server.domain.store.repository.StoreRepository; +import com.assu.server.domain.user.repository.PartnershipUsageRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partner.repository.PartnerRepository; +import com.assu.server.domain.store.converter.StoreConverter; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class StoreServiceImpl implements StoreService { + private final StoreRepository storeRepository; + private final PartnerRepository partnerRepository; + private final PartnershipUsageRepository partnershipUsageRepository; + + @Override + @Transactional + public StoreResponseDTO.todayBest getTodayBestStore() { + List bestStores = storeRepository.findTodayBestStoreNames(); + + return StoreResponseDTO.todayBest.builder() + .bestStores(bestStores) + .build(); + } + @Override + @Transactional + public StoreResponseDTO.WeeklyRankResponseDTO getWeeklyRank(Long memberId) { + + Optional partner = partnerRepository.findById(memberId); + Store store = storeRepository.findByPartner(partner.orElse(null)) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + Long storeId = store.getId(); + + List rows = storeRepository.findGlobalWeeklyRankForStore(storeId); + if (rows.isEmpty()) { + // 데이터가 없을 때 기본값 반환(필요 시 예외로 변경) + return StoreResponseDTO.WeeklyRankResponseDTO.builder() + .rank(null) + .usageCount(0L) + .build(); + } + return StoreConverter.weeklyRankResponseDTO(rows.get(0)); + } + + @Override + @Transactional + public StoreResponseDTO.ListWeeklyRankResponseDTO getListWeeklyRank(Long memberId) { + + Optional partner = partnerRepository.findById(memberId); + Store store = storeRepository.findByPartner(partner.orElse(null)) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STORE)); + Long storeId = store.getId(); + + List rows = storeRepository.findGlobalWeeklyTrendLast6Weeks(storeId); + + String storeName = rows.isEmpty() ? null : rows.get(0).getStoreName(); + return StoreConverter.listWeeklyRankResponseDTO(storeId, storeName, rows); + + } } diff --git a/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java b/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java index e65d5534..1a973246 100644 --- a/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java +++ b/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java @@ -1,4 +1,59 @@ package com.assu.server.domain.suggestion.controller; +import com.assu.server.domain.suggestion.dto.SuggestionRequestDTO; +import com.assu.server.domain.suggestion.dto.SuggestionResponseDTO; +import com.assu.server.domain.suggestion.service.SuggestionService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Suggestion", description = "제휴 건의 API") +@RestController +@RequiredArgsConstructor // 파라미터가 있어야만 하는 생성자 +@RequestMapping("/suggestion") // suggestion 아래에서 시작 public class SuggestionController { + + private final SuggestionService suggestionService; + + + @PostMapping + @Operation( + summary = "제휴 건의 API", + description = "[v1.0 (2025-09-03)](https://www.notion.so/_-2241197c19ed81e68840d565af59b534) 현재 로그인한 학생(User)이 관리자에게 제휴를 건의합니다.\n" + ) + public BaseResponse writeSuggestion( + @RequestBody SuggestionRequestDTO.WriteSuggestionRequestDTO suggestionRequestDTO, + @AuthenticationPrincipal PrincipalDetails pd + ){ + return BaseResponse.onSuccess(SuccessStatus._OK, suggestionService.writeSuggestion(suggestionRequestDTO, pd.getId())); + } + + @GetMapping("/admin") + @Operation( + summary = "제휴 건의대상 조회 API", + description = "[v1.0 (2025-09-03)](https://www.notion.so/_-2241197c19ed81e68840d565af59b534) 현재 로그인한 학생(User)이 제휴를 건의할 수 있는 학생회(Admin)를 조회합니다.\n" + ) + public BaseResponse getSuggestionAdmins( + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, suggestionService.getSuggestionAdmins(pd.getId())); + } + + @GetMapping("/list") + @Operation( + summary = "제휴 건의 조회 API", + description = "[v1.0 (2025-09-03)](https://www.notion.so/_-24c1197c19ed8083bf8be4b6a6a43f18) 현재 로그인한 학생회(Admin)가 받은 모든 제휴 건의를 조회합니다." + ) + public BaseResponse> getSuggestions( + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, suggestionService.getSuggestions(pd.getId())); + } } diff --git a/src/main/java/com/assu/server/domain/suggestion/converter/SuggestionConverter.java b/src/main/java/com/assu/server/domain/suggestion/converter/SuggestionConverter.java index c13cabfe..f5ca5912 100644 --- a/src/main/java/com/assu/server/domain/suggestion/converter/SuggestionConverter.java +++ b/src/main/java/com/assu/server/domain/suggestion/converter/SuggestionConverter.java @@ -1,4 +1,66 @@ package com.assu.server.domain.suggestion.converter; +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.auth.entity.SSUAuth; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.suggestion.dto.SuggestionRequestDTO; +import com.assu.server.domain.suggestion.dto.SuggestionResponseDTO; +import com.assu.server.domain.suggestion.entity.Suggestion; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; + +import java.util.List; +import java.util.stream.Collectors; + public class SuggestionConverter { + + public static SuggestionResponseDTO.WriteSuggestionResponseDTO writeSuggestionResultDTO(Suggestion suggestion){ + return SuggestionResponseDTO.WriteSuggestionResponseDTO.builder() + .suggestionId(suggestion.getId()) + .userId(suggestion.getStudent().getId()) + .adminId(suggestion.getAdmin().getId()) + .storeName(suggestion.getStoreName()) + .suggestionBenefit(suggestion.getContent()) + .build(); + } + + public static Suggestion toSuggestionEntity(SuggestionRequestDTO.WriteSuggestionRequestDTO suggestionRequestDTO, Admin admin, Student student){ + return Suggestion.builder() + .admin(admin) + .student(student) + .storeName(suggestionRequestDTO.getStoreName()) + .content(suggestionRequestDTO.getBenefit()) + .build(); + } + + public static SuggestionResponseDTO.GetSuggestionResponseDTO GetSuggestionResultDTO(Suggestion s){ + + Student student = s.getStudent(); + return SuggestionResponseDTO.GetSuggestionResponseDTO.builder() + .suggestionId(s.getId()) + .createdAt(s.getCreatedAt()) + .storeName(s.getStoreName()) + .content(s.getContent()) + .enrollmentStatus(student.getEnrollmentStatus()) + .studentMajor(student.getMajor()) + .build(); + } + + public static List toGetSuggestionDTOList(List list) { + return list.stream() + .map(SuggestionConverter::GetSuggestionResultDTO) + .collect(Collectors.toList()); + } + + public static SuggestionResponseDTO.GetSuggestionAdminsDTO toGetSuggestionAdmins(Admin universityAdmin, Admin departmentAdmin, Admin majorAdmin) { + return SuggestionResponseDTO.GetSuggestionAdminsDTO.builder() + .adminId(universityAdmin != null ? universityAdmin.getId() : null) + .adminName(universityAdmin != null ? universityAdmin.getName() : null) + .departId(departmentAdmin != null ? departmentAdmin.getId() : null) + .departName(departmentAdmin != null ? departmentAdmin.getName() : null) + .majorId(majorAdmin != null ? majorAdmin.getId() : null) + .majorName(majorAdmin != null ? majorAdmin.getName() : null) + .build(); + } } diff --git a/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionRequestDTO.java b/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionRequestDTO.java index a17553a0..410462dc 100644 --- a/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionRequestDTO.java +++ b/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionRequestDTO.java @@ -1,4 +1,13 @@ package com.assu.server.domain.suggestion.dto; +import lombok.Getter; + public class SuggestionRequestDTO { + + @Getter + public static class WriteSuggestionRequestDTO{ + private Long adminId; // 건의 대상 + private String storeName; // 희망 가게 + private String benefit; // 희망 혜택 + } } diff --git a/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionResponseDTO.java b/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionResponseDTO.java index 83efa3d8..b628bd17 100644 --- a/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionResponseDTO.java +++ b/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionResponseDTO.java @@ -1,4 +1,51 @@ -package com.assu.server.domain.suggestion.dto; + package com.assu.server.domain.suggestion.dto; -public class SuggestionResponseDTO { -} + import com.assu.server.domain.user.entity.enums.EnrollmentStatus; + import com.assu.server.domain.user.entity.enums.Major; + import lombok.AllArgsConstructor; + import lombok.Builder; + import lombok.Getter; + import lombok.NoArgsConstructor; + + import java.time.LocalDateTime; + + public class SuggestionResponseDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class WriteSuggestionResponseDTO { + private Long suggestionId; // 제안 번호 + private Long userId; // 제안인 아이디 + private Long adminId; // 건의 대상 아이디 + private String storeName; // 희망 가게 이름 + private String suggestionBenefit; // 희망 혜택 + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class GetSuggestionResponseDTO { + private Long suggestionId; + private LocalDateTime createdAt; + private String storeName; + private String content; + private Major studentMajor; + private EnrollmentStatus enrollmentStatus; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class GetSuggestionAdminsDTO { + private Long adminId; + private String adminName; + private Long departId; + private String departName; + private Long majorId; + private String majorName; + } + } diff --git a/src/main/java/com/assu/server/domain/suggestion/entity/Suggestion.java b/src/main/java/com/assu/server/domain/suggestion/entity/Suggestion.java index 5354c8d1..228cd8a4 100644 --- a/src/main/java/com/assu/server/domain/suggestion/entity/Suggestion.java +++ b/src/main/java/com/assu/server/domain/suggestion/entity/Suggestion.java @@ -1,4 +1,51 @@ package com.assu.server.domain.suggestion.entity; -public class Suggestion { +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.common.entity.enums.ReportedStatus; +import com.assu.server.domain.user.entity.Student; + +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Entity +@AllArgsConstructor +@RequiredArgsConstructor +@Builder +@Getter +public class Suggestion extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "admin_id") + private Admin admin; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "student_id") + private Student student; + + private String storeName; + private String content; + + @Enumerated(EnumType.STRING) + @Builder.Default + private ReportedStatus status = ReportedStatus.NORMAL; + + // 신고 상태 업데이트 메서드 + public void updateReportedStatus(ReportedStatus status) { + this.status = status; + } } diff --git a/src/main/java/com/assu/server/domain/suggestion/repository/SuggestionRepository.java b/src/main/java/com/assu/server/domain/suggestion/repository/SuggestionRepository.java index 747cf929..146338be 100644 --- a/src/main/java/com/assu/server/domain/suggestion/repository/SuggestionRepository.java +++ b/src/main/java/com/assu/server/domain/suggestion/repository/SuggestionRepository.java @@ -1,4 +1,36 @@ package com.assu.server.domain.suggestion.repository; -public class SuggestionRepository { +import com.assu.server.domain.suggestion.entity.Suggestion; +import com.assu.server.domain.common.entity.enums.ReportedStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface SuggestionRepository extends JpaRepository { + + @Query(""" + select s + from Suggestion s + join fetch s.student st + where s.admin.id = :adminId + AND s.status = :status + AND s.student.status = :studentStatus + order by s.createdAt desc + """) + List findAllSuggestionsWithStatus( + @Param("adminId") Long adminId, + @Param("status") ReportedStatus status, + @Param("studentStatus") ReportedStatus studentStatus + ); + + @Query(""" + select s + from Suggestion s + join fetch s.student st + where s.admin.id = :adminId + order by s.createdAt desc + """) + List findAllSuggestions(@Param("adminId") Long adminId); } diff --git a/src/main/java/com/assu/server/domain/suggestion/service/SuggestionService.java b/src/main/java/com/assu/server/domain/suggestion/service/SuggestionService.java index 15b27d95..d5b9365b 100644 --- a/src/main/java/com/assu/server/domain/suggestion/service/SuggestionService.java +++ b/src/main/java/com/assu/server/domain/suggestion/service/SuggestionService.java @@ -1,4 +1,19 @@ package com.assu.server.domain.suggestion.service; +import com.assu.server.domain.suggestion.dto.SuggestionRequestDTO; +import com.assu.server.domain.suggestion.dto.SuggestionResponseDTO; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + public interface SuggestionService { + + SuggestionResponseDTO.WriteSuggestionResponseDTO writeSuggestion( + @RequestBody SuggestionRequestDTO.WriteSuggestionRequestDTO request, + Long userId + ); + + List getSuggestions(Long adminId); + + SuggestionResponseDTO.GetSuggestionAdminsDTO getSuggestionAdmins(Long userId); } diff --git a/src/main/java/com/assu/server/domain/suggestion/service/SuggestionServiceImpl.java b/src/main/java/com/assu/server/domain/suggestion/service/SuggestionServiceImpl.java index 912d0871..8f1896e8 100644 --- a/src/main/java/com/assu/server/domain/suggestion/service/SuggestionServiceImpl.java +++ b/src/main/java/com/assu/server/domain/suggestion/service/SuggestionServiceImpl.java @@ -1,4 +1,87 @@ package com.assu.server.domain.suggestion.service; -public class SuggestionServiceImpl { +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.admin.repository.AdminRepository; +import com.assu.server.domain.notification.service.NotificationCommandService; +import com.assu.server.domain.suggestion.converter.SuggestionConverter; +import com.assu.server.domain.suggestion.dto.SuggestionRequestDTO; +import com.assu.server.domain.suggestion.dto.SuggestionResponseDTO; +import com.assu.server.domain.suggestion.entity.Suggestion; +import com.assu.server.domain.suggestion.repository.SuggestionRepository; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.domain.user.repository.StudentRepository; +import com.assu.server.domain.common.entity.enums.ReportedStatus; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SuggestionServiceImpl implements SuggestionService { + + private final SuggestionRepository suggestionRepository; + private final AdminRepository adminRepository; + private final StudentRepository studentRepository; + private final NotificationCommandService notificationCommandService; + + @Override + @Transactional + public SuggestionResponseDTO.WriteSuggestionResponseDTO writeSuggestion(SuggestionRequestDTO.WriteSuggestionRequestDTO request, Long userId) { + + Admin admin = adminRepository.findById(request.getAdminId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ADMIN)); + + Student student = studentRepository.findById(userId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STUDENT)); + + Suggestion suggestion = SuggestionConverter.toSuggestionEntity(request, admin, student); + suggestionRepository.save(suggestion); + notificationCommandService.sendPartnerSuggestion(suggestion.getAdmin().getId(), suggestion.getId()); + + return SuggestionConverter.writeSuggestionResultDTO(suggestion); + } + + @Override + public List getSuggestions(Long adminId) { + // 신고되지 않은 건의글과 신고되지 않은 학생이 작성한 건의글만 조회 + List list = suggestionRepository + .findAllSuggestionsWithStatus(adminId, ReportedStatus.NORMAL, ReportedStatus.NORMAL); + + return SuggestionConverter.toGetSuggestionDTOList(list); + } + + @Override + public SuggestionResponseDTO.GetSuggestionAdminsDTO getSuggestionAdmins(Long userId) { + + Student student = studentRepository.findById(userId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STUDENT)); + + List adminList = adminRepository.findMatchingAdmins( + student.getUniversity(), + student.getDepartment(), + student.getMajor() + ); + + Admin universityAdmin = null; + Admin departmentAdmin = null; + Admin majorAdmin = null; + + for (Admin admin : adminList) { + if (admin.getMajor() != null) { + majorAdmin = admin; + } + else if (admin.getDepartment() != null) { + departmentAdmin = admin; + } + else { + universityAdmin = admin; + } + } + + return SuggestionConverter.toGetSuggestionAdmins(universityAdmin, departmentAdmin, majorAdmin); + } } diff --git a/src/main/java/com/assu/server/domain/term/controller/TermController.java b/src/main/java/com/assu/server/domain/term/controller/TermController.java new file mode 100644 index 00000000..abd9dfb5 --- /dev/null +++ b/src/main/java/com/assu/server/domain/term/controller/TermController.java @@ -0,0 +1,4 @@ +package com.assu.server.domain.term.controller; + +public class TermController { +} diff --git a/src/main/java/com/assu/server/domain/term/converter/TermConverter.java b/src/main/java/com/assu/server/domain/term/converter/TermConverter.java new file mode 100644 index 00000000..a5da9407 --- /dev/null +++ b/src/main/java/com/assu/server/domain/term/converter/TermConverter.java @@ -0,0 +1,4 @@ +package com.assu.server.domain.term.converter; + +public class TermConverter { +} diff --git a/src/main/java/com/assu/server/domain/term/dto/TermRequestDTO.java b/src/main/java/com/assu/server/domain/term/dto/TermRequestDTO.java new file mode 100644 index 00000000..65ec572c --- /dev/null +++ b/src/main/java/com/assu/server/domain/term/dto/TermRequestDTO.java @@ -0,0 +1,4 @@ +package com.assu.server.domain.term.dto; + +public class TermRequestDTO { +} diff --git a/src/main/java/com/assu/server/domain/term/dto/TermResponseDTO.java b/src/main/java/com/assu/server/domain/term/dto/TermResponseDTO.java new file mode 100644 index 00000000..2d45f0ec --- /dev/null +++ b/src/main/java/com/assu/server/domain/term/dto/TermResponseDTO.java @@ -0,0 +1,4 @@ +package com.assu.server.domain.term.dto; + +public class TermResponseDTO { +} diff --git a/src/main/java/com/assu/server/domain/term/entity/Term.java b/src/main/java/com/assu/server/domain/term/entity/Term.java new file mode 100644 index 00000000..dcece7d0 --- /dev/null +++ b/src/main/java/com/assu/server/domain/term/entity/Term.java @@ -0,0 +1,40 @@ +package com.assu.server.domain.term.entity; + +import java.time.LocalDate; + +import com.assu.server.domain.common.entity.BaseEntity; + +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class Term extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + private String content; + + private Boolean isAgreed; + + private LocalDate agreedDate; + + private LocalDate disagreedDate; +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/term/entity/mapping/TermAgreement.java b/src/main/java/com/assu/server/domain/term/entity/mapping/TermAgreement.java new file mode 100644 index 00000000..11259e96 --- /dev/null +++ b/src/main/java/com/assu/server/domain/term/entity/mapping/TermAgreement.java @@ -0,0 +1,36 @@ +package com.assu.server.domain.term.entity.mapping; +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.term.entity.Term; + +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class TermAgreement extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "term_id") + private Term term; +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/term/repository/TermRepository.java b/src/main/java/com/assu/server/domain/term/repository/TermRepository.java new file mode 100644 index 00000000..7ffd80eb --- /dev/null +++ b/src/main/java/com/assu/server/domain/term/repository/TermRepository.java @@ -0,0 +1,4 @@ +package com.assu.server.domain.term.repository; + +public class TermRepository { +} diff --git a/src/main/java/com/assu/server/domain/term/service/TermService.java b/src/main/java/com/assu/server/domain/term/service/TermService.java new file mode 100644 index 00000000..e90bbf68 --- /dev/null +++ b/src/main/java/com/assu/server/domain/term/service/TermService.java @@ -0,0 +1,7 @@ +package com.assu.server.domain.term.service; + +import org.springframework.stereotype.Service; + +@Service +public interface TermService { +} diff --git a/src/main/java/com/assu/server/domain/term/service/TermServiceImpl.java b/src/main/java/com/assu/server/domain/term/service/TermServiceImpl.java new file mode 100644 index 00000000..b3d80b20 --- /dev/null +++ b/src/main/java/com/assu/server/domain/term/service/TermServiceImpl.java @@ -0,0 +1,4 @@ +package com.assu.server.domain.term.service; + +public class TermServiceImpl implements TermService { +} diff --git a/src/main/java/com/assu/server/domain/user/controller/StudentController.java b/src/main/java/com/assu/server/domain/user/controller/StudentController.java new file mode 100644 index 00000000..ff98eaa8 --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/controller/StudentController.java @@ -0,0 +1,113 @@ +package com.assu.server.domain.user.controller; + +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.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.user.dto.StudentResponseDTO; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.util.PrincipalDetails; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import com.assu.server.domain.user.dto.StudentResponseDTO; +import com.assu.server.domain.user.service.StudentService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@Tag(name = "유저 관련 api", description = "유저와 관련된 로직을 처리하는 api") +@RequiredArgsConstructor +@RequestMapping("/students") +public class StudentController { + + private final StudentService studentService; + + @GetMapping("/partnerships/{year}/{month}") + @Operation( + summary = "월별 제휴 사용내역 조회 API", + description = "# [v1.0 (2025-09-09)](https://www.notion.so/_-2241197c19ed8134bd49d8841e841634?source=copy_link)\n" + + "- `multipart/form-data`로 호출합니다.\n" + + "\n**Request Parts:**\n" + + " - `year` (Integer, required): 년도\n" + + " - `month` (Long, required): 월\n"+ + "\n**Response:**\n" + + " - 성공 시 partnership Usage 내역 반환 \n"+ + " - 해당 storeId, storeName 반환"+ + " - 해당 월에 사용한 제휴 수 반환" + ) + public ResponseEntity> getMyPartnership( + @PathVariable int year, @PathVariable int month, @AuthenticationPrincipal PrincipalDetails pd + ){ + StudentResponseDTO.myPartnership result = studentService.getMyPartnership(pd.getId(), year, month); + + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus.PARTNERSHIP_HISTORY_SUCCESS, result)); + } + + @GetMapping("/usage") + @Operation( + summary = "월별 제휴 사용내역 조회 API", + description = "# [v1.0 (2025-09-10)](https://www.notion.so/_-24c1197c19ed809a9d81e8f928e8355f?source=copy_link)\n" + + "- `multipart/form-data`로 호출합니다.\n" + + "\n**Request:**\n" + + " - page : (Int, required) 이상의 정수 \n" + + " - size : (Int, required) 기본 값 10 \n" + + " - sort : (String, required) createdAt,desc 문자열로 입력\n" + + "\n**Response:**\n" + + " - 성공 시 리뷰 되지 않은 partnership Usage 내역 반환 \n"+ + " - StudentResponseTO.UsageDetailDTO 객체 반환 \n" + + ) + public ResponseEntity>> getUnreviewedUsage( + @AuthenticationPrincipal PrincipalDetails pd, + Pageable pageable + ){ + return ResponseEntity.ok(BaseResponse + .onSuccess(SuccessStatus.UNREVIEWED_HISTORY_SUCCESS, + studentService.getUnreviewedUsage(pd.getId(), pageable))); + } + + @Operation( + summary = "사용자 stamp 개수 조회 API", + description = "# [v1.0 (2025-09-09)](https://www.notion.so/2691197c19ed805c980dd546adee9301?source=copy_link)\n" + + "- `multipart/form-data`로 호출합니다.\n" + + "- login 필요 "+ + "\n**Response:**\n" + + " - stamp 개수 반환 \n" + ) + @GetMapping("/stamp") + public BaseResponse getStamp( + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, studentService.getStamp(pd.getId())); + } + + @Operation( + summary = "사용자의 이용 가능한 제휴 조회 API", + description = "# [v1.0 (2025-10-30)](https://clumsy-seeder-416.notion.site/API-29c1197c19ed8030b1f5e2a744416651?source=copy_link)\n" + + "- all = true면 전체 조회, false면 2개만 조회" + ) + @GetMapping("/usable") + public BaseResponse> getUsablePartnership( + @AuthenticationPrincipal PrincipalDetails pd, + @RequestParam(name = "all", defaultValue = "false") boolean all + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, studentService.getUsablePartnership(pd.getId(), all)); + } + + @PostMapping("/sync/all") + public BaseResponse syncAllStudentsNow() { + studentService.syncUserPapersForAllStudents(); + return BaseResponse.onSuccess(SuccessStatus._OK, "전체 학생 user_paper 동기화 완료"); + } + +} diff --git a/src/main/java/com/assu/server/domain/user/controller/UserController.java b/src/main/java/com/assu/server/domain/user/controller/UserController.java deleted file mode 100644 index 3e594800..00000000 --- a/src/main/java/com/assu/server/domain/user/controller/UserController.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.user.controller; - -public class UserController { -} diff --git a/src/main/java/com/assu/server/domain/user/converter/StudentConverter.java b/src/main/java/com/assu/server/domain/user/converter/StudentConverter.java new file mode 100644 index 00000000..90f34edf --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/converter/StudentConverter.java @@ -0,0 +1,14 @@ +package com.assu.server.domain.user.converter; + +import com.assu.server.domain.user.dto.StudentResponseDTO; +import com.assu.server.domain.user.entity.Student; + +public class StudentConverter { + public static StudentResponseDTO.CheckStampResponseDTO checkStampResponseDTO(Student student, String message) { + return StudentResponseDTO.CheckStampResponseDTO.builder() + .userId(student.getId()) + .stamp(student.getStamp()) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/assu/server/domain/user/converter/UserConverter.java b/src/main/java/com/assu/server/domain/user/converter/UserConverter.java deleted file mode 100644 index 97c6d6f7..00000000 --- a/src/main/java/com/assu/server/domain/user/converter/UserConverter.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.user.converter; - -public class UserConverter { -} diff --git a/src/main/java/com/assu/server/domain/user/dto/StudentRequestDTO.java b/src/main/java/com/assu/server/domain/user/dto/StudentRequestDTO.java new file mode 100644 index 00000000..2c9043a7 --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/dto/StudentRequestDTO.java @@ -0,0 +1,8 @@ +package com.assu.server.domain.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class StudentRequestDTO { +} diff --git a/src/main/java/com/assu/server/domain/user/dto/StudentResponseDTO.java b/src/main/java/com/assu/server/domain/user/dto/StudentResponseDTO.java new file mode 100644 index 00000000..d415f75a --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/dto/StudentResponseDTO.java @@ -0,0 +1,89 @@ +package com.assu.server.domain.user.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class StudentResponseDTO { + + @Getter + @Builder + @AllArgsConstructor + @RequiredArgsConstructor + public static class myPartnership { + private long serviceCount; + private List details; + } + + @Getter + @AllArgsConstructor + @Builder + public static class UsageDetailDTO { + private String adminName; + private Long partnershipUsageId; + private String storeName; + private Long partnerId; + private Long storeId; + private String usedAt; + private String benefitDescription; + private boolean isReviewed; + } + /* @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CheckPartnershipUsageResponseDTO { + private Long id; + private String place; + private LocalDate date; + private String partnershipContent; + private Boolean isReviewed; //리뷰 작성하기 버튼 활성화 ? + private Integer discount; //가격? 비율 + private LocalDateTime createdAt; + } + */ + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CheckStampResponseDTO { + private Long userId; + private int stamp; + private String message; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class UsablePartnershipDTO { + private Long partnershipId; + private String adminName; + private String partnerName; + private CriterionType criterionType; + private OptionType optionType; + private Integer people; + private Long cost; + private String note; + private Long paperId; + private String category; + private Long discountRate; + } + +} diff --git a/src/main/java/com/assu/server/domain/user/dto/UserRequestDTO.java b/src/main/java/com/assu/server/domain/user/dto/UserRequestDTO.java deleted file mode 100644 index 99ab532c..00000000 --- a/src/main/java/com/assu/server/domain/user/dto/UserRequestDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.user.dto; - -public class UserRequestDTO { -} diff --git a/src/main/java/com/assu/server/domain/user/dto/UserResponseDTO.java b/src/main/java/com/assu/server/domain/user/dto/UserResponseDTO.java deleted file mode 100644 index 935e4e2d..00000000 --- a/src/main/java/com/assu/server/domain/user/dto/UserResponseDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.user.dto; - -public class UserResponseDTO { -} diff --git a/src/main/java/com/assu/server/domain/user/entity/PartnershipUsage.java b/src/main/java/com/assu/server/domain/user/entity/PartnershipUsage.java new file mode 100644 index 00000000..9ab3c004 --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/entity/PartnershipUsage.java @@ -0,0 +1,47 @@ +package com.assu.server.domain.user.entity; +import java.time.LocalDate; + +import com.assu.server.domain.common.entity.BaseEntity; + +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class PartnershipUsage extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "student_id") + private Student student; + + private String place; + private LocalDate date; + private String partnershipContent; + private Boolean isReviewed; + private Integer discount; + private Long paperId; + private Long contentId; + + private String adminName; + + public void setIsReviewed(Boolean isReviewed) { + this.isReviewed = isReviewed; + } + +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/user/entity/Student.java b/src/main/java/com/assu/server/domain/user/entity/Student.java new file mode 100644 index 00000000..0c1b034d --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/entity/Student.java @@ -0,0 +1,76 @@ +package com.assu.server.domain.user.entity; + +import com.assu.server.domain.common.entity.enums.ReportedStatus; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.user.entity.enums.Department; +import com.assu.server.domain.user.entity.enums.EnrollmentStatus; +import com.assu.server.domain.user.entity.enums.Major; +import com.assu.server.domain.user.entity.enums.University; +import jakarta.persistence.*; +import lombok.*; + + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Student { + @Id + private Long id; + + @OneToOne + @JoinColumn(name = "id") // member_id와 공유 + @MapsId + private Member member; + + private String name; + + @Enumerated(EnumType.STRING) + private Department department; + + @Enumerated(EnumType.STRING) + private EnrollmentStatus enrollmentStatus; + + private String yearSemester; + + @Enumerated(EnumType.STRING) + private University university; + + private int stamp; + + @Enumerated(EnumType.STRING) + private Major major; + + @Enumerated(EnumType.STRING) + @Builder.Default + private ReportedStatus status = ReportedStatus.NORMAL; + + public void setMember(Member member) { + this.member = member; + } + + public void setStamp() { + this.stamp++; + } + + /** + * 유세인트에서 크롤링한 최신 정보로 학생 정보를 업데이트합니다. + * + * @param name 학생 이름 + * @param major 전공 + * @param enrollmentStatus 학적 상태 + * @param yearSemester 학년/학기 + */ + public void updateStudentInfo(String name, Major major, EnrollmentStatus enrollmentStatus, String yearSemester) { + this.name = name; + this.major = major; + this.enrollmentStatus = enrollmentStatus; + this.yearSemester = yearSemester; + } + + // 신고 상태 업데이트 메서드 + public void updateReportedStatus(ReportedStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/assu/server/domain/user/entity/UserPaper.java b/src/main/java/com/assu/server/domain/user/entity/UserPaper.java new file mode 100644 index 00000000..a00962cb --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/entity/UserPaper.java @@ -0,0 +1,40 @@ +package com.assu.server.domain.user.entity; +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.entity.PaperContent; + +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.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Entity +@Getter +@NoArgsConstructor +@Builder +@AllArgsConstructor +public class UserPaper extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id") // 제안서 내용 id + private PaperContent paperContent; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "paper_id") + private Paper paper; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "student_id") + private Student student; +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/user/entity/enums/Department.java b/src/main/java/com/assu/server/domain/user/entity/enums/Department.java new file mode 100644 index 00000000..37b91f97 --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/entity/enums/Department.java @@ -0,0 +1,23 @@ +package com.assu.server.domain.user.entity.enums; + +public enum Department { + HUMANITIES("인문대학"), + NATURAL_SCIENCE("자연과학대학"), + LAW("법과대학"), + SOCIAL_SCIENCE("사회과학대학"), + ECONOMICS("경제통상대학"), + BUSINESS("경영대학"), + ENGINEERING("공과대학"), + IT("IT대학"), + LIBERAL_ARTS("자유전공학부"); + + private final String displayName; + + Department(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/src/main/java/com/assu/server/domain/user/entity/enums/EnrollmentStatus.java b/src/main/java/com/assu/server/domain/user/entity/enums/EnrollmentStatus.java new file mode 100644 index 00000000..163e0a4f --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/entity/enums/EnrollmentStatus.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.user.entity.enums; + +public enum EnrollmentStatus { + ENROLLED, LEAVE, GRADUATED +} diff --git a/src/main/java/com/assu/server/domain/user/entity/enums/Major.java b/src/main/java/com/assu/server/domain/user/entity/enums/Major.java new file mode 100644 index 00000000..302babe1 --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/entity/enums/Major.java @@ -0,0 +1,87 @@ +package com.assu.server.domain.user.entity.enums; + +public enum Major { + // 인문대학 + CHRISTIAN_STUDIES(Department.HUMANITIES, "기독교학과"), + KOREAN_LITERATURE(Department.HUMANITIES, "국어국문학과"), + ENGLISH_LITERATURE(Department.HUMANITIES, "영어영문학과"), + GERMAN_LITERATURE(Department.HUMANITIES, "독어독문학과"), + FRENCH_LITERATURE(Department.HUMANITIES, "불어불문학과"), + CHINESE_LITERATURE(Department.HUMANITIES, "중어중문학과"), + JAPANESE_LITERATURE(Department.HUMANITIES, "일어일문학과"), + PHILOSOPHY(Department.HUMANITIES, "철학과"), + HISTORY(Department.HUMANITIES, "사학과"), + CREATIVE_ARTS(Department.HUMANITIES, "예술창작학부"), + SPORTS(Department.HUMANITIES, "스포츠학부"), + + // 자연과학대학 + MATHEMATICS(Department.NATURAL_SCIENCE, "수학과"), + CHEMISTRY(Department.NATURAL_SCIENCE, "화학과"), + BIOMEDICAL_SYSTEMS(Department.NATURAL_SCIENCE, "의생명시스템학부"), + PHYSICS(Department.NATURAL_SCIENCE, "물리학과"), + STATISTICS_ACTUARIAL(Department.NATURAL_SCIENCE, "정보통계ㆍ보험수리학과"), + + // 법과대학 + LAW(Department.LAW, "법학과"), + INTERNATIONAL_LAW(Department.LAW, "국제법무학과"), + + // 사회과학대학 + SOCIAL_WELFARE(Department.SOCIAL_SCIENCE, "사회복지학부"), + POLITICAL_SCIENCE(Department.SOCIAL_SCIENCE, "정치외교학과"), + MEDIA_COMMUNICATION(Department.SOCIAL_SCIENCE, "언론홍보학과"), + PUBLIC_ADMINISTRATION(Department.SOCIAL_SCIENCE, "행정학부"), + INFORMATION_SOCIETY(Department.SOCIAL_SCIENCE, "정보사회학과"), + LIFELONG_EDUCATION(Department.SOCIAL_SCIENCE, "평생교육학과"), + + // 경제통상대학 + ECONOMICS(Department.ECONOMICS, "경제학과"), + FINANCIAL_ECONOMICS(Department.ECONOMICS, "금융경제학과"), + GLOBAL_TRADE(Department.ECONOMICS, "글로벌통상학과"), + INTERNATIONAL_TRADE(Department.ECONOMICS, "국제무역학과"), + + // 경영대학 + BUSINESS_ADMINISTRATION(Department.BUSINESS, "경영학부"), + ACCOUNTING(Department.BUSINESS, "회계학과"), + VENTURE_MANAGEMENT(Department.BUSINESS, "벤처경영학과"), + WELFARE_MANAGEMENT(Department.BUSINESS, "복지경영학과"), + VENTURE_SME(Department.BUSINESS, "벤처중소기업학과"), + FINANCE(Department.BUSINESS, "금융학부"), + INNOVATION_MANAGEMENT(Department.BUSINESS, "혁신경영학과"), + ACCOUNTING_TAX(Department.BUSINESS, "회계세무학과"), + + // 공과대학 + CHEMICAL_ENGINEERING(Department.ENGINEERING, "화학공학과"), + ELECTRICAL_ENGINEERING(Department.ENGINEERING, "전기공학부"), + ARCHITECTURE(Department.ENGINEERING, "건축학부"), + INDUSTRIAL_INFO_SYSTEMS(Department.ENGINEERING, "산업ㆍ정보시스템공학과"), + MECHANICAL_ENGINEERING(Department.ENGINEERING, "기계공학부"), + MATERIALS_SCIENCE(Department.ENGINEERING, "신소재공학과"), + + // IT대학 + SW(Department.IT, "소프트웨어학부"), + GM(Department.IT, "글로벌미디어학부"), + COM(Department.IT, "컴퓨터학부"), + EE(Department.IT, "전자정보공학부"), + IP(Department.IT, "정보보호학과"), + AI(Department.IT, "AI융합학부"), + MB(Department.IT, "미디어경영학과"), + + // 자유전공학부 + LIBERAL_ARTS(Department.LIBERAL_ARTS, "자유전공학부"); + + private final Department department; + private final String displayName; + + Major(Department department, String displayName) { + this.department = department; + this.displayName = displayName; + } + + public Department getDepartment() { + return department; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/src/main/java/com/assu/server/domain/user/entity/enums/University.java b/src/main/java/com/assu/server/domain/user/entity/enums/University.java new file mode 100644 index 00000000..ab18969e --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/entity/enums/University.java @@ -0,0 +1,15 @@ +package com.assu.server.domain.user.entity.enums; + +public enum University { + SSU("숭실대학교"); + + private final String displayName; + + University(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/src/main/java/com/assu/server/domain/user/repository/PartnershipUsageRepository.java b/src/main/java/com/assu/server/domain/user/repository/PartnershipUsageRepository.java new file mode 100644 index 00000000..d457ee85 --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/repository/PartnershipUsageRepository.java @@ -0,0 +1,44 @@ +package com.assu.server.domain.user.repository; + +import java.util.List; +import java.util.Optional; + +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 com.assu.server.domain.user.entity.PartnershipUsage; + +public interface PartnershipUsageRepository extends JpaRepository { + + @Query(value = """ + SELECT place + FROM partnership_usage + WHERE date = DATE(DATE_ADD(NOW(), INTERVAL 9 HOUR)) + GROUP BY place + ORDER BY COUNT(*) DESC + LIMIT 10 + """, nativeQuery = true) + List findTodayPopularPartnership(); + + @Query("SELECT pu FROM PartnershipUsage pu " + + "WHERE pu.student.id= :studentId " + + "AND YEAR(pu.date) = :year " + + "AND MONTH(pu.date) = :month " + + "ORDER BY pu.date DESC") + List findByYearAndMonth( + @Param("studentId") Long studentId, + @Param("year") int year, + @Param("month") int month + ); + + Optional findById(Long id); + + + @Query("SELECT pu FROM PartnershipUsage pu " + + "WHERE pu.student.id = :studentId " + + "AND (pu.isReviewed = false)") + Page findByUnreviewedUsage(Long studentId, Pageable pageable); +} diff --git a/src/main/java/com/assu/server/domain/user/repository/StudentRepository.java b/src/main/java/com/assu/server/domain/user/repository/StudentRepository.java new file mode 100644 index 00000000..625d4fbb --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/repository/StudentRepository.java @@ -0,0 +1,15 @@ +package com.assu.server.domain.user.repository; + + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.assu.server.domain.user.entity.Student; + +public interface StudentRepository extends JpaRepository { + + Optional findStudentById(Long id); + + +} diff --git a/src/main/java/com/assu/server/domain/user/repository/UserPaperRepository.java b/src/main/java/com/assu/server/domain/user/repository/UserPaperRepository.java new file mode 100644 index 00000000..511998f1 --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/repository/UserPaperRepository.java @@ -0,0 +1,29 @@ +package com.assu.server.domain.user.repository; + +import com.assu.server.domain.user.entity.UserPaper; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface UserPaperRepository extends JpaRepository { + + @Query(""" + SELECT up FROM UserPaper up + JOIN FETCH up.paper p + LEFT JOIN FETCH p.store s + LEFT JOIN FETCH p.admin a + WHERE up.student.id = :studentId + AND p.isActivated = com.assu.server.domain.common.enums.ActivationStatus.ACTIVE + AND p.partnershipPeriodStart <= :today + AND p.partnershipPeriodEnd >= :today + ORDER BY p.id DESC + """) + List findActivePartnershipsByStudentId(@Param("studentId") Long studentId, + @Param("today") LocalDate today); + + boolean existsByStudentIdAndPaperId(Long studentId, Long paperId); + +} diff --git a/src/main/java/com/assu/server/domain/user/repository/UserRepository.java b/src/main/java/com/assu/server/domain/user/repository/UserRepository.java deleted file mode 100644 index 1c3be7b0..00000000 --- a/src/main/java/com/assu/server/domain/user/repository/UserRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.user.repository; - -public class UserRepository { -} diff --git a/src/main/java/com/assu/server/domain/user/service/StudentService.java b/src/main/java/com/assu/server/domain/user/service/StudentService.java new file mode 100644 index 00000000..5076c46e --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/service/StudentService.java @@ -0,0 +1,16 @@ +package com.assu.server.domain.user.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.assu.server.domain.user.dto.StudentResponseDTO; + +import java.util.List; + +public interface StudentService { + StudentResponseDTO.myPartnership getMyPartnership(Long studentId, int year, int month); + StudentResponseDTO.CheckStampResponseDTO getStamp(Long memberId);//조회 + Page getUnreviewedUsage(Long memberId, Pageable pageable); + List getUsablePartnership(Long memberId, Boolean all); + void syncUserPapersForAllStudents(); +} diff --git a/src/main/java/com/assu/server/domain/user/service/StudentServiceImpl.java b/src/main/java/com/assu/server/domain/user/service/StudentServiceImpl.java new file mode 100644 index 00000000..a13eb1fd --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/service/StudentServiceImpl.java @@ -0,0 +1,237 @@ +package com.assu.server.domain.user.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.admin.repository.AdminRepository; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partnership.entity.Goods; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.partnership.entity.enums.OptionType; +import com.assu.server.domain.partnership.repository.GoodsRepository; +import com.assu.server.domain.partnership.repository.PaperContentRepository; +import com.assu.server.domain.partnership.repository.PaperRepository; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.user.converter.StudentConverter; +import com.assu.server.domain.user.dto.StudentResponseDTO; +import com.assu.server.domain.user.entity.PartnershipUsage; +import com.assu.server.domain.user.entity.Student; +import com.assu.server.domain.user.entity.UserPaper; +import com.assu.server.domain.user.repository.PartnershipUsageRepository; +import com.assu.server.domain.user.repository.StudentRepository; +import com.assu.server.domain.user.repository.UserPaperRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class StudentServiceImpl implements StudentService { + private final StudentRepository studentRepository; + private final UserPaperRepository userPaperRepository; + private final PaperContentRepository paperContentRepository; + private final PartnershipUsageRepository partnershipUsageRepository; + private final GoodsRepository goodsRepository; + private final AdminRepository adminRepository; + private final PaperRepository paperRepository; + + @Override + @Transactional + public StudentResponseDTO.CheckStampResponseDTO getStamp(Long memberId) { + Student student = studentRepository.findById(memberId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STUDENT)); + + return StudentConverter.checkStampResponseDTO(student, "스탬프 조회 성공"); + } + + @Override + @Transactional + public StudentResponseDTO.myPartnership getMyPartnership(Long studentId, int year, int month) { + List usages = partnershipUsageRepository.findByYearAndMonth(studentId, year, month); + + return StudentResponseDTO.myPartnership.builder() + .serviceCount(usages.size()) + .details(usages.stream() + .map(u -> { + // 1. partnershipUsage의 paperContentId로 paperContent를 조회합니다. + // findById는 Optional을 반환하므로, orElse(null)로 처리합니다. + PaperContent paperContent = paperContentRepository.findById(u.getContentId()) + .orElse(null); + + // 2. PaperContent에서 storeId를 가져옵니다. + Store store = (paperContent != null) ? paperContent.getPaper().getStore() : null; + LocalDateTime ld= u.getCreatedAt(); + String formatDate =ld.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + + return StudentResponseDTO.UsageDetailDTO.builder() + .partnershipUsageId(u.getId()) + .adminName(u.getAdminName()) + .storeName(u.getPlace()) + .usedAt(formatDate) + .benefitDescription(u.getPartnershipContent()) + .isReviewed(u.getIsReviewed()) + .storeId(store.getId()) // 3. storeId를 DTO에 매핑합니다. + .partnerId(store.getPartner().getId()) + .build(); + }).toList() + ) + .build(); + } + + + @Override + @Transactional + public Page getUnreviewedUsage(Long memberId, Pageable pageable) { + // 프론트에서 1-based 페이지를 보낸 경우 0-based 로 보정 + pageable = PageRequest.of( + Math.max(pageable.getPageNumber() - 1, 0), + pageable.getPageSize(), + pageable.getSort() + ); + + Page contentList = + partnershipUsageRepository.findByUnreviewedUsage(memberId, pageable); + + return contentList.map(u -> { + // 1. partnershipUsage의 paperContentId 로 paperContent 조회 + PaperContent paperContent = paperContentRepository.findById(u.getContentId()) + .orElse(null); + + // 2. store 추출 + Store store = (paperContent != null) ? paperContent.getPaper().getStore() : null; + + // 3. 날짜 포맷팅 + LocalDateTime ld = u.getCreatedAt(); + String formatDate = ld.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + + return StudentResponseDTO.UsageDetailDTO.builder() + .partnershipUsageId(u.getId()) + .adminName(u.getAdminName()) + .storeName(u.getPlace()) + .usedAt(formatDate) + .benefitDescription(u.getPartnershipContent()) + .isReviewed(u.getIsReviewed()) + .storeId((store != null) ? store.getId() : null) // store null 체크 + .partnerId((store != null && store.getPartner() != null) ? store.getPartner().getId() : null) + .build(); + }); + } + + @Override + public List getUsablePartnership(Long memberId, Boolean all) { + LocalDate today = LocalDate.now(); + + List userPapers = userPaperRepository.findActivePartnershipsByStudentId(memberId, today); + + List result = userPapers.stream().map(up -> { + Paper paper = up.getPaper(); + PaperContent content = up.getPaperContent(); + Store store = paper.getStore(); + + String adminName = (paper.getAdmin() != null) ? paper.getAdmin().getName() : null; + String partnerName = (store != null) ? store.getName() : null; + + // 카테고리 결정 로직 그대로 + String finalCategory = null; + String note = null; + if (content != null) { + if(content.getNote() != null){ + note = content.getNote(); + } + if (content.getCategory() != null) { + finalCategory = content.getCategory(); + } else if (content.getOptionType() == OptionType.SERVICE) { + List goods = goodsRepository.findByContentId(content.getId()); + if (!goods.isEmpty()) { + finalCategory = goods.get(0).getBelonging(); + } + } + } + + return StudentResponseDTO.UsablePartnershipDTO.builder() + .partnershipId(paper.getId()) + .adminName(adminName) + .partnerName(partnerName) + .note(note).paperId(content != null? content.getPaper().getId(): null) + .criterionType(content != null ? content.getCriterionType() : null) + .optionType(content != null ? content.getOptionType() : null) + .people(content != null ? content.getPeople() : null) + .cost(content != null ? content.getCost() : null) + .category(finalCategory) + .discountRate(content != null ? content.getDiscount() : null) + .build(); + }).toList(); + + return Boolean.FALSE.equals(all) ? result.stream().limit(2).toList() : result; + } + + @Transactional + public void syncUserPapersForStudent(Long studentId) { + Student student = studentRepository.findById(studentId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STUDENT)); + + // 1. 학생 기준으로 admin 찾기 + List admins = adminRepository.findMatchingAdmins( + student.getUniversity(), + student.getDepartment(), + student.getMajor() + ); + + if (admins.isEmpty()) { + return; + } + + List adminIds = admins.stream().map(Admin::getId).toList(); + LocalDate today = LocalDate.now(); + + // 2. admin들이 만든 오늘 유효한 paper 조회 + List papers = paperRepository.findActivePapersByAdminIds( + adminIds, + today, + ActivationStatus.ACTIVE + ); + + // 3. user_paper에 없으면 넣기 + for (Paper paper : papers) { + boolean exists = userPaperRepository.existsByStudentIdAndPaperId(studentId, paper.getId()); + if (exists) continue; + + PaperContent latestContent = paperContentRepository + .findTopByPaperIdOrderByIdDesc(paper.getId()) + .orElse(null); + + UserPaper up = UserPaper.builder() + .paper(paper) + .paperContent(latestContent) + .student(student) + .build(); + + userPaperRepository.save(up); + } + } + + /** + * 전체 학생에 대해 일괄로 user_paper 채워 넣는 메서드 + * (스케줄러에서 이거만 호출하면 됨) + */ + @Transactional + @Override + public void syncUserPapersForAllStudents() { + List students = studentRepository.findAll(); + for (Student s : students) { + syncUserPapersForStudent(s.getId()); + } + } +} + diff --git a/src/main/java/com/assu/server/domain/user/service/UserPaperScheduler.java b/src/main/java/com/assu/server/domain/user/service/UserPaperScheduler.java new file mode 100644 index 00000000..70b70cbd --- /dev/null +++ b/src/main/java/com/assu/server/domain/user/service/UserPaperScheduler.java @@ -0,0 +1,23 @@ +package com.assu.server.domain.user.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UserPaperScheduler { + + private final StudentServiceImpl studentService; // 또는 StudentService + + /** + * 매일 새벽 3시에 전체 학생의 user_paper를 동기화 + * cron 형식: 초 분 시 일 월 요일 + * "0 0 3 * * *" → 매일 03:00:00 + */ + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void syncAllStudentsDaily() { + studentService.syncUserPapersForAllStudents(); + } + +} diff --git a/src/main/java/com/assu/server/domain/user/service/UserService.java b/src/main/java/com/assu/server/domain/user/service/UserService.java deleted file mode 100644 index d73902e6..00000000 --- a/src/main/java/com/assu/server/domain/user/service/UserService.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.user.service; - -public interface UserService { -} diff --git a/src/main/java/com/assu/server/domain/user/service/UserServiceImpl.java b/src/main/java/com/assu/server/domain/user/service/UserServiceImpl.java deleted file mode 100644 index 3c088c92..00000000 --- a/src/main/java/com/assu/server/domain/user/service/UserServiceImpl.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.user.service; - -public class UserServiceImpl { -} diff --git a/src/main/java/com/assu/server/global/apiPayload/BaseResponse.java b/src/main/java/com/assu/server/global/apiPayload/BaseResponse.java index 9be21a33..329da612 100644 --- a/src/main/java/com/assu/server/global/apiPayload/BaseResponse.java +++ b/src/main/java/com/assu/server/global/apiPayload/BaseResponse.java @@ -43,4 +43,13 @@ public static BaseResponse onFailure(BaseErrorCode code, T result) { public static BaseResponse onFailure(String code, String message, T data) { return new BaseResponse<>(false, code, message, data); } + + public static BaseResponse onSuccessWithoutData(BaseCode code) { + return new BaseResponse<>( + true, + code.getReasonHttpStatus().getCode(), + code.getReasonHttpStatus().getMessage(), + null + ); + } } diff --git a/src/main/java/com/assu/server/global/apiPayload/code/BaseCode.java b/src/main/java/com/assu/server/global/apiPayload/code/BaseCode.java new file mode 100644 index 00000000..fbef9c90 --- /dev/null +++ b/src/main/java/com/assu/server/global/apiPayload/code/BaseCode.java @@ -0,0 +1,5 @@ +package com.assu.server.global.apiPayload.code; + +public interface BaseCode { + public ReasonDTO getReasonHttpStatus(); +} diff --git a/src/main/java/com/assu/server/global/apiPayload/code/BaseErrorCode.java b/src/main/java/com/assu/server/global/apiPayload/code/BaseErrorCode.java new file mode 100644 index 00000000..706a1f69 --- /dev/null +++ b/src/main/java/com/assu/server/global/apiPayload/code/BaseErrorCode.java @@ -0,0 +1,5 @@ +package com.assu.server.global.apiPayload.code; + +public interface BaseErrorCode { + public ErrorReasonDTO getReasonHttpStatus(); +} diff --git a/src/main/java/com/assu/server/global/apiPayload/code/ErrorReasonDTO.java b/src/main/java/com/assu/server/global/apiPayload/code/ErrorReasonDTO.java new file mode 100644 index 00000000..4bca93fa --- /dev/null +++ b/src/main/java/com/assu/server/global/apiPayload/code/ErrorReasonDTO.java @@ -0,0 +1,14 @@ +package com.assu.server.global.apiPayload.code; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public class ErrorReasonDTO { + private final HttpStatus httpStatus; + private final String code; + private final String message; + private final boolean isSuccess; +} diff --git a/src/main/java/com/assu/server/global/apiPayload/code/ReasonDTO.java b/src/main/java/com/assu/server/global/apiPayload/code/ReasonDTO.java new file mode 100644 index 00000000..24c72f55 --- /dev/null +++ b/src/main/java/com/assu/server/global/apiPayload/code/ReasonDTO.java @@ -0,0 +1,15 @@ +package com.assu.server.global.apiPayload.code; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public class ReasonDTO { + + private final HttpStatus httpStatus; + private final boolean isSuccess; + private final String code; + private final String message; +} diff --git a/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java new file mode 100644 index 00000000..9e396c56 --- /dev/null +++ b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java @@ -0,0 +1,132 @@ +package com.assu.server.global.apiPayload.code.status; + +import com.assu.server.global.apiPayload.code.BaseErrorCode; +import com.assu.server.global.apiPayload.code.ErrorReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorStatus implements BaseErrorCode { + // 기본 에러 + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), + _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), + _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), + _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), + + // 인가 관련 에러 + AUTHORIZATION_EXCEPTION(HttpStatus.UNAUTHORIZED, "AUTH4001", "인증에 실패하였습니다."), + JWT_ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH4002", "AccessToken이 만료되었습니다."), + JWT_REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH4003", "RefreshToken이 만료되었습니다."), + LOGOUT_USER(HttpStatus.UNAUTHORIZED, "AUTH4004", "로그아웃된 유저입니다."), + JWT_TOKEN_NOT_RECEIVED(HttpStatus.UNAUTHORIZED, "AUTH4005", "JWT 토큰이 전달되지 않았습니다."), + JWT_TOKEN_OUT_OF_FORM(HttpStatus.UNAUTHORIZED, "AUTH4006", "JWT 토큰의 형식이 올바르지 않습니다."), + REFRESH_TOKEN_NOT_EQUAL(HttpStatus.UNAUTHORIZED, "AUTH4007", "Refreash 토큰이 일치하지 않습니다."), + + // 숭실대 관련 에러 + SSU_SAINT_SSO_FAILED(HttpStatus.UNAUTHORIZED, "SSU4000", "숭실대학교 유세인트 SSO 로그인에 실패했습니다."), + SSU_SAINT_PORTAL_FAILED(HttpStatus.UNAUTHORIZED, "SSU4001", "숭실대학교 유세인트 포털 접근에 실패했습니다."), + SSU_SAINT_PARSE_FAILED(HttpStatus.UNAUTHORIZED, "SSU4002", "숭실대학교 유세인트 포털 크롤링 파싱에 실패했습니다."), + SSU_SAINT_UNSUPPORTED_MAJOR(HttpStatus.UNAUTHORIZED, "SSU4003", "지원하는 학과가 아닙니다."), + + // 알리고 SMS 전송 관련 에러 + FAILED_TO_SEND_SMS(HttpStatus.INTERNAL_SERVER_ERROR, "ALIGO500", "알리고 SMS 전송에 실패했습니다."), + FAILED_TO_PARSE_ALIGO(HttpStatus.INTERNAL_SERVER_ERROR, "ALIGO500", "알리고 SMS 파싱에 실패했습니다."), + + // 인증 에러 + NOT_VERIFIED_PHONE_NUMBER(HttpStatus.BAD_REQUEST,"AUTH_4007","전화번호 인증에 실패했습니다."), + + //페이징 에러 + PAGE_UNDER_ONE(HttpStatus.BAD_REQUEST,"PAGE_4001","페이지는 1이상이여야 합니다."), + PAGE_SIZE_INVALID(HttpStatus.BAD_REQUEST,"PAGE_4002","size는 1~200 사이여야 합니다."), + + // 멤버 에러 + NO_SUCH_MEMBER(HttpStatus.NOT_FOUND,"MEMBER_4001","존재하지 않는 멤버 ID입니다."), + NO_STUDENT_TYPE(HttpStatus.BAD_REQUEST, "MEMBER4002", "학생 타입이 아닌 멤버입니다."), + + NO_SUCH_ADMIN(HttpStatus.NOT_FOUND,"MEMBER_4002","존재하지 않는 admin ID 입니다."), + NO_SUCH_PARTNER(HttpStatus.NOT_FOUND,"MEMBER_4003","존재하지 않는 partner ID 입니다."), + NO_SUCH_STUDENT(HttpStatus.NOT_FOUND,"MEMBER_4004","존재하지 않는 student ID 입니다."), + NO_SUCH_STORE(HttpStatus.NOT_FOUND, "STORE_4006", "존재하지 않는 스토어 ID입니다."), + NO_SUCH_USAGE(HttpStatus.NOT_FOUND, "USAGE4001", "존재하지 않는 제휴 사용 내역입니다."), + NO_PAPER_FOR_STORE(HttpStatus.NOT_FOUND, "ADMIN_4005", "존재하지 않는 paper ID입니다."), + NO_AVAILABLE_PARTNER(HttpStatus.NOT_FOUND, "MEMBER_4009", "제휴업체를 찾을 수 없습니다."), + NO_SUCH_STORE_WITH_THAT_PARTNER(HttpStatus.NOT_FOUND,"MEMBER_4006","해당 store ID에 해당하는 partner ID가 존재하지 않습니다."), + EXISTED_PHONE(HttpStatus.CONFLICT,"MEMBER_4007","이미 존재하는 전화번호입니다."), + EXISTED_EMAIL(HttpStatus.CONFLICT,"MEMBER_4008","이미 존재하는 이메일입니다."), + EXISTED_STUDENT(HttpStatus.CONFLICT,"MEMBER_4009","이미 존재하는 학번입니다."), + + MEMBER_ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "MEMBER_4010", "이미 탈퇴된 회원입니다."), + + // 제휴 에러 + NO_SUCH_PAPER(HttpStatus.NOT_FOUND, "PAPER_9001", "제휴를 찾을 수 없습니다."), + NO_SUCH_CONTENT(HttpStatus.NOT_FOUND, "PAPER_4002", "제휴 내용을 찾을 수 없습니다."), + + // session 에러 + NO_SUCH_SESSION(HttpStatus.NOT_FOUND, "SESSION4001", "존재하지 않는 session ID입니다."), + SESSION_NOT_OPENED(HttpStatus.BAD_REQUEST, "SESSION4002", "만료되었거나 인증이 완료된 session ID입니다."), + DOUBLE_CERTIFIED_USER(HttpStatus.BAD_REQUEST, "SESSION4003", "이미 인증된 유저입니다."), + + //리뷰 이미지 에러 + IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR,"REVIEW_4001", "리뷰 이미지 업로드에 실패했습니다"), + IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND,"REVIEW_4001", "존재하지 않는 리뷰이미지 입니다"), + // 채팅 에러 + NO_SUCH_ROOM(HttpStatus.NOT_FOUND, "CHATTING_5001", "존재하지 않는 채팅방 ID 입니다."), + NO_MEMBER_IN_THE_ROOM(HttpStatus.NOT_FOUND, "CHATTING_5002", "해당 방에는 해당 사용자가 없습니다."), + NO_MEMBER(HttpStatus.NOT_FOUND, "CHATTING_5003", "해당 방에는 사용자가 아무도 없습니다."), + NO_MESSAGE(HttpStatus.NOT_FOUND, "CHATTING_5004", "해당 방에는 메시지가 아무것 없습니다."), + + // 알림(Notification) 에러 + INVALID_NOTIFICATION_STATUS_FILTER(HttpStatus.BAD_REQUEST,"NOTIFICATION_4001","유효하지 않은 알림 status 필터입니다. (all | unread 만 허용)"), + INVALID_NOTIFICATION_TYPE(HttpStatus.BAD_REQUEST,"NOTIFICATION_4002","지원하지 않는 알림 타입입니다."), + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND,"NOTIFICATION_4003","존재하지 않는 알림입니다."), + NOTIFICATION_ACCESS_DENIED(HttpStatus.FORBIDDEN,"NOTIFICATION_4004","해당 알림에 접근할 권한이 없습니다."), + MISSING_NOTIFICATION_FIELD(HttpStatus.BAD_REQUEST,"NOTIFICATION_4005","알림 생성에 필요한 필드가 누락되었습니다."), + + // 문의(Inquiry) + INVALID_INQUIRY_STATUS_FILTER(HttpStatus.BAD_REQUEST,"INQUIRY_4001","status는 [all, waiting, answered] 중 하나여야 합니다."), + NO_SUCH_INQUIRY(HttpStatus.NOT_FOUND,"INQUIRY_4002","존재하지 않는 문의입니다."), + FORBIDDEN_INQUIRY(HttpStatus.FORBIDDEN,"INQUIRY_4003","해당 문의에 접근 권한이 없습니다."), + ALREADY_ANSWERED(HttpStatus.CONFLICT,"INQUIRY_4091","이미 답변 완료된 문의입니다."), + + // 디바이스 토큰(DeviceToken) 에러 + DEVICE_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND,"DEVICE_4001","존재하지 않는 Device Token 입니다."), + DEVICE_TOKEN_NOT_OWNED(HttpStatus.FORBIDDEN, "DEVICE_4004","해당 토큰은 본인 소유가 아닙니다."), + DEVICE_TOKEN_REGISTER_FAILED(HttpStatus.INTERNAL_SERVER_ERROR,"DEVICE_5001","Device Token 등록에 실패했습니다."), + + // 주소 에러 + NO_SUCH_ADDRESS(HttpStatus.NOT_FOUND, "ADDRESS_7001", "주소를 찾을 수 없습니다."), + + // 프로필(Profile) 관련 에러 + PROFILE_IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "PROFILE_5001", "프로필 이미지 업로드에 실패했습니다."), + PROFILE_IMAGE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "PROFILE_5002", "프로필 이미지 삭제에 실패했습니다."), + PROFILE_IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "PROFILE_4001", "존재하지 않는 프로필 이미지입니다."), + PROFILE_IMAGE_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "PROFILE_4002", "지원하지 않는 이미지 형식입니다."), + PROFILE_IMAGE_TOO_LARGE(HttpStatus.BAD_REQUEST, "PROFILE_4003", "허용된 크기를 초과한 이미지입니다."), + + // Suggestion 관련 에러 + NO_SUCH_SUGGESTION(HttpStatus.NOT_FOUND, "SUGGESTION_4001", "존재하지 않는 건의글입니다."), + + // 신고(Report) 관련 에러 + REPORT_DUPLICATE(HttpStatus.CONFLICT, "REPORT_4001", "이미 신고한 대상입니다."), + REPORT_SELF_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "REPORT_4002", "자신을 신고할 수 없습니다."), + REVIEW_REPORT_SELF_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "REPORT_4003", "자신의 리뷰를 신고할 수 없습니다."), + SUGGESTION_REPORT_SELF_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "REPORT_4004", "자신의 건의글을 신고할 수 없습니다."), + INVALID_REPORT_TYPE(HttpStatus.BAD_REQUEST, "REPORT_4005", "유효하지 않은 신고 타입입니다."), + NO_USAGE_DATA(HttpStatus.NOT_FOUND, "ADMIN4001", "해당 관리자의 제휴 이용 내역이 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/assu/server/global/apiPayload/code/status/SuccessStatus.java b/src/main/java/com/assu/server/global/apiPayload/code/status/SuccessStatus.java new file mode 100644 index 00000000..79f28369 --- /dev/null +++ b/src/main/java/com/assu/server/global/apiPayload/code/status/SuccessStatus.java @@ -0,0 +1,60 @@ +package com.assu.server.global.apiPayload.code.status; + +import com.assu.server.global.apiPayload.code.BaseCode; +import com.assu.server.global.apiPayload.code.ReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import org.springframework.boot.autoconfigure.graphql.GraphQlProperties; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SuccessStatus implements BaseCode { + _OK(HttpStatus.OK, "COMMON200", "성공입니다."), + _CREATED(HttpStatus.CREATED, "COMMON201", "요청 성공 및 리소스 생성됨"), + + //멤버 성공 + MEMBER_SUCCESS(HttpStatus.OK, "MEMBER_200", "성공적으로 조회되었습니다."), + MEMBER_CREATED(HttpStatus.CREATED, "MEMBER_201", "성공적으로 생성되었습니다."), + + //인증 관련 성공 + SEND_AUTH_NUMBER_SUCCESS(HttpStatus.OK, "AUTH_200", "성공적으로 전송되었습니다."), + VERIFY_AUTH_NUMBER_SUCCESS(HttpStatus.OK, "AUTH_201", "성공적으로 생성되었습니다."), + + //신고 성공 + REPORT_SUCCESS(HttpStatus.CREATED, "REPORT_201", "대상을 성공적으로 신고했습니다."), + + // 제휴 성공 + PAPER_STORE_HISTORY_SUCCESS(HttpStatus.OK, "PAPER201", "가게 별 제휴 내용이 성공적으로 조회되었습니다."), + USER_PAPER_REQUEST_SUCCESS(HttpStatus.OK, "PAPER202", "제휴 요청이 성공적으로 처리되었습니다."), + + PARTNERSHIP_HISTORY_SUCCESS(HttpStatus.OK, "PARTNERSHIP202", "월 별 제휴 사용내역이 성공적으로 조회되었습니다."), + UNREVIEWED_HISTORY_SUCCESS(HttpStatus.OK, "PARTNERSHIP203", "리뷰 되지 않은 제휴 사용내역이 성공적으로 조회되었습니다."), + + // 그룹 인증 + GROUP_SESSION_CREATE(HttpStatus.OK, "GROUP201", "인증 세션 생성 및 대표자 구독이 완료되었습니다."), + GROUP_CERTIFICATION_SUCCESS(HttpStatus.OK, "GROUP202", "그룹 인증 세션에 대한 인증이 완료되었습니다."), + + + // 개인 인증 + PERSONAL_CERTIFICATION_SUCCESS(HttpStatus.OK, "PERSONAL201", "개인 인증이 완료 되었습니다."), + + // 베스트 조회 + BEST_STORE_SUCCESS(HttpStatus.OK, "STORE205", "베스트 매장 조회에 성공하였습니다") + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDTO getReasonHttpStatus() { + return ReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(true) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/assu/server/global/config/AmazonConfig.java b/src/main/java/com/assu/server/global/config/AmazonConfig.java new file mode 100644 index 00000000..8235d4a2 --- /dev/null +++ b/src/main/java/com/assu/server/global/config/AmazonConfig.java @@ -0,0 +1,52 @@ +package com.assu.server.global.config; + +import lombok.Getter; +import java.net.URI; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@Getter +public class AmazonConfig { + + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Bean + public StaticCredentialsProvider awsCredentialsProvider() { + return StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)); + } + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(awsCredentialsProvider()) + .endpointOverride(URI.create("https://s3." + region + ".amazonaws.com")) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(awsCredentialsProvider()) + .endpointOverride(URI.create("https://s3." + region + ".amazonaws.com")) + .build(); + } +} diff --git a/src/main/java/com/assu/server/global/config/AuthProviderConfig.java b/src/main/java/com/assu/server/global/config/AuthProviderConfig.java new file mode 100644 index 00000000..0a1b6f89 --- /dev/null +++ b/src/main/java/com/assu/server/global/config/AuthProviderConfig.java @@ -0,0 +1,23 @@ +package com.assu.server.global.config; + +import com.assu.server.domain.auth.security.provider.RoutingAuthenticationProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class AuthProviderConfig { + + private final RoutingAuthenticationProvider routingAuthenticationProvider; + + @Bean + public AuthenticationManager authenticationManager( + ) { + return new org.springframework.security.authentication.ProviderManager( + List.of(routingAuthenticationProvider) + ); + } +} diff --git a/src/main/java/com/assu/server/global/config/FirebaseConfig.java b/src/main/java/com/assu/server/global/config/FirebaseConfig.java new file mode 100644 index 00000000..22798672 --- /dev/null +++ b/src/main/java/com/assu/server/global/config/FirebaseConfig.java @@ -0,0 +1,44 @@ +package com.assu.server.global.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.Resource; + +import java.io.InputStream; + +@Configuration +@Profile("!test") +public class FirebaseConfig { + + @Value("${firebase.project-id}") + private String projectId; + + @Value("${firebase.credentials.path}") + private Resource serviceAccount; + + @Bean + public FirebaseApp firebaseApp() throws Exception { + if (!FirebaseApp.getApps().isEmpty()) { + return FirebaseApp.getInstance(); + } + + try (InputStream is = serviceAccount.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(is)) + .setProjectId(projectId) + .build(); + return FirebaseApp.initializeApp(options); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging(FirebaseApp app) { + return FirebaseMessaging.getInstance(app); + } +} diff --git a/src/main/java/com/assu/server/global/config/JpaSpatialConfig.java b/src/main/java/com/assu/server/global/config/JpaSpatialConfig.java new file mode 100644 index 00000000..aab5686d --- /dev/null +++ b/src/main/java/com/assu/server/global/config/JpaSpatialConfig.java @@ -0,0 +1,15 @@ +package com.assu.server.global.config; + +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.PrecisionModel; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JpaSpatialConfig { + @Bean + public GeometryFactory geometryFactory() { + // PrecisionModel 기본, SRID=4326 (WGS84) + return new GeometryFactory(new PrecisionModel(), 4326); + } +} diff --git a/src/main/java/com/assu/server/global/config/KakaoLocalClient.java b/src/main/java/com/assu/server/global/config/KakaoLocalClient.java new file mode 100644 index 00000000..b6483ee4 --- /dev/null +++ b/src/main/java/com/assu/server/global/config/KakaoLocalClient.java @@ -0,0 +1,123 @@ +package com.assu.server.global.config; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriBuilder; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class KakaoLocalClient { + + private final WebClient kakaoWebClient; + + /* ========= 공용 DTO ========= */ + @Data + public static class KakaoKeywordResp { + private List documents; + private Meta meta; + @Data public static class Document { + private String id; + private String place_name; + private String category_name; + private String category_group_code; + private String category_group_name; + private String phone; + private String address_name; // 지번 + private String road_address_name; // 도로명 + private String x; // 경도 + private String y; // 위도 + private String place_url; + private String distance; // 좌표 바이어스/카테고리 검색시 제공 (문자열 m) + } + @Data public static class Meta { + private Integer total_count; + private Boolean is_end; + } + } + + @Data + public static class KakaoAddressResp { + private List documents; + private Meta meta; + @Data public static class Document { + private Address address; + private RoadAddress road_address; + private String x; // 일부 응답에는 상위에 x/y가 직접 들어오기도 함 (카카오 문서 참고) + private String y; + + @Data public static class Address { + private String address_name; + private String x; + private String y; + } + @Data public static class RoadAddress { + private String address_name; + private String x; + private String y; + } + } + @Data public static class Meta { + private Integer total_count; + private Boolean is_end; + } + } + + /* ========= 1) 키워드 검색 ========= */ + public KakaoKeywordResp searchByKeyword(String query, Double x, Double y, + Integer radius, Integer page, Integer size) { + return kakaoWebClient.get() + .uri(uri -> { + UriBuilder b = uri.path("/v2/local/search/keyword.json") + .queryParam("query", query) + .queryParam("page", page == null ? 1 : page) + .queryParam("size", size == null ? 15 : size); + if (x != null && y != null) { + b.queryParam("x", x).queryParam("y", y); + if (radius != null) b.queryParam("radius", radius); + } + return b.build(); + }) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(KakaoKeywordResp.class) + .block(); + } + + /* ========= 2) 주소 지오코딩 ========= */ + public KakaoAddressResp searchByAddress(String query, Integer page, Integer size) { + return kakaoWebClient.get() + .uri(uri -> uri.path("/v2/local/search/address.json") + .queryParam("query", query) + .queryParam("page", page == null ? 1 : page) + .queryParam("size", size == null ? 10 : size) + .build()) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(KakaoAddressResp.class) + .block(); + } + + /* ========= 3) 카테고리 근접 검색 ========= */ + public KakaoKeywordResp searchByCategory(String categoryGroupCode, + Double x, Double y, + Integer radius, Integer page, Integer size) { + return kakaoWebClient.get() + .uri(uri -> uri.path("/v2/local/search/category.json") + .queryParam("category_group_code", categoryGroupCode) + .queryParam("x", x) + .queryParam("y", y) + .queryParam("radius", radius == null ? 500 : radius) // m + .queryParam("page", page == null ? 1 : page) + .queryParam("size", size == null ? 15 : size) + .build()) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(KakaoKeywordResp.class) + .block(); + } +} diff --git a/src/main/java/com/assu/server/global/config/KakaoWebClientConfig.java b/src/main/java/com/assu/server/global/config/KakaoWebClientConfig.java new file mode 100644 index 00000000..cdb1827c --- /dev/null +++ b/src/main/java/com/assu/server/global/config/KakaoWebClientConfig.java @@ -0,0 +1,22 @@ +package com.assu.server.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class KakaoWebClientConfig { + + @Bean + public WebClient kakaoWebClient( + @Value("${kakao.base-url}") String baseUrl, + @Value("${kakao.rest-api-key}") String apiKey + ) { + return WebClient.builder() + .baseUrl(baseUrl) + .defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + apiKey) + .build(); + } +} diff --git a/src/main/java/com/assu/server/global/config/ProjectConfig.java b/src/main/java/com/assu/server/global/config/ProjectConfig.java new file mode 100644 index 00000000..62ede62b --- /dev/null +++ b/src/main/java/com/assu/server/global/config/ProjectConfig.java @@ -0,0 +1,14 @@ +package com.assu.server.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class ProjectConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/assu/server/global/config/RedisConfig.java b/src/main/java/com/assu/server/global/config/RedisConfig.java new file mode 100644 index 00000000..135c0c87 --- /dev/null +++ b/src/main/java/com/assu/server/global/config/RedisConfig.java @@ -0,0 +1,41 @@ +package com.assu.server.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@RequiredArgsConstructor +@Profile("!test") +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + + ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + redisTemplate.setValueSerializer(serializer); + redisTemplate.setHashValueSerializer(serializer); + + redisTemplate.afterPropertiesSet(); + return redisTemplate; + } + +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/global/config/SecurityConfig.java b/src/main/java/com/assu/server/global/config/SecurityConfig.java new file mode 100644 index 00000000..2d327200 --- /dev/null +++ b/src/main/java/com/assu/server/global/config/SecurityConfig.java @@ -0,0 +1,60 @@ +package com.assu.server.global.config; + +import com.assu.server.domain.auth.security.jwt.JwtAuthFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> {}) // 기본 CORS 구성 사용(필요하면 CorsConfigurationSource 빈 추가) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + + // ✅ WebSocket 핸드셰이크 허용 (네이티브 + SockJS 모두 포함) + .requestMatchers("/ws/**","/ws").permitAll() + + // Swagger 등 공개 리소스 + .requestMatchers( + "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", + "/swagger-resources/**", "/webjars/**" + ).permitAll() + + .requestMatchers(// Auth (로그아웃 제외) + "/auth/phone-verification/check-and-send", + "/auth/phone-verification/verify", + "/auth/email-verification/check", + "/auth/students/signup", + "/auth/partners/signup", + "/auth/admins/signup", + "/auth/commons/login", + "/auth/students/login", + "/auth/tokens/refresh", + "/auth/students/ssu-verify", + "/map/place", + "/member/inquiries/{inquiry-id}/answer" + ).permitAll() + .requestMatchers("/ws/**").permitAll() + // 나머지는 인증 필요 + + .anyRequest().authenticated() + ) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + + return http.build(); + } + +} diff --git a/src/main/java/com/assu/server/global/config/WebClientConfig.java b/src/main/java/com/assu/server/global/config/WebClientConfig.java new file mode 100644 index 00000000..2aea8300 --- /dev/null +++ b/src/main/java/com/assu/server/global/config/WebClientConfig.java @@ -0,0 +1,25 @@ +package com.assu.server.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient(WebClient.Builder builder) { + // Body buffer 사이즈 확장 (대용량 응답 대비) + ExchangeStrategies strategies = ExchangeStrategies.builder() + .codecs(configurer -> configurer + .defaultCodecs() + .maxInMemorySize(10 * 1024 * 1024)) // 10MB + .build(); + + return builder + .exchangeStrategies(strategies) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/global/config/WebConfig.java b/src/main/java/com/assu/server/global/config/WebConfig.java index bf518545..a861bd7f 100644 --- a/src/main/java/com/assu/server/global/config/WebConfig.java +++ b/src/main/java/com/assu/server/global/config/WebConfig.java @@ -1,6 +1,5 @@ package com.assu.server.global.config; -import com.assu.server.global.util.AuthUserArgumentResolver; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -13,8 +12,6 @@ @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { - private final AuthUserArgumentResolver authUserArgumentResolver; - @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") @@ -24,9 +21,4 @@ public void addCorsMappings(CorsRegistry registry) { .allowCredentials(false) .maxAge(6000); } - - @Override - public void addArgumentResolvers(List resolvers) { - resolvers.add(authUserArgumentResolver); - } } diff --git a/src/main/java/com/assu/server/global/exception/DatabaseException.java b/src/main/java/com/assu/server/global/exception/DatabaseException.java new file mode 100644 index 00000000..67046620 --- /dev/null +++ b/src/main/java/com/assu/server/global/exception/DatabaseException.java @@ -0,0 +1,11 @@ +package com.assu.server.global.exception; + + +import com.assu.server.global.apiPayload.code.BaseErrorCode; + +public class DatabaseException extends GeneralException { + + public DatabaseException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/assu/server/global/exception/GeneralException.java b/src/main/java/com/assu/server/global/exception/GeneralException.java new file mode 100644 index 00000000..86dd3032 --- /dev/null +++ b/src/main/java/com/assu/server/global/exception/GeneralException.java @@ -0,0 +1,18 @@ +package com.assu.server.global.exception; + + +import com.assu.server.global.apiPayload.code.BaseErrorCode; +import com.assu.server.global.apiPayload.code.ErrorReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GeneralException extends RuntimeException { + + private BaseErrorCode code; + + public ErrorReasonDTO getErrorReasonHttpStatus() { + return this.code.getReasonHttpStatus(); + } +} diff --git a/src/main/java/com/assu/server/global/exception/GlobalExceptionAdvice.java b/src/main/java/com/assu/server/global/exception/GlobalExceptionAdvice.java new file mode 100644 index 00000000..0794633c --- /dev/null +++ b/src/main/java/com/assu/server/global/exception/GlobalExceptionAdvice.java @@ -0,0 +1,174 @@ +package com.assu.server.global.exception; + + +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.ErrorReasonDTO; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.TypeMismatchException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@RestControllerAdvice(annotations = {RestController.class}) +public class GlobalExceptionAdvice extends ResponseEntityExceptionHandler { + + @Override + protected ResponseEntity handleTypeMismatch( + TypeMismatchException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + String errorMessage = e.getPropertyName() + ": 올바른 값이 아닙니다."; + + return handleExceptionInternalMessage(e, headers, request, errorMessage); + } + + @Override + protected ResponseEntity handleMissingServletRequestParameter( + MissingServletRequestParameterException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + String errorMessage = e.getParameterName() + ": 올바른 값이 아닙니다."; + + return handleExceptionInternalMessage(e, headers, request, errorMessage); + } + + @ExceptionHandler + public ResponseEntity validation(ConstraintViolationException e, WebRequest request) { + String errorMessage = + e.getConstraintViolations().stream() + .map(constraintViolation -> constraintViolation.getMessage()) + .findFirst() + .orElseThrow( + () -> + new RuntimeException( + "ConstraintViolationException 추출 도중 에러 발생")); + + return handleExceptionInternalConstraint( + e, ErrorStatus.valueOf(errorMessage), HttpHeaders.EMPTY, request); + } + + @Override + public ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + Map errors = new LinkedHashMap<>(); + + e.getBindingResult().getFieldErrors().stream() + .forEach( + fieldError -> { + String fieldName = fieldError.getField(); + String errorMessage; + try { + errorMessage = Optional.ofNullable(ErrorStatus.valueOf(fieldError.getDefaultMessage()).getMessage()).orElse(""); + } catch (IllegalArgumentException ex) { + errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse(""); + } + errors.merge( + fieldName, + errorMessage, + (existingErrorMessage, newErrorMessage) -> + existingErrorMessage + ", " + newErrorMessage); + }); + + return handleExceptionInternalArgs( + e, HttpHeaders.EMPTY, ErrorStatus.valueOf("_BAD_REQUEST"), request, errors); + } + + @ExceptionHandler + public ResponseEntity exception(Exception e, WebRequest request) { + e.printStackTrace(); + + return handleExceptionInternalFalse( + e, + ErrorStatus._INTERNAL_SERVER_ERROR, + HttpHeaders.EMPTY, + ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(), + request, + e.getMessage()); + } + + @ExceptionHandler(value = GeneralException.class) + public ResponseEntity onThrowException( + GeneralException generalException, HttpServletRequest request) { + ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus(); + return handleExceptionInternal(generalException, errorReasonHttpStatus, null, request); + } + + private ResponseEntity handleExceptionInternal( + Exception e, ErrorReasonDTO reason, HttpHeaders headers, HttpServletRequest request) { + + BaseResponse body = + BaseResponse.onFailure(reason.getCode(), reason.getMessage(), null); + + WebRequest webRequest = new ServletWebRequest(request); + return super.handleExceptionInternal(e, body, headers, reason.getHttpStatus(), webRequest); + } + + private ResponseEntity handleExceptionInternalFalse( + Exception e, + ErrorStatus errorCommonStatus, + HttpHeaders headers, + HttpStatus status, + WebRequest request, + String errorPoint) { + BaseResponse body = + BaseResponse.onFailure( + errorCommonStatus.getCode(), errorCommonStatus.getMessage(), errorPoint); + return super.handleExceptionInternal(e, body, headers, status, request); + } + + private ResponseEntity handleExceptionInternalArgs( + Exception e, + HttpHeaders headers, + ErrorStatus errorCommonStatus, + WebRequest request, + Map errorArgs) { + BaseResponse body = + BaseResponse.onFailure( + errorCommonStatus.getCode(), errorCommonStatus.getMessage(), errorArgs); + return super.handleExceptionInternal( + e, body, headers, errorCommonStatus.getHttpStatus(), request); + } + + private ResponseEntity handleExceptionInternalConstraint( + Exception e, ErrorStatus errorCommonStatus, HttpHeaders headers, WebRequest request) { + BaseResponse body = + BaseResponse.onFailure( + errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null); + return super.handleExceptionInternal( + e, body, headers, errorCommonStatus.getHttpStatus(), request); + } + + private ResponseEntity handleExceptionInternalMessage( + Exception e, HttpHeaders headers, WebRequest request, String errorMessage) { + ErrorStatus errorStatus = ErrorStatus._BAD_REQUEST; + BaseResponse body = + BaseResponse.onFailure( + errorStatus.getCode(), errorStatus.getMessage(), errorMessage); + + return super.handleExceptionInternal( + e, body, headers, errorStatus.getHttpStatus(), request); + } +} diff --git a/src/main/java/com/assu/server/global/exception/annotation/CheckPage.java b/src/main/java/com/assu/server/global/exception/annotation/CheckPage.java new file mode 100644 index 00000000..92e2c41b --- /dev/null +++ b/src/main/java/com/assu/server/global/exception/annotation/CheckPage.java @@ -0,0 +1,19 @@ +package com.assu.server.global.exception.annotation; + + +import com.assu.server.global.exception.validator.CheckPageValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = CheckPageValidator.class) +@Target( { ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +public @interface CheckPage { + + String message() default "페이지가 1보다 작을 수 없습니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/assu/server/global/exception/validator/CheckPageValidator.java b/src/main/java/com/assu/server/global/exception/validator/CheckPageValidator.java new file mode 100644 index 00000000..fe7aff68 --- /dev/null +++ b/src/main/java/com/assu/server/global/exception/validator/CheckPageValidator.java @@ -0,0 +1,32 @@ +package com.assu.server.global.exception.validator; + + +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.annotation.CheckPage; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CheckPageValidator implements ConstraintValidator { + + @Override + public void initialize(CheckPage constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(Integer value, ConstraintValidatorContext context) { + boolean isValid = value > 0; + + if (!isValid) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(ErrorStatus.PAGE_UNDER_ONE.toString()).addConstraintViolation(); + } + + return isValid; + + } +} diff --git a/src/main/java/com/assu/server/global/util/AuthUserArgumentResolver.java b/src/main/java/com/assu/server/global/util/AuthUserArgumentResolver.java deleted file mode 100644 index 16aec129..00000000 --- a/src/main/java/com/assu/server/global/util/AuthUserArgumentResolver.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.assu.server.global.util; - -import com.assu.server.domain.common.entity.Member; -import com.assu.server.global.apiPayload.code.status.ErrorStatus; -import com.assu.server.global.exception.exception.GeneralException; -import lombok.RequiredArgsConstructor; -import org.springframework.core.MethodParameter; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -@Component -@RequiredArgsConstructor -public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { - - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.getParameterType().equals(Member.class); - } - - @Override - public Object resolveArgument( - MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) - throws GeneralException { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication == null || !authentication.isAuthenticated()) { - return null; // 로그인하지 않은 사용자 - } - - if (authentication.getPrincipal() instanceof PrincipalDetails principalDetails) { - return principalDetails.getMember(); - } - - throw new GeneralException(ErrorStatus.NO_SUCH_MEMBER); - } -} diff --git a/src/main/java/com/assu/server/global/util/PresenceTracker.java b/src/main/java/com/assu/server/global/util/PresenceTracker.java new file mode 100644 index 00000000..e6f139b7 --- /dev/null +++ b/src/main/java/com/assu/server/global/util/PresenceTracker.java @@ -0,0 +1,90 @@ +package com.assu.server.global.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +import java.security.Principal; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@Slf4j +public class PresenceTracker { + private final Map> roomSubscribers = new ConcurrentHashMap<>(); + private final Map sessionToMember = new ConcurrentHashMap<>(); + private final Map> sessionToRooms = new ConcurrentHashMap<>(); + + private Long parseRoomId(String dest) { // "/sub/chat/26" -> 26 + if (dest == null) return null; + String[] p = dest.split("/"); + if (p.length >= 4 && "chat".equals(p[2])) return Long.valueOf(p[3]); + return null; + } + + private Long memberIdFrom(Principal user) { + if (user == null) return null; + // StompAuthChannelInterceptor 에서 Principal.name을 memberId로 넣어두었다고 가정 + return Long.valueOf(user.getName()); + } + + @EventListener + public void onSubscribe(SessionSubscribeEvent e) { + var acc = StompHeaderAccessor.wrap(e.getMessage()); + Long roomId = parseRoomId(acc.getDestination()); + Long memberId = memberIdFrom(e.getUser()); + if (roomId == null || memberId == null) return; + + String sessionId = acc.getSessionId(); + sessionToMember.put(sessionId, memberId); + sessionToRooms.computeIfAbsent(sessionId, k -> ConcurrentHashMap.newKeySet()).add(roomId); + roomSubscribers.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()).add(memberId); + + log.debug("SUB: member {} -> room {}", memberId, roomId); + } + + @EventListener + public void onUnsubscribe(SessionUnsubscribeEvent e) { + var acc = StompHeaderAccessor.wrap(e.getMessage()); + String sessionId = acc.getSessionId(); + var rooms = sessionToRooms.getOrDefault(sessionId, Set.of()); + Long memberId = sessionToMember.get(sessionId); + if (memberId != null) { + for (Long roomId : rooms) { + var set = roomSubscribers.get(roomId); + if (set != null) { + set.remove(memberId); + if (set.isEmpty()) roomSubscribers.remove(roomId); + } + } + } + sessionToRooms.remove(sessionId); + log.debug("UNSUB: session {}", sessionId); + } + + @EventListener + public void onDisconnect(SessionDisconnectEvent e) { + String sessionId = e.getSessionId(); + Long memberId = sessionToMember.remove(sessionId); + var rooms = sessionToRooms.remove(sessionId); + if (memberId != null && rooms != null) { + for (Long roomId : rooms) { + var set = roomSubscribers.get(roomId); + if (set != null) { + set.remove(memberId); + if (set.isEmpty()) roomSubscribers.remove(roomId); + } + } + } + log.debug("DISCONNECT: session {}", sessionId); + } + + public boolean isInRoom(Long memberId, Long roomId) { + return roomSubscribers.getOrDefault(roomId, Set.of()).contains(memberId); + } +} diff --git a/src/main/java/com/assu/server/global/util/PrincipalDetails.java b/src/main/java/com/assu/server/global/util/PrincipalDetails.java index bc6bd230..dc7582be 100644 --- a/src/main/java/com/assu/server/global/util/PrincipalDetails.java +++ b/src/main/java/com/assu/server/global/util/PrincipalDetails.java @@ -1,50 +1,70 @@ package com.assu.server.global.util; -import com.assu.server.domain.common.entity.Member; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.auth.entity.AuthRealm; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; +import lombok.Builder; +import lombok.Getter; + import lombok.RequiredArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.ArrayList; + import java.util.Collection; import java.util.List; import java.util.stream.Collectors; +@Getter +@Builder @RequiredArgsConstructor public class PrincipalDetails implements UserDetails { + private final Long memberId; // 항상 존재 + private final String username; // email 또는 studentNumber + private final String password; // DaoAuthenticationProvider 단계에서만 사용 + private final UserRole role; // STUDENT/PARTNER/ADMIN + private final AuthRealm authRealm; // COMMON or SSU or 추후 확장.. + private final boolean enabled; + private final Collection authorities; + private final Member member; @Override public Collection getAuthorities() { - List roles = new ArrayList<>(); - roles.add("ROLE_USER"); - - return roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()); + // 단일 롤 → ROLE_ 접두사 필수 + String roleName = "ROLE_" + member.getRole().name(); // STUDENT → ROLE_STUDENT + return List.of(new SimpleGrantedAuthority(roleName)); } @Override public String getPassword() { - return null; + // 폼 로그인/DaoAuthenticationProvider를 쓴다면 반드시 반환 + return member.getCommonAuth().getPassword(); } @Override public String getUsername() { + // 인증 후 Authentication.getName() 으로 쓰일 값 return member.getId().toString(); } - public Member getMember() { - return member; + public Long getId() { + // 인증 후 Authentication.getId() 으로 쓰일 값 + return member.getId(); } @Override - public boolean isAccountNonExpired() { + public boolean isAccountNonLocked() { return true; } @Override - public boolean isAccountNonLocked() { + public boolean isAccountNonExpired() { return true; } @@ -55,7 +75,7 @@ public boolean isCredentialsNonExpired() { @Override public boolean isEnabled() { - return true; + return member.getIsActivated().equals(ActivationStatus.ACTIVE); // 사용자가 Disabled면 DisabledException 방지/반영 } -} +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/global/util/RandomNumberUtil.java b/src/main/java/com/assu/server/global/util/RandomNumberUtil.java new file mode 100644 index 00000000..58ca0235 --- /dev/null +++ b/src/main/java/com/assu/server/global/util/RandomNumberUtil.java @@ -0,0 +1,11 @@ +package com.assu.server.global.util; + +import java.util.Random; + +public class RandomNumberUtil { + public static String generateSixDigit() { + Random random = new Random(); + int number = 100000 + random.nextInt(900000); // 100000~999999 + return String.valueOf(number); + } +} diff --git a/src/main/java/com/assu/server/infra/aligo/client/AligoSmsClient.java b/src/main/java/com/assu/server/infra/aligo/client/AligoSmsClient.java new file mode 100644 index 00000000..d5c89c0a --- /dev/null +++ b/src/main/java/com/assu/server/infra/aligo/client/AligoSmsClient.java @@ -0,0 +1,71 @@ +package com.assu.server.infra.aligo.client; + +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.infra.aligo.dto.AligoSendResponse; +import com.assu.server.infra.aligo.exception.AligoException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AligoSmsClient { + + private final WebClient webClient; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${aligo.key}") + private String apiKey; + + @Value("${aligo.user-id}") + private String userId; + + @Value("${aligo.sender}") + private String sender; + + private static final String SEND_URL = "https://apis.aligo.in/send/"; + + public AligoSendResponse sendSms(String phoneNumber, String message, String name) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("key", apiKey); + params.add("userid", userId); + params.add("sender", sender); + params.add("receiver", phoneNumber); + params.add("msg", message); + params.add("msg_type", "SMS"); + params.add("destination", phoneNumber + "|" + name); + + String body = webClient.post() + .uri(SEND_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(params)) + .retrieve() + .onStatus( + status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(String.class).flatMap(errorBody -> { + log.error("Aligo API 호출 실패. status={}, body={}", clientResponse.statusCode(), errorBody); + return Mono.error(new AligoException(ErrorStatus.FAILED_TO_SEND_SMS)); + }) + ) + .bodyToMono(String.class) + .block(); + + try { + return objectMapper.readValue(body, AligoSendResponse.class); + } catch (Exception e) { + log.error("Aligo 응답 파싱 실패. 원본 body: {}", body, e); + throw new AligoException(ErrorStatus.FAILED_TO_PARSE_ALIGO); + } + } +} + diff --git a/src/main/java/com/assu/server/infra/aligo/dto/AligoSendResponse.java b/src/main/java/com/assu/server/infra/aligo/dto/AligoSendResponse.java new file mode 100644 index 00000000..0b903483 --- /dev/null +++ b/src/main/java/com/assu/server/infra/aligo/dto/AligoSendResponse.java @@ -0,0 +1,13 @@ +package com.assu.server.infra.aligo.dto; + +import lombok.Data; + +@Data +public class AligoSendResponse { + private String result_code; // 성공 여부 + private String message; // 결과 메시지 + private String msg_id; // 메시지 ID + private String success_cnt; // 성공 개수 + private String error_cnt; // 에러 개수 + private String msg_type; // 메시지 타입 +} diff --git a/src/main/java/com/assu/server/infra/aligo/exception/AligoException.java b/src/main/java/com/assu/server/infra/aligo/exception/AligoException.java new file mode 100644 index 00000000..d8ee9c4f --- /dev/null +++ b/src/main/java/com/assu/server/infra/aligo/exception/AligoException.java @@ -0,0 +1,11 @@ +package com.assu.server.infra.aligo.exception; + +import com.assu.server.global.apiPayload.code.BaseErrorCode; +import com.assu.server.global.exception.GeneralException; + +public class AligoException extends GeneralException { + + public AligoException(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/assu/server/infra/firebase/AmqpConfig.java b/src/main/java/com/assu/server/infra/firebase/AmqpConfig.java new file mode 100644 index 00000000..19f37c85 --- /dev/null +++ b/src/main/java/com/assu/server/infra/firebase/AmqpConfig.java @@ -0,0 +1,51 @@ +package com.assu.server.infra.firebase; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.rabbit.listener.MessageListenerContainer; +import org.springframework.amqp.rabbit.listener.RabbitListenerEndpointRegistry; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +// AmqpConfig.java (새 파일, 적절한 패키지: com.assu.server.infra.mq 등) +@Configuration +public class AmqpConfig { + public static final String EXCHANGE = "notif.ex"; + public static final String ROUTING_KEY = "notif.send"; + public static final String QUEUE = "notif.send.q"; + public static final String DLX = "notif.dlx"; + public static final String DLQ = "notif.send.dlq"; + + @Bean DirectExchange exchange() { return new DirectExchange(EXCHANGE, true, false); } + @Bean DirectExchange dlx() { return new DirectExchange(DLX, true, false); } + + @Bean + Queue queue() { + return QueueBuilder.durable(QUEUE) + .withArgument("x-dead-letter-exchange", DLX) + .withArgument("x-dead-letter-routing-key", ROUTING_KEY + ".dead") + .build(); + } + + @Bean Queue dlq() { return QueueBuilder.durable(DLQ).build(); } + + @Bean Binding bind() { return BindingBuilder.bind(queue()).to(exchange()).with(ROUTING_KEY); } + @Bean Binding bindDlq() { return BindingBuilder.bind(dlq()).to(dlx()).with(ROUTING_KEY + ".dead"); } + + @Bean + RabbitTemplate rabbitTemplate(ConnectionFactory cf) { + RabbitTemplate rt = new RabbitTemplate(cf); + rt.setMessageConverter(new Jackson2JsonMessageConverter()); + return rt; + } + + @Bean + public Jackson2JsonMessageConverter jackson2JsonMessageConverter(ObjectMapper om) { + return new Jackson2JsonMessageConverter(om); + } +} diff --git a/src/main/java/com/assu/server/infra/firebase/FcmClient.java b/src/main/java/com/assu/server/infra/firebase/FcmClient.java new file mode 100644 index 00000000..03785bc2 --- /dev/null +++ b/src/main/java/com/assu/server/infra/firebase/FcmClient.java @@ -0,0 +1,141 @@ +package com.assu.server.infra.firebase; + +import com.assu.server.domain.deviceToken.repository.DeviceTokenRepository; +import com.google.api.core.ApiFuture; +import com.google.firebase.messaging.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FcmClient { + + private final FirebaseMessaging messaging; + private final DeviceTokenRepository tokenRepo; + + // 운영에서 3s는 다소 공격적 — 5s 정도 권장 + private static final Duration SEND_TIMEOUT = Duration.ofSeconds(5); + + /** + * 멤버의 활성 토큰 전체에 멀티캐스트 전송. + * - 실패 토큰(UNREGISTERED/INVALID_ARGUMENT)은 즉시 비활성화 + * - 결과 요약을 반환 + */ + public FcmResult sendToMemberId(Long memberId, String title, String body, Map data) + throws TimeoutException, InterruptedException, FirebaseMessagingException, ExecutionException { + if (memberId == null) throw new IllegalArgumentException("receiverId is null"); + + // 1) 토큰 조회 + List tokens = tokenRepo.findActiveTokensByMemberId(memberId); + if (tokens == null || tokens.isEmpty()) { + return FcmResult.empty(); + } + + // 2) 널 세이프 + final String _title = title == null ? "" : title; + final String _body = body == null ? "" : body; + + String type = data != null ? data.getOrDefault("type", "") : ""; + String refId = data != null ? data.getOrDefault("refId", "") : ""; + String deeplink = data != null ? data.getOrDefault("deeplink", "") : ""; + String notificationId = data != null ? data.getOrDefault("notificationId", "") : ""; + + com.google.firebase.messaging.MulticastMessage msg = + com.google.firebase.messaging.MulticastMessage.builder() + .addAllTokens(tokens) + .setAndroidConfig(AndroidConfig.builder() + .setPriority(AndroidConfig.Priority.HIGH) + .build()) + .putData("title", _title) + .putData("body", _body) + .putData("type", type) + .putData("refId", refId) + .putData("deeplink", deeplink) + .putData("notificationId", notificationId) + .build(); + + try { + ApiFuture future = messaging.sendEachForMulticastAsync(msg); + BatchResponse br = future.get(SEND_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + + int success = 0, fail = 0; + List invalidTokens = new ArrayList<>(); + + List responses = br.getResponses(); + for (int i = 0; i < responses.size(); i++) { + SendResponse r = responses.get(i); + if (r.isSuccessful()) { + success++; + } else { + fail++; + FirebaseMessagingException fme = r.getException(); // per-token 예외 + if (fme != null && ( + fme.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED || + fme.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT)) { + invalidTokens.add(tokens.get(i)); + } + log.warn("[FCM] per-token fail memberId={} idx={} code={} root={}", + memberId, i, + (fme != null ? fme.getMessagingErrorCode() : null), + rootSummary(fme)); + } + } + + if (!invalidTokens.isEmpty()) { + try { + tokenRepo.deactivateTokens(invalidTokens); // UPDATE ... SET active=0 WHERE token IN (...) + } catch (Exception e) { + log.error("[FCM] deactivateTokens failed size={} memberId={} root={}", + invalidTokens.size(), memberId, rootSummary(e), e); + } + } + + return new FcmResult(success, fail, invalidTokens); + + } catch (TimeoutException te) { + log.warn("[FCM] timeout ({} ms) memberId={}", SEND_TIMEOUT.toMillis(), memberId); + throw te; + + } catch (ExecutionException ee) { + // ★ 핵심: Future가 싸서 던진 예외를 원형으로 복원 + Throwable c = ee.getCause(); + if (c instanceof FirebaseMessagingException fme) { + log.error("[FCM] FirebaseMessagingException memberId={} http={} code={} root={}", + memberId, httpStatusOf(fme), fme.getMessagingErrorCode(), rootSummary(fme), fme); + throw fme; // 리스너에서 코드/HTTP 기반 분류 가능 + } + throw ee; // 그 외는 그대로 위로 + + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw ie; + } + } + + public static Integer httpStatusOf(com.google.firebase.messaging.FirebaseMessagingException fme) { + try { + var resp = fme.getHttpResponse(); + return resp != null ? resp.getStatusCode() : null; + } catch (Throwable ignore) { return null; } + } + + private String rootSummary(Throwable t) { + if (t == null) return "null"; + Throwable r = t; while (r.getCause() != null) r = r.getCause(); + return r.getClass().getName() + ": " + String.valueOf(r.getMessage()); + } + + public record FcmResult(int successCount, int failureCount, List invalidTokens) { + static FcmResult empty() { return new FcmResult(0, 0, java.util.List.of()); } + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/infra/firebase/FirebaseInitLogger.java b/src/main/java/com/assu/server/infra/firebase/FirebaseInitLogger.java new file mode 100644 index 00000000..a0fc1ee0 --- /dev/null +++ b/src/main/java/com/assu/server/infra/firebase/FirebaseInitLogger.java @@ -0,0 +1,31 @@ +package com.assu.server.infra.firebase; + +import com.google.firebase.FirebaseApp; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class FirebaseInitLogger { + + private static boolean logged = false; + + @PostConstruct + public void printFcmInitOnce() { + if (logged) return; // 이미 찍었으면 무시 + + try { + FirebaseApp app = FirebaseApp.getInstance(); + var options = app.getOptions(); + + log.info("[FCM_INIT] projectId={}", + options.getProjectId()); + + logged = true; + + } catch (Exception e) { + log.error("[FCM_INIT] FirebaseApp 초기화 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/infra/firebase/NotificationFactory.java b/src/main/java/com/assu/server/infra/firebase/NotificationFactory.java new file mode 100644 index 00000000..16e78cc0 --- /dev/null +++ b/src/main/java/com/assu/server/infra/firebase/NotificationFactory.java @@ -0,0 +1,73 @@ +package com.assu.server.infra.firebase; + +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.notification.entity.Notification; +import com.assu.server.domain.notification.entity.NotificationType; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class NotificationFactory { + + public Notification create(Member receiver, NotificationType type, Long refId, Map ctx) { + String title; + String preview; + String deeplink; + + switch (type) { + case CHAT -> { + String sender = asString(ctx.get("senderName"), "알 수 없음"); + String msg = asString(ctx.get("message"), ""); + title = "ASSU"; + preview = sender + ": " + truncateByCodePoint(msg, 200); + deeplink = "/chat/rooms/" + refId; + } + case PARTNER_SUGGESTION -> { + title = "제휴 건의"; + preview = "새로운 제휴 건의가 도착했어요!"; + deeplink = "/partner/suggestions/" + refId; + } + case ORDER -> { + title = "주문 안내"; + String tableNum = asString(ctx.get("table_num"), "?"); + String paper = asString(ctx.get("paper_content"), "선택한 혜택"); + preview = tableNum + "번 테이블에서 " + paper + " 혜택을 선택하셨어요."; + deeplink = "/orders/" + refId; // 애매함, UI 상에서 이동할 곳이 없음 + } + case PARTNER_PROPOSAL -> { + String partnerName = asString(ctx.get("partner_name"), "파트너"); + title = "제휴 제안"; + preview = partnerName + "에서 제휴 제안이 왔어요!"; + deeplink = "/partner/proposals/" + refId; + } + default -> throw new IllegalArgumentException("Unknown type: " + type); + } + + return Notification.builder() + .receiver(receiver) + .type(type) + .refId(refId) + .title(title) + .messagePreview(preview) + .deeplink(deeplink) + .isRead(false) + .build(); + } + + // ===== helpers ===== + private static String asString(Object v, String def) { + if (v == null) return def; + String s = String.valueOf(v).trim(); + return s.isEmpty() ? def : s; + } + + /** 코드포인트 기준 안전 절단(한글/이모지 깨짐 방지) */ + private static String truncateByCodePoint(String src, int maxCodePoints) { + if (src == null) return ""; + int len = src.codePointCount(0, src.length()); + if (len <= maxCodePoints) return src; + int endIdx = src.offsetByCodePoints(0, maxCodePoints); + return src.substring(0, endIdx); + } +} diff --git a/src/main/java/com/assu/server/infra/s3/AmazonS3Manager.java b/src/main/java/com/assu/server/infra/s3/AmazonS3Manager.java new file mode 100644 index 00000000..6c875a49 --- /dev/null +++ b/src/main/java/com/assu/server/infra/s3/AmazonS3Manager.java @@ -0,0 +1,153 @@ +package com.assu.server.infra.s3; + +import com.assu.server.global.config.AmazonConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AmazonS3Manager{ + + private final S3Client s3Client; + private final S3Presigner s3Presigner; + private final AmazonConfig amazonConfig; + + //MultipartFile S3에 비공개 업로드 + public String uploadFile(String keyName, MultipartFile file) { + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(amazonConfig.getBucket()) + .key(keyName) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + } catch (Exception e) { + log.error("S3 파일 업로드에 실패했습니다. key: {}", keyName, e); + throw new RuntimeException("S3 upload failed", e); + } + return keyName; + } + + // fileBytes 를 위한 uploadFile + public String uploadFile(String keyName, byte[] fileBytes, String contentType) { + if (fileBytes == null || fileBytes.length == 0) { + throw new IllegalArgumentException("업로드할 파일이 비어있습니다."); + } + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(amazonConfig.getBucket()) + .key(keyName) + .contentType(contentType) + .contentLength((long) fileBytes.length) + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromBytes(fileBytes)); + } catch (Exception e) { + log.error("S3 파일 업로드에 실패했습니다. key: {}", keyName, e); + throw new RuntimeException("S3 upload failed", e); + } + return keyName; + } + + + // 그대로 사용 + public String generatePresignedUrl(String keyName) { + if (keyName == null || keyName.isBlank()) return null; + + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(amazonConfig.getBucket()) + .key(keyName) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) // 유효기간 10분 + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + // FE로 수정한 파일명으로 다운로드 가능한 url을 보내기 위해 사용하는 메서드 + public String generatePresignedUrlForDownloadPdfAndWord(String keyName, String fileName) { + if (keyName == null || keyName.isBlank()) return null; + + // RFC 5987 인코딩 + String encodedFilename = URLEncoder.encode(fileName, StandardCharsets.UTF_8) + .replace("+", "%20"); // 공백 처리 + String contentDisposition = "attachment; filename*=UTF-8''" + encodedFilename; + + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(amazonConfig.getBucket()) + .key(keyName) + .responseContentDisposition(contentDisposition) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + // S3에서 key에 해당하는 파일을 다운로드하여 byte 배열로 반환 + public byte[] downloadFile(String keyName) { + if (keyName == null || keyName.isBlank()) { + throw new IllegalArgumentException("파일 키가 유효하지 않습니다."); + } + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(amazonConfig.getBucket()) + .key(keyName) + .build(); + + ResponseBytes objectBytes = s3Client.getObjectAsBytes(getObjectRequest); + return objectBytes.asByteArray(); + } catch (Exception e) { + log.error("S3 파일 다운로드에 실패했습니다. key: {}", keyName, e); + throw new RuntimeException("S3 file download failed", e); + } + } + + public String generateKeyName(String path) { + return path + '/' + UUID.randomUUID(); + } + + public void deleteFile(String keyName) { + if (keyName == null || keyName.isBlank()) return; + try { + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(amazonConfig.getBucket()) + .key(keyName) + .build()); + log.debug("S3 삭제 완료 key={}", keyName); + } catch (Exception e) { + log.error("S3 파일 삭제 실패. key={}", keyName, e); + throw new RuntimeException("S3 delete failed", e); + } + } + +} diff --git a/src/main/java/com/assu/server/infra/s3/MultipartJackson2HttpMessageConverter.java b/src/main/java/com/assu/server/infra/s3/MultipartJackson2HttpMessageConverter.java new file mode 100644 index 00000000..da2b7661 --- /dev/null +++ b/src/main/java/com/assu/server/infra/s3/MultipartJackson2HttpMessageConverter.java @@ -0,0 +1,33 @@ +package com.assu.server.infra.s3; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Type; + +@Component +public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter { + + /** "Content-Type: multipart/form-data" 헤더를 지원하는 HTTP 요청 변환기 */ + public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) { + super(objectMapper, MediaType.APPLICATION_OCTET_STREAM); + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + return false; + } + + @Override + public boolean canWrite(Type type, Class clazz, MediaType mediaType) { + return false; + } + + @Override + protected boolean canWrite(MediaType mediaType) { + return false; + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 68e093cf..4c103422 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,9 @@ spring: + batch: + jdbc: + initialize-schema: never + job: + enabled: false config: import: - optional:classpath:application-secret.yml @@ -12,8 +17,25 @@ spring: time_zone: Asia/Seoul show_sql: true highlight_sql : true + lifecycle: + timeout-per-shutdown-phase: 30s + rabbitmq: + listener: + simple: + acknowledge-mode: manual + prefetch: 20 + concurrency: 1 + max-concurrency: 4 + default-requeue-rejected: false logging: level: org.springframework.web: DEBUG - org.springframework.web.client.DefaultRestClient: OFF \ No newline at end of file + org.springframework.web.client.DefaultRestClient: OFF + org.springframework.messaging.simp: DEBUG + org.springframework.messaging.handler: DEBUG + org.springframework.messaging: DEBUG + org.springframework.web.socket: DEBUG + +server: + shutdown: graceful \ No newline at end of file diff --git a/src/main/resources/certify-test.html b/src/main/resources/certify-test.html new file mode 100644 index 00000000..90116f62 --- /dev/null +++ b/src/main/resources/certify-test.html @@ -0,0 +1,335 @@ + + + + + + WebSocket 인증 테스트 - 인증자 + + + + + +
+

🔐 WebSocket 인증 테스트 - 인증자

+ +
+

📋 서버 설정

+
+ + +
+ +
+ + +
+
+ +
+

📱 QR 코드 스캔

+

안드로이드 앱에서 생성된 QR 코드를 스캔하거나

+

아래에 수동으로 정보를 입력하세요

+
+ +
+

✍️ 수동 입력

+
+ + +
+ +
+ + +
+
+ + + +
준비됨 - 정보를 입력하고 인증 요청을 보내세요
+ +
+

📊 빠른 테스트

+

다양한 시나리오로 빠르게 테스트해보세요:

+ + + +
+ +
+

📝 실시간 로그

+ +
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/chattinttest.html b/src/main/resources/chattinttest.html new file mode 100644 index 00000000..a93f1c8b --- /dev/null +++ b/src/main/resources/chattinttest.html @@ -0,0 +1,70 @@ + + + + + WebSocket 테스트 + + + + +

🔌 WebSocket 테스트

+ +
+
+
+
+
+ + +
+ +
+

+
+
+
+
\ No newline at end of file
diff --git a/src/test/java/com/assu/server/ServerApplicationTests.java b/src/test/java/com/assu/server/ServerApplicationTests.java
index b6858217..da762c7d 100644
--- a/src/test/java/com/assu/server/ServerApplicationTests.java
+++ b/src/test/java/com/assu/server/ServerApplicationTests.java
@@ -1,15 +1,77 @@
 package com.assu.server;
 
+import com.assu.server.domain.auth.security.jwt.JwtUtil;
+//import com.google.api.client.http.javanet.ConnectionFactory;
+import com.google.firebase.messaging.FirebaseMessaging;
 import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.amqp.rabbit.listener.RabbitListenerContainerFactory;
 import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.amqp.rabbit.connection.ConnectionFactory;
+
 
 @SpringBootTest
 @ActiveProfiles("test")
 class ServerApplicationTests {
 
-	@Test
-	void contextLoads() {
-	}
+    @Mock
+    private FirebaseMessaging firebaseMessaging;
+
+	@MockitoBean private ConnectionFactory connectionFactory;
+
+	@MockitoBean private RabbitTemplate rabbitTemplate;
+
+
+	@TestConfiguration
+	static class MockConfig {
+		@Bean
+		FirebaseMessaging firebaseMessaging() {
+			return Mockito.mock(FirebaseMessaging.class);
+		}
+
+        @Bean
+        RedisConnectionFactory redisConnectionFactory() {
+            return Mockito.mock(RedisConnectionFactory.class);
+        }
+
+        @Bean
+        @SuppressWarnings("unchecked")
+        RedisTemplate redisTemplate() {
+            return Mockito.mock(RedisTemplate.class);
+        }
+
+        @Bean
+        StringRedisTemplate stringRedisTemplate() {
+            return Mockito.mock(StringRedisTemplate.class);
+        }
+
+        @Bean
+        JwtUtil jwtUtil() {
+            return Mockito.mock(JwtUtil.class);
+        }
+
+        @Bean(name = "rabbitListenerContainerFactory")
+        RabbitListenerContainerFactory rabbitListenerContainerFactory() {
+            var factory = Mockito.mock(RabbitListenerContainerFactory.class);
+            var container = Mockito.mock(org.springframework.amqp.rabbit.listener.MessageListenerContainer.class);
+            Mockito.when(factory.createListenerContainer(Mockito.any()))
+                    .thenReturn(container);
+            return factory;
+        }
+
+    }
+
+    @Test
+    void contextLoads() {
+    }
 
 }
diff --git a/src/test/resources/application-secret.yml b/src/test/resources/application-secret.yml
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
new file mode 100644
index 00000000..8a3197b2
--- /dev/null
+++ b/src/test/resources/application-test.yml
@@ -0,0 +1,63 @@
+spring:
+  autoconfigure:
+    exclude:
+      - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
+      - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration
+      - org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration
+  datasource:
+    url: jdbc:h2:mem:testdb
+    driver-class-name: org.h2.Driver
+    username: sa
+    password:
+  rabbitmq:
+    listener:
+      simple:
+        auto-startup: false
+
+jwt:
+  header: Authorization
+  prefix: Bearer
+  secret: dummy-secret-key-for-testing
+  access-valid-seconds: 3600
+  refresh-valid-seconds: 1209600
+
+assu:
+  security:
+    school-crypto:
+      base64-key: "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=" #"dummy-base64-key"를 Base64로 인코딩한 값
+
+  messaging:
+    rabbit:
+      enabled: false
+    push:
+      enabled: false
+
+cloud:
+  aws:
+    s3:
+      bucket: test-bucket
+    region:
+      static: ap-northeast-2
+    stack:
+      auto: false
+    credentials:
+      accessKey: dummy-access
+      secretKey: dummy-secret
+
+firebase:
+  enabled: false
+
+kakao:
+  base-url: https://dapi.kakao.com
+  rest-api-key: dummy-kakao-key
+
+aligo:
+  key: dummy-aligo-key
+  user-id: dummy-user-id
+  sender: 01012345678
+
+rabbitmq:
+  host: dummy-host
+  port: 1234
+  username: rabbit-username
+  password: rabbit-password